diff --git a/lib/controllers/placar_controller.dart b/lib/controllers/placar_controller.dart index 993d4c3..ca2bf75 100644 --- a/lib/controllers/placar_controller.dart +++ b/lib/controllers/placar_controller.dart @@ -672,8 +672,7 @@ class PlacarController extends ChangeNotifier { _autoSaveTimer?.cancel(); super.dispose(); } - - Future saveGameStats(BuildContext context) async { +Future saveGameStats(BuildContext context) async { final supabase = Supabase.instance.client; isSaving = true; notifyListeners(); @@ -731,6 +730,7 @@ class PlacarController extends ChangeNotifier { } }); + // 1. Atualizar o Jogo await supabase.from('games').update({ 'my_score': myScore, 'opponent_score': opponentScore, @@ -746,6 +746,7 @@ class PlacarController extends ChangeNotifier { 'play_by_play': playByPlay, }).eq('id', gameId); + // 2. Preparar as Estatísticas dos Jogadores List> batchStats = []; playerStats.forEach((playerId, stats) { if (!playerId.startsWith("fake_")) { @@ -772,22 +773,47 @@ class PlacarController extends ChangeNotifier { 'p2a': stats['p2a'], 'p3m': stats['p3m'], 'p3a': stats['p3a'], - 'so': stats['so'], // As Faltas Sofridas + 'so': stats['so'], 'il': stats['il'], 'li': stats['li'], 'pa': stats['pa'], - 'tres_seg': stats['tres_seg'], // Os 3 Segundos com o nome correto + 'tres_seg': stats['tres_seg'], 'dr': stats['dr'], 'minutos_jogados': stats['sec'], }); } }); + // 3. Preparar os Locais dos Lançamentos (MAPA DE CALOR) - O QUE FALTAVA + List> batchShots = []; + for (var shot in matchShots) { + if (!shot.playerId.startsWith("fake_")) { + batchShots.add({ + 'game_id': gameId, + 'member_id': shot.playerId, + 'player_name': shot.playerName, + 'relative_x': shot.relativeX, + 'relative_y': shot.relativeY, + 'is_make': shot.isMake, + 'zone': shot.zone, + 'points': shot.points, + }); + } + } + + // Guardar na BD: Apaga as antigas e insere as atualizadas await supabase.from('player_stats').delete().eq('game_id', gameId); if (batchStats.isNotEmpty) { await supabase.from('player_stats').insert(batchStats); } + // Guardar mapa de calor na BD + await supabase.from('shot_locations').delete().eq('game_id', gameId); + if (batchShots.isNotEmpty) { + await supabase.from('shot_locations').insert(batchShots); + } + + // Limpar backup local final prefs = await SharedPreferences.getInstance(); await prefs.remove('backup_$gameId'); diff --git a/lib/pages/pdf_export_service.dart b/lib/pages/pdf_export_service.dart index 6230705..32bae38 100644 --- a/lib/pages/pdf_export_service.dart +++ b/lib/pages/pdf_export_service.dart @@ -1,10 +1,22 @@ -import 'dart:typed_data'; +import 'dart:math' as math; import 'package:pdf/pdf.dart'; import 'package:pdf/widgets.dart' as pw; import 'package:printing/printing.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; +// Modelo local para os tiros +class _ShotDot { + final double relX; + final double relY; + final bool isMake; + _ShotDot({required this.relX, required this.relY, required this.isMake}); +} + class PdfExportService { + // ════════════════════════════════════════════════════════════════════════════ + // ENTRY POINT + // ════════════════════════════════════════════════════════════════════════════ + static Future generateAndPrintBoxScore({ required String gameId, required String myTeam, @@ -15,83 +27,155 @@ class PdfExportService { }) async { final supabase = Supabase.instance.client; - final gameData = await supabase.from('games').select().eq('id', gameId).single(); + // ── Jogo ──────────────────────────────────────────────────────────────── + final gameData = + await supabase.from('games').select().eq('id', gameId).single(); - final teamsData = await supabase.from('teams').select('id, name').inFilter('name', [myTeam, opponentTeam]); - String? myTeamId, oppTeamId; + // ── Equipas ───────────────────────────────────────────────────────────── + final teamsData = await supabase + .from('teams') + .select('id, name') + .inFilter('name', [myTeam, opponentTeam]); + + String? myTeamId; for (var t in teamsData) { if (t['name'] == myTeam) myTeamId = t['id'].toString(); - if (t['name'] == opponentTeam) oppTeamId = t['id'].toString(); } - List myPlayers = myTeamId != null ? await supabase.from('members').select().eq('team_id', myTeamId).eq('type', 'Jogador') : []; - List oppPlayers = oppTeamId != null ? await supabase.from('members').select().eq('team_id', oppTeamId).eq('type', 'Jogador') : []; + // ── Jogadores (Apenas a minha equipa) ─────────────────────────────────── + List myPlayers = myTeamId != null + ? await supabase + .from('members') + .select() + .eq('team_id', myTeamId) + .eq('type', 'Jogador') + : []; - final statsData = await supabase.from('player_stats').select().eq('game_id', gameId); + // ── Estatísticas ───────────────────────────────────────────────────────── + final statsData = + await supabase.from('player_stats').select().eq('game_id', gameId); Map> statsMap = {}; for (var s in statsData) { statsMap[s['member_id'].toString()] = s; } - List> myTeamTable = _buildTeamTableData(myPlayers, statsMap); - List> oppTeamTable = _buildTeamTableData(oppPlayers, statsMap); + // ── Tiros (para o mapa de calor da minha equipa) ────────────────────── + final shotsData = await supabase + .from('shot_locations') + .select() + .eq('game_id', gameId); + // IDs da minha equipa + final Set myPlayerIds = + myPlayers.map((p) => p['id'].toString()).toSet(); + + // Separa os tiros: todos da minha equipa, depois por jogador + final List<_ShotDot> myTeamShots = []; + final Map> shotsByPlayer = {}; + + for (var shot in shotsData) { + final memberId = shot['member_id'].toString(); + if (!myPlayerIds.contains(memberId)) continue; + + final dot = _ShotDot( + relX: double.tryParse(shot['relative_x'].toString()) ?? 0.5, + relY: double.tryParse(shot['relative_y'].toString()) ?? 0.5, + isMake: shot['is_make'] == true, + ); + myTeamShots.add(dot); + shotsByPlayer.putIfAbsent(memberId, () => []).add(dot); + } + + // ── Tabela de estatísticas (Apenas a minha equipa) ──────────────────── + List> myTeamTable = + _buildTeamTableData(myPlayers, statsMap); + + // ════════════════════════════════════════════════════════════════════════ + // CONSTRUÇÃO DO PDF + // ════════════════════════════════════════════════════════════════════════ final pdf = pw.Document(); -pdf.addPage( - pw.Page( // 1. Trocado de MultiPage para Page + // ── PÁGINA 1: Box Score ────────────────────────────────────────────── + pdf.addPage( + pw.Page( pageFormat: PdfPageFormat.a4.landscape, - margin: const pw.EdgeInsets.all(16), // Margens ligeiramente reduzidas para aproveitar o espaço + margin: const pw.EdgeInsets.all(14), build: (pw.Context context) { - // 2. Envolvemos tudo num FittedBox return pw.FittedBox( - fit: pw.BoxFit.scaleDown, // Reduz o tamanho apenas se não couber na página + fit: pw.BoxFit.scaleDown, child: pw.Container( - // Fixamos a largura do contentor à largura útil da página - width: PdfPageFormat.a4.landscape.availableWidth, - // 3. Colocamos todos os elementos dentro de uma Column + width: PdfPageFormat.a4.landscape.availableWidth, child: pw.Column( crossAxisAlignment: pw.CrossAxisAlignment.start, children: [ pw.Row( mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, children: [ - pw.Text('Relatório Estatístico', style: pw.TextStyle(fontSize: 24, fontWeight: pw.FontWeight.bold)), + pw.Text('Relatório Estatístico', + style: pw.TextStyle( + fontSize: 22, + fontWeight: pw.FontWeight.bold)), pw.Column( crossAxisAlignment: pw.CrossAxisAlignment.end, children: [ - pw.Text('$myTeam vs $opponentTeam', style: pw.TextStyle(fontSize: 16, fontWeight: pw.FontWeight.bold)), - pw.Text('Resultado: $myScore - $opponentScore', style: const pw.TextStyle(fontSize: 14)), - pw.Text('Época: $season', style: const pw.TextStyle(fontSize: 12)), - ] - ) - ] + pw.Text('$myTeam vs $opponentTeam', + style: pw.TextStyle( + fontSize: 15, + fontWeight: pw.FontWeight.bold)), + pw.Text('Resultado: $myScore — $opponentScore', + style: const pw.TextStyle(fontSize: 13)), + pw.Text('Época: $season', + style: const pw.TextStyle(fontSize: 11)), + ], + ), + ], ), - pw.SizedBox(height: 15), // Espaçamentos verticais um pouco mais otimizados + pw.SizedBox(height: 12), - pw.Text('Equipa: $myTeam', style: pw.TextStyle(fontSize: 14, fontWeight: pw.FontWeight.bold, color: const PdfColor.fromInt(0xFFA00000))), - pw.SizedBox(height: 4), - _buildPdfTable(myTeamTable, const PdfColor.fromInt(0xFFA00000)), - - pw.SizedBox(height: 15), + pw.Text('Equipa: $myTeam', + style: pw.TextStyle( + fontSize: 14, + fontWeight: pw.FontWeight.bold, + color: const PdfColor.fromInt(0xFFA00000))), + pw.SizedBox(height: 8), - pw.Text('Equipa: $opponentTeam', style: pw.TextStyle(fontSize: 14, fontWeight: pw.FontWeight.bold, color: PdfColors.grey700)), - pw.SizedBox(height: 4), - _buildPdfTable(oppTeamTable, PdfColors.grey700), + pw.Text('Pontos e Lançamentos', + style: pw.TextStyle( + fontSize: 10, + fontWeight: pw.FontWeight.bold, + color: PdfColors.grey700)), + pw.SizedBox(height: 2), + _buildPdfTablePart1( + myTeamTable, const PdfColor.fromInt(0xFFA00000)), - pw.SizedBox(height: 15), + pw.SizedBox(height: 14), + + pw.Text('Outras Estatísticas (Ressaltos, Faltas, Turnovers, etc.)', + style: pw.TextStyle( + fontSize: 10, + fontWeight: pw.FontWeight.bold, + color: PdfColors.grey700)), + pw.SizedBox(height: 2), + _buildPdfTablePart2( + myTeamTable, const PdfColor.fromInt(0xFFA00000)), + + pw.SizedBox(height: 16), pw.Row( crossAxisAlignment: pw.CrossAxisAlignment.start, children: [ - _buildSummaryBox('Melhor Marcador', gameData['top_pts_name'] ?? '---'), + _buildSummaryBox('Melhor Marcador', + gameData['top_pts_name'] ?? '---'), pw.SizedBox(width: 10), - _buildSummaryBox('Melhor Ressaltador', gameData['top_rbs_name'] ?? '---'), + _buildSummaryBox('Melhor Ressaltador', + gameData['top_rbs_name'] ?? '---'), pw.SizedBox(width: 10), - _buildSummaryBox('Melhor Passador', gameData['top_ast_name'] ?? '---'), + _buildSummaryBox('Melhor Passador', + gameData['top_ast_name'] ?? '---'), pw.SizedBox(width: 10), - _buildSummaryBox('MVP', gameData['mvp_name'] ?? '---'), - ] + _buildSummaryBox( + 'MVP', gameData['mvp_name'] ?? '---'), + ], ), ], ), @@ -101,17 +185,294 @@ pdf.addPage( ), ); + // ── PÁGINA 2: Mapa de Calor — Equipa completa ──────────────────────── + if (myTeamShots.isNotEmpty) { + pdf.addPage( + pw.Page( + pageFormat: PdfPageFormat.a4.landscape, + margin: const pw.EdgeInsets.all(20), + build: (pw.Context context) { + return pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + _heatmapPageHeader('MAPA DE CALOR — $myTeam (Equipa Completa)', + const PdfColor.fromInt(0xFFA00000)), + pw.SizedBox(height: 12), + pw.Expanded( + child: pw.Center( + child: pw.CustomPaint( + size: const PdfPoint(360, 360), + painter: (canvas, size) => + _paintCourt(canvas, size, myTeamShots), + ), + ), + ), + pw.SizedBox(height: 10), + _heatmapLegend(), + ], + ); + }, + ), + ); + } + + // ── PÁGINAS 3+: Mapa de Calor por Jogador (4 por folha) ────────────── + // 👇 FILTRO ATIVO: Só entra aqui quem tiver tiros na lista "shotsByPlayer"! + final activePlayers = myPlayers.where((p) { + final pid = p['id'].toString(); + return shotsByPlayer[pid] != null && shotsByPlayer[pid]!.isNotEmpty; + }).toList(); + + for (int i = 0; i < activePlayers.length; i += 4) { + final chunk = activePlayers.sublist(i, math.min(i + 4, activePlayers.length)); + + pdf.addPage( + pw.Page( + pageFormat: PdfPageFormat.a4.landscape, + margin: const pw.EdgeInsets.all(20), + build: (pw.Context context) { + return pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + _heatmapPageHeader('MAPAS DE CALOR INDIVIDUAIS', const PdfColor.fromInt(0xFFA00000)), + pw.SizedBox(height: 12), + pw.Expanded( + child: pw.Column( + children: [ + if (chunk.isNotEmpty) + pw.Expanded( + child: pw.Row( + mainAxisAlignment: pw.MainAxisAlignment.center, + children: [ + _buildPlayerHeatmap(chunk[0], shotsByPlayer[chunk[0]['id'].toString()]!, statsMap[chunk[0]['id'].toString()] ?? {}), + pw.SizedBox(width: 40), + chunk.length > 1 ? _buildPlayerHeatmap(chunk[1], shotsByPlayer[chunk[1]['id'].toString()]!, statsMap[chunk[1]['id'].toString()] ?? {}) : pw.Container(), + ], + ), + ), + pw.SizedBox(height: 12), + if (chunk.length > 2) + pw.Expanded( + child: pw.Row( + mainAxisAlignment: pw.MainAxisAlignment.center, + children: [ + _buildPlayerHeatmap(chunk[2], shotsByPlayer[chunk[2]['id'].toString()]!, statsMap[chunk[2]['id'].toString()] ?? {}), + pw.SizedBox(width: 40), + chunk.length > 3 ? _buildPlayerHeatmap(chunk[3], shotsByPlayer[chunk[3]['id'].toString()]!, statsMap[chunk[3]['id'].toString()] ?? {}) : pw.Container(), + ], + ), + ), + ], + ), + ), + pw.SizedBox(height: 10), + _heatmapLegend(), + ], + ); + }, + ), + ); + } + await Printing.layoutPdf( onLayout: (PdfPageFormat format) async => pdf.save(), - name: 'BoxScore_${myTeam}_vs_${opponentTeam}.pdf', + name: 'BoxScore_$myTeam.pdf', ); } - static List> _buildTeamTableData(List players, Map> statsMap) { + // ════════════════════════════════════════════════════════════════════════════ + // WIDGET DO MAPA DE CALOR INDIVIDUAL + // ════════════════════════════════════════════════════════════════════════════ + static pw.Widget _buildPlayerHeatmap(dynamic player, List<_ShotDot> shots, Map stats) { + final String playerName = player['name']?.toString() ?? 'Jogador'; + final String playerNumber = player['number']?.toString() ?? '0'; + + final int pts = stats['pts'] ?? 0; + final int fgm = stats['fgm'] ?? 0; + final int fga = stats['fga'] ?? 0; + final String fgPct = fga > 0 ? '${((fgm / fga) * 100).toStringAsFixed(0)}%' : '0%'; + + return pw.Container( + width: 250, + padding: const pw.EdgeInsets.all(8), + decoration: pw.BoxDecoration( + color: PdfColors.white, + border: pw.Border.all(color: PdfColors.grey300), + borderRadius: const pw.BorderRadius.all(pw.Radius.circular(6)), + ), + child: pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.center, + children: [ + pw.Text('#$playerNumber $playerName', style: pw.TextStyle(fontWeight: pw.FontWeight.bold, fontSize: 11, color: const PdfColor.fromInt(0xFFA00000))), + pw.SizedBox(height: 4), + pw.Text('PTS: $pts | FG: $fgm/$fga ($fgPct)', style: const pw.TextStyle(fontSize: 9, color: PdfColors.grey700)), + pw.SizedBox(height: 8), + pw.Expanded( + child: pw.Center( + child: pw.CustomPaint( + size: const PdfPoint(180, 180), + painter: (canvas, size) => _paintCourt(canvas, size, shots), + ) + ) + ) + ] + ) + ); + } + + // ════════════════════════════════════════════════════════════════════════════ + // CORREÇÃO: DESENHO DO CAMPO E LINHAS ADAPTADAS + // ════════════════════════════════════════════════════════════════════════════ + + static void _paintCourt(PdfGraphics canvas, PdfPoint size, List<_ShotDot> shots) { + final double w = size.x; + final double h = size.y; + final double basketX = w / 2; + + // Fundo Amarelo (Toda a área) + canvas + ..setFillColor(const PdfColor.fromInt(0xFFDFAB00)) + ..drawRect(0, 0, w, h) + ..fillPath(); + + final double margin = w * 0.10; + final double length = h * 0.35; + final double larguraDoArco = (w / 2) - margin; + final double alturaDoArco = larguraDoArco * 0.30; + final double totalArcoHeight = alturaDoArco * 4; + + // ── 1. LINHAS BRANCAS ─────────────────────────────────────────────── + canvas.setStrokeColor(PdfColors.white); + canvas.setLineWidth(2.0); + + _drawLine(canvas, h, margin, 0, margin, length); + _drawLine(canvas, h, w - margin, 0, w - margin, length); + _drawLine(canvas, h, 0, length, margin, length); + _drawLine(canvas, h, w - margin, length, w, length); + + // Arco 3pts + _drawEllipseArc(canvas, h, basketX, length, larguraDoArco, totalArcoHeight / 2, 0, math.pi); + + double sXL = basketX + (larguraDoArco * math.cos(math.pi * 0.75)); + double sYL = length + ((totalArcoHeight / 2) * math.sin(math.pi * 0.75)); + double sXR = basketX + (larguraDoArco * math.cos(math.pi * 0.25)); + double sYR = length + ((totalArcoHeight / 2) * math.sin(math.pi * 0.25)); + + _drawLine(canvas, h, sXL, sYL, 0, h * 0.85); + _drawLine(canvas, h, sXR, sYR, w, h * 0.85); + + // ── 2. LINHAS PRETAS ───────────────────────────────────────────────── + canvas.setStrokeColor(PdfColors.black); + canvas.setLineWidth(1.5); + + final double pW = w * 0.28; + final double pH = h * 0.38; + + // Garrafão + _drawRect(canvas, h, basketX - pW / 2, 0, pW, pH); + + // Círculo Lances Livres + final double ftR = pW / 2; + _drawEllipseArc(canvas, h, basketX, pH, ftR, ftR, 0, math.pi); + // Tracejado + for (int i = 0; i < 10; i++) { + _drawEllipseArc(canvas, h, basketX, pH, ftR, ftR, math.pi + (i * 2 * (math.pi / 20)), math.pi / 20); + } + + // Linhas oblíquas do garrafão + _drawLine(canvas, h, basketX - pW / 2, pH, sXL, sYL); + _drawLine(canvas, h, basketX + pW / 2, pH, sXR, sYR); + + // Meio Campo + _drawEllipseArc(canvas, h, basketX, h, w * 0.12, w * 0.12, math.pi, math.pi); + + // Cesto e Tabela + _drawCircle(canvas, h, basketX, h * 0.12, w * 0.02); + _drawLine(canvas, h, basketX - w * 0.08, h * 0.12 - 5, basketX + w * 0.08, h * 0.12 - 5); + + // ── 3. TIROS ───────────────────────────────────────────────────────── + for (final shot in shots) { + final double px = shot.relX * w; + final double py = shot.relY * h; + + final PdfColor dotColor = shot.isMake ? PdfColors.green600 : PdfColors.red600; + + // Desenha Círculo Colorido + _fillCircle(canvas, h, px, py, 6, dotColor); + + // Símbolos + canvas.setStrokeColor(PdfColors.white); + canvas.setLineWidth(1.2); + if (shot.isMake) { + _drawLine(canvas, h, px - 3, py + 1.5, px - 0.5, py - 3); + _drawLine(canvas, h, px - 0.5, py - 3, px + 4.0, py + 3.5); + } else { + _drawLine(canvas, h, px - 3, py - 3, px + 3, py + 3); + _drawLine(canvas, h, px + 3, py - 3, px - 3, py + 3); + } + } + } + + // ── Helpers com inversão automática do Eixo Y para casar com Flutter ── + static void _drawLine(PdfGraphics c, double canvasH, double x1, double y1, double x2, double y2) { + c.moveTo(x1, canvasH - y1); + c.lineTo(x2, canvasH - y2); + c.strokePath(); + } + + static void _lineRaw(PdfGraphics c, double x1, double y1, double x2, double y2) { + c.moveTo(x1, y1); + c.lineTo(x2, y2); + c.strokePath(); + } + + static void _drawRect(PdfGraphics c, double canvasH, double x, double y, double width, double height) { + c.drawRect(x, canvasH - (y + height), width, height); + c.strokePath(); + } + + static void _drawCircle(PdfGraphics c, double canvasH, double cx, double cy, double r) { + c.drawEllipse(cx, canvasH - cy, r, r); + c.strokePath(); + } + + static void _fillCircle(PdfGraphics c, double canvasH, double cx, double cy, double r, PdfColor color) { + c.setFillColor(color); + c.drawEllipse(cx, canvasH - cy, r, r); + c.fillPath(); + } + + static void _drawEllipseArc(PdfGraphics c, double canvasH, double cx, double cy, double rx, double ry, double startAngle, double sweepAngle) { + const int steps = 30; + final double step = sweepAngle / steps; + double angle = startAngle; + + double fx = cx + rx * math.cos(angle); + double fy = cy + ry * math.sin(angle); + c.moveTo(fx, canvasH - fy); + + for (int i = 1; i <= steps; i++) { + angle += step; + fx = cx + rx * math.cos(angle); + fy = cy + ry * math.sin(angle); + c.lineTo(fx, canvasH - fy); + } + c.strokePath(); + } + + // ════════════════════════════════════════════════════════════════════════════ + // TABELAS DE ESTATÍSTICAS + // ════════════════════════════════════════════════════════════════════════════ + + static List> _buildTeamTableData( + List players, Map> statsMap) { List> tableData = []; + int tPts = 0, tFgm = 0, tFga = 0, tFtm = 0, tFta = 0, tFls = 0; - int tOrb = 0, tDrb = 0, tTr = 0, tStl = 0, tAst = 0, tTov = 0, tBlk = 0; + int tOrb = 0, tDrb = 0, tStl = 0, tAst = 0, tTov = 0, tBlk = 0; int tP3m = 0, tP2m = 0, tP3a = 0, tP2a = 0; + int tSo = 0, tIl = 0, tLi = 0, tPa = 0, tTresS = 0, tDr = 0; + int tSec = 0; players.sort((a, b) { int numA = int.tryParse(a['number']?.toString() ?? '0') ?? 0; @@ -121,151 +482,140 @@ pdf.addPage( for (var p in players) { String id = p['id'].toString(); - String name = p['name'] ?? 'Desconhecido'; - String number = p['number']?.toString() ?? '-'; + var s = statsMap[id] ?? {}; - var stat = statsMap[id] ?? {}; - - int pts = stat['pts'] ?? 0; - int fgm = stat['fgm'] ?? 0; - int fga = stat['fga'] ?? 0; - int ftm = stat['ftm'] ?? 0; - int fta = stat['fta'] ?? 0; - int p2m = stat['p2m'] ?? 0; - int p2a = stat['p2a'] ?? 0; - int p3m = stat['p3m'] ?? 0; - int p3a = stat['p3a'] ?? 0; - int fls = stat['fls'] ?? 0; - int orb = stat['orb'] ?? 0; - int drb = stat['drb'] ?? 0; - int tr = orb + drb; - int stl = stat['stl'] ?? 0; - int ast = stat['ast'] ?? 0; - int tov = stat['tov'] ?? 0; - int blk = stat['blk'] ?? 0; + int pts = s['pts'] ?? 0; + int fgm = s['fgm'] ?? 0; + int fga = s['fga'] ?? 0; + int ftm = s['ftm'] ?? 0; + int fta = s['fta'] ?? 0; + int p2m = s['p2m'] ?? 0; + int p2a = s['p2a'] ?? 0; + int p3m = s['p3m'] ?? 0; + int p3a = s['p3a'] ?? 0; + int fls = s['fls'] ?? 0; + int orb = s['orb'] ?? 0; + int drb = s['drb'] ?? 0; + int stl = s['stl'] ?? 0; + int ast = s['ast'] ?? 0; + int tov = s['tov'] ?? 0; + int blk = s['blk'] ?? 0; + int so = s['so'] ?? 0; + int il = s['il'] ?? 0; + int li = s['li'] ?? 0; + int pa = s['pa'] ?? 0; + int tresS = s['tres_seg'] ?? 0; + int dr = s['dr'] ?? 0; + int sec = s['minutos_jogados'] ?? 0; tPts += pts; tFgm += fgm; tFga += fga; tFtm += ftm; tFta += fta; - tFls += fls; tOrb += orb; tDrb += drb; tTr += tr; tStl += stl; + tFls += fls; tOrb += orb; tDrb += drb; tStl += stl; tAst += ast; tTov += tov; tBlk += blk; tP3m += p3m; tP2m += p2m; tP3a += p3a; tP2a += p2a; + tSo += so; tIl += il; tLi += li; tPa += pa; + tTresS += tresS; tDr += dr; tSec += sec; - String p2Pct = p2a > 0 ? ((p2m / p2a) * 100).toStringAsFixed(0) + '%' : '0%'; - String p3Pct = p3a > 0 ? ((p3m / p3a) * 100).toStringAsFixed(0) + '%' : '0%'; - String globalPct = fga > 0 ? ((fgm / fga) * 100).toStringAsFixed(0) + '%' : '0%'; - String llPct = fta > 0 ? ((ftm / fta) * 100).toStringAsFixed(0) + '%' : '0%'; + String p2Pct = p2a > 0 ? '${((p2m / p2a) * 100).toStringAsFixed(0)}%' : '0%'; + String p3Pct = p3a > 0 ? '${((p3m / p3a) * 100).toStringAsFixed(0)}%' : '0%'; + String fgPct = fga > 0 ? '${((fgm / fga) * 100).toStringAsFixed(0)}%' : '0%'; + String ftPct = fta > 0 ? '${((ftm / fta) * 100).toStringAsFixed(0)}%' : '0%'; + + String minStr = _secToMin(sec); tableData.add([ - number, name, pts.toString(), + p['number']?.toString() ?? '-', + p['name']?.toString() ?? '?', + minStr, + pts.toString(), p2m.toString(), p2a.toString(), p2Pct, p3m.toString(), p3a.toString(), p3Pct, - fgm.toString(), fga.toString(), globalPct, - ftm.toString(), fta.toString(), llPct, - fls.toString(), orb.toString(), drb.toString(), tr.toString(), - stl.toString(), ast.toString(), tov.toString(), blk.toString() + fgm.toString(), fga.toString(), fgPct, + ftm.toString(), fta.toString(), ftPct, + orb.toString(), drb.toString(), (orb + drb).toString(), + stl.toString(), ast.toString(), tov.toString(), blk.toString(), fls.toString(), + so.toString(), il.toString(), li.toString(), + pa.toString(), tresS.toString(), dr.toString(), ]); } if (tableData.isEmpty) { - tableData.add([ - '-', 'Sem jogadores registados', '0', - '0', '0', '0%', - '0', '0', '0%', - '0', '0', '0%', - '0', '0', '0%', - '0', '0', '0', '0', '0', '0', '0', '0' - ]); + tableData.add(List.filled(30, '0')..[0] = '-'..[1] = 'Sem jogadores'); } - String tP2Pct = tP2a > 0 ? ((tP2m / tP2a) * 100).toStringAsFixed(0) + '%' : '0%'; - String tP3Pct = tP3a > 0 ? ((tP3m / tP3a) * 100).toStringAsFixed(0) + '%' : '0%'; - String tGlobalPct = tFga > 0 ? ((tFgm / tFga) * 100).toStringAsFixed(0) + '%' : '0%'; - String tLlPct = tFta > 0 ? ((tFtm / tFta) * 100).toStringAsFixed(0) + '%' : '0%'; + String tP2Pct = tP2a > 0 ? '${((tP2m / tP2a) * 100).toStringAsFixed(0)}%' : '0%'; + String tP3Pct = tP3a > 0 ? '${((tP3m / tP3a) * 100).toStringAsFixed(0)}%' : '0%'; + String tFgPct = tFga > 0 ? '${((tFgm / tFga) * 100).toStringAsFixed(0)}%' : '0%'; + String tFtPct = tFta > 0 ? '${((tFtm / tFta) * 100).toStringAsFixed(0)}%' : '0%'; tableData.add([ - '', 'TOTAIS', tPts.toString(), - tP2m.toString(), tP2a.toString(), tP2Pct, - tP3m.toString(), tP3a.toString(), tP3Pct, - tFgm.toString(), tFga.toString(), tGlobalPct, - tFtm.toString(), tFta.toString(), tLlPct, - tFls.toString(), tOrb.toString(), tDrb.toString(), tTr.toString(), - tStl.toString(), tAst.toString(), tTov.toString(), tBlk.toString() + '', 'TOTAIS', _secToMin(tSec), tPts.toString(), + tP2m.toString(), tP2a.toString(), tP2Pct, + tP3m.toString(), tP3a.toString(), tP3Pct, + tFgm.toString(), tFga.toString(), tFgPct, + tFtm.toString(), tFta.toString(), tFtPct, + tOrb.toString(), tDrb.toString(), (tOrb + tDrb).toString(), + tStl.toString(), tAst.toString(), tTov.toString(), tBlk.toString(), tFls.toString(), + tSo.toString(), tIl.toString(), tLi.toString(), + tPa.toString(), tTresS.toString(), tDr.toString(), ]); return tableData; } - static pw.Widget _buildPdfTable(List> data, PdfColor headerColor) { - final headerStyle = pw.TextStyle(color: PdfColors.white, fontWeight: pw.FontWeight.bold, fontSize: 8); - final subHeaderStyle = pw.TextStyle(color: PdfColors.white, fontWeight: pw.FontWeight.bold, fontSize: 7); - final cellStyle = const pw.TextStyle(fontSize: 8); + static String _secToMin(int sec) { + final m = sec ~/ 60; + final s = sec % 60; + return '${m.toString().padLeft(2, '0')}:${s.toString().padLeft(2, '0')}'; + } + + static pw.Widget _buildPdfTablePart1(List> data, PdfColor headerColor) { + final hBold = pw.TextStyle(color: PdfColors.white, fontWeight: pw.FontWeight.bold, fontSize: 9); + final hSub = pw.TextStyle(color: PdfColors.white, fontWeight: pw.FontWeight.bold, fontSize: 8); + final cell = const pw.TextStyle(fontSize: 9); + final cellBold = pw.TextStyle(fontSize: 9, fontWeight: pw.FontWeight.bold); - // Agora usamos apenas 15 colunas principais na tabela. - // Os grupos (2P, 3P, etc.) são subdivididos INTERNAMENTE para evitar erros de colSpan. return pw.Table( border: pw.TableBorder.all(color: PdfColors.grey400, width: 0.5), - columnWidths: { - 0: const pw.FlexColumnWidth(1.2), // Nº - 1: const pw.FlexColumnWidth(5.0), // NOME (Maior para caber nomes como S.Gilgeous-alexander) - 2: const pw.FlexColumnWidth(1.5), // PT - 3: const pw.FlexColumnWidth(4.5), // 2 PONTOS (Grupo de 3) - 4: const pw.FlexColumnWidth(4.5), // 3 PONTOS (Grupo de 3) - 5: const pw.FlexColumnWidth(4.5), // GLOBAL (Grupo de 3) - 6: const pw.FlexColumnWidth(4.5), // L. LIVRES (Grupo de 3) - 7: const pw.FlexColumnWidth(1.5), // FLS - 8: const pw.FlexColumnWidth(1.5), // RO - 9: const pw.FlexColumnWidth(1.5), // RD - 10: const pw.FlexColumnWidth(1.5), // TR - 11: const pw.FlexColumnWidth(1.5), // BR - 12: const pw.FlexColumnWidth(1.5), // AS - 13: const pw.FlexColumnWidth(1.5), // BP - 14: const pw.FlexColumnWidth(1.5), // BLK + columnWidths: { + 0: const pw.FlexColumnWidth(1.2), + 1: const pw.FlexColumnWidth(4.5), + 2: const pw.FlexColumnWidth(2.0), + 3: const pw.FlexColumnWidth(1.5), + 4: const pw.FlexColumnWidth(4.5), + 5: const pw.FlexColumnWidth(4.5), + 6: const pw.FlexColumnWidth(4.5), + 7: const pw.FlexColumnWidth(4.5), }, children: [ - // --- LINHA 1: CABEÇALHOS --- pw.TableRow( decoration: pw.BoxDecoration(color: headerColor), children: [ - _simpleHeader('Nº', subHeaderStyle), - _simpleHeader('NOME', subHeaderStyle, align: pw.Alignment.centerLeft), - _simpleHeader('PT', subHeaderStyle), - _groupHeader('2 PONTOS', headerStyle, subHeaderStyle), - _groupHeader('3 PONTOS', headerStyle, subHeaderStyle), - _groupHeader('GLOBAL', headerStyle, subHeaderStyle), - _groupHeader('L. LIVRES', headerStyle, subHeaderStyle), - _simpleHeader('FLS', subHeaderStyle), - _simpleHeader('RO', subHeaderStyle), - _simpleHeader('RD', subHeaderStyle), - _simpleHeader('TR', subHeaderStyle), - _simpleHeader('BR', subHeaderStyle), - _simpleHeader('AS', subHeaderStyle), - _simpleHeader('BP', subHeaderStyle), - _simpleHeader('BLK', subHeaderStyle), + _sh('Nº', hSub), + _sh('NOME', hSub, left: true), + _sh('MIN', hSub), + _sh('PTS', hSub), + _groupHeader('2 PONTOS', hBold, hSub), + _groupHeader('3 PONTOS', hBold, hSub), + _groupHeader('GLOBAL', hBold, hSub), + _groupHeader('L. LIVRES', hBold, hSub), ], ), - // --- LINHAS 2+: DADOS --- ...data.map((row) { bool isTotais = row[1] == 'TOTAIS'; - var rowStyle = isTotais ? pw.TextStyle(fontSize: 8, fontWeight: pw.FontWeight.bold) : cellStyle; + var s = isTotais ? cellBold : cell; + PdfColor? bg = isTotais ? PdfColors.grey200 : null; + return pw.TableRow( - decoration: pw.BoxDecoration( - color: isTotais ? PdfColors.grey200 : PdfColors.white, - ), + decoration: pw.BoxDecoration(color: bg), children: [ - _simpleData(row[0], rowStyle), - _simpleData(row[1], rowStyle, align: pw.Alignment.centerLeft), - _simpleData(row[2], rowStyle), - _groupData(row[3], row[4], row[5], rowStyle), // 2P: C, T, % - _groupData(row[6], row[7], row[8], rowStyle), // 3P: C, T, % - _groupData(row[9], row[10], row[11], rowStyle), // GLOBAL: C, T, % - _groupData(row[12], row[13], row[14], rowStyle), // L. LIVRES: C, T, % - _simpleData(row[15], rowStyle), - _simpleData(row[16], rowStyle), - _simpleData(row[17], rowStyle), - _simpleData(row[18], rowStyle), - _simpleData(row[19], rowStyle), - _simpleData(row[20], rowStyle), - _simpleData(row[21], rowStyle), - _simpleData(row[22], rowStyle), + _sd(row[0], s), + _sd(row[1], s, left: true), + _sd(row[2], s), + _sd(row[3], s), + _groupData(row[4], row[5], row[6], s), + _groupData(row[7], row[8], row[9], s), + _groupData(row[10], row[11], row[12], s), + _groupData(row[13], row[14], row[15], s), ], ); }), @@ -273,102 +623,224 @@ pdf.addPage( ); } - // ==== WIDGETS AUXILIARES PARA RESOLVER A ESTRUTURA DO PDF ==== + static pw.Widget _buildPdfTablePart2(List> data, PdfColor headerColor) { + final hSub = pw.TextStyle(color: PdfColors.white, fontWeight: pw.FontWeight.bold, fontSize: 8); + final cell = const pw.TextStyle(fontSize: 9); + final cellBold = pw.TextStyle(fontSize: 9, fontWeight: pw.FontWeight.bold); - // Cabeçalho simples (Colunas que não se dividem) - static pw.Widget _simpleHeader(String text, pw.TextStyle style, {pw.Alignment align = pw.Alignment.center}) { + return pw.Table( + border: pw.TableBorder.all(color: PdfColors.grey400, width: 0.5), + columnWidths: { + 0: const pw.FlexColumnWidth(1.2), + 1: const pw.FlexColumnWidth(4.5), + 2: const pw.FlexColumnWidth(1.5), + 3: const pw.FlexColumnWidth(1.5), + 4: const pw.FlexColumnWidth(1.5), + 5: const pw.FlexColumnWidth(1.5), + 6: const pw.FlexColumnWidth(1.5), + 7: const pw.FlexColumnWidth(1.5), + 8: const pw.FlexColumnWidth(1.5), + 9: const pw.FlexColumnWidth(1.5), + 10: const pw.FlexColumnWidth(1.5), + 11: const pw.FlexColumnWidth(1.5), + 12: const pw.FlexColumnWidth(1.5), + 13: const pw.FlexColumnWidth(1.5), + 14: const pw.FlexColumnWidth(1.5), + 15: const pw.FlexColumnWidth(1.5), + }, + children: [ + pw.TableRow( + decoration: pw.BoxDecoration(color: headerColor), + children: [ + _sh('Nº', hSub), + _sh('NOME', hSub, left: true), + _sh('RO', hSub), + _sh('RD', hSub), + _sh('TR', hSub), + _sh('BR', hSub), + _sh('AS', hSub), + _sh('BP', hSub), + _sh('BLK', hSub), + _sh('FLS', hSub), + _sh('SO', hSub), + _sh('IL', hSub), + _sh('LI', hSub), + _sh('PA', hSub), + _sh('3S', hSub), + _sh('DR', hSub), + ], + ), + ...data.map((row) { + bool isTotais = row[1] == 'TOTAIS'; + var s = isTotais ? cellBold : cell; + PdfColor? bg = isTotais ? PdfColors.grey200 : null; + + return pw.TableRow( + decoration: pw.BoxDecoration(color: bg), + children: [ + _sd(row[0], s), + _sd(row[1], s, left: true), + _sd(row[16], s), + _sd(row[17], s), + _sd(row[18], s), + _sd(row[19], s), + _sd(row[20], s), + _sd(row[21], s), + _sd(row[22], s), + _sd(row[23], s), + _sd(row[24], s), + _sd(row[25], s), + _sd(row[26], s), + _sd(row[27], s), + _sd(row[28], s), + _sd(row[29], s), + ], + ); + }), + ], + ); + } + + static pw.Widget _sh(String text, pw.TextStyle style, {bool left = false}) { return pw.Container( - alignment: align, - padding: const pw.EdgeInsets.symmetric(vertical: 2, horizontal: 2), + alignment: left ? pw.Alignment.centerLeft : pw.Alignment.center, + padding: const pw.EdgeInsets.symmetric(vertical: 4, horizontal: 4), child: pw.Text(text, style: style), ); } - // Dados simples - static pw.Widget _simpleData(String text, pw.TextStyle style, {pw.Alignment align = pw.Alignment.center}) { + static pw.Widget _sd(String text, pw.TextStyle style, {bool left = false}) { return pw.Container( - alignment: align, - padding: const pw.EdgeInsets.symmetric(vertical: 3, horizontal: 2), + alignment: left ? pw.Alignment.centerLeft : pw.Alignment.center, + padding: const pw.EdgeInsets.symmetric(vertical: 4, horizontal: 4), child: pw.Text(text, style: style), ); } - // Cria a divisão do Cabeçalho (O falso ColSpan que une "2 PONTOS" sobre "C | T | %") - static pw.Widget _groupHeader(String title, pw.TextStyle hStyle, pw.TextStyle sStyle) { - return pw.Column( - children: [ - pw.Container( - width: double.infinity, - alignment: pw.Alignment.center, - padding: const pw.EdgeInsets.symmetric(vertical: 2), - decoration: const pw.BoxDecoration( - border: pw.Border(bottom: pw.BorderSide(color: PdfColors.white, width: 0.5)), + static pw.Widget _groupHeader( + String title, pw.TextStyle hStyle, pw.TextStyle sStyle) { + return pw.Column( + children: [ + pw.Container( + width: double.infinity, + alignment: pw.Alignment.center, + padding: const pw.EdgeInsets.symmetric(vertical: 2), + decoration: const pw.BoxDecoration( + border: pw.Border( + bottom: pw.BorderSide(color: PdfColors.white, width: 0.5)), + ), + child: pw.Text(title, style: hStyle), ), - child: pw.Text(title, style: hStyle), - ), - pw.Row( - children: [ - pw.Expanded(child: pw.Container(alignment: pw.Alignment.center, child: pw.Text('C', style: sStyle))), - pw.Container(width: 0.5, height: 10, color: PdfColors.white), // Divisória vertical manual - pw.Expanded(child: pw.Container(alignment: pw.Alignment.center, child: pw.Text('T', style: sStyle))), - pw.Container(width: 0.5, height: 10, color: PdfColors.white), // Divisória vertical manual - pw.Expanded(child: pw.Container(alignment: pw.Alignment.center, child: pw.Text('%', style: sStyle))), - ], - ), - ], - ); -} + pw.Row(children: [ + pw.Expanded( + child: pw.Container( + alignment: pw.Alignment.center, + padding: const pw.EdgeInsets.symmetric(vertical: 2), + child: pw.Text('C', style: sStyle))), + pw.Container(width: 0.5, height: 10, color: PdfColors.white), + pw.Expanded( + child: pw.Container( + alignment: pw.Alignment.center, + padding: const pw.EdgeInsets.symmetric(vertical: 2), + child: pw.Text('T', style: sStyle))), + pw.Container(width: 0.5, height: 10, color: PdfColors.white), + pw.Expanded( + child: pw.Container( + alignment: pw.Alignment.center, + padding: const pw.EdgeInsets.symmetric(vertical: 2), + child: pw.Text('%', style: sStyle))), + ]), + ], + ); + } -static pw.Widget _groupData(String c, String t, String pct, pw.TextStyle style) { - return pw.Row( - children: [ + static pw.Widget _groupData( + String c, String t, String pct, pw.TextStyle style) { + return pw.Row(children: [ pw.Expanded( - child: pw.Container( - alignment: pw.Alignment.center, - padding: const pw.EdgeInsets.symmetric(vertical: 3), - child: pw.Text(c, style: style), - ), - ), - pw.Container(width: 0.5, height: 12, color: PdfColors.grey400), // Divisória cinza + child: pw.Container( + alignment: pw.Alignment.center, + padding: const pw.EdgeInsets.symmetric(vertical: 4), + child: pw.Text(c, style: style))), + pw.Container(width: 0.5, height: 12, color: PdfColors.grey400), pw.Expanded( - child: pw.Container( - alignment: pw.Alignment.center, - padding: const pw.EdgeInsets.symmetric(vertical: 3), - child: pw.Text(t, style: style), - ), - ), - pw.Container(width: 0.5, height: 12, color: PdfColors.grey400), // Divisória cinza + child: pw.Container( + alignment: pw.Alignment.center, + padding: const pw.EdgeInsets.symmetric(vertical: 4), + child: pw.Text(t, style: style))), + pw.Container(width: 0.5, height: 12, color: PdfColors.grey400), pw.Expanded( - child: pw.Container( - alignment: pw.Alignment.center, - padding: const pw.EdgeInsets.symmetric(vertical: 3), - child: pw.Text(pct, style: style), - ), + child: pw.Container( + alignment: pw.Alignment.center, + padding: const pw.EdgeInsets.symmetric(vertical: 4), + child: pw.Text(pct, style: style))), + ]); + } + + static pw.Widget _heatmapPageHeader(String title, PdfColor color) { + return pw.Container( + width: double.infinity, + padding: const pw.EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: pw.BoxDecoration( + color: color, + borderRadius: const pw.BorderRadius.all(pw.Radius.circular(6)), ), - ], - ); -} + child: pw.Text( + title, + style: pw.TextStyle( + color: PdfColors.white, + fontSize: 14, + fontWeight: pw.FontWeight.bold), + ), + ); + } + + static pw.Widget _heatmapLegend() { + return pw.Row( + mainAxisAlignment: pw.MainAxisAlignment.center, + children: [ + pw.Container(width: 12, height: 12, + decoration: const pw.BoxDecoration( + color: PdfColors.green600, shape: pw.BoxShape.circle)), + pw.SizedBox(width: 4), + pw.Text('Cesto marcado', style: pw.TextStyle(fontSize: 10)), + pw.SizedBox(width: 20), + pw.Container(width: 12, height: 12, + decoration: const pw.BoxDecoration( + color: PdfColors.red600, shape: pw.BoxShape.circle)), + pw.SizedBox(width: 4), + pw.Text('Cesto falhado', style: pw.TextStyle(fontSize: 10)), + ], + ); + } + static pw.Widget _buildSummaryBox(String title, String value) { return pw.Container( width: 120, decoration: pw.BoxDecoration( border: pw.TableBorder.all(color: PdfColors.black, width: 1), ), - child: pw.Column( - children: [ - pw.Container( - width: double.infinity, - padding: const pw.EdgeInsets.all(4), - color: const PdfColor.fromInt(0xFFA00000), - child: pw.Text(title, style: pw.TextStyle(color: PdfColors.white, fontSize: 9, fontWeight: pw.FontWeight.bold), textAlign: pw.TextAlign.center), - ), - pw.Container( - width: double.infinity, - padding: const pw.EdgeInsets.all(6), - child: pw.Text(value, style: pw.TextStyle(fontSize: 10, fontWeight: pw.FontWeight.bold), textAlign: pw.TextAlign.center), - ), - ] - ) + child: pw.Column(children: [ + pw.Container( + width: double.infinity, + padding: const pw.EdgeInsets.all(6), + color: const PdfColor.fromInt(0xFFA00000), + child: pw.Text(title, + style: pw.TextStyle( + color: PdfColors.white, + fontSize: 9, + fontWeight: pw.FontWeight.bold), + textAlign: pw.TextAlign.center), + ), + pw.Container( + width: double.infinity, + padding: const pw.EdgeInsets.all(8), + child: pw.Text(value, + style: pw.TextStyle( + fontSize: 10, fontWeight: pw.FontWeight.bold), + textAlign: pw.TextAlign.center), + ), + ]), ); } } \ No newline at end of file