bora para o relatorio

This commit is contained in:
2026-05-21 10:51:35 +01:00
parent 332361c296
commit 7d2f3c4679
3 changed files with 435 additions and 293 deletions

View File

@@ -139,6 +139,36 @@ class PlacarController extends ChangeNotifier {
_setTimerRunning(shouldRun, emitSync: false);
}
void applyRemoteAddShot(Map<String, dynamic> shotJson) {
try {
matchShots.add(ShotRecord.fromJson(shotJson));
notifyListeners();
} catch (e) {
debugPrint('Erro ao aplicar shot remoto: $e');
}
}
Future<void> _persistShotRemote(Map<String, dynamic> 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;

View File

@@ -35,6 +35,7 @@
StreamSubscription? _syncSubscription;
bool _isApplyingRemoteSync = false;
final Set<String> _appliedSyncEventIds = {};
final Map<String, DateTime> _lastAppliedActionAt = {};
@override
void initState() {
@@ -169,7 +170,7 @@
_sharedWithName = await _resolveUserName(sharedWith);
}
_setupSyncListener();
await _setupSyncListener();
setState(() {});
}
@@ -189,10 +190,11 @@
}
}
void _setupSyncListener() {
Future<void> _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<void> _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<String, dynamic> 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<String, dynamic>?;
if (shot != null) {
_isApplyingRemoteSync = true;
_controller.applyRemoteAddShot(Map<String, dynamic>.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(() {});
}
}

View File

@@ -404,293 +404,336 @@ class _HomeScreenState extends State<HomeScreen> {
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<List<Map<String, dynamic>>>(
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<List<Map<String, dynamic>>>(
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),
],
);
},
),
),
);