update
This commit is contained in:
parent
7e88c0f8e8
commit
760f15db14
|
|
@ -8,17 +8,24 @@ class TimeSeriesMapper {
|
|||
required double Function(T) getValue,
|
||||
}) {
|
||||
final now = DateTime.now();
|
||||
final slots = List<double>.filled(24, 0.0);
|
||||
|
||||
final sums = List<double>.filled(24, 0.0);
|
||||
final counts = List<int>.filled(24, 0);
|
||||
|
||||
for (final item in data) {
|
||||
final time = getTime(item);
|
||||
|
||||
if (_isSameDay(time, now)) {
|
||||
slots[time.hour] = getValue(item);
|
||||
final hour = time.hour;
|
||||
sums[hour] += getValue(item);
|
||||
counts[hour]++;
|
||||
}
|
||||
}
|
||||
|
||||
return slots;
|
||||
return List.generate(24, (i) {
|
||||
if (counts[i] == 0) return 0;
|
||||
return sums[i] / counts[i]; // 🔥 average, bukan overwrite
|
||||
});
|
||||
}
|
||||
|
||||
/// =========================
|
||||
|
|
@ -32,10 +39,9 @@ class TimeSeriesMapper {
|
|||
final now = DateTime.now();
|
||||
final slots = List<double>.filled(7, 0.0);
|
||||
|
||||
DateTime startOfWeek =
|
||||
now.subtract(Duration(days: now.weekday - 1));
|
||||
startOfWeek = DateTime(
|
||||
startOfWeek.year, startOfWeek.month, startOfWeek.day);
|
||||
DateTime startOfWeek = now.subtract(Duration(days: now.weekday - 1));
|
||||
startOfWeek =
|
||||
DateTime(startOfWeek.year, startOfWeek.month, startOfWeek.day);
|
||||
|
||||
for (final item in data) {
|
||||
final time = getTime(item);
|
||||
|
|
@ -75,8 +81,23 @@ class TimeSeriesMapper {
|
|||
/// 🧠 HELPER
|
||||
/// =========================
|
||||
static bool _isSameDay(DateTime a, DateTime b) {
|
||||
return a.year == b.year &&
|
||||
a.month == b.month &&
|
||||
a.day == b.day;
|
||||
return a.year == b.year && a.month == b.month && a.day == b.day;
|
||||
}
|
||||
}
|
||||
|
||||
static List<double> smooth(List<double> data) {
|
||||
if (data.length < 3) return data;
|
||||
|
||||
List<double> result = [];
|
||||
|
||||
for (int i = 0; i < data.length; i++) {
|
||||
if (i == 0 || i == data.length - 1) {
|
||||
result.add(data[i]);
|
||||
} else {
|
||||
final avg = (data[i - 1] + data[i] + data[i + 1]) / 3;
|
||||
result.add(avg);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ class SignInBloc extends Bloc<SignInEvent, SignInState> {
|
|||
emit(SignInProcess());
|
||||
try {
|
||||
await _userRepository.signInWithGoogle();
|
||||
emit(SignInSuccess());
|
||||
} catch (e) {
|
||||
emit(SignInFailure());
|
||||
emit(SignInInitial());
|
||||
|
|
|
|||
|
|
@ -1,784 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:fl_chart/fl_chart.dart';
|
||||
import 'shared/widgets/monitoring_shared.dart';
|
||||
|
||||
/// AIR QUALITY MONITORING SCREEN
|
||||
class AirQualityMonitoringScreen extends StatefulWidget {
|
||||
const AirQualityMonitoringScreen({super.key});
|
||||
|
||||
@override
|
||||
State<AirQualityMonitoringScreen> createState() =>
|
||||
_AirQualityMonitoringScreenState();
|
||||
}
|
||||
|
||||
class _AirQualityMonitoringScreenState
|
||||
extends State<AirQualityMonitoringScreen> {
|
||||
String _selectedPeriod = "Hari Ini";
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text("Monitoring Kualitas Udara"),
|
||||
centerTitle: true,
|
||||
),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
"Kualitas Udara",
|
||||
style: TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
/// GRAFIK MONITORING
|
||||
_buildMonitoringGraphBlock(),
|
||||
const SizedBox(height: 25),
|
||||
|
||||
/// PENJELASAN
|
||||
_buildAirQualityExplanation(),
|
||||
const SizedBox(height: 25),
|
||||
|
||||
/// STATISTIK TAHUNAN
|
||||
_buildAnnualAirQualityGraphSection(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// EXPORT FUNCTIONS
|
||||
void _exportDailyAirQualityData() {
|
||||
final StringBuffer csvContent = StringBuffer();
|
||||
csvContent.writeln("DATA MONITORING KUALITAS UDARA - HARIAN (24 JAM)");
|
||||
csvContent.writeln("Tanggal: ${DateTime.now().toString().split(' ')[0]}");
|
||||
csvContent.writeln("");
|
||||
csvContent.writeln("Jam,AQI (Air Quality Index)");
|
||||
|
||||
final dailyData = [
|
||||
68.0,
|
||||
70.0,
|
||||
72.0,
|
||||
75.0,
|
||||
78.0,
|
||||
80.0,
|
||||
78.0,
|
||||
75.0,
|
||||
72.0,
|
||||
70.0,
|
||||
68.0,
|
||||
65.0,
|
||||
60.0,
|
||||
58.0,
|
||||
60.0,
|
||||
62.0,
|
||||
65.0,
|
||||
68.0,
|
||||
70.0,
|
||||
72.0,
|
||||
75.0,
|
||||
78.0,
|
||||
76.0,
|
||||
74.0
|
||||
];
|
||||
for (int i = 0; i < dailyData.length; i++) {
|
||||
csvContent.writeln("$i:00,${dailyData[i]}");
|
||||
}
|
||||
|
||||
csvContent.writeln("");
|
||||
csvContent.writeln("Statistik Harian");
|
||||
csvContent.writeln("Total: 1709 AQI");
|
||||
csvContent.writeln("Rata-rata: 71.2 AQI");
|
||||
csvContent.writeln("Maksimal: 80 AQI");
|
||||
csvContent.writeln("Minimal: 58 AQI");
|
||||
|
||||
showExportPreview(
|
||||
context, csvContent.toString(), "Data Harian Kualitas Udara");
|
||||
}
|
||||
|
||||
void _exportWeeklyAirQualityData() {
|
||||
final StringBuffer csvContent = StringBuffer();
|
||||
csvContent.writeln("DATA MONITORING KUALITAS UDARA - MINGGUAN");
|
||||
csvContent.writeln("Tanggal: ${DateTime.now().toString().split(' ')[0]}");
|
||||
csvContent.writeln("");
|
||||
csvContent.writeln("Hari,Rata-rata AQI");
|
||||
|
||||
final days = [
|
||||
'Senin',
|
||||
'Selasa',
|
||||
'Rabu',
|
||||
'Kamis',
|
||||
'Jumat',
|
||||
'Sabtu',
|
||||
'Minggu'
|
||||
];
|
||||
final weeklyData = [68.0, 72.0, 75.0, 78.0, 72.0, 68.0, 62.0];
|
||||
|
||||
for (int i = 0; i < days.length; i++) {
|
||||
csvContent.writeln("${days[i]},${weeklyData[i]}");
|
||||
}
|
||||
|
||||
csvContent.writeln("");
|
||||
csvContent.writeln("Statistik Mingguan");
|
||||
csvContent.writeln("Total: 495 AQI");
|
||||
csvContent.writeln("Rata-rata: 70.7 AQI");
|
||||
csvContent.writeln("Maksimal: 78 AQI");
|
||||
csvContent.writeln("Minimal: 62 AQI");
|
||||
|
||||
showExportPreview(
|
||||
context, csvContent.toString(), "Data Mingguan Kualitas Udara");
|
||||
}
|
||||
|
||||
void _exportMonthlyAirQualityData() {
|
||||
final StringBuffer csvContent = StringBuffer();
|
||||
csvContent.writeln("DATA MONITORING KUALITAS UDARA - BULANAN");
|
||||
csvContent.writeln(
|
||||
"Bulan: ${DateTime.now().toString().split(' ')[0].substring(0, 7)}");
|
||||
csvContent.writeln("");
|
||||
csvContent.writeln("Tanggal,Rata-rata AQI");
|
||||
|
||||
for (int i = 1; i <= 28; i++) {
|
||||
final value = (70.0 + (i % 6) * 2.5).toStringAsFixed(1);
|
||||
csvContent.writeln("$i,$value");
|
||||
}
|
||||
|
||||
csvContent.writeln("");
|
||||
csvContent.writeln("Statistik Bulanan");
|
||||
csvContent.writeln("Total: 1990 AQI");
|
||||
csvContent.writeln("Rata-rata: 71.1 AQI");
|
||||
csvContent.writeln("Maksimal: 82 AQI");
|
||||
csvContent.writeln("Minimal: 55 AQI");
|
||||
|
||||
showExportPreview(
|
||||
context, csvContent.toString(), "Data Bulanan Kualitas Udara");
|
||||
}
|
||||
|
||||
void _exportAnnualAirQualityData() {
|
||||
final StringBuffer csvContent = StringBuffer();
|
||||
csvContent.writeln("DATA MONITORING KUALITAS UDARA - TAHUNAN");
|
||||
csvContent.writeln("Tahun: 2025");
|
||||
csvContent.writeln("");
|
||||
csvContent.writeln("Bulan,Rata-rata AQI,AQI Max");
|
||||
|
||||
final months = [
|
||||
'Januari',
|
||||
'Februari',
|
||||
'Maret',
|
||||
'April',
|
||||
'Mei',
|
||||
'Juni',
|
||||
'Juli',
|
||||
'Agustus',
|
||||
'September',
|
||||
'Oktober',
|
||||
'November',
|
||||
'Desember'
|
||||
];
|
||||
final averages = [
|
||||
68.0,
|
||||
70.0,
|
||||
72.5,
|
||||
75.0,
|
||||
78.0,
|
||||
80.0,
|
||||
82.0,
|
||||
80.0,
|
||||
75.0,
|
||||
70.0,
|
||||
68.0,
|
||||
65.0
|
||||
];
|
||||
final maxAqi = [
|
||||
85.0,
|
||||
88.0,
|
||||
90.0,
|
||||
95.0,
|
||||
98.0,
|
||||
100.0,
|
||||
102.0,
|
||||
100.0,
|
||||
95.0,
|
||||
90.0,
|
||||
85.0,
|
||||
80.0
|
||||
];
|
||||
|
||||
for (int i = 0; i < months.length; i++) {
|
||||
csvContent.writeln("${months[i]},${averages[i]},${maxAqi[i]}");
|
||||
}
|
||||
|
||||
csvContent.writeln("");
|
||||
csvContent.writeln("STATISTIK TAHUNAN");
|
||||
csvContent.writeln("Rata-rata Tahunan: 74.2 AQI");
|
||||
csvContent.writeln("AQI Max Teringgi: 102.0");
|
||||
csvContent.writeln("AQI Min Terendah: 65.0");
|
||||
|
||||
showExportPreview(
|
||||
context, csvContent.toString(), "Data Tahunan Kualitas Udara");
|
||||
}
|
||||
|
||||
/// DATA GENERATORS
|
||||
List<FlSpot> _getAirQualityPeriodData() {
|
||||
switch (_selectedPeriod) {
|
||||
case "Hari Ini":
|
||||
return List.generate(24, (i) {
|
||||
final values = [
|
||||
68.0,
|
||||
70.0,
|
||||
72.0,
|
||||
75.0,
|
||||
78.0,
|
||||
80.0,
|
||||
78.0,
|
||||
75.0,
|
||||
72.0,
|
||||
70.0,
|
||||
68.0,
|
||||
65.0,
|
||||
60.0,
|
||||
58.0,
|
||||
60.0,
|
||||
62.0,
|
||||
65.0,
|
||||
68.0,
|
||||
70.0,
|
||||
72.0,
|
||||
75.0,
|
||||
78.0,
|
||||
76.0,
|
||||
74.0
|
||||
];
|
||||
return FlSpot(i.toDouble(), values[i]);
|
||||
});
|
||||
case "Minggu Ini":
|
||||
return [
|
||||
const FlSpot(0, 68.0),
|
||||
const FlSpot(1, 72.0),
|
||||
const FlSpot(2, 75.0),
|
||||
const FlSpot(3, 78.0),
|
||||
const FlSpot(4, 72.0),
|
||||
const FlSpot(5, 68.0),
|
||||
const FlSpot(6, 62.0),
|
||||
];
|
||||
case "Bulan Ini":
|
||||
return [
|
||||
const FlSpot(0, 70.0),
|
||||
const FlSpot(1, 72.5),
|
||||
const FlSpot(2, 75.0),
|
||||
const FlSpot(3, 77.5),
|
||||
const FlSpot(4, 80.0),
|
||||
const FlSpot(5, 82.0),
|
||||
const FlSpot(6, 80.0),
|
||||
const FlSpot(7, 78.0),
|
||||
const FlSpot(8, 75.0),
|
||||
const FlSpot(9, 72.0),
|
||||
const FlSpot(10, 70.0),
|
||||
const FlSpot(11, 68.0),
|
||||
];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// UI BUILDERS
|
||||
Widget _buildMonitoringGraphBlock() {
|
||||
return Card(
|
||||
elevation: 4,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
"Status Kualitas Udara",
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 5,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.amber.shade100,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: const Text(
|
||||
"Normal",
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.amber,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
/// PERIOD SELECTOR + EXPORT
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children:
|
||||
["Hari Ini", "Minggu Ini", "Bulan Ini"].map((period) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: ChoiceChip(
|
||||
label: Text(period),
|
||||
selected: _selectedPeriod == period,
|
||||
onSelected: (selected) {
|
||||
setState(() {
|
||||
_selectedPeriod = period;
|
||||
});
|
||||
},
|
||||
selectedColor: Colors.orange,
|
||||
labelStyle: TextStyle(
|
||||
color: _selectedPeriod == period
|
||||
? Colors.white
|
||||
: Colors.black,
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
if (_selectedPeriod == "Hari Ini") {
|
||||
_exportDailyAirQualityData();
|
||||
} else if (_selectedPeriod == "Minggu Ini") {
|
||||
_exportWeeklyAirQualityData();
|
||||
} else if (_selectedPeriod == "Bulan Ini") {
|
||||
_exportMonthlyAirQualityData();
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.download, size: 16),
|
||||
label: const Text("Export"),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.orange,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 6,
|
||||
),
|
||||
textStyle: const TextStyle(fontSize: 11),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
/// GRAPH
|
||||
SizedBox(
|
||||
height: 300,
|
||||
child: LineChart(
|
||||
LineChartData(
|
||||
gridData: FlGridData(
|
||||
show: true,
|
||||
drawHorizontalLine: true,
|
||||
drawVerticalLine: false,
|
||||
horizontalInterval: 1,
|
||||
getDrawingHorizontalLine: (value) {
|
||||
return FlLine(
|
||||
color: Colors.grey.shade300,
|
||||
strokeWidth: 1,
|
||||
);
|
||||
},
|
||||
),
|
||||
titlesData: FlTitlesData(
|
||||
show: true,
|
||||
bottomTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
interval: getPeriodInterval(_selectedPeriod),
|
||||
getTitlesWidget: (value, meta) {
|
||||
return Text(
|
||||
getPeriodLabel(_selectedPeriod, value),
|
||||
style: const TextStyle(
|
||||
fontSize: 10,
|
||||
color: Colors.grey,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
leftTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
interval: 1,
|
||||
getTitlesWidget: (value, meta) {
|
||||
return Text(
|
||||
value.toInt().toString(),
|
||||
style: const TextStyle(
|
||||
fontSize: 9,
|
||||
color: Colors.grey,
|
||||
),
|
||||
);
|
||||
},
|
||||
reservedSize: 28,
|
||||
),
|
||||
),
|
||||
topTitles: AxisTitles(
|
||||
sideTitles: SideTitles(showTitles: false),
|
||||
),
|
||||
rightTitles: AxisTitles(
|
||||
sideTitles: SideTitles(showTitles: false),
|
||||
),
|
||||
),
|
||||
borderData: FlBorderData(
|
||||
show: true,
|
||||
border: Border(
|
||||
left: BorderSide(
|
||||
color: Colors.grey.shade300,
|
||||
width: 1,
|
||||
),
|
||||
bottom: BorderSide(
|
||||
color: Colors.grey.shade300,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
lineBarsData: [
|
||||
LineChartBarData(
|
||||
spots: _getAirQualityPeriodData(),
|
||||
isCurved: true,
|
||||
barWidth: 2.5,
|
||||
dotData: FlDotData(
|
||||
show: true,
|
||||
getDotPainter: (spot, percent, barData, index) {
|
||||
return FlDotCirclePainter(
|
||||
radius: 4,
|
||||
color: Colors.orange,
|
||||
strokeWidth: 0,
|
||||
);
|
||||
},
|
||||
),
|
||||
color: Colors.orange,
|
||||
belowBarData: BarAreaData(
|
||||
show: true,
|
||||
color: Colors.orange.withOpacity(0.1),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAirQualityExplanation() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange.shade50,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.orange.shade200),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
"Penjelasan Kualitas Udara",
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.orange,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
const Text(
|
||||
"Kualitas udara diukur menggunakan Air Quality Index (AQI) yang menunjukkan tingkat polusi udara. AQI berkisar dari 0-500, dengan nilai lebih rendah menunjukkan udara lebih bersih. Monitoring kualitas udara penting untuk kesehatan masyarakat, perencanaan lingkungan, dan identifikasi sumber polusi. Kategori: 0-50 (Baik), 51-100 (Wajar), 101-150 (Tidak Sehat), 151+ (Sangat Tidak Sehat).",
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
height: 1.6,
|
||||
color: Colors.orange,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAnnualAirQualityGraphSection() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
"Statistik Tahunan Kualitas Udara",
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 15),
|
||||
|
||||
/// STATS
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: StatCard(
|
||||
title: "Rata-rata Tahunan",
|
||||
value: "74.2 AQI",
|
||||
color: Colors.orange,
|
||||
icon: Icons.cloud,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: StatCard(
|
||||
title: "AQI Maksimal",
|
||||
value: "102.0",
|
||||
color: Colors.red,
|
||||
icon: Icons.warning,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
/// CHART
|
||||
const Text(
|
||||
"Grafik Tahunan - Rata-rata & AQI Maksimal",
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Card(
|
||||
elevation: 3,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: SizedBox(
|
||||
height: 320,
|
||||
child: LineChart(
|
||||
LineChartData(
|
||||
gridData: FlGridData(
|
||||
show: true,
|
||||
drawHorizontalLine: true,
|
||||
drawVerticalLine: false,
|
||||
horizontalInterval: 5,
|
||||
getDrawingHorizontalLine: (value) {
|
||||
return FlLine(
|
||||
color: Colors.grey.shade300,
|
||||
strokeWidth: 1,
|
||||
);
|
||||
},
|
||||
),
|
||||
titlesData: FlTitlesData(
|
||||
show: true,
|
||||
bottomTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
getTitlesWidget: (value, meta) {
|
||||
final months = [
|
||||
'Jan',
|
||||
'Feb',
|
||||
'Mar',
|
||||
'Apr',
|
||||
'Mei',
|
||||
'Jun',
|
||||
'Jul',
|
||||
'Agu',
|
||||
'Sep',
|
||||
'Okt',
|
||||
'Nov',
|
||||
'Des'
|
||||
];
|
||||
return Text(
|
||||
months[value.toInt()],
|
||||
style: const TextStyle(
|
||||
fontSize: 10,
|
||||
color: Colors.grey,
|
||||
),
|
||||
);
|
||||
},
|
||||
reservedSize: 30,
|
||||
),
|
||||
),
|
||||
leftTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
getTitlesWidget: (value, meta) {
|
||||
return Text(
|
||||
'${value.toInt()} AQI',
|
||||
style: const TextStyle(
|
||||
fontSize: 9,
|
||||
color: Colors.grey,
|
||||
),
|
||||
);
|
||||
},
|
||||
reservedSize: 50,
|
||||
),
|
||||
),
|
||||
rightTitles: AxisTitles(
|
||||
sideTitles: SideTitles(showTitles: false),
|
||||
),
|
||||
topTitles: AxisTitles(
|
||||
sideTitles: SideTitles(showTitles: false),
|
||||
),
|
||||
),
|
||||
borderData: FlBorderData(
|
||||
show: true,
|
||||
border: Border(
|
||||
left: BorderSide(
|
||||
color: Colors.grey.shade300,
|
||||
width: 1,
|
||||
),
|
||||
bottom: BorderSide(
|
||||
color: Colors.grey.shade300,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
lineBarsData: [
|
||||
/// Rata-rata
|
||||
LineChartBarData(
|
||||
spots: const [
|
||||
FlSpot(0, 68.0),
|
||||
FlSpot(1, 70.0),
|
||||
FlSpot(2, 72.5),
|
||||
FlSpot(3, 75.0),
|
||||
FlSpot(4, 78.0),
|
||||
FlSpot(5, 80.0),
|
||||
FlSpot(6, 82.0),
|
||||
FlSpot(7, 80.0),
|
||||
FlSpot(8, 75.0),
|
||||
FlSpot(9, 70.0),
|
||||
FlSpot(10, 68.0),
|
||||
FlSpot(11, 65.0),
|
||||
],
|
||||
isCurved: true,
|
||||
barWidth: 2.5,
|
||||
dotData: FlDotData(
|
||||
show: true,
|
||||
getDotPainter: (spot, percent, barData, index) {
|
||||
return FlDotCirclePainter(
|
||||
radius: 4,
|
||||
color: Colors.orange,
|
||||
strokeWidth: 0,
|
||||
);
|
||||
},
|
||||
),
|
||||
color: Colors.orange,
|
||||
belowBarData: BarAreaData(
|
||||
show: true,
|
||||
color: Colors.orange.withOpacity(0.1),
|
||||
),
|
||||
),
|
||||
|
||||
/// AQI Max
|
||||
LineChartBarData(
|
||||
spots: const [
|
||||
FlSpot(0, 85.0),
|
||||
FlSpot(1, 88.0),
|
||||
FlSpot(2, 90.0),
|
||||
FlSpot(3, 95.0),
|
||||
FlSpot(4, 98.0),
|
||||
FlSpot(5, 100.0),
|
||||
FlSpot(6, 102.0),
|
||||
FlSpot(7, 100.0),
|
||||
FlSpot(8, 95.0),
|
||||
FlSpot(9, 90.0),
|
||||
FlSpot(10, 85.0),
|
||||
FlSpot(11, 80.0),
|
||||
],
|
||||
isCurved: true,
|
||||
barWidth: 2.5,
|
||||
dotData: FlDotData(
|
||||
show: true,
|
||||
getDotPainter: (spot, percent, barData, index) {
|
||||
return FlDotCirclePainter(
|
||||
radius: 4,
|
||||
color: Colors.red,
|
||||
strokeWidth: 0,
|
||||
);
|
||||
},
|
||||
),
|
||||
color: Colors.red,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 15),
|
||||
|
||||
/// LEGEND
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 16,
|
||||
height: 16,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange,
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
"Rata-rata AQI",
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
const SizedBox(width: 20),
|
||||
Container(
|
||||
width: 16,
|
||||
height: 16,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red,
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
"AQI Maksimal",
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 15),
|
||||
Center(
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () => _exportAnnualAirQualityData(),
|
||||
icon: const Icon(Icons.file_download),
|
||||
label: const Text("Download Excel Tahunan"),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.green,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -15,7 +15,6 @@ class WindSpeedBloc extends Bloc<WindSpeedEvent, WindSpeedState> {
|
|||
WindSpeedBloc({required MonitoringRepository repository})
|
||||
: _repository = repository,
|
||||
super(const WindSpeedState()) {
|
||||
|
||||
/// 🔥 START MONITORING
|
||||
on<WatchWindSpeedStarted>(_onStarted, transformer: restartable());
|
||||
|
||||
|
|
@ -30,24 +29,24 @@ class WindSpeedBloc extends Bloc<WindSpeedEvent, WindSpeedState> {
|
|||
/// 🚀 START
|
||||
/// =========================
|
||||
Future<void> _onStarted(
|
||||
WatchWindSpeedStarted event,
|
||||
Emitter<WindSpeedState> emit,
|
||||
) async {
|
||||
|
||||
WatchWindSpeedStarted event,
|
||||
Emitter<WindSpeedState> emit,
|
||||
) async {
|
||||
emit(state.copyWith(isLoading: true));
|
||||
|
||||
/// 1. Ambil history SEKALI
|
||||
final history = await _repository.getSensorHistory(
|
||||
'anemometer/history',
|
||||
(json) => MyWindSpeed.fromJson(json),
|
||||
(json) => MyWindSpeed.fromJson(json),
|
||||
);
|
||||
|
||||
/// 2. Mapping default (harian)
|
||||
final dailyGraph = TimeSeriesMapper.toDaily(
|
||||
final raw = TimeSeriesMapper.toDaily(
|
||||
data: history,
|
||||
getTime: (e) => e.timestamp,
|
||||
getValue: (e) => e.speed,
|
||||
);
|
||||
getTime: (e) => e.timestamp,
|
||||
getValue: (e) => e.speed,
|
||||
);
|
||||
final dailyGraph = TimeSeriesMapper.smooth(raw);
|
||||
|
||||
emit(state.copyWith(
|
||||
history: history,
|
||||
|
|
@ -61,7 +60,7 @@ class WindSpeedBloc extends Bloc<WindSpeedEvent, WindSpeedState> {
|
|||
_subscription = _repository
|
||||
.getSensorStream(
|
||||
'anemometer/realtime',
|
||||
(json) => MyWindSpeed.fromJson(json),
|
||||
(json) => MyWindSpeed.fromJson(json),
|
||||
)
|
||||
.listen((data) {
|
||||
add(_WindSpeedRealtimeUpdated(data));
|
||||
|
|
@ -72,22 +71,32 @@ class WindSpeedBloc extends Bloc<WindSpeedEvent, WindSpeedState> {
|
|||
/// ⚡ REALTIME UPDATE
|
||||
/// =========================
|
||||
void _onRealtimeUpdated(
|
||||
_WindSpeedRealtimeUpdated event,
|
||||
Emitter<WindSpeedState> emit,
|
||||
) {
|
||||
_WindSpeedRealtimeUpdated event,
|
||||
Emitter<WindSpeedState> emit,
|
||||
) {
|
||||
final updated = List<double>.from(state.dailySpeeds);
|
||||
|
||||
final updatedSpeeds = List<double>.from(state.dailySpeeds);
|
||||
final index = DateTime.now().hour;
|
||||
double newValue = event.data.speed;
|
||||
|
||||
/// 🔥 FIX: pakai JAM bukan menit
|
||||
final int index = DateTime.now().hour;
|
||||
if (index < updated.length) {
|
||||
final lastValue = updated[index];
|
||||
|
||||
if (index < updatedSpeeds.length) {
|
||||
updatedSpeeds[index] = event.data.speed;
|
||||
/// 🔥 ANTI SPIKE + SMOOTHING
|
||||
if (lastValue != 0) {
|
||||
if ((newValue - lastValue).abs() > 15) {
|
||||
newValue = lastValue; // buang spike
|
||||
} else {
|
||||
newValue = (lastValue + newValue) / 2; // smoothing
|
||||
}
|
||||
}
|
||||
|
||||
updated[index] = newValue;
|
||||
}
|
||||
|
||||
emit(state.copyWith(
|
||||
currentSpeed: event.data.speed,
|
||||
dailySpeeds: updatedSpeeds,
|
||||
currentSpeed: newValue,
|
||||
dailySpeeds: updated,
|
||||
));
|
||||
}
|
||||
|
||||
|
|
@ -95,10 +104,9 @@ class WindSpeedBloc extends Bloc<WindSpeedEvent, WindSpeedState> {
|
|||
/// 📊 CHANGE PERIOD
|
||||
/// =========================
|
||||
Future<void> _onPeriodChanged(
|
||||
WindSpeedPeriodChanged event,
|
||||
Emitter<WindSpeedState> emit,
|
||||
) async {
|
||||
|
||||
WindSpeedPeriodChanged event,
|
||||
Emitter<WindSpeedState> emit,
|
||||
) async {
|
||||
emit(state.copyWith(isLoading: true, selectedPeriod: event.period));
|
||||
|
||||
final history = state.history;
|
||||
|
|
@ -106,24 +114,24 @@ class WindSpeedBloc extends Bloc<WindSpeedEvent, WindSpeedState> {
|
|||
List<double> updatedGraph;
|
||||
|
||||
if (event.period == "Minggu Ini") {
|
||||
updatedGraph = TimeSeriesMapper.toWeekly(
|
||||
data: history,
|
||||
getTime: (e) => e.timestamp,
|
||||
getValue: (e) => e.speed,
|
||||
);
|
||||
} else if (event.period == "Bulan Ini") {
|
||||
updatedGraph = TimeSeriesMapper.toMonthly(
|
||||
data: history,
|
||||
getTime: (e) => e.timestamp,
|
||||
getValue: (e) => e.speed,
|
||||
);
|
||||
} else {
|
||||
updatedGraph = TimeSeriesMapper.toDaily(
|
||||
data: history,
|
||||
getTime: (e) => e.timestamp,
|
||||
getValue: (e) => e.speed,
|
||||
);
|
||||
}
|
||||
updatedGraph = TimeSeriesMapper.toWeekly(
|
||||
data: history,
|
||||
getTime: (e) => e.timestamp,
|
||||
getValue: (e) => e.speed,
|
||||
);
|
||||
} else if (event.period == "Bulan Ini") {
|
||||
updatedGraph = TimeSeriesMapper.toMonthly(
|
||||
data: history,
|
||||
getTime: (e) => e.timestamp,
|
||||
getValue: (e) => e.speed,
|
||||
);
|
||||
} else {
|
||||
updatedGraph = TimeSeriesMapper.toDaily(
|
||||
data: history,
|
||||
getTime: (e) => e.timestamp,
|
||||
getValue: (e) => e.speed,
|
||||
);
|
||||
}
|
||||
|
||||
emit(state.copyWith(
|
||||
dailySpeeds: updatedGraph,
|
||||
|
|
@ -148,4 +156,4 @@ class _WindSpeedRealtimeUpdated extends WindSpeedEvent {
|
|||
|
||||
@override
|
||||
List<Object> get props => [data];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import 'package:firebase_auth/firebase_auth.dart';
|
|||
import 'package:rxdart/rxdart.dart';
|
||||
import 'package:user_repository/user_repository.dart';
|
||||
import 'package:google_sign_in/google_sign_in.dart';
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
|
||||
class FirebaseUserRepo implements UserRepository {
|
||||
final FirebaseAuth _firebaseAuth;
|
||||
|
|
@ -96,38 +97,25 @@ class FirebaseUserRepo implements UserRepository {
|
|||
@override
|
||||
Future<void> signInWithGoogle() async {
|
||||
try {
|
||||
final GoogleSignIn googleSignIn = GoogleSignIn();
|
||||
if (kIsWeb) {
|
||||
final googleProvider = GoogleAuthProvider();
|
||||
await _firebaseAuth.signInWithPopup(googleProvider);
|
||||
} else {
|
||||
final GoogleSignIn googleSignIn = GoogleSignIn();
|
||||
final googleUser = await googleSignIn.signIn();
|
||||
|
||||
final GoogleSignInAccount? googleUser = await googleSignIn.signIn();
|
||||
|
||||
if (googleUser == null) return;
|
||||
|
||||
final GoogleSignInAuthentication googleAuth =
|
||||
await googleUser.authentication;
|
||||
|
||||
final credential = GoogleAuthProvider.credential(
|
||||
accessToken: googleAuth.accessToken,
|
||||
idToken: googleAuth.idToken,
|
||||
);
|
||||
|
||||
final userCredential =
|
||||
await _firebaseAuth.signInWithCredential(credential);
|
||||
|
||||
final user = userCredential.user;
|
||||
|
||||
if (user != null) {
|
||||
final existingUser = await userCollection.doc(user.uid).get();
|
||||
|
||||
if (!existingUser.exists) {
|
||||
final newUser = MyUser(
|
||||
userId: user.uid,
|
||||
email: user.email ?? '',
|
||||
name: user.displayName ?? 'User',
|
||||
hasActiveCart: false,
|
||||
);
|
||||
|
||||
await setUserData(newUser);
|
||||
if (googleUser == null) {
|
||||
throw Exception("cancelled");
|
||||
}
|
||||
|
||||
final GoogleSignInAuthentication googleAuth =
|
||||
await googleUser.authentication;
|
||||
|
||||
final credential = GoogleAuthProvider.credential(
|
||||
accessToken: googleAuth.accessToken,
|
||||
idToken: googleAuth.idToken,
|
||||
);
|
||||
await _firebaseAuth.signInWithCredential(credential);
|
||||
}
|
||||
} catch (e) {
|
||||
log('Google sign-in error: $e');
|
||||
|
|
|
|||
Loading…
Reference in New Issue