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.