426 lines
18 KiB
Dart
426 lines
18 KiB
Dart
import 'package:cloud_firestore/cloud_firestore.dart';
|
|
import 'package:firebase_auth/firebase_auth.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:fl_chart/fl_chart.dart';
|
|
import 'package:intl/intl.dart';
|
|
import 'detail_hasil_screen.dart';
|
|
import 'all_activity_screen.dart';
|
|
|
|
class HomeScreen extends StatelessWidget {
|
|
const HomeScreen({super.key});
|
|
|
|
Future<String> getUsername() async {
|
|
final user = FirebaseAuth.instance.currentUser;
|
|
if (user == null) return '';
|
|
final doc = await FirebaseFirestore.instance.collection('users').doc(user.uid).get();
|
|
return doc.data()?['username'] ?? '';
|
|
}
|
|
|
|
Future<Map<String, double>> fetchRunningData() async {
|
|
final user = FirebaseAuth.instance.currentUser;
|
|
if (user == null) return {};
|
|
|
|
final now = DateTime.now();
|
|
final startOfWeek = DateTime(now.year, now.month, now.day - (now.weekday - 1));
|
|
final endOfWeek = startOfWeek.add(const Duration(days: 7)).subtract(const Duration(seconds: 1));
|
|
|
|
final snapshot = await FirebaseFirestore.instance.collection('activities').get();
|
|
final Map<String, double> distancePerDay = {};
|
|
|
|
for (var doc in snapshot.docs) {
|
|
final data = doc.data()['data'];
|
|
if (data == null || data['userId'] != user.uid) continue;
|
|
|
|
final timestamp = (data['timestamp'] as Timestamp?)?.toDate();
|
|
if (timestamp == null || timestamp.isBefore(startOfWeek) || timestamp.isAfter(endOfWeek)) continue;
|
|
|
|
final day = DateFormat('EEEE', 'id_ID').format(timestamp);
|
|
final jarak = (data['jarak'] ?? 0).toDouble();
|
|
final satuan = data['satuan'] ?? 'M';
|
|
final jarakKm = satuan == 'M' ? jarak / 1000.0 : jarak;
|
|
|
|
distancePerDay[day] = (distancePerDay[day] ?? 0) + jarakKm;
|
|
}
|
|
|
|
const sortedDays = ['Senin', 'Selasa', 'Rabu', 'Kamis', 'Jumat', 'Sabtu', 'Minggu'];
|
|
for (var dayName in sortedDays) {
|
|
distancePerDay.putIfAbsent(dayName, () => 0.0);
|
|
}
|
|
|
|
return distancePerDay;
|
|
}
|
|
|
|
Future<List<Map<String, dynamic>>> fetchRingkasanData() async {
|
|
final user = FirebaseAuth.instance.currentUser;
|
|
if (user == null) return [];
|
|
|
|
final snapshot = await FirebaseFirestore.instance.collection('activities').get();
|
|
final List<Map<String, dynamic>> result = [];
|
|
|
|
for (var doc in snapshot.docs) {
|
|
final data = doc.data()['data']; // penting!
|
|
if (data == null || data['userId'] != user.uid) continue;
|
|
result.add({
|
|
...data,
|
|
'docId': doc.id, // tambahkan docId untuk referensi penghapusan nanti
|
|
});
|
|
}
|
|
|
|
result.sort((a, b) {
|
|
final aTime = (a['timestamp'] as Timestamp?)?.toDate() ?? DateTime(2000);
|
|
final bTime = (b['timestamp'] as Timestamp?)?.toDate() ?? DateTime(2000);
|
|
return bTime.compareTo(aTime);
|
|
});
|
|
|
|
return result;
|
|
}
|
|
|
|
String getGreeting() {
|
|
final hour = DateTime.now().hour;
|
|
if (hour >= 6 && hour <= 10) {
|
|
return 'Selamat Pagi';
|
|
} else if (hour >= 11 && hour <= 14) {
|
|
return 'Selamat Siang';
|
|
} else if (hour >= 15 && hour <= 17) {
|
|
return 'Selamat Sore';
|
|
} else {
|
|
return 'Selamat Malam';
|
|
}
|
|
}
|
|
|
|
DateTime? parseTimestamp(dynamic raw) {
|
|
if (raw is Timestamp) {
|
|
return raw.toDate();
|
|
} else if (raw is DateTime) {
|
|
return raw;
|
|
} else if (raw is String) {
|
|
return DateTime.tryParse(raw);
|
|
} else if (raw is int) {
|
|
return DateTime.fromMillisecondsSinceEpoch(raw);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
backgroundColor: Colors.white,
|
|
appBar: PreferredSize(
|
|
preferredSize: const Size.fromHeight(80),
|
|
child: FutureBuilder(
|
|
future: getUsername(),
|
|
builder: (context, snapshot) {
|
|
final greeting = getGreeting();
|
|
final username = snapshot.data ?? '...';
|
|
|
|
return Container(
|
|
decoration: const BoxDecoration(
|
|
color: Colors.blue,
|
|
borderRadius: BorderRadius.only(
|
|
bottomLeft: Radius.circular(20),
|
|
bottomRight: Radius.circular(20),
|
|
),
|
|
),
|
|
padding: const EdgeInsets.only(top: 40, left: 20, right: 20, bottom: 20),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Text(
|
|
'$greeting, $username',
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 20,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
Image.asset(
|
|
'images/run.png',
|
|
height: 55,
|
|
width: 55,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
body: RefreshIndicator(
|
|
onRefresh: () async {
|
|
// Memanggil kembali data setelah pull-to-refresh
|
|
fetchRunningData();
|
|
fetchRingkasanData();
|
|
},
|
|
child: Padding(
|
|
padding: const EdgeInsets.only(top: 0.0, bottom: 0.0, right: 14.0, left: 14.0),
|
|
child: FutureBuilder(
|
|
future: Future.wait([fetchRunningData(), fetchRingkasanData(), getUsername()]),
|
|
builder: (context, snapshot) {
|
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
|
return const Center(child: CircularProgressIndicator());
|
|
}
|
|
|
|
final runningData = snapshot.data?[0] as Map<String, double>? ?? {};
|
|
final ringkasanData = snapshot.data?[1] as List<Map<String, dynamic>>? ?? [];
|
|
|
|
final sortedDays = ['Senin', 'Selasa', 'Rabu', 'Kamis', 'Jumat', 'Sabtu', 'Minggu'];
|
|
final values = sortedDays.map((day) => runningData[day] ?? 0.0).toList();
|
|
final totalMinggu = values.fold(0.0, (a, b) => a + b);
|
|
int totalDurasiDetik = 0;
|
|
final now = DateTime.now();
|
|
final startOfWeek = DateTime(now.year, now.month, now.day - (now.weekday - 1));
|
|
final endOfWeek = startOfWeek.add(const Duration(days: 7)).subtract(const Duration(seconds: 1));
|
|
|
|
for (var data in ringkasanData) {
|
|
final timestamp = parseTimestamp(data['timestamp']);
|
|
if (timestamp == null) continue;
|
|
if (timestamp.isBefore(startOfWeek) || timestamp.isAfter(endOfWeek)) continue;
|
|
|
|
totalDurasiDetik += (((data['duration'] ?? data['durasi'] ?? 0) as num).toInt() ~/ 1000);
|
|
}
|
|
|
|
String formatDurasi(int totalSeconds) {
|
|
final jam = totalSeconds ~/ 3600;
|
|
final menit = (totalSeconds % 3600) ~/ 60;
|
|
final detik = totalSeconds % 60;
|
|
return '${jam}j ${menit}m ${detik}d';
|
|
}
|
|
|
|
final totalDurasiFormatted = formatDurasi(totalDurasiDetik);
|
|
|
|
final maxValue = values.reduce((a, b) => a > b ? a : b);
|
|
final highestDay = sortedDays[values.indexOf(maxValue)];
|
|
|
|
return SingleChildScrollView(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
const Text(
|
|
'Minggu ini',
|
|
style: TextStyle(
|
|
fontSize: 22,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
TextButton(
|
|
onPressed: () {
|
|
Navigator.push(
|
|
context,
|
|
MaterialPageRoute(builder: (_) => const AllActivityScreen()),
|
|
);
|
|
},
|
|
child: const Text(
|
|
'View all weeks',
|
|
style: TextStyle(color: Colors.blue),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 1),
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.start,
|
|
children: [
|
|
Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const Text(
|
|
'Jarak',
|
|
style: TextStyle(fontSize: 12, fontWeight: FontWeight.normal),
|
|
),
|
|
Text(
|
|
'${totalMinggu.toStringAsFixed(2)} km',
|
|
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(width: 20),
|
|
Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const Text(
|
|
'Waktu',
|
|
style: TextStyle(fontSize: 12, fontWeight: FontWeight.normal),
|
|
),
|
|
Text(
|
|
totalDurasiFormatted,
|
|
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 20),
|
|
SizedBox(
|
|
height: 170,
|
|
child: Padding(
|
|
padding: const EdgeInsets.only(left: 5.0, right: 6.0),
|
|
child: LineChart(
|
|
LineChartData(
|
|
minY: 0.0,
|
|
maxY: (values.isEmpty ? 5.0 : values.reduce((a, b) => a > b ? a : b)),
|
|
gridData: FlGridData(
|
|
show: true,
|
|
drawVerticalLine: true,
|
|
drawHorizontalLine: false,
|
|
verticalInterval: 1,
|
|
getDrawingVerticalLine: (value) {
|
|
return FlLine(
|
|
color: Colors.blue.withOpacity(0.2),
|
|
strokeWidth: 1,
|
|
);
|
|
},
|
|
),
|
|
extraLinesData: ExtraLinesData(
|
|
verticalLines: [
|
|
VerticalLine(
|
|
x: 0,
|
|
color: Colors.blue.withOpacity(0.2),
|
|
strokeWidth: 1,
|
|
),
|
|
VerticalLine(
|
|
x: (sortedDays.length - 1).toDouble(),
|
|
color: Colors.blue.withOpacity(0.2),
|
|
strokeWidth: 1,
|
|
),
|
|
],
|
|
),
|
|
titlesData: FlTitlesData(
|
|
bottomTitles: AxisTitles(
|
|
sideTitles: SideTitles(
|
|
showTitles: true,
|
|
interval: 1,
|
|
getTitlesWidget: (value, meta) {
|
|
int index = value.toInt();
|
|
if (index < 0 || index >= sortedDays.length) {
|
|
return const SizedBox.shrink();
|
|
}
|
|
EdgeInsets padding = EdgeInsets.zero;
|
|
if (index == 0) {
|
|
padding = const EdgeInsets.only(left: 12);
|
|
} else if (index == sortedDays.length - 1) {
|
|
padding = const EdgeInsets.only(right: 12);
|
|
}
|
|
return Padding(
|
|
padding: padding.add(const EdgeInsets.only(top: 4)), // tambah jarak dari grafik ke teks
|
|
child: Text(
|
|
sortedDays[index].substring(0, 3),
|
|
style: const TextStyle(fontSize: 10),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
leftTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
|
rightTitles: AxisTitles(
|
|
sideTitles: SideTitles(
|
|
showTitles: true,
|
|
reservedSize: 35,
|
|
getTitlesWidget: (value, _) {
|
|
if (value == 0.0 || value == maxValue || value == (maxValue + 1)) {
|
|
return Padding(
|
|
padding: const EdgeInsets.only(left: 8),
|
|
child: Text('${value.toStringAsFixed(1)} KM', style: const TextStyle(fontSize: 10)),
|
|
);
|
|
}
|
|
return const SizedBox.shrink();
|
|
},
|
|
),
|
|
),
|
|
topTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
|
),
|
|
borderData: FlBorderData(show: false),
|
|
lineBarsData: [
|
|
LineChartBarData(
|
|
spots: List.generate(values.length, (i) => FlSpot(i.toDouble(), values[i])),
|
|
isCurved: false,
|
|
barWidth: 3,
|
|
dotData: FlDotData(show: true),
|
|
belowBarData: BarAreaData(
|
|
show: true,
|
|
color: Colors.lightBlueAccent.withOpacity(0.3),
|
|
),
|
|
color: Colors.blue,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 24),
|
|
Text('Hari Teraktif: $highestDay (${maxValue.toStringAsFixed(2)} KM)',
|
|
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500)),
|
|
const SizedBox(height: 16),
|
|
|
|
const Text('Ringkasan Hasil Lari',
|
|
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600)),
|
|
const SizedBox(height: 8),
|
|
|
|
ringkasanData.isEmpty
|
|
? const Text('Belum ada data untuk hari teraktif.')
|
|
: ListView.separated(
|
|
shrinkWrap: true,
|
|
physics: const NeverScrollableScrollPhysics(),
|
|
itemCount: ringkasanData.length,
|
|
separatorBuilder: (_, __) => const SizedBox(height: 12),
|
|
itemBuilder: (context, index) {
|
|
final data = ringkasanData[index];
|
|
final timestamp = (data['timestamp'] as Timestamp?)?.toDate();
|
|
if (timestamp == null) return const SizedBox();
|
|
|
|
final jarak = (data['jarak'] ?? 0).toDouble();
|
|
final satuan = data['satuan'] ?? 'M';
|
|
final jarakKm = satuan == 'M' ? jarak / 1000.0 : jarak;
|
|
|
|
return GestureDetector(
|
|
onTap: () {
|
|
Navigator.push(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder: (_) => DetailHasilLariScreen(
|
|
data: {
|
|
...data,
|
|
'docId': ringkasanData[index]['docId'] ?? '', // tambahkan docId kalau tersedia
|
|
},
|
|
),
|
|
),
|
|
);
|
|
},
|
|
|
|
child: Card(
|
|
color: const Color.fromARGB(255, 60, 193, 255),
|
|
elevation: 2,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: ListTile(
|
|
leading: Padding(
|
|
padding: const EdgeInsets.only(top: 12.0), // geser ke bawah
|
|
child: const Icon(Icons.directions_run),
|
|
),
|
|
title: Text('${jarak.toStringAsFixed(0)} $satuan'),
|
|
subtitle: Text(
|
|
'${DateFormat('dd MMM yyyy, HH:mm').format(timestamp)}\n(${jarakKm.toStringAsFixed(2)} KM)',
|
|
),
|
|
isThreeLine: true,
|
|
trailing: Padding(
|
|
padding: const EdgeInsets.only(top: 12.0), // geser ke bawah
|
|
child: const Icon(Icons.arrow_forward_ios, size: 16),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
)
|
|
);
|
|
}
|
|
}
|