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); _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 isLoading = true;
bool isSaving = false; bool isSaving = false;
bool gameWasAlreadyFinished = false; bool gameWasAlreadyFinished = false;
@@ -666,6 +696,14 @@ class PlacarController extends ChangeNotifier {
String finalAction = isMake ? "add_pts_$points" : "miss_$points"; String finalAction = isMake ? "add_pts_$points" : "miss_$points";
commitStat(finalAction, targetPlayer); 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(); 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!); commitStat(pendingAction!, pendingPlayerId!);
isSelectingShotLocation = false; isSelectingShotLocation = false;

View File

@@ -35,6 +35,7 @@
StreamSubscription? _syncSubscription; StreamSubscription? _syncSubscription;
bool _isApplyingRemoteSync = false; bool _isApplyingRemoteSync = false;
final Set<String> _appliedSyncEventIds = {}; final Set<String> _appliedSyncEventIds = {};
final Map<String, DateTime> _lastAppliedActionAt = {};
@override @override
void initState() { void initState() {
@@ -169,7 +170,7 @@
_sharedWithName = await _resolveUserName(sharedWith); _sharedWithName = await _resolveUserName(sharedWith);
} }
_setupSyncListener(); await _setupSyncListener();
setState(() {}); setState(() {});
} }
@@ -189,10 +190,11 @@
} }
} }
void _setupSyncListener() { Future<void> _setupSyncListener() async {
if (_sessionId == null) return; if (_sessionId == null) return;
_syncSubscription?.cancel(); _syncSubscription?.cancel();
_appliedSyncEventIds.clear(); _appliedSyncEventIds.clear();
await _seedAppliedSyncEventIds();
_syncSubscription = _sharingController _syncSubscription = _sharingController
.listenToGameSyncOthers(_sessionId!) .listenToGameSyncOthers(_sessionId!)
.listen( .listen(
@@ -238,7 +240,29 @@
continue; 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); _appliedSyncEventIds.add(recordId);
continue;
}
}
_appliedSyncEventIds.add(recordId);
if (actionType != null && recordTime != null) {
_lastAppliedActionAt[actionType] = recordTime;
}
print( print(
"🔄 Evento remoto recebido: ${record['action_type']} - ${record['action_data']}", "🔄 Evento remoto recebido: ${record['action_type']} - ${record['action_data']}",
); );
@@ -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) { void _handleSyncRecords(Map<String, dynamic> record) {
// Mantido apenas como fallback, mas a escuta principal usa listenToGameSyncOthers. // Mantido apenas como fallback, mas a escuta principal usa listenToGameSyncOthers.
_applyRemoteSyncEvent(record); _applyRemoteSyncEvent(record);
@@ -317,7 +364,6 @@
if (remoteIsRunning != _controller.isRunning) { if (remoteIsRunning != _controller.isRunning) {
_isApplyingRemoteSync = true; _isApplyingRemoteSync = true;
_controller.applyRemoteTimerState(remoteIsRunning); _controller.applyRemoteTimerState(remoteIsRunning);
_controller.notifyListeners();
_isApplyingRemoteSync = false; _isApplyingRemoteSync = false;
} }
@@ -362,6 +408,13 @@
_isApplyingRemoteSync = true; _isApplyingRemoteSync = true;
_controller.useTimeout(isOpponent); _controller.useTimeout(isOpponent);
_isApplyingRemoteSync = false; _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) { if (result != null) {
_sessionId = result['session_id']?.toString(); _sessionId = result['session_id']?.toString();
_shareCode = result['share_code']?.toString(); _shareCode = result['share_code']?.toString();
_setupSyncListener(); await _setupSyncListener();
setState(() {}); setState(() {});
} }
} }
@@ -398,7 +451,7 @@
_sessionId = result['session_id']?.toString(); _sessionId = result['session_id']?.toString();
_shareCode = result['share_code']?.toString(); _shareCode = result['share_code']?.toString();
_sharedWithName = result['creator_name']?.toString() ?? ''; _sharedWithName = result['creator_name']?.toString() ?? '';
_setupSyncListener(); await _setupSyncListener();
setState(() {}); setState(() {});
} }
} }

View File

@@ -404,62 +404,16 @@ class _HomeScreenState extends State<HomeScreen> {
padding: EdgeInsets.symmetric( padding: EdgeInsets.symmetric(
horizontal: 22.0 * context.sf, horizontal: 22.0 * context.sf,
vertical: 16.0 * context.sf), vertical: 16.0 * context.sf),
child: Column( child: LayoutBuilder(
crossAxisAlignment: CrossAxisAlignment.start, builder: (context, constraints) {
children: [ final bool isWide = constraints.maxWidth >= 1100;
InkWell( final double effectiveCardHeight =
onTap: () => _showTeamSelector(context), isWide ? 280 * context.sf : cardHeight;
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),
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),
],
),
),
),
SizedBox(height: 20 * context.sf),
Widget statsSection = Column(
children: [
SizedBox( SizedBox(
height: cardHeight, height: effectiveCardHeight,
child: Row( child: Row(
children: [ children: [
Expanded( Expanded(
@@ -484,9 +438,8 @@ class _HomeScreenState extends State<HomeScreen> {
), ),
), ),
SizedBox(height: 12 * context.sf), SizedBox(height: 12 * context.sf),
SizedBox( SizedBox(
height: cardHeight, height: effectiveCardHeight,
child: Row( child: Row(
children: [ children: [
Expanded( Expanded(
@@ -510,15 +463,18 @@ class _HomeScreenState extends State<HomeScreen> {
], ],
), ),
), ),
SizedBox(height: 40 * context.sf), ],
);
Widget historySection = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Histórico de Jogos', Text('Histórico de Jogos',
style: TextStyle( style: TextStyle(
fontSize: 20 * context.sf, fontSize: 20 * context.sf,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: textColor)), color: textColor)),
SizedBox(height: 16 * context.sf), SizedBox(height: 16 * context.sf),
_selectedTeamName == "Selecionar Equipa" _selectedTeamName == "Selecionar Equipa"
? Container( ? Container(
width: double.infinity, width: double.infinity,
@@ -542,7 +498,8 @@ class _HomeScreenState extends State<HomeScreen> {
Container( Container(
padding: EdgeInsets.all(18 * context.sf), padding: EdgeInsets.all(18 * context.sf),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppTheme.primaryRed.withOpacity(0.08), color: AppTheme.primaryRed
.withOpacity(0.08),
shape: BoxShape.circle), shape: BoxShape.circle),
child: Icon(Icons.shield_outlined, child: Icon(Icons.shield_outlined,
color: AppTheme.primaryRed, color: AppTheme.primaryRed,
@@ -591,13 +548,14 @@ class _HomeScreenState extends State<HomeScreen> {
: StreamBuilder<List<Map<String, dynamic>>>( : StreamBuilder<List<Map<String, dynamic>>>(
stream: _supabase stream: _supabase
.from('games') .from('games')
.stream(primaryKey: ['id']).order('game_date', .stream(primaryKey: ['id'])
ascending: false), .order('game_date', ascending: false),
builder: (context, gameSnapshot) { builder: (context, gameSnapshot) {
if (gameSnapshot.hasError) { if (gameSnapshot.hasError) {
return Text("Erro: ${gameSnapshot.error}", return Text(
style: "Erro: ${gameSnapshot.error}",
const TextStyle(color: Colors.red)); style: const TextStyle(
color: Colors.red));
} }
if (!gameSnapshot.hasData && if (!gameSnapshot.hasData &&
gameSnapshot.connectionState == gameSnapshot.connectionState ==
@@ -679,18 +637,103 @@ class _HomeScreenState extends State<HomeScreen> {
myScore: myScore, myScore: myScore,
oppScore: oppScore, oppScore: oppScore,
date: date, date: date,
topPts: game['top_pts_name'] ?? '---', topPts:
topAst: game['top_ast_name'] ?? '---', game['top_pts_name'] ?? '---',
topRbs: game['top_rbs_name'] ?? '---', topAst:
topDef: game['top_def_name'] ?? '---', game['top_ast_name'] ?? '---',
topRbs:
game['top_rbs_name'] ?? '---',
topDef:
game['top_def_name'] ?? '---',
mvp: game['mvp_name'] ?? '---', mvp: game['mvp_name'] ?? '---',
); );
}).toList(), }).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),
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),
],
),
),
),
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), SizedBox(height: 20 * context.sf),
], ],
);
},
), ),
), ),
); );