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'),
)
],
),
),
);
}
}