620 lines
20 KiB
Dart
620 lines
20 KiB
Dart
import 'dart:math' as math;
|
|
|
|
import 'package:fl_chart/fl_chart.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
import 'package:google_fonts/google_fonts.dart';
|
|
import 'package:monitoring_repository/monitoring_repository.dart';
|
|
|
|
import '../../../blocs/authentication_bloc/authentication_bloc.dart';
|
|
import '../../../core/utils/time_series_mapper.dart';
|
|
import 'main_drawer.dart';
|
|
|
|
class HomeScreen extends StatefulWidget {
|
|
const HomeScreen({super.key});
|
|
|
|
@override
|
|
State<HomeScreen> createState() => _HomeScreenState();
|
|
}
|
|
|
|
class _HomeScreenState extends State<HomeScreen> {
|
|
late Future<_DashboardData> _dashboardFuture;
|
|
String _selectedPeriod = 'Hari Ini';
|
|
bool _initialized = false;
|
|
|
|
@override
|
|
void didChangeDependencies() {
|
|
super.didChangeDependencies();
|
|
|
|
if (!_initialized) {
|
|
_dashboardFuture = _loadDashboardData(context.read<MonitoringRepository>());
|
|
_initialized = true;
|
|
}
|
|
}
|
|
|
|
Future<_DashboardData> _loadDashboardData(MonitoringRepository repository) async {
|
|
final windHistory = await repository.getSensorHistory(
|
|
'anemometer/history',
|
|
(json) => MyWindSpeed.fromJson(json),
|
|
);
|
|
|
|
final evaporasiHistory = await repository.getSensorHistory(
|
|
'evaporasi/history',
|
|
(json) => Evaporasi.fromJson(json),
|
|
);
|
|
|
|
final atmosphericLatest = await repository.getSensorSnapshot(
|
|
'/sensor/latest',
|
|
(json) => AtmosphericConditions.fromJson(json),
|
|
);
|
|
|
|
windHistory.sort((a, b) => a.timestamp.compareTo(b.timestamp));
|
|
evaporasiHistory.sort((a, b) => a.timestamp.compareTo(b.timestamp));
|
|
|
|
return _DashboardData(
|
|
windHistory: windHistory,
|
|
evaporasiHistory: evaporasiHistory,
|
|
latestWindSpeed: windHistory.isNotEmpty ? windHistory.last.speed : 0.0,
|
|
latestEvaporasi: evaporasiHistory.isNotEmpty ? evaporasiHistory.last.evaporasi : 0.0,
|
|
latestHumidityRh: atmosphericLatest.humidity,
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
drawer: const MainDrawer(),
|
|
appBar: AppBar(
|
|
backgroundColor: Colors.white,
|
|
elevation: 0.5,
|
|
centerTitle: false,
|
|
leading: Builder(
|
|
builder: (context) => IconButton(
|
|
icon: const Icon(Icons.menu, color: Colors.black),
|
|
onPressed: () => Scaffold.of(context).openDrawer(),
|
|
),
|
|
),
|
|
title: Row(
|
|
children: [
|
|
Image.asset(
|
|
'images/logo_klimatologi.png',
|
|
height: 90,
|
|
fit: BoxFit.contain,
|
|
),
|
|
const SizedBox(width: 10),
|
|
],
|
|
),
|
|
actions: [
|
|
IconButton(
|
|
icon: const Icon(Icons.notifications_none, color: Colors.black),
|
|
onPressed: () {},
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.logout, color: Colors.black),
|
|
onPressed: () {
|
|
context.read<AuthenticationBloc>().add(AuthenticationLogoutRequested());
|
|
},
|
|
),
|
|
const SizedBox(width: 8),
|
|
],
|
|
),
|
|
body: FutureBuilder<_DashboardData>(
|
|
future: _dashboardFuture,
|
|
builder: (context, snapshot) {
|
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
|
return const Center(child: CircularProgressIndicator());
|
|
}
|
|
|
|
if (snapshot.hasError || !snapshot.hasData) {
|
|
return Center(
|
|
child: Text(
|
|
'Gagal memuat dashboard',
|
|
style: GoogleFonts.poppins(fontSize: 14, color: Colors.redAccent),
|
|
),
|
|
);
|
|
}
|
|
|
|
final data = snapshot.data!;
|
|
|
|
final windSeries = _seriesForPeriod(
|
|
data.windHistory,
|
|
period: _selectedPeriod,
|
|
getTime: (item) => item.timestamp,
|
|
getValue: (item) => item.speed,
|
|
);
|
|
final evaporasiSeries = _seriesForPeriod(
|
|
data.evaporasiHistory,
|
|
period: _selectedPeriod,
|
|
getTime: (item) => item.timestamp,
|
|
getValue: (item) => item.evaporasi,
|
|
);
|
|
|
|
return SingleChildScrollView(
|
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'Monitoring Realtime',
|
|
style: GoogleFonts.poppins(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.w700,
|
|
color: Colors.black87,
|
|
),
|
|
),
|
|
const SizedBox(height: 14),
|
|
LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final cardWidth = (constraints.maxWidth - 12) / 2;
|
|
|
|
return Wrap(
|
|
spacing: 12,
|
|
runSpacing: 12,
|
|
children: [
|
|
SizedBox(
|
|
width: cardWidth,
|
|
child: _MetricCard(
|
|
icon: Icons.air,
|
|
iconColor: const Color(0xFF6BA7E6),
|
|
value: data.latestWindSpeed,
|
|
unit: 'm/s',
|
|
label: 'Kecepatan Angin',
|
|
),
|
|
),
|
|
SizedBox(
|
|
width: cardWidth,
|
|
child: _MetricCard(
|
|
icon: Icons.water_drop_outlined,
|
|
iconColor: const Color(0xFF4CB3B3),
|
|
value: data.latestEvaporasi,
|
|
unit: 'mm',
|
|
label: 'Evaporasi',
|
|
badgeText: 'Tinggi',
|
|
badgeColor: const Color(0xFFEB5757),
|
|
),
|
|
),
|
|
SizedBox(
|
|
width: constraints.maxWidth,
|
|
child: _MetricCard(
|
|
icon: Icons.water_drop,
|
|
iconColor: const Color(0xFF4CB3B3),
|
|
value: data.latestHumidityRh,
|
|
unit: '% RH',
|
|
label: 'Kelembapan RH',
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
const SizedBox(height: 18),
|
|
Text(
|
|
'Grafik Sensor',
|
|
style: GoogleFonts.poppins(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.w700,
|
|
color: Colors.black87,
|
|
),
|
|
),
|
|
const SizedBox(height: 10),
|
|
Wrap(
|
|
spacing: 8,
|
|
runSpacing: 8,
|
|
children: [
|
|
_PeriodChip(
|
|
label: 'Hari Ini',
|
|
selected: _selectedPeriod == 'Hari Ini',
|
|
onTap: () => setState(() => _selectedPeriod = 'Hari Ini'),
|
|
),
|
|
_PeriodChip(
|
|
label: 'Minggu Ini',
|
|
selected: _selectedPeriod == 'Minggu Ini',
|
|
onTap: () => setState(() => _selectedPeriod = 'Minggu Ini'),
|
|
),
|
|
_PeriodChip(
|
|
label: 'Bulan Ini',
|
|
selected: _selectedPeriod == 'Bulan Ini',
|
|
onTap: () => setState(() => _selectedPeriod = 'Bulan Ini'),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 14),
|
|
LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final chartWidth = constraints.maxWidth > 600 ? (constraints.maxWidth - 12) / 2 : constraints.maxWidth;
|
|
|
|
return Wrap(
|
|
spacing: 12,
|
|
runSpacing: 12,
|
|
children: [
|
|
SizedBox(
|
|
width: chartWidth,
|
|
child: _SensorChartCard(
|
|
title: 'Kecepatan Angin',
|
|
unit: 'm/s',
|
|
currentValue: data.latestWindSpeed,
|
|
series: windSeries,
|
|
icon: Icons.air,
|
|
accentColor: const Color(0xFF6BA7E6),
|
|
subtitle: _chartSubtitle(_selectedPeriod, 'kecepatan angin'),
|
|
),
|
|
),
|
|
SizedBox(
|
|
width: chartWidth,
|
|
child: _SensorChartCard(
|
|
title: 'Evaporasi',
|
|
unit: 'mm',
|
|
currentValue: data.latestEvaporasi,
|
|
series: evaporasiSeries,
|
|
icon: Icons.water_drop_outlined,
|
|
accentColor: const Color(0xFF4CB3B3),
|
|
subtitle: _chartSubtitle(_selectedPeriod, 'evaporasi'),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
List<double> _seriesForPeriod<T>(
|
|
List<T> history, {
|
|
required String period,
|
|
required DateTime Function(T) getTime,
|
|
required double Function(T) getValue,
|
|
}) {
|
|
List<double> raw;
|
|
|
|
if (period == 'Minggu Ini') {
|
|
raw = TimeSeriesMapper.toWeekly(
|
|
data: history,
|
|
getTime: getTime,
|
|
getValue: getValue,
|
|
);
|
|
} else if (period == 'Bulan Ini') {
|
|
raw = TimeSeriesMapper.toMonthly(
|
|
data: history,
|
|
getTime: getTime,
|
|
getValue: getValue,
|
|
);
|
|
} else {
|
|
raw = TimeSeriesMapper.toDaily(
|
|
data: history,
|
|
getTime: getTime,
|
|
getValue: getValue,
|
|
);
|
|
}
|
|
|
|
return TimeSeriesMapper.smooth(raw);
|
|
}
|
|
|
|
String _chartSubtitle(String period, String sensorName) {
|
|
if (period == 'Hari Ini') {
|
|
return 'Rata-rata per jam hari ini';
|
|
}
|
|
|
|
if (period == 'Minggu Ini') {
|
|
return 'Rata-rata per hari minggu ini';
|
|
}
|
|
|
|
return 'Rata-rata per hari bulan ini';
|
|
}
|
|
}
|
|
|
|
class _DashboardData {
|
|
final List<MyWindSpeed> windHistory;
|
|
final List<Evaporasi> evaporasiHistory;
|
|
final double latestWindSpeed;
|
|
final double latestEvaporasi;
|
|
final double latestHumidityRh;
|
|
|
|
const _DashboardData({
|
|
required this.windHistory,
|
|
required this.evaporasiHistory,
|
|
required this.latestWindSpeed,
|
|
required this.latestEvaporasi,
|
|
required this.latestHumidityRh,
|
|
});
|
|
}
|
|
|
|
class _MetricCard extends StatelessWidget {
|
|
final IconData icon;
|
|
final Color iconColor;
|
|
final double value;
|
|
final String unit;
|
|
final String label;
|
|
final String? badgeText;
|
|
final Color? badgeColor;
|
|
|
|
const _MetricCard({
|
|
required this.icon,
|
|
required this.iconColor,
|
|
required this.value,
|
|
required this.unit,
|
|
required this.label,
|
|
this.badgeText,
|
|
this.badgeColor,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final hasBadge = badgeText != null && badgeText!.isNotEmpty;
|
|
|
|
return Container(
|
|
padding: const EdgeInsets.all(14),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white,
|
|
borderRadius: BorderRadius.circular(18),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withOpacity(0.04),
|
|
blurRadius: 12,
|
|
offset: const Offset(0, 4),
|
|
),
|
|
],
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Container(
|
|
width: 34,
|
|
height: 34,
|
|
decoration: BoxDecoration(
|
|
color: iconColor.withOpacity(0.12),
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Icon(icon, color: iconColor, size: 20),
|
|
),
|
|
const SizedBox(height: 12),
|
|
RichText(
|
|
text: TextSpan(
|
|
style: GoogleFonts.poppins(
|
|
color: Colors.black87,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
children: [
|
|
TextSpan(
|
|
text: value.toStringAsFixed(1),
|
|
style: const TextStyle(fontSize: 20),
|
|
),
|
|
TextSpan(
|
|
text: ' $unit',
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.w600,
|
|
color: Colors.black.withOpacity(0.55),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
if (hasBadge) ...[
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
badgeText!,
|
|
style: GoogleFonts.poppins(
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.w600,
|
|
color: badgeColor ?? Colors.orange,
|
|
),
|
|
),
|
|
],
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
label,
|
|
style: GoogleFonts.poppins(
|
|
fontSize: 11,
|
|
color: Colors.black.withOpacity(0.45),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _PeriodChip extends StatelessWidget {
|
|
final String label;
|
|
final bool selected;
|
|
final VoidCallback onTap;
|
|
|
|
const _PeriodChip({
|
|
required this.label,
|
|
required this.selected,
|
|
required this.onTap,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return InkWell(
|
|
borderRadius: BorderRadius.circular(18),
|
|
onTap: onTap,
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
|
decoration: BoxDecoration(
|
|
color: selected ? Colors.blue.shade600 : Colors.white,
|
|
borderRadius: BorderRadius.circular(18),
|
|
border: Border.all(
|
|
color: selected ? Colors.blue.shade600 : Colors.grey.shade300,
|
|
),
|
|
),
|
|
child: Text(
|
|
label,
|
|
style: GoogleFonts.poppins(
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w600,
|
|
color: selected ? Colors.white : Colors.black87,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SensorChartCard extends StatelessWidget {
|
|
final String title;
|
|
final String unit;
|
|
final double currentValue;
|
|
final List<double> series;
|
|
final IconData icon;
|
|
final Color accentColor;
|
|
final String subtitle;
|
|
|
|
const _SensorChartCard({
|
|
required this.title,
|
|
required this.unit,
|
|
required this.currentValue,
|
|
required this.series,
|
|
required this.icon,
|
|
required this.accentColor,
|
|
required this.subtitle,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final hasData = series.any((value) => value.isFinite && value != 0);
|
|
final chartSeries = series.map((value) => value.isFinite ? value : 0.0).toList();
|
|
final maxY = chartSeries.isEmpty ? 10.0 : math.max(chartSeries.reduce(math.max), 1.0) + 2;
|
|
|
|
return Container(
|
|
padding: const EdgeInsets.all(14),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white,
|
|
borderRadius: BorderRadius.circular(18),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withOpacity(0.04),
|
|
blurRadius: 12,
|
|
offset: const Offset(0, 4),
|
|
),
|
|
],
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Container(
|
|
width: 28,
|
|
height: 28,
|
|
decoration: BoxDecoration(
|
|
color: accentColor.withOpacity(0.12),
|
|
borderRadius: BorderRadius.circular(10),
|
|
),
|
|
child: Icon(icon, color: accentColor, size: 16),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
title,
|
|
style: GoogleFonts.poppins(
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w700,
|
|
color: Colors.black87,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
RichText(
|
|
text: TextSpan(
|
|
style: GoogleFonts.poppins(
|
|
color: accentColor,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
children: [
|
|
TextSpan(
|
|
text: currentValue.toStringAsFixed(1),
|
|
style: const TextStyle(fontSize: 20),
|
|
),
|
|
TextSpan(
|
|
text: ' $unit',
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.w600,
|
|
color: Colors.black.withOpacity(0.45),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 10),
|
|
SizedBox(
|
|
height: 150,
|
|
child: hasData
|
|
? LineChart(
|
|
LineChartData(
|
|
minX: 0,
|
|
maxX: math.max(chartSeries.length - 1, 1).toDouble(),
|
|
minY: 0,
|
|
maxY: maxY,
|
|
gridData: const FlGridData(show: false),
|
|
borderData: FlBorderData(show: false),
|
|
titlesData: const FlTitlesData(
|
|
show: false,
|
|
topTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
|
rightTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
|
leftTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
|
bottomTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
|
),
|
|
lineBarsData: [
|
|
LineChartBarData(
|
|
spots: chartSeries.asMap().entries.map((entry) {
|
|
return FlSpot(entry.key.toDouble(), entry.value);
|
|
}).toList(),
|
|
isCurved: true,
|
|
curveSmoothness: 0.25,
|
|
color: accentColor,
|
|
barWidth: 3,
|
|
isStrokeCapRound: true,
|
|
dotData: FlDotData(
|
|
show: chartSeries.length <= 12,
|
|
getDotPainter: (spot, percent, bar, index) {
|
|
final isLast = index == chartSeries.length - 1;
|
|
return FlDotCirclePainter(
|
|
radius: isLast ? 4 : 0,
|
|
color: isLast ? accentColor : Colors.transparent,
|
|
strokeWidth: isLast ? 2 : 0,
|
|
strokeColor: Colors.white,
|
|
);
|
|
},
|
|
),
|
|
belowBarData: BarAreaData(
|
|
show: true,
|
|
color: accentColor.withOpacity(0.12),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
)
|
|
: Center(
|
|
child: Text(
|
|
'Belum ada data',
|
|
style: GoogleFonts.poppins(
|
|
fontSize: 12,
|
|
color: Colors.grey.shade400,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 6),
|
|
Text(
|
|
subtitle,
|
|
style: GoogleFonts.poppins(
|
|
fontSize: 11,
|
|
color: Colors.black.withOpacity(0.35),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|