This commit is contained in:
kleponijo 2026-05-09 20:52:03 +07:00
parent 5913bd0366
commit f3ab71616e
7 changed files with 299 additions and 161 deletions

View File

@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:monitoring_repository/monitoring_repository.dart';
import '../../../blocs/authentication_bloc/authentication_bloc.dart';
<<<<<<< HEAD
import '../../../blocs/notification_bloc/notification_bloc.dart';
import '../../monitoring/wind_speed/blocs/wind_speed_bloc.dart';
import '../../monitoring/evaporasi/blocs/evaporasi_bloc.dart';
@ -10,8 +9,6 @@ import '../../monitoring/atmospheric_conditions/blocs/atmospheric_conditions_blo
import '../widgets/notification_panel.dart';
import '../widgets/sensor_grid.dart';
import '../widgets/dashboard_charts.dart';
=======
>>>>>>> parent of 6f38d3b (update evaporsi)
import 'main_drawer.dart';
class HomeScreen extends StatefulWidget {
@ -100,7 +97,6 @@ class _HomeScreenState extends State<HomeScreen>
height: 90,
fit: BoxFit.contain,
),
<<<<<<< HEAD
actions: [
// Icon lonceng dengan badge
BlocBuilder<NotificationBloc, NotificationState>(
@ -178,17 +174,6 @@ class _HomeScreenState extends State<HomeScreen>
),
),
],
=======
const SizedBox(width: 10),
],
),
actions: [
IconButton(
icon: const Icon(Icons.notifications_none, color: Colors.black),
onPressed: () {
// Aksi notifikasi
},
>>>>>>> parent of 6f38d3b (update evaporsi)
),
),
),

View File

@ -79,11 +79,16 @@ class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> {
);
final lastValue = history.isNotEmpty ? history.last.evaporasi : 0.0;
final lastWaterLevel = history.isNotEmpty ? history.last.tinggiAir : 0.0;
final lastTemperature = history.isNotEmpty ? history.last.suhu : 0.0;
final (status, rain) = _computeWeatherStatus(lastValue);
_emitEvaporasiAlert(status, rain, lastValue);
emit(state.copyWith(
history: history,
currentValue: lastValue,
waterLevel: lastWaterLevel,
temperature: lastTemperature,
dailyValues: dailyGraph,
dailyTemperatures: dailyTempGraph,
weeklyValues: weeklyGraph,
@ -299,4 +304,3 @@ class _EvaporasiRealtimeUpdated extends EvaporasiEvent {
@override
List<Object> get props => [data];
}

View File

@ -0,0 +1,170 @@
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
class EvaporasiChartWidget extends StatelessWidget {
final List<double> dailyValues;
final List<double> dailyTemperatures;
final String period;
final List<String> chartLabels;
const EvaporasiChartWidget({
super.key,
required this.dailyValues,
required this.dailyTemperatures,
required this.period,
required this.chartLabels,
});
double _safeValue(double value) {
if (value.isNaN || value.isInfinite) return 0.0;
return value < 0 ? 0.0 : value;
}
double _maxY() {
final values = [
...dailyValues.map(_safeValue),
...dailyTemperatures.map(_safeValue),
];
if (values.isEmpty) return 10;
final maxValue = values.reduce((a, b) => a > b ? a : b);
return (maxValue * 1.4).clamp(10.0, 100.0);
}
List<FlSpot> _lineSpots(List<double> series) {
return series.asMap().entries.map((entry) {
return FlSpot(entry.key.toDouble(), _safeValue(entry.value));
}).toList();
}
String _getBottomLabel(int index) {
if (chartLabels.isEmpty || index < 0 || index >= chartLabels.length) {
return index.toString();
}
return chartLabels[index];
}
@override
Widget build(BuildContext context) {
final values = _lineSpots(dailyValues);
final temperatures = _lineSpots(dailyTemperatures);
if (values.isEmpty && temperatures.isEmpty) {
return Container(
height: 240,
alignment: Alignment.center,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(25),
boxShadow: [
BoxShadow(
color: Colors.black.withAlpha(13),
blurRadius: 10,
offset: const Offset(0, 5),
)
],
),
child: const Text('Tidak ada data chart'),
);
}
return Container(
height: 300,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(25),
boxShadow: [
BoxShadow(
color: Colors.black.withAlpha(13),
blurRadius: 10,
offset: const Offset(0, 5),
)
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(25),
child: Padding(
padding: const EdgeInsets.all(12),
child: LineChart(
LineChartData(
minY: 0,
maxY: _maxY(),
gridData: FlGridData(show: false),
borderData: FlBorderData(show: false),
titlesData: FlTitlesData(
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 32,
interval: 1,
getTitlesWidget: (value, meta) {
final index = value.toInt();
return Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
_getBottomLabel(index),
style:
const TextStyle(color: Colors.grey, fontSize: 10),
),
);
},
),
),
leftTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
topTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
rightTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
),
lineTouchData: LineTouchData(
handleBuiltInTouches: true,
touchTooltipData: LineTouchTooltipData(
getTooltipItems: (spots) {
return spots.map((spot) {
return LineTooltipItem(
spot.y.toStringAsFixed(1),
const TextStyle(color: Colors.white, fontSize: 12),
);
}).toList();
},
),
),
lineBarsData: [
if (values.isNotEmpty)
LineChartBarData(
spots: values,
isCurved: true,
color: Colors.blue.shade700,
barWidth: 3,
dotData: FlDotData(show: false),
belowBarData: BarAreaData(
show: true,
gradient: LinearGradient(
colors: [
Colors.blue.withAlpha(64),
Colors.blue.withAlpha(13),
],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
),
if (temperatures.isNotEmpty)
LineChartBarData(
spots: temperatures,
isCurved: true,
color: Colors.orange.shade700,
barWidth: 3,
dotData: FlDotData(show: false),
),
],
),
),
),
),
);
}
}

View File

@ -83,6 +83,7 @@ class _EvaporasiDatePickerState extends State<EvaporasiDatePicker> {
),
// Calendar
Expanded(
child: SingleChildScrollView(
child: TableCalendar(
firstDay: DateTime.utc(2020, 1, 1),
lastDay: DateTime.now(),
@ -174,6 +175,7 @@ class _EvaporasiDatePickerState extends State<EvaporasiDatePicker> {
),
),
),
),
// Selected date display
if (_selectedDay != null)
Container(

View File

@ -1,7 +1,4 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:monitoring_repository/monitoring_repository.dart';
class EvaporasiDateSearchBar extends StatefulWidget {
final String initialQuery;
@ -13,14 +10,12 @@ class EvaporasiDateSearchBar extends StatefulWidget {
required this.onQueryChanged,
});
@override
State<EvaporasiDateSearchBar> createState() => _EvaporasiDateSearchBarState();
}
class _EvaporasiDateSearchBarState extends State<EvaporasiDateSearchBar> {
late final TextEditingController _controller;
DateTime? _selected;
@override
void initState() {
@ -34,21 +29,9 @@ class _EvaporasiDateSearchBarState extends State<EvaporasiDateSearchBar> {
super.dispose();
}
bool _matches(Evaporasi item, String query) {
if (query.trim().isEmpty) return true;
final date = item.timestamp;
final ddMMyyyy = DateFormat('dd/MM/yyyy', 'id_ID').format(date);
final ddMMMYYYY = DateFormat('dd MMM yyyy', 'id_ID').format(date);
final q = query.trim().toLowerCase();
return ddMMyyyy.toLowerCase().contains(q) || ddMMMYYYY.toLowerCase().contains(q);
}
@override
Widget build(BuildContext context) {
return TextField(
controller: _controller,
decoration: InputDecoration(
hintText: 'Cari tanggal (dd/MM/yyyy)',
@ -69,4 +52,3 @@ class _EvaporasiDateSearchBarState extends State<EvaporasiDateSearchBar> {
);
}
}

View File

@ -19,17 +19,21 @@ class Evaporasi {
);
factory Evaporasi.fromJson(Map<dynamic, dynamic> json) {
// Handle perbedaan field name antara history dan realtime:
// History path : suhu, tinggi, evaporasi, waktu
// Realtime path : suhu_air, tinggi_air, evaporasi, waktu, status
final suhu = (json['suhu'] ?? json['suhu_air'] ?? 0).toDouble();
final tinggi = (json['tinggi'] ?? json['tinggi_air_cm'] ?? 0).toDouble();
double toDoubleSafe(dynamic v) {
if (v is num) return v.toDouble();
if (v is String) return double.tryParse(v) ?? 0;
return 0;
}
// Prioritas waktu sesuai firmware ESP32:
// 1) timestamp (Unix atau ISO string, kalau pernah dikirim)
// 2) datetime ("YYYY-MM-DD HH:MM:SS")
// 3) waktu ("HH:MM:SS") fallback (pakai tanggal hari ini)
final evaporasiVal = toDoubleSafe(
json['evaporasi_mm'] ?? json['evaporasi'],
);
final suhuVal = toDoubleSafe(json['suhu'] ?? json['suhu_air']);
final tinggiVal = toDoubleSafe(json['tinggi_air_cm'] ?? json['tinggi_air']);
DateTime timestamp = DateTime.now();
final rawTimestamp = json['timestamp'];
if (rawTimestamp != null) {
if (rawTimestamp is int) {
timestamp = DateTime.fromMillisecondsSinceEpoch(rawTimestamp);
@ -61,6 +65,7 @@ class Evaporasi {
final jam = int.tryParse(parts[0]) ?? 0;
final menit = int.tryParse(parts[1]) ?? 0;
final detik = parts.length >= 3 ? (int.tryParse(parts[2]) ?? 0) : 0;
final now = DateTime.now();
timestamp = DateTime(
now.year,
now.month,
@ -74,27 +79,11 @@ class Evaporasi {
}
}
// Field di DB (dari info Anda):
// - evaporasi_mm
// - suhu_air
// - tinggi_air_cm
// Namun tetap toleran jika key berubah.
final evaporasiVal = json['evaporasi_mm'] ?? json['evaporasi'] ?? 0;
final suhuVal = json['suhu_air'] ?? json['suhu'] ?? 0;
final tinggiVal = json['tinggi_air_cm'] ?? json['tinggi_air'] ?? 0;
double toDoubleSafe(dynamic v) {
if (v is num) return v.toDouble();
if (v is String) return double.tryParse(v) ?? 0;
return 0;
}
return Evaporasi(
evaporasi: (json['evaporasi_mm'] ?? 0).toDouble(),
suhu: suhu,
tinggiAir: tinggi,
status: json['status'] ?? '',
timestamp: TimestampParser.parse(rawTime), // pakai TimestampParser
evaporasi: evaporasiVal,
suhu: suhuVal,
tinggiAir: tinggiVal,
timestamp: timestamp,
);
}
}

View File

@ -5,12 +5,11 @@
// gestures. You can also use WidgetTester to find child widgets in the widget
// tree, read text, and verify that the values of widget properties are correct.
import 'package:firebase_database/firebase_database.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:klimatologiot/app.dart';
import 'package:monitoring_repository/monitoring_repository.dart';
import 'package:user_repository/user_repository.dart';
import '../lib/app.dart';
// import 'package:klimatologiot/main.dart';
@ -49,31 +48,38 @@ class FakeUserRepository implements UserRepository {
}
class FakeMonitoringRepository implements MonitoringRepository {
get _db => null;
// Satu fungsi untuk semua jenis sensor
// Kamu cukup masukkan "path" database-nya saja
@override
// Jika ingin mengambil data sekali saja (bukan stream)
Future<DataSnapshot> getSensorSnapshot(String path) async {
return await _db.ref(path).get();
Future<T> getSensorSnapshot<T>(
String path,
T Function(Map<dynamic, dynamic>) mapper,
) async {
return mapper(<dynamic, dynamic>{});
}
@override
Stream<DatabaseEvent> getSensorStream(String path) {
// TODO: implement getSensorStream
throw UnimplementedError();
Stream<T> getSensorStream<T>(
String path,
T Function(Map<dynamic, dynamic>) mapper,
) {
return const Stream.empty();
}
@override
Future<List<T>> getSensorHistory<T>(
String path,
T Function(Map<dynamic, dynamic>) mapper,
) async {
return <T>[];
}
}
void main() {
testWidgets('App loads', (WidgetTester tester) async {
final fakeRepo = FakeUserRepository();
final fakeMonitoring = FakeUserRepository();
final fakeMonitoring = FakeMonitoringRepository();
// Build our app and trigger a frame.
await tester.pumpWidget(
MyApp(fakeRepo, fakeMonitoring as MonitoringRepository),
MyApp(fakeRepo, fakeMonitoring),
);
await tester.pump();