Construindo um App de Gestão de Corridas com Flutter 3.41: Dashboard, Sprints e Menu Rápido (Parte 3)

Neste artigo, vamos transformar a interface inicial em um verdadeiro painel de controle (Pit Wall). Atualizaremos nossa regra de negócios para suportar diferentes formatos de prova e estruturaremos a Home com dados reativos e navegação fluida.


🧮 1. Evolução da Lógica Matemática: Corridas Sprint

O automobilismo moderno possui formatos diferentes de pontuação. Além da corrida principal (Feature Race), precisamos calcular os pontos das corridas curtas (Sprint), onde apenas os 8 primeiros pontuam (1º = 8pts … 8º = 1pt).

Atualizamos nossa classe utilitária (Domain Logic) para suportar essa variação de forma limpa.

Arquivo: lib/core/utils/scoring_system.dart

class ScoringSystem {
  // Pontuação Corrida Principal (Top 10)
  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
  };

  // Pontuação Corrida Sprint (Top 8)
  static const Map<int, int> _sprintPoints = {
    1: 8, 2: 7, 3: 6, 4: 5, 5: 4, 6: 3, 7: 2, 8: 1
  };

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

🗄️ 2. Atualização do Modelo e Banco de Dados (Provas Sprint)

Para que o BLoC saiba qual pontuação aplicar, a tabela de Corridas (races) precisa ter uma nova coluna identificando se a prova é Sprint.

Atualização no DatabaseHelper (Migration):

-- Adicionado o campo is_sprint
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 -- 0 para Principal, 1 para Sprint
)

O Modelo Completo de Provas (race.dart)

Crie ou atualize o arquivo lib/data/models/race.dart. Este modelo agora suporta a identificação de provas Sprint e se a corrida já foi finalizada.

class Race {
  final int? id;
  final String name;
  final String date;
  final bool isCompleted;
  final bool isSprint;

  Race({
    this.id,
    required this.name,
    required this.date,
    this.isCompleted = false,
    this.isSprint = false,
  });

  // 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 para false e 1 para true.
      'is_completed': isCompleted ? 1 : 0,
      'is_sprint': isSprint ? 1 : 0,
    };
  }

  // 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,
      // Convertendo os inteiros do SQLite de volta para booleanos no Dart
      isCompleted: map['is_completed'] == 1,
      isSprint: map['is_sprint'] == 1,
    );
  }
}

🧠 3. Refatoração do Championship BLoC (Cálculo Dinâmico)

O ChampionshipBloc agora precisa fazer um JOIN lógico ou consultar se a corrida atrelada ao resultado é Sprint para aplicar a pontuação correta.

Arquivo: lib/blocs/championship/championship_bloc.dart

on<CalculateStandings>((event, emit) async {
  emit(ChampionshipLoading());
  
  final drivers = await driverRepo.getAllDrivers();
  final results = await resultRepo.getAllResults(); 
  final races = await raceRepo.getAllRaces(); // Precisamos das provas para saber o tipo

  List<DriverStanding> standings = [];

  for (var driver in drivers) {
    int totalPoints = 0;
    
    // Filtra os resultados deste piloto
    final driverResults = results.where((r) => r.driverId == driver.id);
    
    for (var result in driverResults) {
      // Encontra a corrida correspondente a este resultado
      final race = races.firstWhere((r) => r.id == result.raceId);
      
      // Aplica a regra matemática passando a flag isSprint
      totalPoints += ScoringSystem.calculatePoints(
        result.position, 
        isSprint: race.isSprint,
      );
    }
    standings.add(DriverStanding(driver: driver, points: totalPoints));
  }

  standings.sort((a, b) => b.points.compareTo(a.points));
  emit(ChampionshipLoaded(standings));
});

🧭 4. O Menu Suspenso de Acesso Rápido (AppBar)

Conforme solicitado, substituiremos a navegação em Grid por um menu suspenso (Dropdown) direto na AppBar, liberando a Home para focar exclusivamente nos dados do campeonato.

Snippet do AppBar na HomeScreen:

AppBar(
  title: const Text('Racing Dashboard'),
  actions: [
    PopupMenuButton<String>(
      icon: const Icon(Icons.menu),
      tooltip: 'Menu de Acesso Rápido',
      onSelected: (value) {
        // Usa o GoRouter para navegação baseada no value
        context.push('/$value');
      },
      itemBuilder: (BuildContext context) => <PopupMenuEntry<String>>[
        const PopupMenuItem<String>(value: 'next-race', child: ListTile(leading: Icon(Icons.flag), title: Text('Próxima Corrida'))),
        const PopupMenuDivider(),
        const PopupMenuItem<String>(value: 'drivers', child: ListTile(leading: Icon(Icons.sports_motorsports), title: Text('Cadastro de Pilotos'))),
        const PopupMenuItem<String>(value: 'teams', child: ListTile(leading: Icon(Icons.build), title: Text('Cadastro de Equipes'))),
        const PopupMenuItem<String>(value: 'races', child: ListTile(leading: Icon(Icons.add_road), title: Text('Cadastro de Provas'))),
        const PopupMenuDivider(),
        const PopupMenuItem<String>(value: 'profile', child: ListTile(leading: Icon(Icons.person), title: Text('Perfil'))),
      ],
    ),
  ],
)

🏁 5. Card em Destaque: A Próxima Corrida

A Home precisa exibir qual é o próximo evento. Vamos criar um Widget dedicado utilizando a elevação tonal do Material 3. O RaceBloc deve fornecer a próxima corrida com is_completed == 0.

Arquivo: lib/modules/home/widgets/next_race_card.dart

class NextRaceCard extends StatelessWidget {
  final Race nextRace;
  const NextRaceCard({super.key, required this.nextRace});

  @override
  Widget build(BuildContext context) {
    return Card(
      color: Theme.of(context).colorScheme.primaryContainer,
      elevation: 0,
      margin: const EdgeInsets.all(16),
      child: Padding(
        padding: const EdgeInsets.all(20),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Row(
              mainAxisAlignment: MainAxisAlignment.spaceBetween,
              children: [
                Text('PRÓXIMA PROVA', style: Theme.of(context).textTheme.labelMedium?.copyWith(letterSpacing: 1.5)),
                if (nextRace.isSprint)
                  Chip(label: const Text('SPRINT', style: TextStyle(fontSize: 10)), backgroundColor: Colors.orange.shade800),
              ],
            ),
            const SizedBox(height: 12),
            Text(nextRace.name, style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold)),
            const SizedBox(height: 4),
            Row(
              children: [
                const Icon(Icons.calendar_month, size: 16),
                const SizedBox(width: 8),
                Text(nextRace.date, style: Theme.of(context).textTheme.bodyLarge),
              ],
            ),
          ],
        ),
      ),
    );
  }
}

Utilizando as Sealed Classes do Dart moderno (Flutter 3.41), aqui está o código completo do RaceBloc, dividido em Eventos, Estados e a Lógica Principal.


1. Os Eventos (race_event.dart)

Definimos as ações que a interface pode solicitar ao BLoC de Corridas: carregar a lista, adicionar uma nova prova ou marcar uma corrida como concluída.

import 'package:equatable/equatable.dart';
import '../../data/models/race.dart';

sealed class RaceEvent extends Equatable {
  const RaceEvent();

  @override
  List<Object> get props => [];
}

class LoadRaces extends RaceEvent {}

class AddRace extends RaceEvent {
  final Race race;
  
  const AddRace(this.race);

  @override
  List<Object> get props => [race];
}

class CompleteRace extends RaceEvent {
  final int raceId;
  
  const CompleteRace(this.raceId);

  @override
  List<Object> get props => [raceId];
}

2. Os Estados (race_state.dart)

O estado RaceLoaded é o mais importante aqui. Note que além da lista completa de corridas (races), ele também expõe um objeto opcional nextRace. Isso facilita muito a vida da Home Screen, que não precisará fazer cálculos na interface.

import 'package:equatable/equatable.dart';
import '../../data/models/race.dart';

sealed class RaceState extends Equatable {
  const RaceState();
  
  @override
  List<Object?> get props => [];
}

final class RaceInitial extends RaceState {}

final class RaceLoading extends RaceState {}

final class RaceLoaded extends RaceState {
  final List<Race> races;
  final Race? nextRace; // Exposto diretamente para o Card da Home

  const RaceLoaded({required this.races, this.nextRace});

  @override
  List<Object?> get props => [races, nextRace];
}

final class RaceError extends RaceState {
  final String message;
  
  const RaceError(this.message);

  @override
  List<Object> get props => [message];
}

3. A Lógica de Negócio (race_bloc.dart)

O motor do BLoC interage com o RaceRepository (que criamos na parte anterior) e processa os dados.

import 'package:flutter_bloc/flutter_bloc.dart';
import '../../data/repositories/race_repository.dart';
import '../../data/models/race.dart';
import 'race_event.dart';
import 'race_state.dart';

class RaceBloc extends Bloc<RaceEvent, RaceState> {
  final RaceRepository repository;

  RaceBloc(this.repository) : super(RaceInitial()) {
    on<LoadRaces>(_onLoadRaces);
    on<AddRace>(_onAddRace);
    on<CompleteRace>(_onCompleteRace);
  }

  Future<void> _onLoadRaces(LoadRaces event, Emitter<RaceState> emit) async {
    emit(RaceLoading());
    try {
      // Busca todas as corridas ordenadas por data (Lógica do Repository)
      final races = await repository.getAllRaces();

      // Encontra a próxima corrida (a primeira da lista que não foi concluída)
      Race? nextRace;
      try {
        nextRace = races.firstWhere((r) => !r.isCompleted);
      } catch (_) {
        nextRace = null; // Todas as provas foram concluídas ou calendário vazio
      }

      emit(RaceLoaded(races: races, nextRace: nextRace));
    } catch (e) {
      emit(const RaceError("Erro ao carregar o calendário de provas."));
    }
  }

  Future<void> _onAddRace(AddRace event, Emitter<RaceState> emit) async {
    try {
      await repository.insertRace(event.race);
      add(LoadRaces()); // Recarrega a lista após salvar para atualizar a Home
    } catch (e) {
      emit(const RaceError("Erro ao salvar a prova de corrida."));
    }
  }

  Future<void> _onCompleteRace(CompleteRace event, Emitter<RaceState> emit) async {
    try {
      await repository.markRaceAsCompleted(event.raceId);
      add(LoadRaces()); // Atualiza o calendário e recalcula qual é a nova "Próxima Corrida"
    } catch (e) {
      emit(const RaceError("Erro ao finalizar a corrida."));
    }
  }
}

💡 Dica de Integração na Home:

Com esse BLoC pronto, lá no Assunto 7 (Nova Estrutura da Home Screen), você pode envolver o NextRaceCard com um BlocBuilder de forma muito limpa:

// Snippet atualizado para o Assunto 7
BlocBuilder<RaceBloc, RaceState>(
  builder: (context, state) {
    if (state is RaceLoaded && state.nextRace != null) {
      return NextRaceCard(nextRace: state.nextRace!);
    } else if (state is RaceLoaded && state.nextRace == null) {
      return const Center(child: Text('Temporada Finalizada!'));
    }
    return const Center(child: CircularProgressIndicator());
  },
)

📊 6. Classificação Atual (Standings List)

Abaixo do Card da próxima corrida, exibiremos a lista do campeonato. Vamos usar o BlocBuilder para escutar o ChampionshipBloc e renderizar uma lista elegante.

Snippet para a Home (ListView.builder embutido):

BlocBuilder<ChampionshipBloc, ChampionshipState>(
  builder: (context, state) {
    if (state is ChampionshipLoaded) {
      return ListView.separated(
        shrinkWrap: true, // Necessário se estiver dentro de um SingleChildScrollView
        physics: const NeverScrollableScrollPhysics(),
        itemCount: state.standings.length,
        separatorBuilder: (_, __) => const Divider(height: 1),
        itemBuilder: (context, index) {
          final standing = state.standings[index];
          return ListTile(
            leading: CircleAvatar(
              backgroundColor: Theme.of(context).colorScheme.surfaceVariant,
              child: Text('${index + 1}º', style: const TextStyle(fontWeight: FontWeight.bold)),
            ),
            title: Text(standing.driver.name, style: const TextStyle(fontWeight: FontWeight.bold)),
            subtitle: Text(standing.driver.team),
            trailing: Text(
              '${standing.points} pts', 
              style: Theme.of(context).textTheme.titleMedium?.copyWith(color: Theme.of(context).colorScheme.primary),
            ),
          );
        },
      );
    }
    return const Center(child: CircularProgressIndicator());
  },
)

🏗️ 7. Nova Estrutura da Home Screen (CustomScrollView)

Para combinar o Card de Destaque e a Lista de Classificação de forma performática (evitando o erro clássico de “Listas infinitas dentro de Colunas”), usaremos Slivers.

Arquivo: lib/modules/home/home_screen.dart

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

  @override
  Widget build(BuildContext context) {
    // Despacha o evento para calcular a tabela assim que a tela abre
    context.read<ChampionshipBloc>().add(CalculateStandings());
    
    return Scaffold(
      appBar: /* AppBar com o PopupMenu (Assunto 4) */,
      body: CustomScrollView(
        slivers: [
          // Espaço para o Card da Próxima Corrida
          SliverToBoxAdapter(
             // Substitua por um BlocBuilder buscando a próxima corrida
            child: NextRaceCard(nextRace: Race(name: 'GP de Interlagos', date: '25/11/2026', isSprint: true)),
          ),
          
          // Título da Seção
          SliverToBoxAdapter(
            child: Padding(
              padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
              child: Text('CLASSIFICAÇÃO DO CAMPEONATO', style: Theme.of(context).textTheme.titleSmall),
            ),
          ),

          // Lista de Classificação usando SliverList
          SliverToBoxAdapter(
            child: /* O BlocBuilder do Assunto 6 vai aqui */,
          ),
        ],
      ),
    );
  }
}

🏁 8. Tela de Gestão de Provas de Corrida (CRUD)

O usuário precisa cadastrar as provas e definir se são normais ou Sprint. Usaremos o SegmentedButton do Material 3 para essa seleção.

Arquivo: lib/modules/races/race_form_screen.dart

class RaceFormScreen extends StatefulWidget {
  const RaceFormScreen({super.key});

  @override
  State<RaceFormScreen> createState() => _RaceFormScreenState();
}

class _RaceFormScreenState extends State<RaceFormScreen> {
  final _nameController = TextEditingController();
  final _dateController = TextEditingController();
  bool _isSprint = false;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Cadastrar Prova')),
      body: Padding(
        padding: const EdgeInsets.all(24),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            TextField(controller: _nameController, decoration: const InputDecoration(labelText: 'Nome do GP (Ex: GP de Mônaco)', border: OutlineInputBorder())),
            const SizedBox(height: 16),
            TextField(
              controller: _dateController, 
              decoration: const InputDecoration(labelText: 'Data da Prova', border: OutlineInputBorder(), prefixIcon: Icon(Icons.calendar_today)),
            ),
            const SizedBox(height: 24),
            
            // Material 3 SegmentedButton para o Tipo de Prova
            SegmentedButton<bool>(
              segments: const [
                ButtonSegment(value: false, label: Text('Feature Race (25pts)'), icon: Icon(Icons.flag)),
                ButtonSegment(value: true, label: Text('Sprint (8pts)'), icon: Icon(Icons.bolt)),
              ],
              selected: {_isSprint},
              onSelectionChanged: (val) => setState(() => _isSprint = val.first),
            ),
            
            const Spacer(),
            FilledButton(
              onPressed: () {
                final newRace = Race(name: _nameController.text, date: _dateController.text, isSprint: _isSprint);
                context.read<RaceBloc>().add(AddRace(newRace));
                Navigator.pop(context);
              },
              child: const Text('SALVAR PROVA'),
            )
          ],
        ),
      ),
    );
  }
}
Please follow and like us:
error0
fb-share-icon
Tweet 20
fb-share-icon20