Merge branch 'branch' of https://github.com/kleponijo/klimatologi into branch_percobaan

This commit is contained in:
kleponijo 2026-04-29 11:42:42 +07:00
commit 9c7558cdb5
8 changed files with 517 additions and 16 deletions

11
TODO.md Normal file
View File

@ -0,0 +1,11 @@
# TODO: Fitur Baru Evaporasi (Period Selector + Status + Notifikasi)
## Steps
- [x] 1. Edit `evaporasi_state.dart` — tambahkan `weatherStatus` & `willRain`
- [x] 2. Edit `evaporasi_bloc.dart` — hitung status & update global notifier
- [x] 3. Create `notification_notifier.dart` — global ValueNotifier untuk alert
- [x] 4. Create `evaporasi_period_selector.dart` — tombol periode
- [x] 5. Edit `evaporasi_screen.dart` — tambahkan period selector & status card
- [x] 6. Edit `home_screen.dart` — badge merah di icon lonceng
- [x] 7. Verifikasi kompilasi

View File

@ -0,0 +1,9 @@
import 'package:flutter/foundation.dart';
/// Global notifier untuk peringatan cuaca di dashboard
/// Di-update dari EvaporasiBloc, di-listen di HomeScreen
final ValueNotifier<bool> hasWeatherAlert = ValueNotifier<bool>(false);
/// Pesan peringatan yang akan ditampilkan
final ValueNotifier<String> weatherAlertMessage = ValueNotifier<String>('');

View File

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../blocs/authentication_bloc/authentication_bloc.dart'; import '../../../blocs/authentication_bloc/authentication_bloc.dart';
import '../../../core/notification_notifier.dart';
import 'main_drawer.dart'; import 'main_drawer.dart';
class HomeScreen extends StatelessWidget { class HomeScreen extends StatelessWidget {
@ -36,10 +37,42 @@ class HomeScreen extends StatelessWidget {
], ],
), ),
actions: [ actions: [
IconButton( ValueListenableBuilder<bool>(
icon: const Icon(Icons.notifications_none, color: Colors.black), valueListenable: hasWeatherAlert,
onPressed: () { builder: (context, hasAlert, child) {
// Aksi notifikasi return Stack(
children: [
IconButton(
icon: const Icon(Icons.notifications_none,
color: Colors.black),
onPressed: () {
// Aksi notifikasi
if (hasAlert) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(weatherAlertMessage.value),
backgroundColor: Colors.orange.shade800,
behavior: SnackBarBehavior.floating,
),
);
}
},
),
if (hasAlert)
Positioned(
right: 8,
top: 8,
child: Container(
width: 10,
height: 10,
decoration: const BoxDecoration(
color: Colors.red,
shape: BoxShape.circle,
),
),
),
],
);
}, },
), ),
IconButton( IconButton(

View File

@ -4,6 +4,7 @@ import 'package:equatable/equatable.dart';
import 'package:monitoring_repository/monitoring_repository.dart'; import 'package:monitoring_repository/monitoring_repository.dart';
import '../../../../core/utils/time_series_mapper.dart'; import '../../../../core/utils/time_series_mapper.dart';
import '../../../../core/notification_notifier.dart';
part 'evaporasi_event.dart'; part 'evaporasi_event.dart';
part 'evaporasi_state.dart'; part 'evaporasi_state.dart';
@ -37,12 +38,26 @@ class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> {
final dailyGraph = TimeSeriesMapper.toDaily( final dailyGraph = TimeSeriesMapper.toDaily(
data: history, data: history,
getTime: (e) => e.timestamp, getTime: (e) => e.timestamp,
getValue: (e) => e.evaporasi, // sesuaikan nama field getValue: (e) => e.evaporasi,
); );
final dailyTempGraph = TimeSeriesMapper.toDaily(
data: history,
getTime: (e) => e.timestamp,
getValue: (e) => e.suhu,
);
// Hitung status dari data terakhir history jika ada
final lastValue = history.isNotEmpty ? history.last.evaporasi : 0.0;
final (status, rain) = _computeWeatherStatus(lastValue);
_updateGlobalNotifier(status, rain);
emit(state.copyWith( emit(state.copyWith(
history: history, history: history,
dailyValues: dailyGraph, dailyValues: dailyGraph,
dailyTemperatures: dailyTempGraph,
weatherStatus: status,
willRain: rain,
isLoading: false, isLoading: false,
)); ));
@ -66,18 +81,26 @@ class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> {
Emitter<EvaporasiState> emit, Emitter<EvaporasiState> emit,
) { ) {
final updated = List<double>.from(state.dailyValues); final updated = List<double>.from(state.dailyValues);
final updatedTemp = List<double>.from(state.dailyTemperatures);
final index = DateTime.now().hour; final index = DateTime.now().hour;
if (index < updated.length) { if (index < updated.length) {
updated[index] = event.data.evaporasi; // sesuaikan field updated[index] = event.data.evaporasi;
updatedTemp[index] = event.data.suhu;
} }
final (status, rain) = _computeWeatherStatus(event.data.evaporasi);
_updateGlobalNotifier(status, rain);
emit(state.copyWith( emit(state.copyWith(
currentValue: event.data.evaporasi, currentValue: event.data.evaporasi,
temperature: event.data.suhu, temperature: event.data.suhu,
waterLevel: event.data.tinggiAir, waterLevel: event.data.tinggiAir,
dailyValues: updated, dailyValues: updated,
dailyTemperatures: updatedTemp,
weatherStatus: status,
willRain: rain,
)); ));
} }
@ -93,6 +116,7 @@ class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> {
final history = state.history; final history = state.history;
List<double> updated; List<double> updated;
List<double> updatedTemp;
if (event.period == "Minggu Ini") { if (event.period == "Minggu Ini") {
updated = TimeSeriesMapper.toWeekly( updated = TimeSeriesMapper.toWeekly(
@ -100,22 +124,39 @@ class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> {
getTime: (e) => e.timestamp, getTime: (e) => e.timestamp,
getValue: (e) => e.evaporasi, getValue: (e) => e.evaporasi,
); );
updatedTemp = TimeSeriesMapper.toWeekly(
data: history,
getTime: (e) => e.timestamp,
getValue: (e) => e.suhu,
);
} else if (event.period == "Bulan Ini") { } else if (event.period == "Bulan Ini") {
updated = TimeSeriesMapper.toMonthly( updated = TimeSeriesMapper.toMonthly(
data: history, data: history,
getTime: (e) => e.timestamp, getTime: (e) => e.timestamp,
getValue: (e) => e.evaporasi, getValue: (e) => e.evaporasi,
); );
updatedTemp = TimeSeriesMapper.toMonthly(
data: history,
getTime: (e) => e.timestamp,
getValue: (e) => e.suhu,
);
} else { } else {
updated = TimeSeriesMapper.toDaily( updated = TimeSeriesMapper.toDaily(
data: history, data: history,
getTime: (e) => e.timestamp, getTime: (e) => e.timestamp,
getValue: (e) => e.evaporasi, getValue: (e) => e.evaporasi,
); );
updatedTemp = TimeSeriesMapper.toDaily(
data: history,
getTime: (e) => e.timestamp,
getValue: (e) => e.suhu,
);
} }
// Pertahankan status cuaca saat ini (tidak berubah karena hanya ganti periode)
emit(state.copyWith( emit(state.copyWith(
dailyValues: updated, dailyValues: updated,
dailyTemperatures: updatedTemp,
isLoading: false, isLoading: false,
)); ));
} }
@ -125,6 +166,29 @@ class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> {
await _subscription?.cancel(); await _subscription?.cancel();
return super.close(); return super.close();
} }
/// =========================
/// 🌤 HELPER: Status Cuaca
/// =========================
static (String status, bool willRain) _computeWeatherStatus(double value) {
if (value <= 5.0) {
return ("Baik", false);
} else if (value > 5.0 && value <= 10.0) {
return ("Sedang", true);
} else {
return ("Buruk", true);
}
}
static void _updateGlobalNotifier(String status, bool willRain) {
hasWeatherAlert.value = willRain;
if (willRain) {
weatherAlertMessage.value =
'Status evaporasi $status — potensi hujan tinggi';
} else {
weatherAlertMessage.value = '';
}
}
} }
/// INTERNAL EVENT /// INTERNAL EVENT

View File

@ -6,9 +6,13 @@ class EvaporasiState extends Equatable {
final double waterLevel; // tinggi air final double waterLevel; // tinggi air
final String selectedPeriod; final String selectedPeriod;
final List<double> dailyValues; // untuk grafik final List<double> dailyValues; // untuk grafik evaporasi
final List<double> dailyTemperatures; // untuk grafik suhu
final List<Evaporasi> history; final List<Evaporasi> history;
final String weatherStatus; // Baik / Sedang / Buruk
final bool willRain; // true jika status Sedang/Buruk
final bool isLoading; final bool isLoading;
const EvaporasiState({ const EvaporasiState({
@ -17,7 +21,10 @@ class EvaporasiState extends Equatable {
this.waterLevel = 0.0, this.waterLevel = 0.0,
this.selectedPeriod = "Hari Ini", this.selectedPeriod = "Hari Ini",
this.dailyValues = const [], this.dailyValues = const [],
this.dailyTemperatures = const [],
this.history = const [], this.history = const [],
this.weatherStatus = "Baik",
this.willRain = false,
this.isLoading = true, this.isLoading = true,
}); });
@ -27,7 +34,10 @@ class EvaporasiState extends Equatable {
double? waterLevel, double? waterLevel,
String? selectedPeriod, String? selectedPeriod,
List<double>? dailyValues, List<double>? dailyValues,
List<double>? dailyTemperatures,
List<Evaporasi>? history, List<Evaporasi>? history,
String? weatherStatus,
bool? willRain,
bool? isLoading, bool? isLoading,
}) { }) {
return EvaporasiState( return EvaporasiState(
@ -36,7 +46,10 @@ class EvaporasiState extends Equatable {
waterLevel: waterLevel ?? this.waterLevel, waterLevel: waterLevel ?? this.waterLevel,
selectedPeriod: selectedPeriod ?? this.selectedPeriod, selectedPeriod: selectedPeriod ?? this.selectedPeriod,
dailyValues: dailyValues ?? this.dailyValues, dailyValues: dailyValues ?? this.dailyValues,
dailyTemperatures: dailyTemperatures ?? this.dailyTemperatures,
history: history ?? this.history, history: history ?? this.history,
weatherStatus: weatherStatus ?? this.weatherStatus,
willRain: willRain ?? this.willRain,
isLoading: isLoading ?? this.isLoading, isLoading: isLoading ?? this.isLoading,
); );
} }
@ -48,7 +61,10 @@ class EvaporasiState extends Equatable {
waterLevel, waterLevel,
selectedPeriod, selectedPeriod,
dailyValues, dailyValues,
dailyTemperatures,
history, history,
weatherStatus,
willRain,
isLoading, isLoading,
]; ];
} }

View File

@ -1,6 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import '../blocs/evaporasi_bloc.dart'; import '../blocs/evaporasi_bloc.dart';
import 'widgets/evaporasi_chart_widget.dart';
import 'widgets/evaporasi_period_selector.dart';
import '../../shared/utils/pdf/pdf_export_service.dart'; import '../../shared/utils/pdf/pdf_export_service.dart';
import '../../shared/widgets/export_pdf_button.dart'; import '../../shared/widgets/export_pdf_button.dart';
@ -44,7 +46,19 @@ class EvaporasiScreen extends StatelessWidget {
const SizedBox(height: 25), const SizedBox(height: 25),
_infoRow(state), _infoRow(state),
const SizedBox(height: 25), const SizedBox(height: 25),
_trendSection(), _statusCard(state),
const SizedBox(height: 25),
const Text("Tren Evaporasi & Suhu",
style:
TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
const SizedBox(height: 15),
const EvaporasiPeriodSelector(),
const SizedBox(height: 15),
EvaporasiChartWidget(
dailyValues: state.dailyValues,
dailyTemperatures: state.dailyTemperatures,
period: state.selectedPeriod,
),
const SizedBox(height: 25), const SizedBox(height: 25),
ExportPdfButton( ExportPdfButton(
onExport: () => PdfExportService.evaporasi( onExport: () => PdfExportService.evaporasi(
@ -146,21 +160,70 @@ class EvaporasiScreen extends StatelessWidget {
} }
/// ========================= /// =========================
/// 📈 TREND (SIMPLE PLACEHOLDER) /// 🌤 STATUS CARD
/// ========================= /// =========================
Widget _trendSection() { Widget _statusCard(EvaporasiState state) {
Color statusColor;
IconData statusIcon;
switch (state.weatherStatus) {
case "Sedang":
statusColor = Colors.orange;
statusIcon = Icons.warning_amber_rounded;
break;
case "Buruk":
statusColor = Colors.red;
statusIcon = Icons.error_outline;
break;
case "Baik":
default:
statusColor = Colors.green;
statusIcon = Icons.check_circle_outline;
break;
}
return Container( return Container(
width: double.infinity, width: double.infinity,
height: 200, padding: const EdgeInsets.all(20),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: Colors.white,
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
border: Border.all(color: statusColor.withOpacity(0.3), width: 1.5),
), ),
child: const Center( child: Row(
child: Text( children: [
"Grafik Evaporasi", Icon(statusIcon, color: statusColor, size: 40),
style: TextStyle(color: Colors.grey), const SizedBox(width: 15),
), Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
"Status Cuaca",
style: TextStyle(fontSize: 12, color: Colors.grey),
),
const SizedBox(height: 4),
Text(
state.weatherStatus,
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: statusColor,
),
),
if (state.willRain)
const Text(
"⚠️ Potensi hujan tinggi",
style: TextStyle(
fontSize: 12,
color: Colors.red,
fontWeight: FontWeight.w500,
),
),
],
),
),
],
), ),
); );
} }

View File

@ -0,0 +1,250 @@
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;
const EvaporasiChartWidget({
super.key,
required this.dailyValues,
required this.dailyTemperatures,
required this.period,
});
double _getMaxY(List<double> data) {
if (data.isEmpty) return 10;
final max = data.reduce((a, b) => a > b ? a : b);
return (max + 5).clamp(10, 100);
}
List<double> _safeData(List<double> data) {
return data.map((e) {
if (e.isNaN || e.isInfinite) return 0.0;
return e < 0 ? 0.0 : e;
}).toList();
}
@override
Widget build(BuildContext context) {
final safeValues = _safeData(dailyValues);
final safeTemps = _safeData(dailyTemperatures);
// Gunakan max dari kedua dataset agar skala Y sesuai
final maxY = _getMaxY([
...safeValues,
...safeTemps,
]);
return Container(
height: 280,
padding: const EdgeInsets.fromLTRB(10, 20, 20, 10),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(25),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 5),
)
],
),
child: Column(
children: [
// Legend
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_legendItem(Colors.blue.shade700, "Evaporasi (mm)"),
const SizedBox(width: 20),
_legendItem(Colors.orange.shade600, "Suhu (°C)"),
],
),
const SizedBox(height: 10),
Expanded(
child: LineChart(
duration: const Duration(milliseconds: 600),
curve: Curves.easeOutCubic,
LineChartData(
minY: 0,
maxY: maxY,
gridData: const FlGridData(show: false),
borderData: FlBorderData(show: false),
titlesData: FlTitlesData(
show: true,
rightTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false)),
topTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false)),
leftTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false)),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 30,
interval: _getInterval(),
getTitlesWidget: (value, meta) {
return SideTitleWidget(
meta: meta,
space: 8,
child: Text(
_getBottomTitle(value),
style: const TextStyle(
color: Colors.grey, fontSize: 10),
),
);
},
),
),
),
lineBarsData: [
// === Garis Evaporasi ===
LineChartBarData(
spots: safeValues.asMap().entries.map((e) {
return FlSpot(e.key.toDouble(), e.value);
}).toList(),
isCurved: true,
curveSmoothness: 0.2,
preventCurveOverShooting: true,
color: Colors.blue.shade700,
barWidth: 3,
isStrokeCapRound: true,
dotData: FlDotData(
show: true,
getDotPainter: (spot, percent, bar, index) {
if (index == safeValues.length - 1) {
return FlDotCirclePainter(
radius: 5,
color: Colors.blue.shade900,
strokeWidth: 2,
strokeColor: Colors.white,
);
}
return FlDotCirclePainter(radius: 0);
},
),
belowBarData: BarAreaData(
show: true,
gradient: LinearGradient(
colors: [
Colors.blue.withOpacity(0.25),
Colors.blue.withOpacity(0.05),
Colors.transparent
],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
),
// === Garis Suhu ===
LineChartBarData(
spots: safeTemps.asMap().entries.map((e) {
return FlSpot(e.key.toDouble(), e.value);
}).toList(),
isCurved: true,
curveSmoothness: 0.2,
preventCurveOverShooting: true,
color: Colors.orange.shade600,
barWidth: 3,
isStrokeCapRound: true,
dotData: FlDotData(
show: true,
getDotPainter: (spot, percent, bar, index) {
if (index == safeTemps.length - 1) {
return FlDotCirclePainter(
radius: 5,
color: Colors.orange.shade800,
strokeWidth: 2,
strokeColor: Colors.white,
);
}
return FlDotCirclePainter(radius: 0);
},
),
belowBarData: BarAreaData(
show: true,
gradient: LinearGradient(
colors: [
Colors.orange.withOpacity(0.25),
Colors.orange.withOpacity(0.05),
Colors.transparent
],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
),
],
lineTouchData: LineTouchData(
handleBuiltInTouches: true,
touchTooltipData: LineTouchTooltipData(
getTooltipItems: (touchedSpots) {
return touchedSpots.map((spot) {
final isEvaporasi = spot.barIndex == 0;
final label = isEvaporasi ? "Evaporasi" : "Suhu";
final unit = isEvaporasi ? "mm" : "°C";
return LineTooltipItem(
"$label: ${spot.y.toStringAsFixed(1)} $unit",
TextStyle(
color: isEvaporasi
? Colors.blue.shade100
: Colors.orange.shade100,
fontWeight: FontWeight.bold,
),
);
}).toList();
},
),
),
),
),
),
],
),
);
}
Widget _legendItem(Color color, String label) {
return Row(
children: [
Container(
width: 12,
height: 12,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(3),
),
),
const SizedBox(width: 6),
Text(
label,
style: const TextStyle(fontSize: 12, color: Colors.grey),
),
],
);
}
String _getBottomTitle(double value) {
int index = value.toInt();
final len = dailyValues.length;
if (index < 0 || index >= len) return '';
if (period == "Hari Ini") {
return index % 4 == 0 ? "${index.toString().padLeft(2, '0')}:00" : "";
} else if (period == "Minggu Ini") {
const days = ["Sen", "Sel", "Rab", "Kam", "Jum", "Sab", "Min"];
return days[index % 7];
} else {
return (index + 1) % 5 == 0 ? "${index + 1}" : "";
}
}
double _getInterval() {
if (period == "Hari Ini") return 1;
if (period == "Minggu Ini") return 1;
return 1;
}
}

View File

@ -0,0 +1,55 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../blocs/evaporasi_bloc.dart';
class EvaporasiPeriodSelector extends StatelessWidget {
const EvaporasiPeriodSelector({super.key});
@override
Widget build(BuildContext context) {
final selectedPeriod =
context.select((EvaporasiBloc bloc) => bloc.state.selectedPeriod);
return Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(15),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildTab(context, "Hari Ini", selectedPeriod),
_buildTab(context, "Minggu Ini", selectedPeriod),
_buildTab(context, "Bulan Ini", selectedPeriod),
],
),
);
}
Widget _buildTab(BuildContext context, String label, String current) {
bool isActive = label == current;
return GestureDetector(
onTap: () {
context.read<EvaporasiBloc>().add(EvaporasiPeriodChanged(label));
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: isActive ? Colors.blue : Colors.transparent,
borderRadius: BorderRadius.circular(12),
),
child: Text(
label,
style: TextStyle(
color: isActive ? Colors.white : Colors.grey,
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
),
);
}
}