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 createState() => _HomeScreenState(); } class _HomeScreenState extends State { late Future<_DashboardData> _dashboardFuture; String _selectedPeriod = 'Hari Ini'; bool _initialized = false; @override void didChangeDependencies() { super.didChangeDependencies(); if (!_initialized) { _dashboardFuture = _loadDashboardData(context.read()); _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().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 _seriesForPeriod( List history, { required String period, required DateTime Function(T) getTime, required double Function(T) getValue, }) { List 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 windHistory; final List 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 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), ), ), ], ), ); } }