379 lines
15 KiB
Dart
379 lines
15 KiB
Dart
import 'package:firebase_core/firebase_core.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
|
import 'package:intl/intl.dart';
|
|
|
|
class EventResultPage extends StatelessWidget {
|
|
final String eventId;
|
|
final String eventName;
|
|
|
|
EventResultPage({required this.eventId, required this.eventName});
|
|
|
|
final Color primaryBlue = Color(0xFF42A5F5);
|
|
final Color lightBlue = Color(0xFFBBDEFB);
|
|
final Color darkBlue = Color(0xFF1976D2);
|
|
final Color successGreen = Colors.green.shade700;
|
|
final Color warningOrange = Colors.orange.shade700;
|
|
final Color dangerRed = Colors.red.shade700;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final docRef = FirebaseFirestore.instance.collection('events').doc(eventId);
|
|
|
|
return Scaffold(
|
|
backgroundColor: Colors.grey[50],
|
|
appBar: AppBar(
|
|
title: Text(
|
|
'Hasil Event: $eventName',
|
|
style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
|
|
),
|
|
centerTitle: true,
|
|
backgroundColor: primaryBlue,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.only(
|
|
bottomLeft: Radius.circular(20),
|
|
bottomRight: Radius.circular(20),
|
|
),
|
|
),
|
|
iconTheme: IconThemeData(color: Colors.white),
|
|
elevation: 5,
|
|
),
|
|
body: StreamBuilder<DocumentSnapshot>(
|
|
stream: docRef.snapshots(),
|
|
builder: (context, snapshot) {
|
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
|
return Center(child: CircularProgressIndicator(color: primaryBlue));
|
|
}
|
|
if (snapshot.hasError) {
|
|
return Center(child: Text('Error: ${snapshot.error}', style: TextStyle(color: dangerRed)));
|
|
}
|
|
if (!snapshot.hasData || !snapshot.data!.exists) {
|
|
return Center(child: Text('Event tidak ditemukan.', style: TextStyle(color: Colors.grey[600])));
|
|
}
|
|
|
|
final data = snapshot.data!.data() as Map<String, dynamic>?;
|
|
|
|
if (data == null || data['participants'] == null) {
|
|
return Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(Icons.people_alt_outlined, size: 60, color: Colors.grey[400]),
|
|
SizedBox(height: 16),
|
|
Text('Belum ada peserta yang terdaftar untuk event ini.', style: TextStyle(fontSize: 16, color: Colors.grey[600])),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
final participants = data['participants'] as Map<String, dynamic>;
|
|
|
|
if (participants.isEmpty) {
|
|
return Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(Icons.people_alt_outlined, size: 60, color: Colors.grey[400]),
|
|
SizedBox(height: 16),
|
|
Text('Belum ada peserta yang terdaftar untuk event ini.', style: TextStyle(fontSize: 16, color: Colors.grey[600])),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
List<MapEntry<String, dynamic>> sortedParticipants = participants.entries.toList();
|
|
sortedParticipants.sort((a, b) {
|
|
final pA = a.value as Map<String, dynamic>;
|
|
final pB = b.value as Map<String, dynamic>;
|
|
|
|
final finishedA = pA['finished'] == true;
|
|
final finishedB = pB['finished'] == true;
|
|
|
|
if (finishedA && !finishedB) return -1;
|
|
if (!finishedA && finishedB) return 1;
|
|
|
|
if (finishedA && finishedB) {
|
|
final timeA = _parseTimeToSeconds(pA['total_time']);
|
|
final timeB = _parseTimeToSeconds(pB['total_time']);
|
|
return timeA.compareTo(timeB);
|
|
}
|
|
|
|
final lapsA = pA['laps'] ?? 0;
|
|
final lapsB = pB['laps'] ?? 0;
|
|
return lapsB.compareTo(lapsA);
|
|
});
|
|
|
|
|
|
return ListView.builder(
|
|
padding: EdgeInsets.all(16),
|
|
itemCount: sortedParticipants.length,
|
|
itemBuilder: (context, index) {
|
|
final entry = sortedParticipants[index];
|
|
final p = entry.value as Map<String, dynamic>;
|
|
|
|
final name = p['name'] ?? 'Peserta Tidak Dikenal';
|
|
final laps = p['laps'] ?? 0;
|
|
final finished = p['finished'] == true;
|
|
final totalTime = p['total_time'] ?? '-';
|
|
final rfidUid = p['rfid_uid'] ?? '-';
|
|
final lapDurations = p['lap_durations'] as Map<String, dynamic>? ?? {};
|
|
|
|
String participantStatus = finished ? 'Selesai' : 'Berlangsung';
|
|
Color participantStatusColor = finished ? successGreen : warningOrange;
|
|
|
|
String formattedTotalTime = '-';
|
|
if (finished && totalTime != '-') {
|
|
try {
|
|
final duration = Duration(milliseconds: _parseMilliseconds(totalTime));
|
|
formattedTotalTime = _formatDuration(duration);
|
|
} catch (e) {
|
|
formattedTotalTime = totalTime;
|
|
}
|
|
}
|
|
|
|
List<Widget> kecepatanWidgets = [];
|
|
final sortedLapKeys = lapDurations.keys.toList()..sort((a, b) {
|
|
final intA = int.parse(a.split('_')[1]);
|
|
final intB = int.parse(b.split('_')[1]);
|
|
return intA.compareTo(intB);
|
|
});
|
|
|
|
for (String lapKey in sortedLapKeys) {
|
|
final dynamic durasiDetikRaw = lapDurations[lapKey];
|
|
double durasiDetik = 0.0;
|
|
if (durasiDetikRaw is int) {
|
|
durasiDetik = durasiDetikRaw.toDouble();
|
|
} else if (durasiDetikRaw is double) {
|
|
durasiDetik = durasiDetikRaw;
|
|
}
|
|
|
|
String formattedDuration = '${durasiDetik.toStringAsFixed(2)} s';
|
|
|
|
if (durasiDetik > 0) {
|
|
final kecepatan = 400 / durasiDetik;
|
|
kecepatanWidgets.add(
|
|
Row(
|
|
children: [
|
|
SizedBox(width: 24),
|
|
Text(
|
|
"Lap ${lapKey.split('_')[1]}: ${kecepatan.toStringAsFixed(2)} m/s ",
|
|
style: TextStyle(fontSize: 13, color: Colors.grey[800]),
|
|
),
|
|
Text(
|
|
"($formattedDuration)",
|
|
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
} else {
|
|
kecepatanWidgets.add(
|
|
Padding(
|
|
padding: const EdgeInsets.only(left: 24.0),
|
|
child: Text(
|
|
"Lap ${lapKey.split('_')[1]}: Durasi tidak valid",
|
|
style: TextStyle(fontSize: 13, color: Colors.grey[500], fontStyle: FontStyle.italic),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
return Card(
|
|
margin: EdgeInsets.only(bottom: 16),
|
|
elevation: 4,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
color: Colors.white,
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16.0),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Flexible(
|
|
child: Text(
|
|
name,
|
|
style: TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.bold,
|
|
color: darkBlue,
|
|
),
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
Container(
|
|
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
|
decoration: BoxDecoration(
|
|
color: participantStatusColor.withOpacity(0.1),
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: Text(
|
|
participantStatus,
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.bold,
|
|
color: participantStatusColor,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
SizedBox(height: 8),
|
|
Divider(color: Colors.grey[200]),
|
|
SizedBox(height: 8),
|
|
_buildInfoRow("RFID Tag", rfidUid, darkBlue),
|
|
_buildInfoRow("Putaran Selesai", "$laps", darkBlue),
|
|
if (finished) _buildInfoRow("Waktu Total", formattedTotalTime, darkBlue),
|
|
|
|
if (kecepatanWidgets.isNotEmpty) ...[
|
|
SizedBox(height: 12),
|
|
Text(
|
|
"Detail Kecepatan per Lap:",
|
|
style: TextStyle(
|
|
fontSize: 15,
|
|
fontWeight: FontWeight.bold,
|
|
color: darkBlue,
|
|
),
|
|
),
|
|
SizedBox(height: 8),
|
|
...kecepatanWidgets,
|
|
],
|
|
|
|
// Tombol hapus peserta
|
|
SizedBox(height: 16),
|
|
Align(
|
|
alignment: Alignment.centerRight,
|
|
child: IconButton( // <-- DIUBAH DARI TextButton.icon MENJADI IconButton
|
|
icon: Icon(Icons.delete_outline, color: dangerRed, size: 24), // Ukuran ikon diperbesar sedikit
|
|
tooltip: 'Hapus Peserta', // Tooltip tetap ada saat ditekan lama
|
|
onPressed: () async {
|
|
final confirm = await showDialog<bool>(
|
|
context: context,
|
|
builder: (_) => AlertDialog(
|
|
title: Text('Konfirmasi Hapus Peserta', style: TextStyle(color: dangerRed)),
|
|
content: Text('Apakah Anda yakin ingin menghapus peserta "$name" dari event ini?', style: TextStyle(color: Colors.grey[800])),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context, false),
|
|
child: Text('Batal', style: TextStyle(color: primaryBlue)),
|
|
),
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context, true),
|
|
child: Text('Hapus', style: TextStyle(color: dangerRed)),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
if (confirm == true) {
|
|
try {
|
|
await docRef.update({
|
|
'participants.${entry.key}': FieldValue.delete(),
|
|
});
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text('Peserta "$name" berhasil dihapus.'), backgroundColor: Colors.green),
|
|
);
|
|
} catch (e) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text('Gagal menghapus peserta: $e'), backgroundColor: Colors.red),
|
|
);
|
|
print('Error deleting participant: $e');
|
|
}
|
|
}
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
// Helper widget untuk baris informasi yang clean tanpa ikon
|
|
Widget _buildInfoRow(String label, String value, Color valueColor) {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 4.0),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Expanded(
|
|
flex: 2,
|
|
child: Text(
|
|
"$label:",
|
|
style: TextStyle(fontWeight: FontWeight.w500, fontSize: 14, color: Colors.grey[700]),
|
|
),
|
|
),
|
|
Expanded(
|
|
flex: 3,
|
|
child: Text(
|
|
value,
|
|
style: TextStyle(fontSize: 15, color: valueColor),
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
// Helper untuk parsing waktu (misal "00:01:23.456" menjadi milidetik)
|
|
int _parseMilliseconds(String timeString) {
|
|
if (timeString == '-') return 0;
|
|
|
|
final parts = timeString.split(':');
|
|
if (parts.length < 3) return 0; // Invalid format
|
|
|
|
int hours = int.parse(parts[0]);
|
|
int minutes = int.parse(parts[1]);
|
|
double secondsWithMs = double.parse(parts[2]);
|
|
|
|
int totalMilliseconds = ((hours * 3600 + minutes * 60 + secondsWithMs) * 1000).toInt();
|
|
return totalMilliseconds;
|
|
}
|
|
|
|
// Helper untuk format total_time dari milidetik
|
|
String _formatDuration(Duration duration) {
|
|
String twoDigits(int n) => n.toString().padLeft(2, "0");
|
|
String threeDigits(int n) => n.toString().padLeft(3, "0");
|
|
|
|
String minutes = twoDigits(duration.inMinutes.remainder(60));
|
|
String seconds = twoDigits(duration.inSeconds.remainder(60));
|
|
String milliseconds = threeDigits(duration.inMilliseconds.remainder(1000));
|
|
String hours = twoDigits(duration.inHours);
|
|
|
|
if (duration.inHours > 0) {
|
|
return "$hours:$minutes:$seconds.$milliseconds";
|
|
} else {
|
|
return "$minutes:$seconds.$milliseconds";
|
|
}
|
|
}
|
|
|
|
// Helper untuk mengonversi string waktu (HH:MM:SS.ms) ke total detik (untuk sorting)
|
|
double _parseTimeToSeconds(String timeString) {
|
|
if (timeString == '-') return double.infinity;
|
|
|
|
try {
|
|
final parts = timeString.split(':');
|
|
if (parts.length != 3) return double.infinity;
|
|
|
|
final hours = int.parse(parts[0]);
|
|
final minutes = int.parse(parts[1]);
|
|
final secondsParts = parts[2].split('.');
|
|
final seconds = int.parse(secondsParts[0]);
|
|
final milliseconds = secondsParts.length > 1 ? int.parse(secondsParts[1]) : 0;
|
|
|
|
return (hours * 3600 + minutes * 60 + seconds + milliseconds / 1000).toDouble();
|
|
} catch (e) {
|
|
return double.infinity;
|
|
}
|
|
}
|
|
} |