From 7d2f3c4679ab50b5d140c2ffa80a39a283463248 Mon Sep 17 00:00:00 2001 From: 230404 <230404@epvc.pt> Date: Thu, 21 May 2026 10:51:35 +0100 Subject: [PATCH] bora para o relatorio --- lib/controllers/placar_controller.dart | 46 ++ lib/pages/PlacarPage.dart | 79 +++- lib/pages/home.dart | 603 +++++++++++++------------ 3 files changed, 435 insertions(+), 293 deletions(-) diff --git a/lib/controllers/placar_controller.dart b/lib/controllers/placar_controller.dart index 56cd88c..05693c8 100644 --- a/lib/controllers/placar_controller.dart +++ b/lib/controllers/placar_controller.dart @@ -139,6 +139,36 @@ class PlacarController extends ChangeNotifier { _setTimerRunning(shouldRun, emitSync: false); } + void applyRemoteAddShot(Map shotJson) { + try { + matchShots.add(ShotRecord.fromJson(shotJson)); + notifyListeners(); + } catch (e) { + debugPrint('Erro ao aplicar shot remoto: $e'); + } + } + + Future _persistShotRemote(Map shotJson) async { + try { + final supabase = Supabase.instance.client; + final row = { + 'game_id': gameId, + 'member_id': shotJson['playerId'] ?? shotJson['player_id'], + 'player_name': shotJson['playerName'] ?? shotJson['player_name'], + 'relative_x': shotJson['relativeX'] ?? shotJson['relative_x'], + 'relative_y': shotJson['relativeY'] ?? shotJson['relative_y'], + 'is_make': shotJson['isMake'] ?? shotJson['is_make'], + 'zone': shotJson['zone'], + 'points': shotJson['points'], + }; + + await supabase.from('shot_locations').insert(row); + debugPrint('✅ Shot persisted remotely'); + } catch (e) { + debugPrint('❌ Erro ao persistir shot remoto: $e'); + } + } + bool isLoading = true; bool isSaving = false; bool gameWasAlreadyFinished = false; @@ -666,6 +696,14 @@ class PlacarController extends ChangeNotifier { String finalAction = isMake ? "add_pts_$points" : "miss_$points"; commitStat(finalAction, targetPlayer); + // Emitir evento de shot para parceiros remotos + try { + final shotJson = matchShots.last.toJson(); + _dispatchSyncAction('add_shot', {'shot': shotJson}); + // Persist shot immediately on server (fire-and-forget) + _persistShotRemote(shotJson); + } catch (_) {} + notifyListeners(); } @@ -697,6 +735,14 @@ class PlacarController extends ChangeNotifier { ), ); + // Emitir evento de shot para parceiros remotos + try { + final shotJson = matchShots.last.toJson(); + _dispatchSyncAction('add_shot', {'shot': shotJson}); + // Persist shot immediately on server (fire-and-forget) + _persistShotRemote(shotJson); + } catch (_) {} + commitStat(pendingAction!, pendingPlayerId!); isSelectingShotLocation = false; diff --git a/lib/pages/PlacarPage.dart b/lib/pages/PlacarPage.dart index 0a7f3df..cb46ed7 100644 --- a/lib/pages/PlacarPage.dart +++ b/lib/pages/PlacarPage.dart @@ -35,6 +35,7 @@ StreamSubscription? _syncSubscription; bool _isApplyingRemoteSync = false; final Set _appliedSyncEventIds = {}; + final Map _lastAppliedActionAt = {}; @override void initState() { @@ -169,7 +170,7 @@ _sharedWithName = await _resolveUserName(sharedWith); } - _setupSyncListener(); + await _setupSyncListener(); setState(() {}); } @@ -189,10 +190,11 @@ } } - void _setupSyncListener() { + Future _setupSyncListener() async { if (_sessionId == null) return; _syncSubscription?.cancel(); _appliedSyncEventIds.clear(); + await _seedAppliedSyncEventIds(); _syncSubscription = _sharingController .listenToGameSyncOthers(_sessionId!) .listen( @@ -233,17 +235,39 @@ }); for (final record in rows) { - final recordId = record['id']?.toString(); - if (recordId == null || _appliedSyncEventIds.contains(recordId)) { + final recordId = record['id']?.toString(); + if (recordId == null || _appliedSyncEventIds.contains(recordId)) { + continue; + } + + // Skip if this event is older or equal to the last applied event of the same type + final actionType = record['action_type']?.toString(); + DateTime? recordTime; + try { + final created = record['created_at']?.toString(); + if (created != null) recordTime = DateTime.parse(created); + } catch (_) { + recordTime = null; + } + + if (actionType != null && recordTime != null) { + final last = _lastAppliedActionAt[actionType]; + if (last != null && !recordTime.isAfter(last)) { + _appliedSyncEventIds.add(recordId); continue; } - - _appliedSyncEventIds.add(recordId); - print( - "🔄 Evento remoto recebido: ${record['action_type']} - ${record['action_data']}", - ); - _applyRemoteSyncEvent(record); } + + _appliedSyncEventIds.add(recordId); + if (actionType != null && recordTime != null) { + _lastAppliedActionAt[actionType] = recordTime; + } + + print( + "🔄 Evento remoto recebido: ${record['action_type']} - ${record['action_data']}", + ); + _applyRemoteSyncEvent(record); + } }, onError: (error) { print("⚠️ Erro no stream de sync: $error"); @@ -251,6 +275,29 @@ ); } + Future _seedAppliedSyncEventIds() async { + if (_sessionId == null) return; + + try { + final response = await Supabase.instance.client + .from('game_sync_events') + .select('id') + .eq('session_id', _sessionId!) + .order('created_at', ascending: true); + + if (response is List) { + for (final item in response) { + final id = item['id']?.toString(); + if (id != null) { + _appliedSyncEventIds.add(id); + } + } + } + } catch (e) { + print('⚠️ Erro ao semear eventos históricos de sync: $e'); + } + } + void _handleSyncRecords(Map record) { // Mantido apenas como fallback, mas a escuta principal usa listenToGameSyncOthers. _applyRemoteSyncEvent(record); @@ -317,7 +364,6 @@ if (remoteIsRunning != _controller.isRunning) { _isApplyingRemoteSync = true; _controller.applyRemoteTimerState(remoteIsRunning); - _controller.notifyListeners(); _isApplyingRemoteSync = false; } @@ -362,6 +408,13 @@ _isApplyingRemoteSync = true; _controller.useTimeout(isOpponent); _isApplyingRemoteSync = false; + } else if (actionType == 'add_shot') { + final shot = actionData['shot'] as Map?; + if (shot != null) { + _isApplyingRemoteSync = true; + _controller.applyRemoteAddShot(Map.from(shot)); + _isApplyingRemoteSync = false; + } } } @@ -383,7 +436,7 @@ if (result != null) { _sessionId = result['session_id']?.toString(); _shareCode = result['share_code']?.toString(); - _setupSyncListener(); + await _setupSyncListener(); setState(() {}); } } @@ -398,7 +451,7 @@ _sessionId = result['session_id']?.toString(); _shareCode = result['share_code']?.toString(); _sharedWithName = result['creator_name']?.toString() ?? ''; - _setupSyncListener(); + await _setupSyncListener(); setState(() {}); } } diff --git a/lib/pages/home.dart b/lib/pages/home.dart index d4d672e..4abed75 100644 --- a/lib/pages/home.dart +++ b/lib/pages/home.dart @@ -404,293 +404,336 @@ class _HomeScreenState extends State { padding: EdgeInsets.symmetric( horizontal: 22.0 * context.sf, vertical: 16.0 * context.sf), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - InkWell( - onTap: () => _showTeamSelector(context), - child: Container( - padding: EdgeInsets.all(12 * context.sf), - decoration: BoxDecoration( - color: Theme.of(context).cardTheme.color, - borderRadius: - BorderRadius.circular(15 * context.sf), - border: - Border.all(color: Colors.grey.withOpacity(0.2)), + child: LayoutBuilder( + builder: (context, constraints) { + final bool isWide = constraints.maxWidth >= 1100; + final double effectiveCardHeight = + isWide ? 280 * context.sf : cardHeight; + + Widget statsSection = Column( + children: [ + SizedBox( + height: effectiveCardHeight, + child: Row( + children: [ + Expanded( + child: _buildStatCard( + context: context, + title: 'Mais Pontos', + playerName: leaders['pts_name'], + statValue: leaders['pts_val'].toString(), + statLabel: 'TOTAL', + color: AppTheme.statPtsBg, + isHighlighted: true)), + SizedBox(width: 12 * context.sf), + Expanded( + child: _buildStatCard( + context: context, + title: 'Assistências', + playerName: leaders['ast_name'], + statValue: leaders['ast_val'].toString(), + statLabel: 'TOTAL', + color: AppTheme.statAstBg)), + ], + ), ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row(children: [ - (_selectedTeamLogo != null && - _selectedTeamLogo!.isNotEmpty) - ? ClipOval( - child: CachedNetworkImage( - imageUrl: _selectedTeamLogo!, - width: 24 * context.sf, - height: 24 * context.sf, - fit: BoxFit.cover, - placeholder: (context, url) => Icon( - Icons.shield, - color: AppTheme.primaryRed, - size: 24 * context.sf), - errorWidget: - (context, url, error) => Icon( + SizedBox(height: 12 * context.sf), + SizedBox( + height: effectiveCardHeight, + child: Row( + children: [ + Expanded( + child: _buildStatCard( + context: context, + title: 'Rebotes', + playerName: leaders['rbs_name'], + statValue: leaders['rbs_val'].toString(), + statLabel: 'TOTAL', + color: AppTheme.statRebBg)), + SizedBox(width: 12 * context.sf), + Expanded( + child: PieChartCard( + victories: _teamWins, + defeats: _teamLosses, + draws: _teamDraws, + title: 'DESEMPENHO', + subtitle: 'Temporada', + backgroundColor: AppTheme.statPieBg, + sf: context.sf)), + ], + ), + ), + ], + ); + + Widget historySection = Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Histórico de Jogos', + style: TextStyle( + fontSize: 20 * context.sf, + fontWeight: FontWeight.bold, + color: textColor)), + SizedBox(height: 16 * context.sf), + _selectedTeamName == "Selecionar Equipa" + ? Container( + width: double.infinity, + padding: EdgeInsets.all(24.0 * context.sf), + decoration: BoxDecoration( + color: Theme.of(context).cardTheme.color ?? + Colors.white, + borderRadius: + BorderRadius.circular(16 * context.sf), + border: Border.all( + color: Colors.grey.withOpacity(0.1)), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.04), + blurRadius: 10, + offset: const Offset(0, 4)) + ], + ), + child: Column( + children: [ + Container( + padding: EdgeInsets.all(18 * context.sf), + decoration: BoxDecoration( + color: AppTheme.primaryRed + .withOpacity(0.08), + shape: BoxShape.circle), + child: Icon(Icons.shield_outlined, + color: AppTheme.primaryRed, + size: 42 * context.sf), + ), + SizedBox(height: 20 * context.sf), + Text("Nenhuma Equipa Ativa", + style: TextStyle( + fontSize: 18 * context.sf, + fontWeight: FontWeight.bold, + color: textColor)), + SizedBox(height: 8 * context.sf), + Text( + "Escolha uma equipa no seletor acima para ver as estatísticas e o histórico.", + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 13 * context.sf, + color: Colors.grey.shade600, + height: 1.4), + ), + SizedBox(height: 24 * context.sf), + SizedBox( + width: double.infinity, + height: 48 * context.sf, + child: ElevatedButton.icon( + onPressed: () => _showTeamSelector(context), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.primaryRed, + foregroundColor: Colors.white, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + 10 * context.sf)), + ), + icon: Icon(Icons.touch_app, + size: 20 * context.sf), + label: Text("Selecionar Agora", + style: TextStyle( + fontSize: 15 * context.sf, + fontWeight: FontWeight.bold)), + ), + ), + ], + ), + ) + : StreamBuilder>>( + stream: _supabase + .from('games') + .stream(primaryKey: ['id']) + .order('game_date', ascending: false), + builder: (context, gameSnapshot) { + if (gameSnapshot.hasError) { + return Text( + "Erro: ${gameSnapshot.error}", + style: const TextStyle( + color: Colors.red)); + } + if (!gameSnapshot.hasData && + gameSnapshot.connectionState == + ConnectionState.waiting) { + return const Center( + child: CircularProgressIndicator()); + } + + final todosOsJogos = gameSnapshot.data ?? []; + final gamesList = todosOsJogos.where((game) { + String myT = + game['my_team']?.toString() ?? ''; + String oppT = + game['opponent_team']?.toString() ?? ''; + String status = + game['status']?.toString() ?? ''; + return (myT == _selectedTeamName || + oppT == _selectedTeamName) && + status == 'Terminado'; + }).take(3).toList(); + + if (gamesList.isEmpty) { + return Container( + width: double.infinity, + padding: EdgeInsets.all(20 * context.sf), + decoration: BoxDecoration( + color: Theme.of(context).cardTheme.color, + borderRadius: BorderRadius.circular(14), + ), + alignment: Alignment.center, + child: const Text( + "Ainda não há jogos terminados.", + style: TextStyle(color: Colors.grey)), + ); + } + + return Column( + children: gamesList.map((game) { + String dbMyTeam = + game['my_team']?.toString() ?? ''; + String dbOppTeam = + game['opponent_team']?.toString() ?? ''; + int dbMyScore = int.tryParse( + game['my_score']?.toString() ?? + '0') ?? + 0; + int dbOppScore = int.tryParse( + game['opponent_score'] + ?.toString() ?? + '0') ?? + 0; + String opponent; + int myScore; + int oppScore; + + if (dbMyTeam == _selectedTeamName) { + opponent = dbOppTeam; + myScore = dbMyScore; + oppScore = dbOppScore; + } else { + opponent = dbMyTeam; + myScore = dbOppScore; + oppScore = dbMyScore; + } + + String rawDate = + game['game_date']?.toString() ?? '---'; + String date = rawDate.length >= 10 + ? rawDate.substring(0, 10) + : rawDate; + String result = myScore > oppScore + ? 'V' + : (myScore < oppScore ? 'D' : 'E'); + + return _buildGameHistoryCard( + context: context, + opponent: opponent, + result: result, + myScore: myScore, + oppScore: oppScore, + date: date, + topPts: + game['top_pts_name'] ?? '---', + topAst: + game['top_ast_name'] ?? '---', + topRbs: + game['top_rbs_name'] ?? '---', + topDef: + game['top_def_name'] ?? '---', + mvp: game['mvp_name'] ?? '---', + ); + }).toList(), + ); + }, + ), + ], + ); + + Widget mainContent = Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + InkWell( + onTap: () => _showTeamSelector(context), + child: Container( + padding: EdgeInsets.all(12 * context.sf), + decoration: BoxDecoration( + color: Theme.of(context).cardTheme.color, + borderRadius: + BorderRadius.circular(15 * context.sf), + border: + Border.all(color: Colors.grey.withOpacity(0.2)), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row(children: [ + (_selectedTeamLogo != null && + _selectedTeamLogo!.isNotEmpty) + ? ClipOval( + child: CachedNetworkImage( + imageUrl: _selectedTeamLogo!, + width: 24 * context.sf, + height: 24 * context.sf, + fit: BoxFit.cover, + placeholder: (context, url) => Icon( Icons.shield, color: AppTheme.primaryRed, size: 24 * context.sf), - ), - ) - : Icon(Icons.shield, - color: AppTheme.primaryRed, - size: 24 * context.sf), - SizedBox(width: 10 * context.sf), - Text(_selectedTeamName, - style: TextStyle( - fontSize: 16 * context.sf, - fontWeight: FontWeight.bold, - color: textColor)), - ]), - Icon(Icons.arrow_drop_down, color: textColor), - ], - ), - ), - ), - SizedBox(height: 20 * context.sf), - - SizedBox( - height: cardHeight, - child: Row( - children: [ - Expanded( - child: _buildStatCard( - context: context, - title: 'Mais Pontos', - playerName: leaders['pts_name'], - statValue: leaders['pts_val'].toString(), - statLabel: 'TOTAL', - color: AppTheme.statPtsBg, - isHighlighted: true)), - SizedBox(width: 12 * context.sf), - Expanded( - child: _buildStatCard( - context: context, - title: 'Assistências', - playerName: leaders['ast_name'], - statValue: leaders['ast_val'].toString(), - statLabel: 'TOTAL', - color: AppTheme.statAstBg)), - ], - ), - ), - SizedBox(height: 12 * context.sf), - - SizedBox( - height: cardHeight, - child: Row( - children: [ - Expanded( - child: _buildStatCard( - context: context, - title: 'Rebotes', - playerName: leaders['rbs_name'], - statValue: leaders['rbs_val'].toString(), - statLabel: 'TOTAL', - color: AppTheme.statRebBg)), - SizedBox(width: 12 * context.sf), - Expanded( - child: PieChartCard( - victories: _teamWins, - defeats: _teamLosses, - draws: _teamDraws, - title: 'DESEMPENHO', - subtitle: 'Temporada', - backgroundColor: AppTheme.statPieBg, - sf: context.sf)), - ], - ), - ), - SizedBox(height: 40 * context.sf), - - Text('Histórico de Jogos', - style: TextStyle( - fontSize: 20 * context.sf, - fontWeight: FontWeight.bold, - color: textColor)), - SizedBox(height: 16 * context.sf), - - _selectedTeamName == "Selecionar Equipa" - ? Container( - width: double.infinity, - padding: EdgeInsets.all(24.0 * context.sf), - decoration: BoxDecoration( - color: Theme.of(context).cardTheme.color ?? - Colors.white, - borderRadius: - BorderRadius.circular(16 * context.sf), - border: Border.all( - color: Colors.grey.withOpacity(0.1)), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.04), - blurRadius: 10, - offset: const Offset(0, 4)) + errorWidget: + (context, url, error) => Icon( + Icons.shield, + color: AppTheme.primaryRed, + size: 24 * context.sf), + ), + ) + : Icon(Icons.shield, + color: AppTheme.primaryRed, + size: 24 * context.sf), + SizedBox(width: 10 * context.sf), + Text(_selectedTeamName, + style: TextStyle( + fontSize: 16 * context.sf, + fontWeight: FontWeight.bold, + color: textColor)), + ]), + Icon(Icons.arrow_drop_down, color: textColor), ], ), - child: Column( - children: [ - Container( - padding: EdgeInsets.all(18 * context.sf), - decoration: BoxDecoration( - color: AppTheme.primaryRed.withOpacity(0.08), - shape: BoxShape.circle), - child: Icon(Icons.shield_outlined, - color: AppTheme.primaryRed, - size: 42 * context.sf), - ), - SizedBox(height: 20 * context.sf), - Text("Nenhuma Equipa Ativa", - style: TextStyle( - fontSize: 18 * context.sf, - fontWeight: FontWeight.bold, - color: textColor)), - SizedBox(height: 8 * context.sf), - Text( - "Escolha uma equipa no seletor acima para ver as estatísticas e o histórico.", - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 13 * context.sf, - color: Colors.grey.shade600, - height: 1.4), - ), - SizedBox(height: 24 * context.sf), - SizedBox( - width: double.infinity, - height: 48 * context.sf, - child: ElevatedButton.icon( - onPressed: () => _showTeamSelector(context), - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.primaryRed, - foregroundColor: Colors.white, - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular( - 10 * context.sf)), - ), - icon: Icon(Icons.touch_app, - size: 20 * context.sf), - label: Text("Selecionar Agora", - style: TextStyle( - fontSize: 15 * context.sf, - fontWeight: FontWeight.bold)), - ), - ), - ], - ), - ) - : StreamBuilder>>( - stream: _supabase - .from('games') - .stream(primaryKey: ['id']).order('game_date', - ascending: false), - builder: (context, gameSnapshot) { - if (gameSnapshot.hasError) { - return Text("Erro: ${gameSnapshot.error}", - style: - const TextStyle(color: Colors.red)); - } - if (!gameSnapshot.hasData && - gameSnapshot.connectionState == - ConnectionState.waiting) { - return const Center( - child: CircularProgressIndicator()); - } - - final todosOsJogos = gameSnapshot.data ?? []; - final gamesList = todosOsJogos.where((game) { - String myT = - game['my_team']?.toString() ?? ''; - String oppT = - game['opponent_team']?.toString() ?? ''; - String status = - game['status']?.toString() ?? ''; - return (myT == _selectedTeamName || - oppT == _selectedTeamName) && - status == 'Terminado'; - }).take(3).toList(); - - if (gamesList.isEmpty) { - return Container( - width: double.infinity, - padding: EdgeInsets.all(20 * context.sf), - decoration: BoxDecoration( - color: Theme.of(context).cardTheme.color, - borderRadius: BorderRadius.circular(14), - ), - alignment: Alignment.center, - child: const Text( - "Ainda não há jogos terminados.", - style: TextStyle(color: Colors.grey)), - ); - } - - return Column( - children: gamesList.map((game) { - String dbMyTeam = - game['my_team']?.toString() ?? ''; - String dbOppTeam = - game['opponent_team']?.toString() ?? ''; - int dbMyScore = int.tryParse( - game['my_score']?.toString() ?? - '0') ?? - 0; - int dbOppScore = int.tryParse( - game['opponent_score'] - ?.toString() ?? - '0') ?? - 0; - String opponent; - int myScore; - int oppScore; - - if (dbMyTeam == _selectedTeamName) { - opponent = dbOppTeam; - myScore = dbMyScore; - oppScore = dbOppScore; - } else { - opponent = dbMyTeam; - myScore = dbOppScore; - oppScore = dbMyScore; - } - - String rawDate = - game['game_date']?.toString() ?? '---'; - String date = rawDate.length >= 10 - ? rawDate.substring(0, 10) - : rawDate; - String result = myScore > oppScore - ? 'V' - : (myScore < oppScore ? 'D' : 'E'); - - return _buildGameHistoryCard( - context: context, - opponent: opponent, - result: result, - myScore: myScore, - oppScore: oppScore, - date: date, - topPts: game['top_pts_name'] ?? '---', - topAst: game['top_ast_name'] ?? '---', - topRbs: game['top_rbs_name'] ?? '---', - topDef: game['top_def_name'] ?? '---', - mvp: game['mvp_name'] ?? '---', - ); - }).toList(), - ); - }, ), - SizedBox(height: 20 * context.sf), - ], + ), + SizedBox(height: 20 * context.sf), + if (!isWide) ...[ + statsSection, + SizedBox(height: 40 * context.sf), + historySection, + ] + ], + ); + + if (isWide) { + mainContent = Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(flex: 5, child: statsSection), + SizedBox(width: 20 * context.sf), + Expanded(flex: 6, child: historySection), + ], + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + mainContent, + SizedBox(height: 20 * context.sf), + ], + ); + }, ), ), );