Klimatologiot/lib/screens/home/views/home_screen.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),
),
),
],
),
);
}
}