Construindo um App de Gestão de Corridas (F1) com Flutter 3.41: Lógica de Negócios e Relacionamentos (Parte 2)
Na Parte 1, configuramos o coração do nosso aplicativo: a arquitetura base, o SQLite e a exportação de dados. Agora, vamos mergulhar na Lógica de Negócios e Relacionamentos.
Um campeonato não existe sem Equipes, Pilotos e um Calendário de Provas. Mais do que isso, precisamos de um sistema matemático infalível para calcular a pontuação. Neste artigo de alto nível técnico, implementaremos o CRUD relacional, a arquitetura BLoC de ponta a ponta e a matemática por trás da tabela de classificação.
Dividimos esta implementação em 8 Assuntos Técnicos e Arquiteturais.
🗄️ 1. Evolução do SQLite: Chaves Estrangeiras e Relacionamentos
No automobilismo, um piloto pertence a uma equipe, e um resultado pertence a uma corrida. Precisamos atualizar o nosso DatabaseHelper para suportar FOREIGN KEYS.
Atualize o _createDB no database_helper.dart:
// Habilitar chaves estrangeiras no SQLite
await db.execute('PRAGMA foreign_keys = ON');
// 1. Tabela de Equipes (Pai)
await db.execute('''
CREATE TABLE teams (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
color_hex TEXT NOT NULL
)
''');
// 2. Tabela de Pilotos (Filho)
await db.execute('''
CREATE TABLE drivers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
number INTEGER NOT NULL,
team_id INTEGER NOT NULL,
FOREIGN KEY (team_id) REFERENCES teams (id) ON DELETE CASCADE
)
''');
// 3. Calendário (Provas)
await db.execute('''
CREATE TABLE races (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
date TEXT NOT NULL,
is_completed INTEGER DEFAULT 0
)
''');
// 4. Resultados (Relacionamento N:M entre Pilotos e Provas)
await db.execute('''
CREATE TABLE results (
id INTEGER PRIMARY KEY AUTOINCREMENT,
race_id INTEGER NOT NULL,
driver_id INTEGER NOT NULL,
position INTEGER NOT NULL,
FOREIGN KEY (race_id) REFERENCES races (id) ON DELETE CASCADE,
FOREIGN KEY (driver_id) REFERENCES drivers (id) ON DELETE CASCADE
)
''');
🧩 2. Repositórios (CRUD Abstraction)
Para não poluir nossos BLoCs com código SQL, criamos o padrão Repository. Ele interage com o DatabaseHelper e devolve objetos Dart limpos.
Exemplo: lib/data/repositories/team_repository.dart
class TeamRepository {
final DatabaseHelper _dbHelper = DatabaseHelper.instance;
Future<int> insertTeam(Team team) async {
final db = await _dbHelper.database;
return await db.insert('teams', team.toMap());
}
Future<List<Team>> getAllTeams() async {
final db = await _dbHelper.database;
final maps = await db.query('teams');
return maps.map((map) => Team.fromMap(map)).toList();
}
}
Crie lib/data/repositories/driver_repository.dart:
Neste repositório, lidamos com a tabela drivers. Note que, ao fazer consultas mais avançadas no futuro, poderíamos fazer um JOIN com a tabela de equipes aqui mesmo.
import 'package:sqflite/sqflite.dart';
import '../../core/database/database_helper.dart';
import '../models/driver.dart';
class DriverRepository {
final DatabaseHelper _dbHelper = DatabaseHelper.instance;
// Criar (Create)
Future<int> insertDriver(Driver driver) async {
final db = await _dbHelper.database;
return await db.insert(
'drivers',
driver.toMap(),
conflictAlgorithm: ConflictAlgorithm.replace, // Substitui se houver conflito de ID
);
}
// Ler (Read)
Future<List<Driver>> getAllDrivers() async {
final db = await _dbHelper.database;
// Consulta simples. Para trazer o nome da equipe, faríamos um JOIN SQL aqui.
final maps = await db.query('drivers', orderBy: 'number ASC');
return maps.map((map) => Driver.fromMap(map)).toList();
}
// Atualizar (Update)
Future<int> updateDriver(Driver driver) async {
final db = await _dbHelper.database;
return await db.update(
'drivers',
driver.toMap(),
where: 'id = ?',
whereArgs: [driver.id],
);
}
// Deletar (Delete)
Future<int> deleteDriver(int id) async {
final db = await _dbHelper.database;
return await db.delete(
'drivers',
where: 'id = ?',
whereArgs: [id],
);
}
}
Crie lib/data/repositories/race_repository.dart:
O repositório do calendário de provas. Ele gerencia o status da corrida (se já foi concluída ou não).
import 'package:sqflite/sqflite.dart';
import '../../core/database/database_helper.dart';
import '../models/race.dart';
class RaceRepository {
final DatabaseHelper _dbHelper = DatabaseHelper.instance;
Future<int> insertRace(Race race) async {
final db = await _dbHelper.database;
return await db.insert('races', race.toMap());
}
Future<List<Race>> getAllRaces() async {
final db = await _dbHelper.database;
// Ordena pela data para manter o calendário cronológico
final maps = await db.query('races', orderBy: 'date ASC');
return maps.map((map) => Race.fromMap(map)).toList();
}
// Método específico para marcar uma corrida como concluída
Future<int> markRaceAsCompleted(int raceId) async {
final db = await _dbHelper.database;
return await db.update(
'races',
{'is_completed': 1}, // 1 representa TRUE no SQLite
where: 'id = ?',
whereArgs: [raceId],
);
}
}
Crie lib/data/repositories/result_repository.dart:
Este é o repositório que alimenta o nosso BLoC de Classificação (o cérebro do app). Ele salva em qual posição cada piloto chegou em determinada corrida.
import 'package:sqflite/sqflite.dart';
import '../../core/database/database_helper.dart';
import '../models/result.dart';
class ResultRepository {
final DatabaseHelper _dbHelper = DatabaseHelper.instance;
// Insere o resultado de um piloto em uma corrida
Future<int> insertResult(Result result) async {
final db = await _dbHelper.database;
return await db.insert('results', result.toMap());
}
// Salva os resultados de todos os pilotos de uma vez (Batch Insert)
Future<void> insertRaceResultsBatch(List<Result> results) async {
final db = await _dbHelper.database;
await db.transaction((txn) async {
for (var result in results) {
await txn.insert('results', result.toMap());
}
});
}
// Busca todos os resultados para o cálculo do campeonato
Future<List<Result>> getAllResults() async {
final db = await _dbHelper.database;
final maps = await db.query('results');
return maps.map((map) => Result.fromMap(map)).toList();
}
// Busca o grid de chegada de uma corrida específica
Future<List<Result>> getResultsByRace(int raceId) async {
final db = await _dbHelper.database;
final maps = await db.query(
'results',
where: 'race_id = ?',
whereArgs: [raceId],
orderBy: 'position ASC', // Traz do 1º colocado em diante
);
return maps.map((map) => Result.fromMap(map)).toList();
}
}
🧮 3. Lógica Matemática de Pontuação (O Motor F1)
A regra de ouro da Fórmula 1: o 1º ganha 25, o 2º ganha 18… até o 10º que ganha 1.
Criaremos uma classe utilitária (Domain Logic) puramente matemática para resolver isso, garantindo que seja 100% testável.
Crie lib/core/utils/scoring_system.dart:
class ScoringSystem {
static const Map<int, int> _f1Points = {
1: 25, 2: 18, 3: 15, 4: 12, 5: 10,
6: 8, 7: 6, 8: 4, 9: 2, 10: 1
};
/// Retorna os pontos baseados na posição final. Fora do Top 10 = 0 pontos.
static int calculatePoints(int position) {
return _f1Points[position] ?? 0;
}
/// Calcula o total do campeonato para um piloto somando todos os seus resultados.
static int calculateChampionshipTotal(List<int> finishingPositions) {
return finishingPositions.fold(0, (total, pos) => total + calculatePoints(pos));
}
}
🧠 4. BLoC Architecture: Gerenciando as Equipes
Usaremos o BLoC para injetar as equipes na tela.
Criar team_bloc.dart:
import 'package:flutter_bloc/flutter_bloc.dart';
// Eventos
abstract class TeamEvent {}
class LoadTeams extends TeamEvent {}
class AddTeam extends TeamEvent { final Team team; AddTeam(this.team); }
// Estados
abstract class TeamState {}
class TeamLoading extends TeamState {}
class TeamLoaded extends TeamState { final List<Team> teams; TeamLoaded(this.teams); }
// BLoC
class TeamBloc extends Bloc<TeamEvent, TeamState> {
final TeamRepository repository;
TeamBloc(this.repository) : super(TeamLoading()) {
on<LoadTeams>((event, emit) async {
emit(TeamLoading());
final teams = await repository.getAllTeams();
emit(TeamLoaded(teams));
});
on<AddTeam>((event, emit) async {
await repository.insertTeam(event.team);
add(LoadTeams()); // Recarrega a lista após inserir
});
}
}
🏆 5. BLoC de Classificação (Championship Standings)
Este BLoC é o mais complexo. Ele ouve o banco de dados de results, aplica a matemática do ScoringSystem e cospe a tabela do campeonato.
Criar championship_bloc.dart:
class ChampionshipBloc extends Bloc<ChampionshipEvent, ChampionshipState> {
final ResultRepository resultRepo;
final DriverRepository driverRepo;
ChampionshipBloc(this.resultRepo, this.driverRepo) : super(ChampionshipLoading()) {
on<CalculateStandings>((event, emit) async {
emit(ChampionshipLoading());
final drivers = await driverRepo.getAllDrivers();
final results = await resultRepo.getAllResults(); // Retorna todos os resultados
List<DriverStanding> standings = [];
for (var driver in drivers) {
// Pega todas as posições deste piloto
final driverResults = results
.where((r) => r.driverId == driver.id)
.map((r) => r.position)
.toList();
// Aplica a matemática F1
final totalPoints = ScoringSystem.calculateChampionshipTotal(driverResults);
standings.add(DriverStanding(driver: driver, points: totalPoints));
}
// Ordena do maior pro menor
standings.sort((a, b) => b.points.compareTo(a.points));
emit(ChampionshipLoaded(standings));
});
}
}
🏭 6. Interface: Cadastro de Equipes (Material 3)
No M3, usamos cartões sem elevação (Tonal) e Floating Action Buttons proeminentes.
Criar team_list_screen.dart:
class TeamListScreen extends StatelessWidget {
const TeamListScreen({super.key});
@override
Widget build(BuildContext context) {
// Dispara o carregamento ao abrir a tela
context.read<TeamBloc>().add(LoadTeams());
return Scaffold(
appBar: AppBar(title: const Text('Equipes (Construtores)')),
floatingActionButton: FloatingActionButton.extended(
onPressed: () => _showAddTeamModal(context),
icon: const Icon(Icons.add),
label: const Text('NOVA EQUIPE'),
),
body: BlocBuilder<TeamBloc, TeamState>(
builder: (context, state) {
if (state is TeamLoading) return const Center(child: CircularProgressIndicator());
if (state is TeamLoaded) {
return ListView.builder(
itemCount: state.teams.length,
itemBuilder: (context, index) {
final team = state.teams[index];
return Card(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: ListTile(
leading: CircleAvatar(backgroundColor: Color(int.parse(team.colorHex))),
title: Text(team.name, style: const TextStyle(fontWeight: FontWeight.bold)),
),
);
},
);
}
return const SizedBox.shrink();
},
),
);
}
}
🏎️ 7. Interface: Cadastro de Pilotos com Dropdown Relacional
Ao cadastrar um piloto, precisamos selecionar a Equipe dele (Chave Estrangeira team_id). Faremos isso combinando dois BLoCs na mesma UI.
Criar driver_form_screen.dart (Trecho principal):
BlocBuilder<TeamBloc, TeamState>(
builder: (context, teamState) {
if (teamState is TeamLoaded) {
return DropdownButtonFormField<int>(
decoration: const InputDecoration(
labelText: 'Selecione a Equipe',
border: OutlineInputBorder(),
),
items: teamState.teams.map((team) {
return DropdownMenuItem<int>(
value: team.id,
child: Text(team.name),
);
}).toList(),
onChanged: (value) => setState(() => _selectedTeamId = value),
);
}
return const CircularProgressIndicator(); // Enquanto carrega as equipes
},
),
// Botão de Salvar:
FilledButton(
onPressed: () {
final driver = Driver(
name: _nameController.text,
number: int.parse(_numberController.text),
teamId: _selectedTeamId!
);
context.read<DriverBloc>().add(AddDriver(driver));
Navigator.pop(context);
},
child: const Text('SALVAR PILOTO'),
)
📅 8. Interface: Calendário das Provas
A tela de provas exibe o calendário e permite clicar em uma corrida para registrar os resultados.
Criar calendar_screen.dart:
class CalendarScreen extends StatelessWidget {
const CalendarScreen({super.key});
@override
Widget build(BuildContext context) {
context.read<RaceBloc>().add(LoadRaces());
return Scaffold(
appBar: AppBar(title: const Text('Calendário 2026')),
body: BlocBuilder<RaceBloc, RaceState>(
builder: (context, state) {
if (state is RaceLoaded) {
return ListView.separated(
itemCount: state.races.length,
separatorBuilder: (_, __) => const Divider(),
itemBuilder: (context, index) {
final race = state.races[index];
return ListTile(
leading: const Icon(Icons.flag),
title: Text(race.name),
subtitle: Text(race.date),
trailing: race.isCompleted
? const Chip(label: Text('Concluída'), backgroundColor: Colors.green)
: FilledButton.tonal(
onPressed: () {
// Navega para a tela de Input de Resultados
},
child: const Text('Inserir Resultado')
),
);
},
);
}
return const Center(child: CircularProgressIndicator());
},
),
);
}
}
✅ Resumo e Próximos Passos
Nesta segunda parte, transformamos o banco SQLite em um sistema vivo de telemetria e gestão.
-
Arquitetura: Dominamos o CRUD com
RepositorieseBLoCs. -
Lógica: O
ScoringSystemtraduziu a tabela de pontos da F1 (25, 18, 15…) para código Dart. -
UI Avançada: Usamos o Material 3 com Múltiplos BLoCs na mesma tela (para chaves estrangeiras).
Na Parte 3, podemos implementar a interface pesada de Input de Resultados da Corrida (um formulário dinâmico estilo Drag-and-Drop para definir quem chegou de 1º a 20º) e o Dashboard de Classificação Mundial (Standings).