Construindo um App de Gestão de Corridas com Flutter 3.41: GoRouter, Input de Resultados e Regras de Bandeira Vermelha (Parte 4)

Nas etapas anteriores, estruturamos o banco de dados local com SQLite, criamos o Dashboard da Home e introduzimos a pontuação das provas Sprint. Agora, entraremos em uma das áreas mais complexas e reais do automobilismo: as famosas Corridas Curtas (Red Flags / Bandeiras Vermelhas), onde a pontuação é fracionada dependendo da distância percorrida.

Além disso, vamos unificar a navegação do aplicativo implantando o GoRouter em todas as telas, conectando o menu suspenso, e construindo a tão aguardada tela de Input de Resultados, onde a magia do arrastar e soltar (Drag and Drop) acontece.

🧮 1. Lógica Matemática Avançada: Exceções para Corridas Curtas

No regulamento moderno, se uma corrida é interrompida (Bandeira Vermelha) e não pode ser reiniciada, a pontuação distribuída depende da porcentagem da prova que foi completada.

Vamos atualizar nossa classe de domínio (ScoringSystem) para suportar essas 4 faixas críticas utilizando os recursos modernos do Dart.

Arquivo: lib/core/utils/scoring_system.dart

class ScoringSystem {
  // Pontuações existentes (Feature e Sprint)...
  static const Map<int, int> _featurePoints = {1: 25, 2: 18, 3: 15, 4: 12, 5: 10, 6: 8, 7: 6, 8: 4, 9: 2, 10: 1};
  static const Map<int, int> _sprintPoints = {1: 8, 2: 7, 3: 6, 4: 5, 5: 4, 6: 3, 7: 2, 8: 1};

  // Novas Pontuações (Corridas Interrompidas - Bandeira Vermelha)
  static const Map<int, int> _under25Points = {1: 6, 2: 4, 3: 3, 4: 2, 5: 1};
  static const Map<int, int> _under50Points = {1: 13, 2: 10, 3: 8, 4: 6, 5: 5, 6: 4, 7: 3, 8: 2, 9: 1};
  static const Map<int, int> _under75Points = {1: 19, 2: 14, 3: 12, 4: 9, 5: 8, 6: 6, 7: 5, 8: 3, 9: 2, 10: 1};

  /// Calcula os pontos com base na posição, tipo de prova e % de conclusão.
  static int calculatePoints(int position, {required bool isSprint, required double completedPercentage}) {
    if (isSprint) {
      return _sprintPoints[position] ?? 0;
    }

    // Lógica de Exceção: Corridas Curtas
    if (completedPercentage < 25.0) {
      return _under25Points[position] ?? 0;
    } else if (completedPercentage >= 25.0 && completedPercentage < 50.0) {
      return _under50Points[position] ?? 0;
    } else if (completedPercentage >= 50.0 && completedPercentage < 75.0) {
      return _under75Points[position] ?? 0;
    }

    // Mais de 75%: Pontuação Completa
    return _featurePoints[position] ?? 0;
  }
}

🗄️ 2. Atualização do Banco de Dados: Porcentagem Concluída

Para que o BLoC calcule essa nova regra, a tabela races precisa armazenar a porcentagem da corrida que foi efetivamente disputada.

Alteração na tabela races no DatabaseHelper:

CREATE TABLE races (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  name TEXT NOT NULL,
  date TEXT NOT NULL,
  is_completed INTEGER DEFAULT 0,
  is_sprint INTEGER DEFAULT 0,
  completed_percentage REAL DEFAULT 100.0 -- Nova coluna (100% por padrão)
)

O Modelo Atualizado (race.dart)

Substitua o conteúdo do seu arquivo lib/data/models/race.dart por este:

class Race {
  final int? id;
  final String name;
  final String date;
  final bool isCompleted;
  final bool isSprint;
  final double completedPercentage; // Novo campo para gerenciar Bandeiras Vermelhas

  Race({
    this.id,
    required this.name,
    required this.date,
    this.isCompleted = false,
    this.isSprint = false,
    this.completedPercentage = 100.0, // Por padrão, assume-se que a corrida foi até o fim
  });

  // Converte o objeto Race para um Map (Formato aceito pelo SQLite)
  Map<String, dynamic> toMap() {
    return {
      if (id != null) 'id': id,
      'name': name,
      'date': date,
      // SQLite não possui tipo booleano nativo. Usamos 0 e 1.
      'is_completed': isCompleted ? 1 : 0,
      'is_sprint': isSprint ? 1 : 0,
      'completed_percentage': completedPercentage, // Salvando a porcentagem
    };
  }

  // Converte o Map vindo do SQLite de volta para um objeto Race no Dart
  factory Race.fromMap(Map<String, dynamic> map) {
    return Race(
      id: map['id'] as int?,
      name: map['name'] as String,
      date: map['date'] as String,
      isCompleted: map['is_completed'] == 1,
      isSprint: map['is_sprint'] == 1,
      // Usamos .toDouble() por segurança, pois o SQLite pode retornar um int 
      // se o valor for redondo (ex: 100, 50, 75).
      completedPercentage: (map['completed_percentage'] ?? 100.0).toDouble(),
    );
  }
}

🧠 3. Refatoração do Championship BLoC

Agora, o BLoC do campeonato consome a nova propriedade da prova (completedPercentage) e a injeta no ScoringSystem.

Snippet de atualização em championship_bloc.dart:

// Dentro do loop de resultados do piloto:
final race = races.firstWhere((r) => r.id == result.raceId);

totalPoints += ScoringSystem.calculatePoints(
  result.position, 
  isSprint: race.isSprint,
  completedPercentage: race.completedPercentage, // Nova injeção de dados
);

🧭 4. Roteamento Profissional: Implementando o GoRouter

Nós substituímos a navegação imperativa clássica por GoRouter. Isso nos permite lidar com links profundos, passagem de parâmetros limpa (como o ID da corrida para a tela de resultados) e transições fluidas.

Arquivo: lib/router/app_router.dart

import 'package:go_router/go_router.dart';
// Importe suas telas...

final appRouter = GoRouter(
  initialLocation: '/home',
  routes: [
    GoRoute(
      path: '/home',
      builder: (context, state) => const HomeScreen(),
    ),
    GoRoute(
      path: '/drivers',
      builder: (context, state) => const DriverListScreen(),
    ),
    GoRoute(
      path: '/teams',
      builder: (context, state) => const TeamListScreen(),
    ),
    GoRoute(
      path: '/races',
      builder: (context, state) => const CalendarScreen(),
      routes: [
        // Rota aninhada com parâmetro de ID
        GoRoute(
          path: ':id/results',
          builder: (context, state) {
            final raceId = int.parse(state.pathParameters['id']!);
            return RaceResultInputScreen(raceId: raceId);
          },
        ),
      ]
    ),
    GoRoute(
      path: '/profile',
      builder: (context, state) => const ProfileScreen(),
    ),
  ],
);

(Lembre-se de substituir o MaterialApp por MaterialApp.router no seu main.dart).

🔗 5. Conectando o Menu Suspenso (AppBar) ao GoRouter

Na Parte 3, deixamos o PopupMenuButton pronto com os valores (value). Agora que o GoRouter está configurado com rotas exatas correspondentes a esses valores, a mágica acontece em uma única linha.

Snippet na HomeScreen:

PopupMenuButton<String>(
  icon: const Icon(Icons.menu),
  onSelected: (value) {
    // O value ('drivers', 'teams', 'profile') se torna o path da rota
    context.push('/$value'); 
  },
  itemBuilder: (BuildContext context) => <PopupMenuEntry<String>>[
    const PopupMenuItem<String>(value: 'drivers', child: Text('Cadastro de Pilotos')),
    const PopupMenuItem<String>(value: 'teams', child: Text('Cadastro de Equipes')),
    const PopupMenuItem<String>(value: 'races', child: Text('Cadastro de Provas')),
    const PopupMenuItem<String>(value: 'profile', child: Text('Perfil')),
  ],
)

🕹️ 6. Arquitetura do Painel de Resultados (Result BLoC)

Antes de criar a tela, precisamos do BLoC que processará o salvamento em lote (Batch Insert) que preparamos no ResultRepository na Parte 2.

Arquivo: lib/blocs/result/result_bloc.dart

class ResultBloc extends Bloc<ResultEvent, ResultState> {
  final ResultRepository repository;
  final RaceRepository raceRepository;

  ResultBloc(this.repository, this.raceRepository) : super(ResultInitial()) {
    on<SubmitRaceResults>((event, emit) async {
      emit(ResultSubmitting());
      try {
        // 1. Salva todos os resultados da corrida
        await repository.insertRaceResultsBatch(event.results);
        
        // 2. Marca a corrida como concluída no banco e define a % disputada
        await raceRepository.markRaceAsCompleted(event.raceId, completedPercentage: event.percentage);
        
        emit(ResultSubmitSuccess());
      } catch (e) {
        emit(const ResultSubmitError("Erro ao salvar resultados da prova."));
      }
    });
  }
}

📱 7. Painel de Input de Resultados (Drag and Drop UI)

Esta é a tela mais interativa do app. O diretor de prova precisa definir a ordem de chegada. Em vez de digitar manualmente a posição de 20 pilotos, usaremos o ReorderableListView do Material 3, permitindo arrastar os pilotos para cima ou para baixo para definir a classificação final.

Arquivo: lib/modules/races/race_result_input_screen.dart

class RaceResultInputScreen extends StatefulWidget {
  final int raceId;
  const RaceResultInputScreen({super.key, required this.raceId});

  @override
  State<RaceResultInputScreen> createState() => _RaceResultInputScreenState();
}

class _RaceResultInputScreenState extends State<RaceResultInputScreen> {
  List<Driver> _grid = [];
  double _completionPercentage = 100.0; // Padrão: Corrida Completa

  @override
  void initState() {
    super.initState();
    // Carrega os pilotos para o estado inicial da lista
    _grid = List.from(context.read<DriverBloc>().state.drivers); 
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Definir Grid de Chegada'),
        actions: [
          IconButton(
            icon: const Icon(Icons.save),
            onPressed: () => _submitResults(),
          )
        ],
      ),
      body: Column(
        children: [
          // Controle de Porcentagem (Bandeira Vermelha)
          Padding(
            padding: const EdgeInsets.all(16.0),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Text('Porcentagem Concluída da Prova:', style: Theme.of(context).textTheme.titleSmall),
                Slider(
                  value: _completionPercentage,
                  min: 0, max: 100, divisions: 4,
                  label: '${_completionPercentage.round()}%',
                  onChanged: (val) => setState(() => _completionPercentage = val),
                ),
              ],
            ),
          ),
          Expanded(
            // Lista interativa Drag and Drop
            child: ReorderableListView.builder(
              itemCount: _grid.length,
              onReorder: (oldIndex, newIndex) {
                setState(() {
                  if (newIndex > oldIndex) newIndex -= 1;
                  final item = _grid.removeAt(oldIndex);
                  _grid.insert(newIndex, item);
                });
              },
              itemBuilder: (context, index) {
                final driver = _grid[index];
                return ListTile(
                  key: ValueKey(driver.id),
                  leading: CircleAvatar(child: Text('${index + 1}º')),
                  title: Text(driver.name),
                  subtitle: Text(driver.team),
                  trailing: const Icon(Icons.drag_handle),
                  tileColor: Theme.of(context).colorScheme.surface,
                );
              },
            ),
          ),
        ],
      ),
    );
  }

  void _submitResults() {
    // Transforma a lista ordenada da UI em objetos Result
    final results = List.generate(_grid.length, (index) {
      return Result(
        raceId: widget.raceId,
        driverId: _grid[index].id!,
        position: index + 1,
      );
    });

    context.read<ResultBloc>().add(SubmitRaceResults(widget.raceId, results, _completionPercentage));
  }
}

👤 8. Criação da Tela de Perfil

Para fechar as opções do nosso menu suspenso, criamos a Tela de Perfil, que exibe dados simulados do usuário, do último acesso e a versão técnica do aplicativo.

Arquivo: lib/modules/profile/profile_screen.dart

class ProfileScreen extends StatelessWidget {
  const ProfileScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Meu Perfil')),
      body: ListView(
        padding: const EdgeInsets.all(24),
        children: [
          const Center(
            child: CircleAvatar(
              radius: 50,
              child: Icon(Icons.admin_panel_settings, size: 50),
            ),
          ),
          const SizedBox(height: 16),
          Center(
            child: Text('Diretor de Prova (Admin)', style: Theme.of(context).textTheme.headlineSmall),
          ),
          const SizedBox(height: 32),
          
          Card(
            elevation: 0,
            color: Theme.of(context).colorScheme.surfaceContainerHighest,
            child: Column(
              children: [
                const ListTile(
                  leading: Icon(Icons.email),
                  title: Text('E-mail'),
                  subtitle: Text('admin@racingmanager.com'),
                ),
                const Divider(height: 1),
                ListTile(
                  leading: const Icon(Icons.access_time),
                  title: const Text('Último Acesso'),
                  subtitle: Text('${DateTime.now().day}/${DateTime.now().month}/${DateTime.now().year} às 08:30'),
                ),
              ],
            ),
          ),
          
          const SizedBox(height: 24),
          const Divider(),
          const SizedBox(height: 24),
          
          Row(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              const Icon(Icons.info_outline, size: 16, color: Colors.grey),
              const SizedBox(width: 8),
              Text('Versão do Aplicativo: 1.0.4 (Build 42)', style: Theme.of(context).textTheme.bodySmall),
            ],
          ),
          const SizedBox(height: 8),
          Center(
            child: Text('Desenvolvido com Flutter 3.41', style: Theme.of(context).textTheme.labelSmall?.copyWith(color: Theme.of(context).colorScheme.primary)),
          ),
        ],
      ),
    );
  }
}

Conclusão e Próximos Passos

Nesta Parte 4, alcançamos um marco crítico:

  • Consolidamos a navegação usando GoRouter, a ferramenta de roteamento mais robusta do Flutter.

  • Implementamos a física de arrastar e soltar (ReorderableListView), garantindo uma excelente Experiência do Usuário (UX) no painel de controle do Diretor de Prova.

  • E, mais importante, o nosso motor matemático agora lida com regras complexas da vida real (Corridas Curtas e Bandeiras Vermelhas), calculando pontos baseados em porcentagem sem travar a interface.

Na Parte 5 (Final), podemos finalizar os ajustes finos e preparar o app para produção, abordando gráficos de telemetria (evolução dos pontos por corrida) e otimizações finais de performance do SQLite.

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