Merge branch 'branch' of https://github.com/kleponijo/klimatologi into branch_percobaan
This commit is contained in:
commit
9c7558cdb5
|
|
@ -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
|
||||||
|
|
||||||
|
|
@ -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>('');
|
||||||
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Loading…
Reference in New Issue