diff --git a/lib/calibrador_page.dart b/lib/calibrador_page.dart new file mode 100644 index 0000000..f3ed3b1 --- /dev/null +++ b/lib/calibrador_page.dart @@ -0,0 +1,208 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'dart:math' as math; + +class CalibradorPage extends StatefulWidget { + const CalibradorPage({super.key}); + + @override + State createState() => _CalibradorPageState(); +} + +class _CalibradorPageState extends State { + // --- 👇 VALORES INICIAIS 👇 --- + double hoopBaseX = 0.08; + double arcRadius = 0.28; + double cornerY = 0.40; + // ----------------------------------------------------- + + @override + void initState() { + super.initState(); + SystemChrome.setPreferredOrientations([ + DeviceOrientation.landscapeRight, + DeviceOrientation.landscapeLeft, + ]); + } + + @override + void dispose() { + SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final double wScreen = MediaQuery.of(context).size.width; + final double hScreen = MediaQuery.of(context).size.height; + + // O MESMO CÁLCULO EXATO DO PLACAR + final double sf = math.min(wScreen / 1150, hScreen / 720); + + return Scaffold( + backgroundColor: const Color(0xFF266174), + body: SafeArea( + top: false, + bottom: false, + child: Stack( + children: [ + // 👇 1. O CAMPO COM AS MARGENS EXATAS DO PLACAR 👇 + Container( + margin: EdgeInsets.only(left: 65 * sf, right: 65 * sf, bottom: 55 * sf), + decoration: BoxDecoration( + border: Border.all(color: Colors.white, width: 2.5), + image: const DecorationImage( + image: AssetImage('assets/campo.png'), + fit: BoxFit.fill, + ), + ), + child: LayoutBuilder( + builder: (context, constraints) { + return CustomPaint( + painter: LinePainter( + hoopBaseX: hoopBaseX, + arcRadius: arcRadius, + cornerY: cornerY, + color: Colors.redAccent, + width: constraints.maxWidth, + height: constraints.maxHeight, + ), + ); + }, + ), + ), + + // 👇 2. TOPO: MOSTRADORES DE VALORES COM FITTEDBOX (Não transborda) 👇 + Positioned( + top: 0, left: 0, right: 0, + child: Container( + color: Colors.black87.withOpacity(0.8), + padding: EdgeInsets.symmetric(vertical: 5 * sf, horizontal: 15 * sf), + child: FittedBox( // Isto impede o ecrã de dar o erro dos 179 pixels! + fit: BoxFit.scaleDown, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _buildValueDisplay("Aro X", hoopBaseX, sf), + SizedBox(width: 20 * sf), + _buildValueDisplay("Raio", arcRadius, sf), + SizedBox(width: 20 * sf), + _buildValueDisplay("Canto", cornerY, sf), + SizedBox(width: 30 * sf), + ElevatedButton.icon( + onPressed: () => Navigator.pop(context), + icon: Icon(Icons.check, size: 18 * sf), + label: Text("FECHAR", style: TextStyle(fontSize: 14 * sf, fontWeight: FontWeight.bold)), + style: ElevatedButton.styleFrom(backgroundColor: Colors.green), + ) + ], + ), + ), + ), + ), + + // 👇 3. FUNDO: SLIDERS (Com altura fixa para não dar o erro "hasSize") 👇 + Positioned( + bottom: 0, left: 0, right: 0, + child: Container( + color: Colors.black87.withOpacity(0.8), + height: 80 * sf, // Altura segura para os sliders + child: Row( + children: [ + Expanded(child: _buildSlider("Pos. do Aro", hoopBaseX, 0.0, 0.25, (val) => setState(() => hoopBaseX = val), sf)), + Expanded(child: _buildSlider("Tam. da Curva", arcRadius, 0.1, 0.5, (val) => setState(() => arcRadius = val), sf)), + Expanded(child: _buildSlider("Pos. do Canto", cornerY, 0.2, 0.5, (val) => setState(() => cornerY = val), sf)), + ], + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildValueDisplay(String label, double value, double sf) { + return Row( + children: [ + Text("$label: ", style: TextStyle(color: Colors.white70, fontSize: 16 * sf)), + Text(value.toStringAsFixed(3), style: TextStyle(color: Colors.yellow, fontSize: 20 * sf, fontWeight: FontWeight.bold)), + ], + ); + } + + Widget _buildSlider(String label, double value, double min, double max, ValueChanged onChanged, double sf) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(label, style: TextStyle(color: Colors.white, fontSize: 12 * sf)), + SizedBox( + height: 40 * sf, // Altura exata para o Slider não crashar + child: Slider( + value: value, min: min, max: max, + activeColor: Colors.yellow, inactiveColor: Colors.white24, + onChanged: onChanged, + ), + ), + ], + ); + } +} + +// ============================================================== +// 📐 PINTOR: DESENHA A LINHA MATEMÁTICA NA TELA +// ============================================================== +class LinePainter extends CustomPainter { + final double hoopBaseX; + final double arcRadius; + final double cornerY; + final Color color; + final double width; + final double height; + + LinePainter({ + required this.hoopBaseX, required this.arcRadius, required this.cornerY, + required this.color, required this.width, required this.height, + }); + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = color + ..style = PaintingStyle.stroke + ..strokeWidth = 4; + + double aspectRatio = width / height; + double hoopY = 0.50 * height; + + // O cornerY controla a que distância do meio (50%) estão as linhas retas + double cornerDistY = cornerY * height; + + // --- CESTO ESQUERDO --- + double hoopLX = hoopBaseX * width; + + canvas.drawLine(Offset(0, hoopY - cornerDistY), Offset(width * 0.35, hoopY - cornerDistY), paint); // Cima + canvas.drawLine(Offset(0, hoopY + cornerDistY), Offset(width * 0.35, hoopY + cornerDistY), paint); // Baixo + + canvas.drawArc( + Rect.fromCenter(center: Offset(hoopLX, hoopY), width: arcRadius * width * 2 / aspectRatio, height: arcRadius * height * 2), + -math.pi / 2, math.pi, false, paint, + ); + + // --- CESTO DIREITO --- + double hoopRX = (1.0 - hoopBaseX) * width; + + canvas.drawLine(Offset(width, hoopY - cornerDistY), Offset(width * 0.65, hoopY - cornerDistY), paint); // Cima + canvas.drawLine(Offset(width, hoopY + cornerDistY), Offset(width * 0.65, hoopY + cornerDistY), paint); // Baixo + + canvas.drawArc( + Rect.fromCenter(center: Offset(hoopRX, hoopY), width: arcRadius * width * 2 / aspectRatio, height: arcRadius * height * 2), + math.pi / 2, math.pi, false, paint, + ); + } + + @override + bool shouldRepaint(covariant LinePainter oldDelegate) { + return oldDelegate.hoopBaseX != hoopBaseX || oldDelegate.arcRadius != arcRadius || oldDelegate.cornerY != cornerY; + } +} \ No newline at end of file diff --git a/lib/controllers/placar_controller.dart b/lib/controllers/placar_controller.dart index 0cc2aa4..f9e9afc 100644 --- a/lib/controllers/placar_controller.dart +++ b/lib/controllers/placar_controller.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; @@ -6,7 +7,7 @@ class ShotRecord { final double relativeX; final double relativeY; final bool isMake; - final String playerName; // Bónus: Agora guardamos quem foi o jogador! + final String playerName; ShotRecord({ required this.relativeX, @@ -32,7 +33,6 @@ class PlacarController { bool isLoading = true; bool isSaving = false; - // 👇 TRINCO DE SEGURANÇA: Evita contar vitórias duas vezes se clicares no Guardar repetidamente! bool gameWasAlreadyFinished = false; int myScore = 0; @@ -67,7 +67,12 @@ class PlacarController { Timer? timer; bool isRunning = false; - // --- 🔄 CARREGAMENTO COMPLETO (DADOS REAIS + ESTATÍSTICAS SALVAS) --- + // 👇 VARIÁVEIS DE CALIBRAÇÃO DO CAMPO (OS TEUS NÚMEROS!) 👇 + bool isCalibrating = false; + double hoopBaseX = 0.088; + double arcRadius = 0.459; + double cornerY = 0.440; + Future loadPlayers() async { final supabase = Supabase.instance.client; try { @@ -95,7 +100,6 @@ class PlacarController { opponentTimeoutsUsed = int.tryParse(gameResponse['opp_timeouts']?.toString() ?? '0') ?? 0; currentQuarter = int.tryParse(gameResponse['current_quarter']?.toString() ?? '1') ?? 1; - // 👇 Verifica se o jogo já tinha acabado noutra sessão gameWasAlreadyFinished = gameResponse['status'] == 'Terminado'; final teamsResponse = await supabase.from('teams').select('id, name').inFilter('name', [myTeam, opponentTeam]); @@ -269,29 +273,34 @@ class PlacarController { onUpdate(); } -void registerShotLocation(BuildContext context, Offset position, Size size) { + // ========================================================================= + // 👇 A MÁGICA DOS PONTOS ACONTECE AQUI 👇 + // ========================================================================= + void registerShotLocation(BuildContext context, Offset position, Size size) { if (pendingAction == null || pendingPlayer == null) return; + bool is3Pt = pendingAction!.contains("_3"); bool is2Pt = pendingAction!.contains("_2"); + // O ÁRBITRO MATEMÁTICO COM AS TUAS VARIÁVEIS CALIBRADAS if (is3Pt || is2Pt) { bool isValid = _validateShotZone(position, size, is3Pt); + + // SE A JOGADA FOI NO SÍTIO ERRADO if (!isValid) { - ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('🛑 Local incompatível com a pontuação.'), backgroundColor: Colors.red, duration: Duration(seconds: 2))); - return; + + return; // <-- ESTE RETURN BLOQUEIA A GRAVAÇÃO DO PONTO! } } + // SE A JOGADA FOI VÁLIDA: bool isMake = pendingAction!.startsWith("add_pts_"); - // 👇 A MÁGICA DAS COORDENADAS RELATIVAS (0.0 a 1.0) 👇 double relX = position.dx / size.width; double relY = position.dy / size.height; - // Extrai só o nome do jogador String name = pendingPlayer!.replaceAll("player_my_", "").replaceAll("player_opp_", ""); - // Guarda na lista! matchShots.add(ShotRecord( relativeX: relX, relativeY: relY, @@ -307,18 +316,36 @@ void registerShotLocation(BuildContext context, Offset position, Size size) { onUpdate(); } - bool _validateShotZone(Offset pos, Size size, bool is3Pt) { - double w = size.width; double h = size.height; - Offset leftHoop = Offset(w * 0.12, h * 0.5); - Offset rightHoop = Offset(w * 0.88, h * 0.5); - double threePointRadius = w * 0.28; - Offset activeHoop = pos.dx < w / 2 ? leftHoop : rightHoop; - double distanceToHoop = (pos - activeHoop).distance; - bool isCorner3 = (pos.dy < h * 0.15 || pos.dy > h * 0.85) && (pos.dx < w * 0.20 || pos.dx > w * 0.80); + bool _validateShotZone(Offset position, Size size, bool is3Pt) { + double relX = position.dx / size.width; + double relY = position.dy / size.height; - if (is3Pt) return distanceToHoop >= threePointRadius || isCorner3; - else return distanceToHoop < threePointRadius && !isCorner3; + bool isLeftHalf = relX < 0.5; + double hoopX = isLeftHalf ? hoopBaseX : (1.0 - hoopBaseX); + double hoopY = 0.50; + + double aspectRatio = size.width / size.height; + double distFromCenterY = (relY - hoopY).abs(); + + bool isInside2Pts; + + // Lógica das laterais (Cantos) + if (distFromCenterY > cornerY) { + double distToBaseline = isLeftHalf ? relX : (1.0 - relX); + isInside2Pts = distToBaseline <= hoopBaseX; + } + // Lógica da Curva Frontal + else { + double dx = (relX - hoopX) * aspectRatio; + double dy = (relY - hoopY); + double distanceToHoop = math.sqrt((dx * dx) + (dy * dy)); + isInside2Pts = distanceToHoop < arcRadius; + } + + if (is3Pt) return !isInside2Pts; + return isInside2Pts; } + // 👆 ===================================================================== 👆 void cancelShotLocation() { isSelectingShotLocation = false; pendingAction = null; pendingPlayer = null; onUpdate(); @@ -368,7 +395,6 @@ void registerShotLocation(BuildContext context, Offset position, Size size) { } } - // --- 💾 FUNÇÃO PARA GUARDAR DADOS NA BD --- Future saveGameStats(BuildContext context) async { final supabase = Supabase.instance.client; isSaving = true; @@ -378,14 +404,12 @@ void registerShotLocation(BuildContext context, Offset position, Size size) { bool isGameFinishedNow = currentQuarter >= 4 && duration.inSeconds == 0; String newStatus = isGameFinishedNow ? 'Terminado' : 'Pausado'; - // 👇👇👇 0. CÉREBRO: CALCULAR OS LÍDERES E MVP DO JOGO 👇👇👇 String topPtsName = '---'; int maxPts = -1; String topAstName = '---'; int maxAst = -1; String topRbsName = '---'; int maxRbs = -1; String topDefName = '---'; int maxDef = -1; String mvpName = '---'; int maxMvpScore = -1; - // Passa por todos os jogadores e calcula a matemática playerStats.forEach((playerName, stats) { int pts = stats['pts'] ?? 0; int ast = stats['ast'] ?? 0; @@ -393,19 +417,16 @@ void registerShotLocation(BuildContext context, Offset position, Size size) { int stl = stats['stl'] ?? 0; int blk = stats['blk'] ?? 0; - int defScore = stl + blk; // Defesa: Roubos + Cortes - int mvpScore = pts + ast + rbs + defScore; // Impacto Total (MVP) + int defScore = stl + blk; + int mvpScore = pts + ast + rbs + defScore; - // Compara com o máximo atual e substitui se for maior if (pts > maxPts && pts > 0) { maxPts = pts; topPtsName = '$playerName ($pts)'; } if (ast > maxAst && ast > 0) { maxAst = ast; topAstName = '$playerName ($ast)'; } if (rbs > maxRbs && rbs > 0) { maxRbs = rbs; topRbsName = '$playerName ($rbs)'; } if (defScore > maxDef && defScore > 0) { maxDef = defScore; topDefName = '$playerName ($defScore)'; } - if (mvpScore > maxMvpScore && mvpScore > 0) { maxMvpScore = mvpScore; mvpName = playerName; } // MVP não leva nº à frente, fica mais limpo + if (mvpScore > maxMvpScore && mvpScore > 0) { maxMvpScore = mvpScore; mvpName = playerName; } }); - // 👆👆👆 FIM DO CÉREBRO 👆👆👆 - // 1. Atualizar o Jogo na BD (Agora inclui os Reis da partida!) await supabase.from('games').update({ 'my_score': myScore, 'opponent_score': opponentScore, @@ -414,8 +435,6 @@ void registerShotLocation(BuildContext context, Offset position, Size size) { 'opp_timeouts': opponentTimeoutsUsed, 'current_quarter': currentQuarter, 'status': newStatus, - - // ENVIA A MATEMÁTICA PARA A TUA BASE DE DADOS 'top_pts_name': topPtsName, 'top_ast_name': topAstName, 'top_rbs_name': topRbsName, @@ -423,7 +442,6 @@ void registerShotLocation(BuildContext context, Offset position, Size size) { 'mvp_name': mvpName, }).eq('id', gameId); - // 2. LÓGICA DE VITÓRIAS, DERROTAS E EMPATES if (isGameFinishedNow && !gameWasAlreadyFinished && myTeamDbId != null && oppTeamDbId != null) { final teamsData = await supabase.from('teams').select('id, wins, losses, draws').inFilter('id', [myTeamDbId, oppTeamDbId]); @@ -458,7 +476,6 @@ void registerShotLocation(BuildContext context, Offset position, Size size) { gameWasAlreadyFinished = true; } - // 3. Atualizar as Estatísticas dos Jogadores List> batchStats = []; playerStats.forEach((playerName, stats) { String? memberDbId = playerDbIds[playerName]; diff --git a/lib/pages/heatmap_page.dart b/lib/pages/heatmap_page.dart deleted file mode 100644 index 37c522d..0000000 --- a/lib/pages/heatmap_page.dart +++ /dev/null @@ -1,92 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import '../controllers/placar_controller.dart'; // Ajusta o caminho se for preciso - -class HeatmapPage extends StatefulWidget { - final List shots; - final String teamName; - - const HeatmapPage({super.key, required this.shots, required this.teamName}); - - @override - State createState() => _HeatmapPageState(); -} - -class _HeatmapPageState extends State { - - @override - void initState() { - super.initState(); - // Força o ecrã a ficar deitado para vermos bem o campo - SystemChrome.setPreferredOrientations([ - DeviceOrientation.landscapeRight, - DeviceOrientation.landscapeLeft, - ]); - } - - @override - void dispose() { - // Volta ao normal quando saímos - SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: const Color(0xFF16202C), - appBar: AppBar( - backgroundColor: Colors.transparent, - elevation: 0, - title: Text("Mapa de Lançamentos - ${widget.teamName}", style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold)), - iconTheme: const IconThemeData(color: Colors.white), - ), - body: Center( - child: AspectRatio( - aspectRatio: 1150 / 720, // Mantém o campo proporcional - child: Container( - margin: const EdgeInsets.only(bottom: 20, left: 20, right: 20), - decoration: BoxDecoration( - border: Border.all(color: Colors.white, width: 3), - image: const DecorationImage( - image: AssetImage('assets/campo.png'), - fit: BoxFit.fill, - ), - ), - child: LayoutBuilder( - builder: (context, constraints) { - final double w = constraints.maxWidth; - final double h = constraints.maxHeight; - - return Stack( - children: widget.shots.map((shot) { - // 👇 Converte de volta de % para Pixels reais do ecrã atual - double pixelX = shot.relativeX * w; - double pixelY = shot.relativeY * h; - - return Positioned( - left: pixelX - 12, // -12 para centrar a bolinha - top: pixelY - 12, - child: Tooltip( - message: "${shot.playerName}\n${shot.isMake ? 'Cesto' : 'Falha'}", - child: CircleAvatar( - radius: 12, - backgroundColor: shot.isMake ? Colors.green.withOpacity(0.85) : Colors.red.withOpacity(0.85), - child: Icon( - shot.isMake ? Icons.check : Icons.close, - size: 14, - color: Colors.white, - ), - ), - ), - ); - }).toList(), - ); - }, - ), - ), - ), - ), - ); - } -} \ No newline at end of file diff --git a/pubspec.lock b/pubspec.lock index 720ba58..bf5456b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -61,10 +61,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" clock: dependency: transitive description: @@ -268,18 +268,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.18" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" meta: dependency: transitive description: @@ -553,10 +553,10 @@ packages: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.9" typed_data: dependency: transitive description: