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(
@@ -238,7 +240,29 @@
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);
if (actionType != null && recordTime != null) {
_lastAppliedActionAt[actionType] = recordTime;
}
print(
"🔄 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) {
// 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,62 +404,16 @@ 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: 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),
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: cardHeight,
height: effectiveCardHeight,
child: Row(
children: [
Expanded(
@@ -484,9 +438,8 @@ class _HomeScreenState extends State<HomeScreen> {
),
),
SizedBox(height: 12 * context.sf),
SizedBox(
height: cardHeight,
height: effectiveCardHeight,
child: Row(
children: [
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',
style: TextStyle(
fontSize: 20 * context.sf,
fontWeight: FontWeight.bold,
color: textColor)),
SizedBox(height: 16 * context.sf),
_selectedTeamName == "Selecionar Equipa"
? Container(
width: double.infinity,
@@ -542,7 +498,8 @@ class _HomeScreenState extends State<HomeScreen> {
Container(
padding: EdgeInsets.all(18 * context.sf),
decoration: BoxDecoration(
color: AppTheme.primaryRed.withOpacity(0.08),
color: AppTheme.primaryRed
.withOpacity(0.08),
shape: BoxShape.circle),
child: Icon(Icons.shield_outlined,
color: AppTheme.primaryRed,
@@ -591,13 +548,14 @@ class _HomeScreenState extends State<HomeScreen> {
: StreamBuilder<List<Map<String, dynamic>>>(
stream: _supabase
.from('games')
.stream(primaryKey: ['id']).order('game_date',
ascending: false),
.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));
return Text(
"Erro: ${gameSnapshot.error}",
style: const TextStyle(
color: Colors.red));
}
if (!gameSnapshot.hasData &&
gameSnapshot.connectionState ==
@@ -679,18 +637,103 @@ class _HomeScreenState extends State<HomeScreen> {
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'] ?? '---',
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),
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),
],
);
},
),
),
);