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.

  1. Arquitetura: Dominamos o CRUD com Repositories e BLoCs.

  2. Lógica: O ScoringSystem traduziu a tabela de pontos da F1 (25, 18, 15…) para código Dart.

  3. 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).

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