From 2d7ee2ea986daa98bc1da11f911fe57eb97fe321 Mon Sep 17 00:00:00 2001 From: Mochamad ongki ramadani Date: Fri, 1 May 2026 21:00:57 +0700 Subject: [PATCH 01/22] update search periode --- TODO.md | 37 ++- lib/core/utils/time_series_mapper.dart | 28 +++ .../evaporasi/blocs/evaporasi_bloc.dart | 55 ++++- .../evaporasi/blocs/evaporasi_event.dart | 20 ++ .../evaporasi/blocs/evaporasi_state.dart | 16 +- .../evaporasi/views/evaporasi_screen.dart | 63 ++++- .../views/widgets/evaporasi_date_picker.dart | 232 ++++++++++++++++++ .../widgets/evaporasi_period_selector.dart | 67 ++++- pubspec.lock | 16 ++ pubspec.yaml | 3 + 10 files changed, 512 insertions(+), 25 deletions(-) create mode 100644 lib/screens/monitoring/evaporasi/views/widgets/evaporasi_date_picker.dart diff --git a/TODO.md b/TODO.md index 828454f..a5449e5 100644 --- a/TODO.md +++ b/TODO.md @@ -1,11 +1,30 @@ -# TODO: Fitur Baru Evaporasi (Period Selector + Status + Notifikasi) +# TODO - Evaporasi Date Picker Feature -## 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 +## Status: COMPLETED ✅ +### Steps: + +- [x] 1. Add table_calendar package to pubspec.yaml +- [x] 2. Update EvaporasiState - add selectedDate and EvaporasiViewMode +- [x] 3. Update EvaporasiEvent - add EvaporasiDateSelected and EvaporasiViewModeChanged +- [x] 4. Update EvaporasiBloc - handle date selection + view mode +- [x] 5. Add toSpecificDate function to TimeSeriesMapper +- [x] 6. Create EvaporasiDatePicker widget (WhatsApp-style calendar) +- [x] 7. Update EvaporasiPeriodSelector - add date picker button +- [x] 8. Update EvaporasiScreen - integrate date picker + show selected date +- [x] 9. Update EvaporasiChartWidget - handle custom date display (already supports "Tanggal Khusus") + +### Summary: + +Feature implemented successfully: + +1. **Date Picker Button**: Added next to period tabs (Hari Ini, Minggu Ini, Bulan Ini) +2. **WhatsApp-style Calendar**: Using table_calendar with Indonesian format +3. **Custom Date Display**: Shows selected date above the period selector +4. **24-hour Chart**: For custom date, shows hourly data (same as "Hari Ini") +5. **Period Mode**: Users can switch back to period mode by clicking any period tab + +### Notes: +- Using table_calendar: ^3.1.2 +- Date format: Indonesian locale (id_ID) +- Similar to WhatsApp chat date picker functionality diff --git a/lib/core/utils/time_series_mapper.dart b/lib/core/utils/time_series_mapper.dart index 3133012..0fa54cb 100644 --- a/lib/core/utils/time_series_mapper.dart +++ b/lib/core/utils/time_series_mapper.dart @@ -91,6 +91,34 @@ class TimeSeriesMapper { }); } +/// ========================= + /// 📅 SPECIFIC DATE (24 JAM - TANGGAL KHUSUS) + /// ========================= + static List toSpecificDate({ + required List data, + required DateTime Function(T) getTime, + required double Function(T) getValue, + required DateTime targetDate, + }) { + final sums = List.filled(24, 0.0); + final counts = List.filled(24, 0); + + for (final item in data) { + final time = getTime(item); + + if (_isSameDay(time, targetDate)) { + final hour = time.hour; + sums[hour] += getValue(item); + counts[hour]++; + } + } + + return List.generate(24, (i) { + if (counts[i] == 0) return 0; + return sums[i] / counts[i]; + }); + } + /// ========================= /// 🧠 HELPER /// ========================= diff --git a/lib/screens/monitoring/evaporasi/blocs/evaporasi_bloc.dart b/lib/screens/monitoring/evaporasi/blocs/evaporasi_bloc.dart index 7758d67..32cee33 100644 --- a/lib/screens/monitoring/evaporasi/blocs/evaporasi_bloc.dart +++ b/lib/screens/monitoring/evaporasi/blocs/evaporasi_bloc.dart @@ -23,6 +23,8 @@ class EvaporasiBloc extends Bloc { on(_onStarted); on<_EvaporasiRealtimeUpdated>(_onRealtimeUpdated); on(_onPeriodChanged); + on(_onDateSelected); + on(_onViewModeChanged); } /// ========================= @@ -149,7 +151,7 @@ class EvaporasiBloc extends Bloc { ); } - // Pertahankan status cuaca saat ini (tidak berubah karena hanya ganti periode) +// Pertahankan status cuaca saat ini (tidak berubah karena hanya ganti periode) emit(state.copyWith( dailyValues: updated, dailyTemperatures: updatedTemp, @@ -157,6 +159,57 @@ class EvaporasiBloc extends Bloc { )); } + /// ========================= + /// 📅 DATE SELECTED (Custom Date) + /// ========================= + Future _onDateSelected( + EvaporasiDateSelected event, + Emitter emit, + ) async { + emit(state.copyWith( + isLoading: true, + selectedDate: event.date, + viewMode: EvaporasiViewMode.customDate, + )); + + final history = state.history; + + final updated = TimeSeriesMapper.toSpecificDate( + data: history, + getTime: (e) => e.timestamp, + getValue: (e) => e.evaporasi, + targetDate: event.date, + ); + + final updatedTemp = TimeSeriesMapper.toSpecificDate( + data: history, + getTime: (e) => e.timestamp, + getValue: (e) => e.suhu, + targetDate: event.date, + ); + + emit(state.copyWith( + dailyValues: updated, + dailyTemperatures: updatedTemp, + isLoading: false, + )); + } + + /// ========================= + /// 🔄 VIEW MODE CHANGED + /// ========================= + Future _onViewModeChanged( + EvaporasiViewModeChanged event, + Emitter emit, + ) async { + if (event.mode == EvaporasiViewMode.period) { + // Kembali ke mode period - trigger period change + add(EvaporasiPeriodChanged(state.selectedPeriod)); + } else { + emit(state.copyWith(viewMode: event.mode)); + } + } + @override Future close() async { await _subscription?.cancel(); diff --git a/lib/screens/monitoring/evaporasi/blocs/evaporasi_event.dart b/lib/screens/monitoring/evaporasi/blocs/evaporasi_event.dart index 735954a..cbf007d 100644 --- a/lib/screens/monitoring/evaporasi/blocs/evaporasi_event.dart +++ b/lib/screens/monitoring/evaporasi/blocs/evaporasi_event.dart @@ -19,3 +19,23 @@ class EvaporasiPeriodChanged extends EvaporasiEvent { @override List get props => [period]; } + +/// 📅 PILIH TANGGAL KHUSUS (Custom Date Picker) +class EvaporasiDateSelected extends EvaporasiEvent { + final DateTime date; + + const EvaporasiDateSelected(this.date); + + @override + List get props => [date]; +} + +/// 🔄 KEMBALI KE MODE PERIOD +class EvaporasiViewModeChanged extends EvaporasiEvent { + final EvaporasiViewMode mode; + + const EvaporasiViewModeChanged(this.mode); + + @override + List get props => [mode]; +} diff --git a/lib/screens/monitoring/evaporasi/blocs/evaporasi_state.dart b/lib/screens/monitoring/evaporasi/blocs/evaporasi_state.dart index 742ce96..640d2dd 100644 --- a/lib/screens/monitoring/evaporasi/blocs/evaporasi_state.dart +++ b/lib/screens/monitoring/evaporasi/blocs/evaporasi_state.dart @@ -1,10 +1,14 @@ part of 'evaporasi_bloc.dart'; +enum EvaporasiViewMode { period, customDate } + class EvaporasiState extends Equatable { final double currentValue; // nilai evaporasi realtime final double temperature; // suhu (opsional dari firebase) final double waterLevel; // tinggi air final String selectedPeriod; + final DateTime? selectedDate; // tanggal spesifik untuk custom date + final EvaporasiViewMode viewMode; // period vs custom date final List dailyValues; // untuk grafik evaporasi final List dailyTemperatures; // untuk grafik suhu @@ -20,6 +24,8 @@ class EvaporasiState extends Equatable { this.temperature = 0.0, this.waterLevel = 0.0, this.selectedPeriod = "Hari Ini", + this.selectedDate, + this.viewMode = EvaporasiViewMode.period, this.dailyValues = const [], this.dailyTemperatures = const [], this.history = const [], @@ -28,11 +34,13 @@ class EvaporasiState extends Equatable { this.isLoading = true, }); - EvaporasiState copyWith({ +EvaporasiState copyWith({ double? currentValue, double? temperature, double? waterLevel, String? selectedPeriod, + DateTime? selectedDate, + EvaporasiViewMode? viewMode, List? dailyValues, List? dailyTemperatures, List? history, @@ -45,6 +53,8 @@ class EvaporasiState extends Equatable { temperature: temperature ?? this.temperature, waterLevel: waterLevel ?? this.waterLevel, selectedPeriod: selectedPeriod ?? this.selectedPeriod, + selectedDate: selectedDate ?? this.selectedDate, + viewMode: viewMode ?? this.viewMode, dailyValues: dailyValues ?? this.dailyValues, dailyTemperatures: dailyTemperatures ?? this.dailyTemperatures, history: history ?? this.history, @@ -55,11 +65,13 @@ class EvaporasiState extends Equatable { } @override - List get props => [ + List get props => [ currentValue, temperature, waterLevel, selectedPeriod, + selectedDate, + viewMode, dailyValues, dailyTemperatures, history, diff --git a/lib/screens/monitoring/evaporasi/views/evaporasi_screen.dart b/lib/screens/monitoring/evaporasi/views/evaporasi_screen.dart index 629029b..eb3343a 100644 --- a/lib/screens/monitoring/evaporasi/views/evaporasi_screen.dart +++ b/lib/screens/monitoring/evaporasi/views/evaporasi_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:intl/intl.dart'; import '../blocs/evaporasi_bloc.dart'; import 'widgets/evaporasi_chart_widget.dart'; import 'widgets/evaporasi_period_selector.dart'; @@ -52,12 +53,44 @@ class EvaporasiScreen extends StatelessWidget { style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), const SizedBox(height: 15), - const EvaporasiPeriodSelector(), +// Period selector with date picker + Builder( + builder: (context) { + final state = context.watch().state; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Show selected date info when in custom mode + if (state.viewMode == EvaporasiViewMode.customDate && + state.selectedDate != null) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text( + "📅 ${_formatDateInfo(state.selectedDate!)}", + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Colors.blue.shade700, + ), + ), + ), + const EvaporasiPeriodSelector(), + ], + ); + }, + ), const SizedBox(height: 15), - EvaporasiChartWidget( - dailyValues: state.dailyValues, - dailyTemperatures: state.dailyTemperatures, - period: state.selectedPeriod, + Builder( + builder: (context) { + final state = context.watch().state; + return EvaporasiChartWidget( + dailyValues: state.dailyValues, + dailyTemperatures: state.dailyTemperatures, + period: state.viewMode == EvaporasiViewMode.customDate + ? "Tanggal Khusus" + : state.selectedPeriod, + ); + }, ), const SizedBox(height: 25), ExportPdfButton( @@ -223,8 +256,26 @@ class EvaporasiScreen extends StatelessWidget { ], ), ), - ], +], ), ); } + + /// ========================= + /// 📅 FORMAT DATE INFO (for custom date display) + /// ========================= + String _formatDateInfo(DateTime date) { + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + final yesterday = today.subtract(const Duration(days: 1)); + final selected = DateTime(date.year, date.month, date.day); + + if (selected == today) { + return "Hari Ini"; + } else if (selected == yesterday) { + return "Kemarin"; + } else { + return DateFormat('EEEE, d MMMM yyyy', 'id_ID').format(date); + } + } } diff --git a/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_date_picker.dart b/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_date_picker.dart new file mode 100644 index 0000000..1f08115 --- /dev/null +++ b/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_date_picker.dart @@ -0,0 +1,232 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:table_calendar/table_calendar.dart'; +import 'package:intl/intl.dart'; +import '../../blocs/evaporasi_bloc.dart'; + +/// 📅 EVAPORASI DATE PICKER - WhatsApp Style +class EvaporasiDatePicker extends StatefulWidget { + const EvaporasiDatePicker({super.key}); + + @override + State createState() => _EvaporasiDatePickerState(); +} + +class _EvaporasiDatePickerState extends State { + CalendarFormat _calendarFormat = CalendarFormat.month; + DateTime _focusedDay = DateTime.now(); + DateTime? _selectedDay; + + @override + void initState() { + super.initState(); + _selectedDay = _focusedDay; + } + + @override + Widget build(BuildContext context) { + return Container( + height: 400, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + ), + child: Column( + children: [ + // Header with close button + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + TextButton( + onPressed: () { + // Kembali ke mode period DAN TUTUP bottom sheet + context.read().add( + const EvaporasiViewModeChanged( + EvaporasiViewMode.period), + ); + Navigator.of(context).pop(); + }, + child: const Text( + "Kembali", + style: TextStyle(color: Colors.grey), + ), + ), + Text( + "Pilih Tanggal", + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.grey.shade700, + ), + ), + TextButton( + onPressed: () { + if (_selectedDay != null) { + context.read().add( + EvaporasiDateSelected(_selectedDay!), + ); + Navigator.of(context).pop(); + } + }, + child: const Text( + "OK", + style: TextStyle( + color: Colors.blue, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + ), + // Calendar + Expanded( + child: TableCalendar( + firstDay: DateTime.utc(2020, 1, 1), + lastDay: DateTime.now(), + focusedDay: _focusedDay, + calendarFormat: _calendarFormat, + selectedDayPredicate: (day) { + return isSameDay(_selectedDay, day); + }, + onDaySelected: (selectedDay, focusedDay) { + setState(() { + _selectedDay = selectedDay; + _focusedDay = focusedDay; + }); + }, + onFormatChanged: (format) { + if (_calendarFormat != format) { + setState(() { + _calendarFormat = format; + }); + } + }, + onPageChanged: (focusedDay) { + _focusedDay = focusedDay; + }, + calendarStyle: CalendarStyle( + // Default + defaultDecoration: BoxDecoration( + color: Colors.transparent, + shape: BoxShape.circle, + ), + // Today + todayDecoration: BoxDecoration( + color: Colors.blue.shade100, + shape: BoxShape.circle, + ), + todayTextStyle: TextStyle( + color: Colors.blue.shade700, + fontWeight: FontWeight.bold, + ), + // Selected + selectedDecoration: BoxDecoration( + color: Colors.blue, + shape: BoxShape.circle, + ), + selectedTextStyle: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + ), + // Outside days + outsideDaysVisible: false, + weekendTextStyle: TextStyle(color: Colors.grey.shade600), + ), + headerStyle: HeaderStyle( + formatButtonVisible: true, + titleCentered: true, + formatButtonShowsNext: false, + formatButtonDecoration: BoxDecoration( + border: Border.all(color: Colors.blue), + borderRadius: BorderRadius.circular(12), + ), + formatButtonTextStyle: const TextStyle( + color: Colors.blue, + ), + titleTextStyle: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.grey.shade700, + ), + leftChevronIcon: Icon( + Icons.chevron_left, + color: Colors.grey.shade600, + ), + rightChevronIcon: Icon( + Icons.chevron_right, + color: Colors.grey.shade600, + ), + ), + daysOfWeekStyle: DaysOfWeekStyle( + weekdayStyle: TextStyle( + color: Colors.grey.shade500, + fontWeight: FontWeight.w500, + fontSize: 12, + ), + weekendStyle: TextStyle( + color: Colors.grey.shade500, + fontWeight: FontWeight.w500, + fontSize: 12, + ), + ), + ), + ), + // Selected date display + if (_selectedDay != null) + Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: const BorderRadius.only( + bottomLeft: Radius.circular(20), + bottomRight: Radius.circular(20), + ), + ), + child: Text( + _formatDate(_selectedDay!), + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Colors.grey.shade700, + ), + ), + ), + ], + ), + ); + } + + String _formatDate(DateTime date) { + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + final yesterday = today.subtract(const Duration(days: 1)); + final selected = DateTime(date.year, date.month, date.day); + + if (selected == today) { + return "Hari Ini"; + } else if (selected == yesterday) { + return "Kemarin"; + } else { + return DateFormat('EEEE, d MMMM yyyy', 'id_ID').format(date); + } + } +} + +/// 🔹 Show Date Picker Dialog +void showEvaporasiDatePicker(BuildContext context) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => Container( + margin: const EdgeInsets.all(16), + height: 450, + child: const EvaporasiDatePicker(), + ), + ); +} diff --git a/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_period_selector.dart b/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_period_selector.dart index dafe16f..ee5ea72 100644 --- a/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_period_selector.dart +++ b/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_period_selector.dart @@ -1,14 +1,17 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../blocs/evaporasi_bloc.dart'; +import 'evaporasi_date_picker.dart'; class EvaporasiPeriodSelector extends StatelessWidget { const EvaporasiPeriodSelector({super.key}); @override Widget build(BuildContext context) { - final selectedPeriod = - context.select((EvaporasiBloc bloc) => bloc.state.selectedPeriod); + final state = context.select((EvaporasiBloc bloc) => bloc.state); + final selectedPeriod = state.selectedPeriod; + final viewMode = state.viewMode; + final selectedDate = state.selectedDate; return Container( padding: const EdgeInsets.all(4), @@ -19,16 +22,20 @@ class EvaporasiPeriodSelector extends StatelessWidget { child: Row( mainAxisSize: MainAxisSize.min, children: [ - _buildTab(context, "Hari Ini", selectedPeriod), - _buildTab(context, "Minggu Ini", selectedPeriod), - _buildTab(context, "Bulan Ini", selectedPeriod), + _buildTab(context, "Hari Ini", selectedPeriod, viewMode), + _buildTab(context, "Minggu Ini", selectedPeriod, viewMode), + _buildTab(context, "Bulan Ini", selectedPeriod, viewMode), + const SizedBox(width: 8), + // Date picker button + _buildDatePickerButton(context, viewMode, selectedDate), ], ), ); } - Widget _buildTab(BuildContext context, String label, String current) { - bool isActive = label == current; + Widget _buildTab(BuildContext context, String label, String current, EvaporasiViewMode viewMode) { + // If in customDate mode, show period tabs as inactive + bool isActive = viewMode == EvaporasiViewMode.period && label == current; return GestureDetector( onTap: () { @@ -51,5 +58,51 @@ class EvaporasiPeriodSelector extends StatelessWidget { ), ); } + +Widget _buildDatePickerButton(BuildContext context, EvaporasiViewMode viewMode, DateTime? selectedDate) { + final isActive = viewMode == EvaporasiViewMode.customDate; + + return GestureDetector( + onTap: () { + // Set mode ke customDate SEBELUM membuka date picker + context.read().add( + const EvaporasiViewModeChanged(EvaporasiViewMode.customDate), + ); + showEvaporasiDatePicker(context); + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: isActive ? Colors.blue : Colors.grey.shade200, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.calendar_today, + size: 14, + color: isActive ? Colors.white : Colors.grey.shade600, + ), + const SizedBox(width: 4), + Text( + isActive && selectedDate != null + ? _formatDateShort(selectedDate) + : "Pilih Tanggal", + style: TextStyle( + color: isActive ? Colors.white : Colors.grey.shade600, + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), + ], + ), + ), + ); + } + + String _formatDateShort(DateTime date) { + return "${date.day}/${date.month}"; + } } diff --git a/pubspec.lock b/pubspec.lock index ffc5bd8..f3cf197 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -687,6 +687,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.27.7" + simple_gesture_detector: + dependency: transitive + description: + name: simple_gesture_detector + sha256: ba2cd5af24ff20a0b8d609cec3f40e5b0744d2a71804a2616ae086b9c19d19a3 + url: "https://pub.dev" + source: hosted + version: "0.2.1" sky_engine: dependency: transitive description: flutter @@ -732,6 +740,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + table_calendar: + dependency: "direct main" + description: + name: table_calendar + sha256: "0c0c6219878b363a2d5f40c7afb159d845f253d061dc3c822aa0d5fe0f721982" + url: "https://pub.dev" + source: hosted + version: "3.2.0" term_glyph: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 4a46cde..8f5fb66 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -4,6 +4,7 @@ publish_to: 'none' version: 1.0.0+1 environment: sdk: ^3.1.0 + dependencies: bloc: ^8.1.0 bloc_concurrency: ^0.2.5 @@ -15,6 +16,7 @@ dependencies: firebase_core: ^4.3.0 firebase_database: ^12.1.3 fl_chart: ^1.1.1 + table_calendar: ^3.1.2 flutter: sdk: flutter flutter_bloc: ^8.1.0 @@ -33,6 +35,7 @@ dev_dependencies: flutter_lints: ^3.0.0 flutter_test: sdk: flutter + flutter: uses-material-design: true assets: From 9ef8baa9b9986b437981d535f38f69d41be59c3a Mon Sep 17 00:00:00 2001 From: kleponijo <158453666+kleponijo@users.noreply.github.com> Date: Sun, 3 May 2026 09:29:13 +0700 Subject: [PATCH 02/22] yap maaf ya, kalau judul commit aneh --- lib/screens/home/views/home_screen.dart | 4 + .../home/widgets/dashboard_charts.dart | 605 ++++++++++++++++++ 2 files changed, 609 insertions(+) create mode 100644 lib/screens/home/widgets/dashboard_charts.dart diff --git a/lib/screens/home/views/home_screen.dart b/lib/screens/home/views/home_screen.dart index 71a1af0..a495092 100644 --- a/lib/screens/home/views/home_screen.dart +++ b/lib/screens/home/views/home_screen.dart @@ -8,6 +8,7 @@ import '../../monitoring/evaporasi/blocs/evaporasi_bloc.dart'; import '../../monitoring/atmospheric_conditions/blocs/atmospheric_conditions_bloc.dart'; import '../widgets/notification_panel.dart'; import '../widgets/sensor_grid.dart'; +import '../widgets/dashboard_charts.dart'; import 'main_drawer.dart'; class HomeScreen extends StatefulWidget { @@ -199,6 +200,9 @@ class _HomeBody extends StatelessWidget { ), const SizedBox(height: 12), const SensorGrid(), + const SizedBox(height: 24), // ← TAMBAH + const DashboardCharts(), // ← TAMBAH + const SizedBox(height: 16), ], ), ); diff --git a/lib/screens/home/widgets/dashboard_charts.dart b/lib/screens/home/widgets/dashboard_charts.dart new file mode 100644 index 0000000..4dc5c45 --- /dev/null +++ b/lib/screens/home/widgets/dashboard_charts.dart @@ -0,0 +1,605 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../monitoring/wind_speed/blocs/wind_speed_bloc.dart'; +import '../../monitoring/evaporasi/blocs/evaporasi_bloc.dart'; +import '../../monitoring/atmospheric_conditions/blocs/atmospheric_conditions_bloc.dart'; + +/// Period selector khusus untuk dashboard +class DashboardPeriodSelector extends StatelessWidget { + final String selected; + final ValueChanged onChanged; + + const DashboardPeriodSelector({ + super.key, + required this.selected, + required this.onChanged, + }); + + static const _periods = ["Hari Ini", "Minggu Ini", "Bulan Ini"]; + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: _periods.map((p) { + final isSelected = p == selected; + return Padding( + padding: const EdgeInsets.only(right: 8), + child: GestureDetector( + onTap: () => onChanged(p), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 7), + decoration: BoxDecoration( + color: isSelected ? Colors.blue.shade700 : Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: isSelected + ? Colors.blue.shade700 + : Colors.grey.shade300, + ), + ), + child: Text( + p, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: isSelected ? Colors.white : Colors.grey.shade600, + ), + ), + ), + ), + ); + }).toList(), + ), + ); + } +} + +/// Widget utama — semua grafik dashboard +class DashboardCharts extends StatefulWidget { + const DashboardCharts({super.key}); + + @override + State createState() => _DashboardChartsState(); +} + +class _DashboardChartsState extends State { + String _period = "Hari Ini"; + + void _onPeriodChanged(String p) { + setState(() => _period = p); + context.read().add(WindSpeedPeriodChanged(p)); + context.read().add(EvaporasiPeriodChanged(p)); + // AtmosphericBloc tidak punya period (hanya realtime) + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ── Header + Period Selector ────────────────────────── + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Grafik Sensor', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: Colors.grey.shade700, + ), + ), + ], + ), + const SizedBox(height: 10), + DashboardPeriodSelector( + selected: _period, + onChanged: _onPeriodChanged, + ), + const SizedBox(height: 14), + + // ── Scrollable chart list ───────────────────────────── + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + // 1. Kecepatan Angin + BlocBuilder( + builder: (context, state) { + final data = switch (_period) { + "Minggu Ini" => state.weeklySpeeds, + "Bulan Ini" => state.monthlySpeeds, + _ => state.dailySpeeds, + }; + return _SensorChartCard( + title: 'Kecepatan Angin', + unit: 'm/s', + color: Colors.blue.shade600, + bgColor: Colors.blue.shade50, + icon: Icons.air, + currentValue: state.currentSpeed, + data: data, + period: _period, + isLoading: state.isLoading, + ); + }, + ), + const SizedBox(width: 12), + + // 2. Evaporasi + BlocBuilder( + builder: (context, state) { + final data = switch (_period) { + "Minggu Ini" => state.dailyValues, // weekly jika ada + "Bulan Ini" => state.dailyValues, + _ => state.dailyValues, + }; + return _SensorChartCard( + title: 'Evaporasi', + unit: 'mm', + color: Colors.teal.shade600, + bgColor: Colors.teal.shade50, + icon: Icons.water_drop_outlined, + currentValue: state.currentValue, + data: data, + period: _period, + isLoading: state.isLoading, + ); + }, + ), + const SizedBox(width: 12), + + // 3. Tekanan Udara — hanya realtime (tidak ada history) + BlocBuilder( + builder: (context, state) { + return _AtmosphericCard(state: state); + }, + ), + ], + ), + ), + ], + ); + } +} + +// ══════════════════════════════════════════════════════════════════ +// Sensor Chart Card — untuk angin & evaporasi +// ══════════════════════════════════════════════════════════════════ +class _SensorChartCard extends StatelessWidget { + final String title; + final String unit; + final Color color; + final Color bgColor; + final IconData icon; + final double currentValue; + final List data; + final String period; + final bool isLoading; + + const _SensorChartCard({ + required this.title, + required this.unit, + required this.color, + required this.bgColor, + required this.icon, + required this.currentValue, + required this.data, + required this.period, + required this.isLoading, + }); + + @override + Widget build(BuildContext context) { + return Container( + width: 260, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.06), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header + Row( + children: [ + Container( + padding: const EdgeInsets.all(7), + decoration: BoxDecoration( + color: bgColor, + borderRadius: BorderRadius.circular(10), + ), + child: Icon(icon, color: color, size: 18), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + title, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + const SizedBox(height: 10), + + // Nilai saat ini + if (isLoading) + Container( + height: 28, + width: 80, + decoration: BoxDecoration( + color: Colors.grey.shade200, + borderRadius: BorderRadius.circular(6), + ), + ) + else + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + currentValue.toStringAsFixed(1), + style: TextStyle( + fontSize: 26, + fontWeight: FontWeight.bold, + color: color, + ), + ), + const SizedBox(width: 4), + Padding( + padding: const EdgeInsets.only(bottom: 3), + child: Text( + unit, + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade500, + ), + ), + ), + ], + ), + const SizedBox(height: 12), + + // Mini chart + isLoading + ? Container( + height: 70, + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(8), + ), + ) + : _MiniLineChart( + data: data, + color: color, + period: period, + ), + + const SizedBox(height: 8), + Text( + _periodLabel(period), + style: TextStyle(fontSize: 10, color: Colors.grey.shade400), + ), + ], + ), + ); + } + + String _periodLabel(String p) { + return switch (p) { + "Minggu Ini" => "Rata-rata per hari minggu ini", + "Bulan Ini" => "Rata-rata per hari bulan ini", + _ => "Rata-rata per jam hari ini", + }; + } +} + +// ══════════════════════════════════════════════════════════════════ +// Atmospheric Card — tekanan udara (realtime only, no history) +// ══════════════════════════════════════════════════════════════════ +class _AtmosphericCard extends StatelessWidget { + final AtmosphericConditionsState state; + + const _AtmosphericCard({required this.state}); + + @override + Widget build(BuildContext context) { + // Klasifikasi tekanan udara + final (label, labelColor) = _classifyPressure(state.pressure); + + return Container( + width: 260, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.06), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header + Row( + children: [ + Container( + padding: const EdgeInsets.all(7), + decoration: BoxDecoration( + color: Colors.purple.shade50, + borderRadius: BorderRadius.circular(10), + ), + child: Icon(Icons.compress, + color: Colors.purple.shade400, size: 18), + ), + const SizedBox(width: 8), + const Expanded( + child: Text( + 'Kondisi Atmosfer', + style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600), + ), + ), + ], + ), + const SizedBox(height: 10), + + // Tekanan utama + if (state.isLoading) + Container( + height: 28, + width: 80, + decoration: BoxDecoration( + color: Colors.grey.shade200, + borderRadius: BorderRadius.circular(6), + ), + ) + else ...[ + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + state.pressure.toStringAsFixed(1), + style: TextStyle( + fontSize: 26, + fontWeight: FontWeight.bold, + color: Colors.purple.shade400, + ), + ), + const SizedBox(width: 4), + Padding( + padding: const EdgeInsets.only(bottom: 3), + child: Text( + 'hPa', + style: TextStyle(fontSize: 12, color: Colors.grey.shade500), + ), + ), + const Spacer(), + Container( + padding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: labelColor.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + label, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: labelColor, + ), + ), + ), + ], + ), + const SizedBox(height: 12), + + // Info tambahan: suhu, humidity, altitude + _AtmosInfoRow( + items: [ + _AtmosItem( + Icons.thermostat_rounded, + '${state.temperature.toStringAsFixed(1)}°C', + 'Suhu', + Colors.orange.shade400), + _AtmosItem( + Icons.water_drop_rounded, + '${state.humidity.toStringAsFixed(0)}%', + 'Kelembapan', + Colors.blue.shade400), + _AtmosItem( + Icons.landscape_rounded, + '${state.altitude.toStringAsFixed(0)} m', + 'Altitud', + Colors.green.shade400), + ], + ), + ], + + const SizedBox(height: 8), + Text( + 'Data realtime — tidak ada history', + style: TextStyle(fontSize: 10, color: Colors.grey.shade400), + ), + ], + ), + ); + } + + static (String, Color) _classifyPressure(double hpa) { + if (hpa < 1000) return ('Rendah', Colors.orange.shade600); + if (hpa > 1020) return ('Tinggi', Colors.blue.shade600); + return ('Normal', Colors.green.shade600); + } +} + +class _AtmosInfoRow extends StatelessWidget { + final List<_AtmosItem> items; + const _AtmosInfoRow({required this.items}); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: items + .map((item) => Expanded( + child: Column( + children: [ + Icon(item.icon, color: item.color, size: 18), + const SizedBox(height: 3), + Text( + item.value, + style: const TextStyle( + fontSize: 12, fontWeight: FontWeight.bold), + ), + Text( + item.label, + style: + TextStyle(fontSize: 10, color: Colors.grey.shade500), + ), + ], + ), + )) + .toList(), + ); + } +} + +class _AtmosItem { + final IconData icon; + final String value; + final String label; + final Color color; + const _AtmosItem(this.icon, this.value, this.label, this.color); +} + +// ══════════════════════════════════════════════════════════════════ +// Mini Line Chart — custom painter, tanpa library tambahan +// ══════════════════════════════════════════════════════════════════ +class _MiniLineChart extends StatelessWidget { + final List data; + final Color color; + final String period; + + const _MiniLineChart({ + required this.data, + required this.color, + required this.period, + }); + + @override + Widget build(BuildContext context) { + final nonZero = data.where((v) => v > 0).toList(); + if (nonZero.isEmpty) { + return SizedBox( + height: 70, + child: Center( + child: Text( + 'Belum ada data', + style: TextStyle(fontSize: 11, color: Colors.grey.shade400), + ), + ), + ); + } + + return SizedBox( + height: 70, + child: CustomPaint( + painter: _LineChartPainter(data: data, color: color), + size: const Size(double.infinity, 70), + ), + ); + } +} + +class _LineChartPainter extends CustomPainter { + final List data; + final Color color; + + const _LineChartPainter({required this.data, required this.color}); + + @override + void paint(Canvas canvas, Size size) { + if (data.isEmpty) return; + + final maxVal = data.reduce((a, b) => a > b ? a : b); + if (maxVal == 0) return; + + final points = []; + for (int i = 0; i < data.length; i++) { + final x = i / (data.length - 1) * size.width; + final y = size.height - (data[i] / maxVal) * size.height; + points.add(Offset(x, y)); + } + + // Area fill (gradient) + final path = Path(); + path.moveTo(points.first.dx, size.height); + for (final p in points) { + path.lineTo(p.dx, p.dy); + } + path.lineTo(points.last.dx, size.height); + path.close(); + + final fillPaint = Paint() + ..shader = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [color.withValues(alpha: 0.25), color.withValues(alpha: 0.0)], + ).createShader(Rect.fromLTWH(0, 0, size.width, size.height)); + canvas.drawPath(path, fillPaint); + + // Line + final linePaint = Paint() + ..color = color + ..strokeWidth = 2 + ..style = PaintingStyle.stroke + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round; + + final linePath = Path(); + linePath.moveTo(points.first.dx, points.first.dy); + for (int i = 1; i < points.length; i++) { + linePath.lineTo(points[i].dx, points[i].dy); + } + canvas.drawPath(linePath, linePaint); + + // Dot di titik terakhir + final lastPoint = points.last; + canvas.drawCircle( + lastPoint, + 3.5, + Paint()..color = color, + ); + canvas.drawCircle( + lastPoint, + 3.5, + Paint() + ..color = Colors.white + ..style = PaintingStyle.stroke + ..strokeWidth = 1.5, + ); + } + + @override + bool shouldRepaint(_LineChartPainter old) => + old.data != data || old.color != color; +} From e37fd688fb3f0719ac1e3bb9c34c9a6cb0e37747 Mon Sep 17 00:00:00 2001 From: kleponijo <158453666+kleponijo@users.noreply.github.com> Date: Wed, 6 May 2026 09:09:38 +0700 Subject: [PATCH 03/22] saddad --- lib/core/utils/timestamp_parser.dart | 6 ++++++ packages/monitoring_repository/lib/src/models/models.dart | 4 ++++ 2 files changed, 10 insertions(+) create mode 100644 lib/core/utils/timestamp_parser.dart diff --git a/lib/core/utils/timestamp_parser.dart b/lib/core/utils/timestamp_parser.dart new file mode 100644 index 0000000..8ef2ca1 --- /dev/null +++ b/lib/core/utils/timestamp_parser.dart @@ -0,0 +1,6 @@ +// Perlu handle semua kasus ini: +TimestampParser.parse(1777807041) // Unix detik → DateTime +TimestampParser.parse(1777807041000) // Unix milidetik → DateTime +TimestampParser.parse("14:26:01") // String jam saja → DateTime hari ini +TimestampParser.parse("2025-03-05T14:26:01") // ISO string → DateTime +TimestampParser.parse(null) // fallback → DateTime.now() \ No newline at end of file diff --git a/packages/monitoring_repository/lib/src/models/models.dart b/packages/monitoring_repository/lib/src/models/models.dart index ae80da6..bf7f507 100644 --- a/packages/monitoring_repository/lib/src/models/models.dart +++ b/packages/monitoring_repository/lib/src/models/models.dart @@ -1,3 +1,7 @@ export 'wind_speed.dart'; export 'evaporasi.dart'; export 'atmospheric_conditions.dart'; + +abstract class HasTimestamp { + DateTime get timestamp; +} From 41e707d20967ec7bbbaf5644d622a5ac3538911c Mon Sep 17 00:00:00 2001 From: kleponijo <158453666+kleponijo@users.noreply.github.com> Date: Wed, 6 May 2026 17:47:40 +0700 Subject: [PATCH 04/22] update pembaruan time series mapper --- lib/core/utils/time_series_mapper.dart | 28 ++++++ lib/core/utils/timestamp_parser.dart | 6 -- .../evaporasi/blocs/evaporasi_bloc.dart | 89 ++++++------------- .../evaporasi/blocs/evaporasi_state.dart | 27 +++++- .../wind_speed/blocs/wind_speed_bloc.dart | 73 +++------------ .../lib/monitoring_repository.dart | 1 + .../lib/src/firebase_monitoring_repo.dart | 71 +++++++-------- .../lib/src/models/evaporasi.dart | 38 ++++---- .../lib/src/models/has_timestamp.dart | 5 ++ .../lib/src/models/models.dart | 5 +- .../lib/src/models/wind_speed.dart | 16 ++-- .../lib/src/monitoring_repo.dart | 15 ++-- .../lib/src/utils/timestamp_parser.dart | 46 ++++++++++ 13 files changed, 206 insertions(+), 214 deletions(-) delete mode 100644 lib/core/utils/timestamp_parser.dart create mode 100644 packages/monitoring_repository/lib/src/models/has_timestamp.dart create mode 100644 packages/monitoring_repository/lib/src/utils/timestamp_parser.dart diff --git a/lib/core/utils/time_series_mapper.dart b/lib/core/utils/time_series_mapper.dart index 3133012..f93a5f6 100644 --- a/lib/core/utils/time_series_mapper.dart +++ b/lib/core/utils/time_series_mapper.dart @@ -1,3 +1,5 @@ +import 'package:monitoring_repository/monitoring_repository.dart'; + class TimeSeriesMapper { /// ========================= /// 📅 DAILY (24 JAM) @@ -114,4 +116,30 @@ class TimeSeriesMapper { return result; } + + // ── Tambahkan ini setelah method smooth() ────────────────────── + + /// Shortcut untuk model yang implement HasTimestamp. + /// Dari: TimeSeriesMapper.toDaily(data: h, getTime: (e) => e.timestamp, getValue: (e) => e.speed) + /// Ke: TimeSeriesMapper.dailyFrom(h, (e) => e.speed) + static List dailyFrom( + List data, + double Function(T) getValue, + ) => + smooth( + toDaily(data: data, getTime: (e) => e.timestamp, getValue: getValue)); + + static List weeklyFrom( + List data, + double Function(T) getValue, + ) => + smooth(toWeekly( + data: data, getTime: (e) => e.timestamp, getValue: getValue)); + + static List monthlyFrom( + List data, + double Function(T) getValue, + ) => + smooth(toMonthly( + data: data, getTime: (e) => e.timestamp, getValue: getValue)); } diff --git a/lib/core/utils/timestamp_parser.dart b/lib/core/utils/timestamp_parser.dart deleted file mode 100644 index 8ef2ca1..0000000 --- a/lib/core/utils/timestamp_parser.dart +++ /dev/null @@ -1,6 +0,0 @@ -// Perlu handle semua kasus ini: -TimestampParser.parse(1777807041) // Unix detik → DateTime -TimestampParser.parse(1777807041000) // Unix milidetik → DateTime -TimestampParser.parse("14:26:01") // String jam saja → DateTime hari ini -TimestampParser.parse("2025-03-05T14:26:01") // ISO string → DateTime -TimestampParser.parse(null) // fallback → DateTime.now() \ No newline at end of file diff --git a/lib/screens/monitoring/evaporasi/blocs/evaporasi_bloc.dart b/lib/screens/monitoring/evaporasi/blocs/evaporasi_bloc.dart index 7758d67..94edc46 100644 --- a/lib/screens/monitoring/evaporasi/blocs/evaporasi_bloc.dart +++ b/lib/screens/monitoring/evaporasi/blocs/evaporasi_bloc.dart @@ -35,26 +35,29 @@ class EvaporasiBloc extends Bloc { emit(state.copyWith(isLoading: true)); final history = await _repository.getSensorHistory( - 'evaporasi/history', + 'Monitoring/History', (json) => Evaporasi.fromJson(json), + orderByChild: null, // waktu = string, tidak bisa orderByChild + limit: 500, ); - final dailyGraph = TimeSeriesMapper.toDaily( - data: history, - getTime: (e) => e.timestamp, - getValue: (e) => e.evaporasi, - ); - - final dailyTempGraph = TimeSeriesMapper.toDaily( - data: history, - getTime: (e) => e.timestamp, - getValue: (e) => e.suhu, - ); + final dailyGraph = + TimeSeriesMapper.dailyFrom(history, (e) => e.evaporasi); + final dailyTempGraph = + TimeSeriesMapper.dailyFrom(history, (e) => e.suhu); + final weeklyGraph = + TimeSeriesMapper.weeklyFrom(history, (e) => e.evaporasi); + final monthlyGraph = + TimeSeriesMapper.monthlyFrom(history, (e) => e.evaporasi); + final weeklyTemp = + TimeSeriesMapper.weeklyFrom(history, (e) => e.suhu); + final monthlyTemp = + TimeSeriesMapper.monthlyFrom(history, (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); - _emitEvaporasiAlert(status, rain, lastValue); + _emitEvaporasiAlert(status, lastValue); emit(state.copyWith( history: history, @@ -63,6 +66,10 @@ class EvaporasiBloc extends Bloc { weatherStatus: status, willRain: rain, isLoading: false, + weeklyValues: weeklyGraph, + monthlyValues: monthlyGraph, + weeklyTemperatures: weeklyTemp, + monthlyTemperatures: monthlyTemp, )); await _subscription?.cancel(); @@ -88,7 +95,7 @@ class EvaporasiBloc extends Bloc { } final (status, rain) = _computeWeatherStatus(event.data.evaporasi); - _emitEvaporasiAlert(status, rain, event.data.evaporasi); + _emitEvaporasiAlert(status, event.data.evaporasi); emit(state.copyWith( currentValue: event.data.evaporasi, @@ -104,57 +111,11 @@ class EvaporasiBloc extends Bloc { /// ========================= /// 📊 PERIOD /// ========================= - Future _onPeriodChanged( + void _onPeriodChanged( EvaporasiPeriodChanged event, Emitter emit, - ) async { - emit(state.copyWith(isLoading: true, selectedPeriod: event.period)); - final history = state.history; - - List updated; - List updatedTemp; - - if (event.period == "Minggu Ini") { - updated = TimeSeriesMapper.toWeekly( - data: history, - getTime: (e) => e.timestamp, - getValue: (e) => e.evaporasi, - ); - updatedTemp = TimeSeriesMapper.toWeekly( - data: history, - getTime: (e) => e.timestamp, - getValue: (e) => e.suhu, - ); - } else if (event.period == "Bulan Ini") { - updated = TimeSeriesMapper.toMonthly( - data: history, - getTime: (e) => e.timestamp, - getValue: (e) => e.evaporasi, - ); - updatedTemp = TimeSeriesMapper.toMonthly( - data: history, - getTime: (e) => e.timestamp, - getValue: (e) => e.suhu, - ); - } else { - updated = TimeSeriesMapper.toDaily( - data: history, - getTime: (e) => e.timestamp, - 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( - dailyValues: updated, - dailyTemperatures: updatedTemp, - isLoading: false, - )); + ) { + emit(state.copyWith(selectedPeriod: event.period)); } @override @@ -172,7 +133,7 @@ class EvaporasiBloc extends Bloc { return ('Buruk', true); } - void _emitEvaporasiAlert(String status, bool willRain, double value) { + void _emitEvaporasiAlert(String status, double value) { final AlertSeverity severity; final String message; diff --git a/lib/screens/monitoring/evaporasi/blocs/evaporasi_state.dart b/lib/screens/monitoring/evaporasi/blocs/evaporasi_state.dart index 742ce96..2cac51b 100644 --- a/lib/screens/monitoring/evaporasi/blocs/evaporasi_state.dart +++ b/lib/screens/monitoring/evaporasi/blocs/evaporasi_state.dart @@ -7,12 +7,17 @@ class EvaporasiState extends Equatable { final String selectedPeriod; final List dailyValues; // untuk grafik evaporasi + final List weeklyValues; + final List monthlyValues; + final List dailyTemperatures; // untuk grafik suhu + final List weeklyTemperatures; + final List monthlyTemperatures; + final List history; - final String weatherStatus; // Baik / Sedang / Buruk - final bool willRain; // true jika status Sedang/Buruk - + final String weatherStatus; + final bool willRain; final bool isLoading; const EvaporasiState({ @@ -21,7 +26,11 @@ class EvaporasiState extends Equatable { this.waterLevel = 0.0, this.selectedPeriod = "Hari Ini", this.dailyValues = const [], + this.weeklyValues = const [], + this.monthlyValues = const [], this.dailyTemperatures = const [], + this.weeklyTemperatures = const [], + this.monthlyTemperatures = const [], this.history = const [], this.weatherStatus = "Baik", this.willRain = false, @@ -34,7 +43,11 @@ class EvaporasiState extends Equatable { double? waterLevel, String? selectedPeriod, List? dailyValues, + List? weeklyValues, + List? monthlyValues, List? dailyTemperatures, + List? weeklyTemperatures, + List? monthlyTemperatures, List? history, String? weatherStatus, bool? willRain, @@ -46,7 +59,11 @@ class EvaporasiState extends Equatable { waterLevel: waterLevel ?? this.waterLevel, selectedPeriod: selectedPeriod ?? this.selectedPeriod, dailyValues: dailyValues ?? this.dailyValues, + weeklyValues: weeklyValues ?? this.weeklyValues, + monthlyValues: monthlyValues ?? this.monthlyValues, dailyTemperatures: dailyTemperatures ?? this.dailyTemperatures, + weeklyTemperatures: weeklyTemperatures ?? this.weeklyTemperatures, + monthlyTemperatures: monthlyTemperatures ?? this.monthlyTemperatures, history: history ?? this.history, weatherStatus: weatherStatus ?? this.weatherStatus, willRain: willRain ?? this.willRain, @@ -61,7 +78,11 @@ class EvaporasiState extends Equatable { waterLevel, selectedPeriod, dailyValues, + weeklyValues, + monthlyValues, dailyTemperatures, + weeklyTemperatures, + monthlyTemperatures, history, weatherStatus, willRain, diff --git a/lib/screens/monitoring/wind_speed/blocs/wind_speed_bloc.dart b/lib/screens/monitoring/wind_speed/blocs/wind_speed_bloc.dart index 9a995a7..05f0616 100644 --- a/lib/screens/monitoring/wind_speed/blocs/wind_speed_bloc.dart +++ b/lib/screens/monitoring/wind_speed/blocs/wind_speed_bloc.dart @@ -48,32 +48,16 @@ class WindSpeedBloc extends Bloc { final history = await _repository.getSensorHistory( 'anemometer/history', (json) => MyWindSpeed.fromJson(json), + orderByChild: 'timestamp', + limit: 500, ); - // ✅ Ini saja yang benar - final dailyGraph = TimeSeriesMapper.smooth( - TimeSeriesMapper.toDaily( - data: history, - getTime: (e) => e.timestamp, - getValue: (e) => e.speed, - ), - ); - - final weekly = TimeSeriesMapper.smooth( - TimeSeriesMapper.toWeekly( - data: history, - getTime: (e) => e.timestamp, - getValue: (e) => e.speed, - ), - ); - - final monthly = TimeSeriesMapper.smooth( - TimeSeriesMapper.toMonthly( - data: history, - getTime: (e) => e.timestamp, - getValue: (e) => e.speed, - ), - ); + final dailyGraph = + TimeSeriesMapper.dailyFrom(history, (e) => e.speed); + final weekly = + TimeSeriesMapper.weeklyFrom(history, (e) => e.speed); + final monthly = + TimeSeriesMapper.monthlyFrom(history, (e) => e.speed); // Cek kondisi awal dari history if (history.isNotEmpty) { @@ -141,48 +125,11 @@ class WindSpeedBloc extends Bloc { /// ========================= /// 📊 CHANGE PERIOD /// ========================= - Future _onPeriodChanged( + void _onPeriodChanged( WindSpeedPeriodChanged event, Emitter emit, - ) async { + ) { emit(state.copyWith(selectedPeriod: event.period)); - - final history = state.history; - - List raw; - - if (event.period == "Minggu Ini") { - raw = TimeSeriesMapper.toWeekly( - data: history, - getTime: (e) => e.timestamp, - getValue: (e) => e.speed, - ); - } else if (event.period == "Bulan Ini") { - raw = TimeSeriesMapper.toMonthly( - data: history, - getTime: (e) => e.timestamp, - getValue: (e) => e.speed, - ); - } else { - raw = TimeSeriesMapper.toDaily( - data: history, - getTime: (e) => e.timestamp, - getValue: (e) => e.speed, - ); - } - - /// 🔥 baru di-smooth - final updatedGraph = TimeSeriesMapper.smooth(raw); - - emit(state.copyWith( - dailySpeeds: - event.period == "Hari Ini" ? updatedGraph : state.dailySpeeds, - weeklySpeeds: - event.period == "Minggu Ini" ? updatedGraph : state.weeklySpeeds, - monthlySpeeds: - event.period == "Bulan Ini" ? updatedGraph : state.monthlySpeeds, - isLoading: false, - )); } @override diff --git a/packages/monitoring_repository/lib/monitoring_repository.dart b/packages/monitoring_repository/lib/monitoring_repository.dart index e5ff71e..b69e0b2 100644 --- a/packages/monitoring_repository/lib/monitoring_repository.dart +++ b/packages/monitoring_repository/lib/monitoring_repository.dart @@ -3,3 +3,4 @@ library monitoring_repository; export 'src/monitoring_repo.dart'; export 'src/firebase_monitoring_repo.dart'; export 'src/models/models.dart'; +export 'src/utils/timestamp_parser.dart'; diff --git a/packages/monitoring_repository/lib/src/firebase_monitoring_repo.dart b/packages/monitoring_repository/lib/src/firebase_monitoring_repo.dart index f7cf78c..a8f54eb 100644 --- a/packages/monitoring_repository/lib/src/firebase_monitoring_repo.dart +++ b/packages/monitoring_repository/lib/src/firebase_monitoring_repo.dart @@ -1,72 +1,61 @@ -// import 'dart:developer'; import 'dart:async'; -// import 'package:rxdart/rxdart.dart'; import 'package:firebase_database/firebase_database.dart'; -import 'package:monitoring_repository/monitoring_repository.dart'; +import 'monitoring_repo.dart'; +import 'models/has_timestamp.dart'; +import 'models/models.dart'; -/// === ambil data dari realtime database firebase === /// class FirebaseMonitoringRepo implements MonitoringRepository { final FirebaseDatabase _db = FirebaseDatabase.instance; - // Satu fungsi untuk semua jenis sensor - // Kamu cukup masukkan "path" database-nya saja @override Stream getSensorStream( String path, T Function(Map json) mapper, ) { - /// === ambil data mentah dari firebase === /// return _db.ref(path).onValue.map((event) { - final Object? value = event.snapshot.value; - - if (value is Map) { - return mapper(value); - } else { - // Jika data kosong atau bukan Map, berikan Map kosong agar mapper tidak crash - return mapper({}); - } + final value = event.snapshot.value; + return mapper(value is Map ? value : {}); }); } @override - // Jika ingin mengambil data sekali saja (bukan stream) Future getSensorSnapshot( String path, T Function(Map json) mapper, ) async { final snapshot = await _db.ref(path).get(); - final data = snapshot.value as Map? ?? {}; - return mapper(data); + return mapper(snapshot.value as Map? ?? {}); } @override - Future> getSensorHistory( + Future> getSensorHistory( String path, - T Function(Map json) mapper, - ) async { + T Function(Map json) mapper, { + String? orderByChild, + int limit = 500, + }) async { try { - // Ambil data dari path history - final snapshot = await _db.ref(path).get(); + Query query = _db.ref(path); - if (snapshot.exists && snapshot.value is Map) { - final Map data = snapshot.value as Map; - - final list = data.values.map((item) { - return mapper(item as Map); - }).toList(); - - // ✅ Sort by timestamp — Firebase push tidak jamin urutan - list.sort((a, b) { - if (a is MyWindSpeed && b is MyWindSpeed) { - return a.timestamp.compareTo(b.timestamp); - } - return 0; - }); - - return list; + if (orderByChild != null) { + query = query.orderByChild(orderByChild).limitToLast(limit); + } else { + query = query.limitToLast(limit); } - return []; - } catch (e) { + + final snapshot = await query.get(); + if (!snapshot.exists || snapshot.value is! Map) return []; + + final data = snapshot.value as Map; + final list = data.values + .whereType() + .map((item) => mapper(item)) + .toList(); + + // Sort generik — works untuk SEMUA sensor karena T extends HasTimestamp + list.sort((a, b) => a.timestamp.compareTo(b.timestamp)); + return list; + } catch (_) { return []; } } diff --git a/packages/monitoring_repository/lib/src/models/evaporasi.dart b/packages/monitoring_repository/lib/src/models/evaporasi.dart index 907cbc5..caec82e 100644 --- a/packages/monitoring_repository/lib/src/models/evaporasi.dart +++ b/packages/monitoring_repository/lib/src/models/evaporasi.dart @@ -1,7 +1,13 @@ -class Evaporasi { +import 'has_timestamp.dart'; +import '../utils/timestamp_parser.dart'; + +class Evaporasi implements HasTimestamp { final double evaporasi; final double suhu; final double tinggiAir; + final String status; + + @override final DateTime timestamp; Evaporasi({ @@ -9,6 +15,7 @@ class Evaporasi { required this.suhu, required this.tinggiAir, required this.timestamp, + this.status = '', }); static final empty = Evaporasi( @@ -19,27 +26,22 @@ class Evaporasi { ); factory Evaporasi.fromJson(Map json) { - final now = DateTime.now(); - DateTime timestamp = now; + // ✅ 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'] ?? 0).toDouble(); - final waktuStr = json['waktu'] as String?; - if (waktuStr != null) { - final parts = waktuStr.split(':'); - if (parts.length >= 2) { - 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; - timestamp = DateTime(now.year, now.month, now.day, jam, menit, detik); - } - } + // ✅ Prioritas: timestamp (Unix, kalau firmware sudah diupdate) + // → fallback ke waktu ("HH:MM:SS") + final rawTime = json['timestamp'] ?? json['waktu']; return Evaporasi( evaporasi: (json['evaporasi'] ?? 0).toDouble(), - suhu: (json['suhu_air'] ?? 0).toDouble(), - tinggiAir: (json['tinggi_air'] ?? 0).toDouble(), - - /// 🔥 bikin timestamp dari jam & menit - timestamp: timestamp, + suhu: suhu, + tinggiAir: tinggi, + status: json['status'] ?? '', + timestamp: TimestampParser.parse(rawTime), // ✅ pakai TimestampParser ); } } diff --git a/packages/monitoring_repository/lib/src/models/has_timestamp.dart b/packages/monitoring_repository/lib/src/models/has_timestamp.dart new file mode 100644 index 0000000..46a4e63 --- /dev/null +++ b/packages/monitoring_repository/lib/src/models/has_timestamp.dart @@ -0,0 +1,5 @@ +/// Semua model sensor wajib implement ini. +/// Memungkinkan sorting & TimeSeriesMapper yang generik. +abstract class HasTimestamp { + DateTime get timestamp; +} diff --git a/packages/monitoring_repository/lib/src/models/models.dart b/packages/monitoring_repository/lib/src/models/models.dart index bf7f507..c51ddde 100644 --- a/packages/monitoring_repository/lib/src/models/models.dart +++ b/packages/monitoring_repository/lib/src/models/models.dart @@ -1,7 +1,4 @@ export 'wind_speed.dart'; export 'evaporasi.dart'; export 'atmospheric_conditions.dart'; - -abstract class HasTimestamp { - DateTime get timestamp; -} +export 'has_timestamp.dart'; diff --git a/packages/monitoring_repository/lib/src/models/wind_speed.dart b/packages/monitoring_repository/lib/src/models/wind_speed.dart index b35d05a..90ab814 100644 --- a/packages/monitoring_repository/lib/src/models/wind_speed.dart +++ b/packages/monitoring_repository/lib/src/models/wind_speed.dart @@ -1,23 +1,23 @@ -class MyWindSpeed { - double speed; +import 'has_timestamp.dart'; +import '../utils/timestamp_parser.dart'; - DateTime timestamp; +class MyWindSpeed implements HasTimestamp { + final double speed; + + @override + final DateTime timestamp; MyWindSpeed({required this.speed, required this.timestamp}); static final empty = MyWindSpeed( speed: 0.0, - timestamp: DateTime.fromMillisecondsSinceEpoch(0), ); factory MyWindSpeed.fromJson(Map json) { return MyWindSpeed( speed: (json['speed'] ?? 0).toDouble(), - - timestamp: DateTime.fromMillisecondsSinceEpoch( - (json['timestamp'] ?? 0) * 1000, // kalau dari Unix detik - ).toLocal(), + timestamp: TimestampParser.parse(json['timestamp']), ); } } diff --git a/packages/monitoring_repository/lib/src/monitoring_repo.dart b/packages/monitoring_repository/lib/src/monitoring_repo.dart index 555796a..9d0afb5 100644 --- a/packages/monitoring_repository/lib/src/monitoring_repo.dart +++ b/packages/monitoring_repository/lib/src/monitoring_repo.dart @@ -1,21 +1,22 @@ -// import 'models/models.dart'; +/// file monitoring_repo.dart di "C:\App_project\klimatologi\packages\monitoring_repository\lib\src\monitoring_repo.dart" + +import 'models/has_timestamp.dart'; abstract class MonitoringRepository { - // fungsi untuk ambil data berkali/streaming Stream getSensorStream( String path, T Function(Map json) mapper, ); - // fungsi untuk ambil sensor sekali Future getSensorSnapshot( String path, T Function(Map json) mapper, ); - // Fungsi untuk ambil riwayat (List) - Future> getSensorHistory( + Future> getSensorHistory( String path, - T Function(Map json) mapper, - ); + T Function(Map json) mapper, { + String? orderByChild, + int limit = 500, + }); } diff --git a/packages/monitoring_repository/lib/src/utils/timestamp_parser.dart b/packages/monitoring_repository/lib/src/utils/timestamp_parser.dart new file mode 100644 index 0000000..dd8c978 --- /dev/null +++ b/packages/monitoring_repository/lib/src/utils/timestamp_parser.dart @@ -0,0 +1,46 @@ +/// Mengubah berbagai format waktu Firebase → DateTime. +/// +/// Format yang didukung: +/// int Unix detik 1777807041 (anemometer) +/// int Unix milidetik 1777950497446 (sensor/atmospheric) +/// String "HH:MM:SS" "11:09:00" (Monitoring/evaporasi, intraday only) +/// String ISO 8601 "2025-03-05T14:26" (untuk masa depan) +class TimestampParser { + // Angka > ini dianggap milidetik (threshold: ~Nov 2001 dalam detik) + static const int _msThreshold = 10000000000; + + static DateTime parse(dynamic raw) { + if (raw == null) return DateTime.now(); + + if (raw is int) { + if (raw <= 0) return DateTime.now(); + if (raw > _msThreshold) { + return DateTime.fromMillisecondsSinceEpoch(raw).toLocal(); + } + return DateTime.fromMillisecondsSinceEpoch(raw * 1000).toLocal(); + } + + if (raw is double) return parse(raw.toInt()); + + if (raw is String) { + // "HH:MM:SS" → pakai tanggal hari ini (intraday only) + final m = RegExp(r'^(\d{1,2}):(\d{2}):(\d{2})$').firstMatch(raw); + if (m != null) { + final now = DateTime.now(); + return DateTime( + now.year, + now.month, + now.day, + int.parse(m.group(1)!), + int.parse(m.group(2)!), + int.parse(m.group(3)!), + ); + } + try { + return DateTime.parse(raw).toLocal(); + } catch (_) {} + } + + return DateTime.now(); + } +} From 42bac0c98054df4b68fa8de4179690bf2b04b5b2 Mon Sep 17 00:00:00 2001 From: Mochamad ongki ramadani Date: Fri, 8 May 2026 12:36:45 +0700 Subject: [PATCH 05/22] Fix evaporasi dashboard: firebase history list + sync chart labels --- .../evaporasi/blocs/evaporasi_bloc.dart | 30 ++++++ .../evaporasi/blocs/evaporasi_state.dart | 15 +++ .../evaporasi/views/evaporasi_screen.dart | 101 ++++++++++++++++-- .../views/widgets/evaporasi_chart_widget.dart | 23 ++-- .../widgets/evaporasi_period_selector.dart | 15 ++- 5 files changed, 163 insertions(+), 21 deletions(-) diff --git a/lib/screens/monitoring/evaporasi/blocs/evaporasi_bloc.dart b/lib/screens/monitoring/evaporasi/blocs/evaporasi_bloc.dart index 32cee33..011dd9f 100644 --- a/lib/screens/monitoring/evaporasi/blocs/evaporasi_bloc.dart +++ b/lib/screens/monitoring/evaporasi/blocs/evaporasi_bloc.dart @@ -34,6 +34,8 @@ class EvaporasiBloc extends Bloc { WatchEvaporasiStarted event, Emitter emit, ) async { + // chartLabels & listData untuk list/label di UI + emit(state.copyWith(isLoading: true)); final history = await _repository.getSensorHistory( @@ -41,7 +43,13 @@ class EvaporasiBloc extends Bloc { (json) => Evaporasi.fromJson(json), ); + // listData dipakai untuk tab/list di UI (default: ambil data yang sudah ada di Firebase) + final listData = List.from(history) + ..sort((a, b) => a.timestamp.compareTo(b.timestamp)); + + final dailyGraph = TimeSeriesMapper.toDaily( + data: history, getTime: (e) => e.timestamp, getValue: (e) => e.evaporasi, @@ -60,8 +68,12 @@ class EvaporasiBloc extends Bloc { emit(state.copyWith( history: history, + listData: listData, dailyValues: dailyGraph, dailyTemperatures: dailyTempGraph, + chartLabels: _buildChartLabels( + period: 'Hari Ini', + ), weatherStatus: status, willRain: rain, isLoading: false, @@ -155,6 +167,7 @@ class EvaporasiBloc extends Bloc { emit(state.copyWith( dailyValues: updated, dailyTemperatures: updatedTemp, + chartLabels: _buildChartLabels(period: event.period), isLoading: false, )); } @@ -191,6 +204,7 @@ class EvaporasiBloc extends Bloc { emit(state.copyWith( dailyValues: updated, dailyTemperatures: updatedTemp, + chartLabels: _buildChartLabels(period: 'Tanggal Khusus'), isLoading: false, )); } @@ -225,6 +239,22 @@ class EvaporasiBloc extends Bloc { return ('Buruk', true); } + List _buildChartLabels({required String period}) { + if (period == 'Minggu Ini') { + return const ['Sen', 'Sel', 'Rab', 'Kam', 'Jum', 'Sab', 'Min']; + } + + // Untuk Bulan Ini & Tanggal Khusus/ Hari Ini, biarkan chart pakai 0..23 / 0..days + final now = DateTime.now(); + if (period == 'Bulan Ini') { + final daysInMonth = DateTime(now.year, now.month + 1, 0).day; + return List.generate(daysInMonth, (i) => '${i + 1}'); + } + + // Hari Ini / Tanggal Khusus => 24 jam + return List.generate(24, (i) => '${i.toString().padLeft(2, '0')}:00'); + } + void _emitEvaporasiAlert(String status, bool willRain, double value) { final AlertSeverity severity; final String message; diff --git a/lib/screens/monitoring/evaporasi/blocs/evaporasi_state.dart b/lib/screens/monitoring/evaporasi/blocs/evaporasi_state.dart index 640d2dd..fcbd8cf 100644 --- a/lib/screens/monitoring/evaporasi/blocs/evaporasi_state.dart +++ b/lib/screens/monitoring/evaporasi/blocs/evaporasi_state.dart @@ -12,6 +12,15 @@ class EvaporasiState extends Equatable { final List dailyValues; // untuk grafik evaporasi final List dailyTemperatures; // untuk grafik suhu + + /// Label X-axis sesuai agregasi period + /// Contoh: Hari Ini => ["00:00","01:00",...] + /// Minggu Ini => ["Sen","Sel",...] + /// Bulan Ini => ["1","2",...] + final List chartLabels; + + /// Data untuk list (timestamp asli dari firebase) + final List listData; final List history; final String weatherStatus; // Baik / Sedang / Buruk @@ -28,6 +37,8 @@ class EvaporasiState extends Equatable { this.viewMode = EvaporasiViewMode.period, this.dailyValues = const [], this.dailyTemperatures = const [], + this.chartLabels = const [], + this.listData = const [], this.history = const [], this.weatherStatus = "Baik", this.willRain = false, @@ -43,6 +54,8 @@ EvaporasiState copyWith({ EvaporasiViewMode? viewMode, List? dailyValues, List? dailyTemperatures, + List? chartLabels, + List? listData, List? history, String? weatherStatus, bool? willRain, @@ -58,6 +71,8 @@ EvaporasiState copyWith({ dailyValues: dailyValues ?? this.dailyValues, dailyTemperatures: dailyTemperatures ?? this.dailyTemperatures, history: history ?? this.history, + chartLabels: chartLabels ?? this.chartLabels, + listData: listData ?? this.listData, weatherStatus: weatherStatus ?? this.weatherStatus, willRain: willRain ?? this.willRain, isLoading: isLoading ?? this.isLoading, diff --git a/lib/screens/monitoring/evaporasi/views/evaporasi_screen.dart b/lib/screens/monitoring/evaporasi/views/evaporasi_screen.dart index eb3343a..cc403a7 100644 --- a/lib/screens/monitoring/evaporasi/views/evaporasi_screen.dart +++ b/lib/screens/monitoring/evaporasi/views/evaporasi_screen.dart @@ -53,7 +53,8 @@ class EvaporasiScreen extends StatelessWidget { style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), const SizedBox(height: 15), -// Period selector with date picker + + // Period selector with date picker Builder( builder: (context) { final state = context.watch().state; @@ -83,16 +84,24 @@ class EvaporasiScreen extends StatelessWidget { Builder( builder: (context) { final state = context.watch().state; - return EvaporasiChartWidget( - dailyValues: state.dailyValues, - dailyTemperatures: state.dailyTemperatures, - period: state.viewMode == EvaporasiViewMode.customDate - ? "Tanggal Khusus" - : state.selectedPeriod, + return Column( + children: [ + EvaporasiChartWidget( + dailyValues: state.dailyValues, + dailyTemperatures: state.dailyTemperatures, + period: state.viewMode == EvaporasiViewMode.customDate + ? "Tanggal Khusus" + : state.selectedPeriod, + chartLabels: state.chartLabels, + ), + const SizedBox(height: 20), + _evaporasiList(state), + const SizedBox(height: 10), + ], ); }, ), - const SizedBox(height: 25), + const SizedBox(height: 10), ExportPdfButton( onExport: () => PdfExportService.evaporasi( evaporasi: state.currentValue, @@ -261,6 +270,82 @@ class EvaporasiScreen extends StatelessWidget { ); } + /// ========================= + /// 🧾 LIST DATA EVAPORASI + /// ========================= + Widget _evaporasiList(EvaporasiState state) { + final data = state.listData; + if (data.isEmpty) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + ), + child: const Text('Belum ada data evaporasi', + style: TextStyle(color: Colors.grey)), + ); + } + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'List Data Evaporasi', + style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 12), + ListView.separated( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: data.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (context, index) { + final e = data[index]; + final dateLabel = DateFormat('dd MMM yyyy HH:mm:ss', 'id_ID') + .format(e.timestamp); + return Padding( + padding: const EdgeInsets.symmetric(vertical: 10), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + dateLabel, + style: const TextStyle( + fontSize: 12, + color: Colors.black87, + ), + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 12), + Text( + '${e.evaporasi.toStringAsFixed(1)} mm', + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: Colors.blue, + ), + ), + ], + ), + ); + }, + ), + ], + ), + ); + } + /// ========================= /// 📅 FORMAT DATE INFO (for custom date display) /// ========================= diff --git a/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_chart_widget.dart b/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_chart_widget.dart index fe0a686..af307b4 100644 --- a/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_chart_widget.dart +++ b/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_chart_widget.dart @@ -5,14 +5,18 @@ class EvaporasiChartWidget extends StatelessWidget { final List dailyValues; final List dailyTemperatures; final String period; + final List chartLabels; + const EvaporasiChartWidget({ super.key, required this.dailyValues, required this.dailyTemperatures, required this.period, + required this.chartLabels, }); + double _getMaxY(List data) { if (data.isEmpty) return 10; final max = data.reduce((a, b) => a > b ? a : b); @@ -93,6 +97,7 @@ class EvaporasiChartWidget extends StatelessWidget { _getBottomTitle(value), style: const TextStyle( color: Colors.grey, fontSize: 10), + ), ); }, @@ -227,20 +232,14 @@ class EvaporasiChartWidget extends StatelessWidget { } 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}" : ""; - } + final index = value.toInt(); + if (chartLabels.isEmpty) return ''; + if (index < 0 || index >= chartLabels.length) return ''; + return chartLabels[index]; } + + double _getInterval() { if (period == "Hari Ini") return 1; if (period == "Minggu Ini") return 1; diff --git a/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_period_selector.dart b/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_period_selector.dart index ee5ea72..45b79ff 100644 --- a/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_period_selector.dart +++ b/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_period_selector.dart @@ -68,7 +68,20 @@ Widget _buildDatePickerButton(BuildContext context, EvaporasiViewMode viewMode, context.read().add( const EvaporasiViewModeChanged(EvaporasiViewMode.customDate), ); - showEvaporasiDatePicker(context); + + // Penting: pastikan bottom sheet masih punya context yang memiliki EvaporasiBloc + final bloc = context.read(); + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (sheetContext) { + return BlocProvider.value( + value: bloc, + child: const EvaporasiDatePicker(), + ); + }, + ); }, child: Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), From 2675ed7bd408c6f188645e73930b362beb6feb9f Mon Sep 17 00:00:00 2001 From: Mochamad ongki ramadani Date: Fri, 8 May 2026 15:04:33 +0700 Subject: [PATCH 06/22] Evaporasi list: show evaporasi + suhu with firebase timestamp --- .../evaporasi/views/evaporasi_screen.dart | 35 ++++++++++++++----- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/lib/screens/monitoring/evaporasi/views/evaporasi_screen.dart b/lib/screens/monitoring/evaporasi/views/evaporasi_screen.dart index cc403a7..e1ea08c 100644 --- a/lib/screens/monitoring/evaporasi/views/evaporasi_screen.dart +++ b/lib/screens/monitoring/evaporasi/views/evaporasi_screen.dart @@ -310,8 +310,11 @@ class EvaporasiScreen extends StatelessWidget { separatorBuilder: (_, __) => const Divider(height: 1), itemBuilder: (context, index) { final e = data[index]; - final dateLabel = DateFormat('dd MMM yyyy HH:mm:ss', 'id_ID') - .format(e.timestamp); + final dateLabel = DateFormat('dd MMM yyyy', 'id_ID') + .format(e.timestamp) + + ' • ' + + DateFormat('HH:mm:ss', 'id_ID').format(e.timestamp); + return Padding( padding: const EdgeInsets.symmetric(vertical: 10), child: Row( @@ -328,13 +331,27 @@ class EvaporasiScreen extends StatelessWidget { ), ), const SizedBox(width: 12), - Text( - '${e.evaporasi.toStringAsFixed(1)} mm', - style: const TextStyle( - fontSize: 13, - fontWeight: FontWeight.w700, - color: Colors.blue, - ), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + '${e.evaporasi.toStringAsFixed(1)} mm', + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: Colors.blue, + ), + ), + const SizedBox(height: 4), + Text( + '${e.suhu.toStringAsFixed(1)} °C', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Colors.orange.shade700, + ), + ), + ], ), ], ), From 6a6b6a43cd5af09b43d2214a5597880f72c979c1 Mon Sep 17 00:00:00 2001 From: kleponijo <158453666+kleponijo@users.noreply.github.com> Date: Sat, 9 May 2026 15:43:31 +0700 Subject: [PATCH 07/22] Revert "tes" This reverts commit fff7f8b5db371b3171fdf121de40619bc97257c3, reversing changes made to 2675ed7bd408c6f188645e73930b362beb6feb9f. --- analyze_log.txt | Bin 23910 -> 0 bytes .../reports/problems/problems-report.html | 663 ------------------ build_log.txt | Bin 250806 -> 0 bytes build_log2.txt | Bin 102994 -> 0 bytes lib/core/utils/time_series_mapper.dart | 28 - .../wind_speed/blocs/wind_speed_bloc.dart | 73 +- .../lib/monitoring_repository.dart | 1 - .../lib/src/firebase_monitoring_repo.dart | 71 +- .../lib/src/models/evaporasi.dart | 38 +- .../lib/src/models/has_timestamp.dart | 5 - .../lib/src/models/models.dart | 1 - .../lib/src/models/wind_speed.dart | 16 +- .../lib/src/monitoring_repo.dart | 15 +- .../lib/src/utils/timestamp_parser.dart | 46 -- 14 files changed, 137 insertions(+), 820 deletions(-) delete mode 100644 analyze_log.txt delete mode 100644 android/build/reports/problems/problems-report.html delete mode 100644 build_log.txt delete mode 100644 build_log2.txt delete mode 100644 packages/monitoring_repository/lib/src/models/has_timestamp.dart delete mode 100644 packages/monitoring_repository/lib/src/utils/timestamp_parser.dart diff --git a/analyze_log.txt b/analyze_log.txt deleted file mode 100644 index fe29a4afcdbaf8ab2ead336548ab12d99fdff8ec..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 23910 zcmeI4S#ul55y$sARrwCcOGuYu+Pp-OrE*2FWk(N6Di)QOD24EVD3o~>07_yW{L!8K zf8A)#EPw@;KW_x=2o}Qlh-)}z`ouXfy701Qr(uU4_D-q!VgtXHd8&aSt0y(fJ4^xUyN_x0H+zAC_se)jbIr63;Y_nIKA z8k}!+wVYkATFYuEKs_@UPYlLNv0)Ilbp=%5O~WFdZwtnmVRNC;GF~vd)YIP%A#EC5 zt{CquxCg~M_1n|?U`Vr|*2eH@rxjPT#&}?3ybuTBjdP{pXOt(xaA5jMbm{R`*t;#` z6q4+B)@IVop#iBYxNZrElkqxj^Up+|U5f;Wj-x-P7;@pgg``iT-ih>XpkBhlg#YqL zZq@V-jh+UB$eF0*z3DJ99N4<`!aiWh0!QGx&2b!E%csNd6(6NpM4nhew+X;WlwC7C zXUQV74I<$K!?Uk=3f+y7J3dg)>FE7T$BdZ+Q7cy9ToiEZ%D8a_=chx0c0|?AW0wG; zFtjdgl+PjRaP_^#wnTJ2n_V$t#_9T-^n>ewK8?d41@Bl8hy^@~3fmS`t9tq;jR>w) z&FubQbEkESR7dAX;hW5(f{ z0^Z)oGyOuxFN^=2 z91NPFSslUimb-%I{l2xk1A78^AJhgbScT79gX5Cpe#@=S*F!tGB7S;2�mOe(^>y z&-H4X{vdlRg6}qHN1F7c;FE}vXD0RB76&_eGiJBjjJlfE$i!dN!>6G)y}kO(XujPr z%|rH|C!2xgK*Dk>aiJ@>(=Gc(8?ZZQh}$YMSjaN^j}~K_fKpew*5uiCWS8-0 zJmb=d)FVd;m;856ybQehl~VR_M+|T0ZN!m>>zg&fQy@)vn67x0JVBb5VRh=X%Wct` zJTkV3sJU5b!QvvfA%9)PVR(g@0ln5g;SIvV6G;-O64|@7MrJ9riU<}D!T)`eW^6C^ zoB31Fhq*Q_%34EvTGJ}0>YFPo&E3F{p0MEgQ#s|J+osLVlV%bH;PuHU&=Yuk)VJ#t zo~Eb1@lJSZo_np|;nBhtp!owqhg$gUZ{!6$r`y->gdcDc_pAR&SVQlGEn{txB6u}2 zi1YxLVqDChv2i}GDjzmWUS0^|=PWN!=X&zuks4giqpHw^zl);ydm_}!E6PlcCT<#6 ztz3Dtf2uE{SY&xwYZl?Xh}pXFh#RqBOJJLuE@?XCymA?GhOkkI+SiPCe@GUMRU-09o>4|ik1Sg_key@2|J-tLtOOri&0MH@gn~Y!DeF2} z#Ys;(c$h$)HnWrb^J#TjTwED8O8-KSfTkK&b+bNa_-|N@g`L> zQjwctg51}Wy~Ee9%XT;8>la*Vo=fa%STjCRmA&9osu{_nEc9Zs2O8IoatxPi2T;b_ zT-^iW*sL)qH2@?#?6Bp|RHrrCiEVe6z4`V+SJjTcF1do9@ zccUtU=X)4fWAdk1l}kI+a>}qSnyL-TWAYus`XnIyO{Gb6t(%*< zOR9ZIT0V&f(`TrYo~XyQ_2F9dB0v3UtkBIlMMVqqi^W>P&DOmqQU0#9Dyokby!sq? zMU24nhv4e_C3%ew#)$koyu}?UvJlG9P9gN3Hd{gJpvlU`L)yXSj?#?oeMo`u3 z&8)mYCB7XWc2kXy5eR8i`DtXVd*`D{5fWo&uW9U!+ckQ5eO1uvb@6lYLOm00LDSfs z*U?@!_V&cywDOMo9Yv%0X)NV^YTr!#H${Z8L#+&(i2Fi4gMC|X&UvH0DJq{X^?!f%M zB%XY8=fchi1vSrk8A#JvMMWm{%h z!hgo83{*yP5{y@s<~y4GnbG_UJz=T*e$a}j0}ooRdZGr?@8=!~|DSwP+G*<^Fvl-4 zCFPyg)n5Ol=s>Iljz_}M_cVG1D44`fbh0*)Wl1LvM4YfBTJ76t$U1R;MA_55lm{5C zP7eE=`|cItfX8^HPj==Lo&41>^txu>-An~aP^k{}HcdjJ?i>HjZuwD+b8F-g6ta%@ zr`~*0d*~78vQ*h_{L`MTQ{RRLuY@r*jARvwK9Heh^Lc&amP;mn?>S8n~C zTK}k4oF5YP-pgv0BF3)-i=EuL4@UZ_oJ4Bz-hL_xF{7Waka&OhBik(|jp1p*{qjiu zBusbp9v|;Zqu^7Gg_Cvn#5pPmXkWhbohT9XxW1(Mif-)pM5_`F+ha7mYpH?5RC1qO zNsM#d?|3Hn? z$M6|QPx&5xiJ8uddiZF)tf|!>Gnl^?&AmdeI5z`)@$PsG=B2D3yb!*AlE(NMXwdR+ zYKIqMj=?Dw%;1>6@!#^TDZMjddt1f7ihr9W!Y8uw01R~pAh;lX?+?05RdM~J;U r*6XtPhkm)$4y>PR%s$h9VV2xS=w2Ls|6V<^#{-TXs^4hseen5z9iw3* diff --git a/android/build/reports/problems/problems-report.html b/android/build/reports/problems/problems-report.html deleted file mode 100644 index 9ff7a95..0000000 --- a/android/build/reports/problems/problems-report.html +++ /dev/null @@ -1,663 +0,0 @@ - - - - - - - - - - - - - Gradle Configuration Cache - - - -
- -
- Loading... -
- - - - - - diff --git a/build_log.txt b/build_log.txt deleted file mode 100644 index 1b849d52c9d4c2dc277b91a04f0914aa28f66b44..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 250806 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 Gwf{e_9NqB% diff --git a/build_log2.txt b/build_log2.txt deleted file mode 100644 index 8da834077df8f80e6e2ff483470285251a91a43e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 102994 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 TcbSdw*xJVvQ8!*w9&7)9nO`8m diff --git a/lib/core/utils/time_series_mapper.dart b/lib/core/utils/time_series_mapper.dart index 1300b84..0fa54cb 100644 --- a/lib/core/utils/time_series_mapper.dart +++ b/lib/core/utils/time_series_mapper.dart @@ -1,5 +1,3 @@ -import 'package:monitoring_repository/monitoring_repository.dart'; - class TimeSeriesMapper { /// ========================= /// 📅 DAILY (24 JAM) @@ -144,30 +142,4 @@ class TimeSeriesMapper { return result; } - - // ── Tambahkan ini setelah method smooth() ────────────────────── - - /// Shortcut untuk model yang implement HasTimestamp. - /// Dari: TimeSeriesMapper.toDaily(data: h, getTime: (e) => e.timestamp, getValue: (e) => e.speed) - /// Ke: TimeSeriesMapper.dailyFrom(h, (e) => e.speed) - static List dailyFrom( - List data, - double Function(T) getValue, - ) => - smooth( - toDaily(data: data, getTime: (e) => e.timestamp, getValue: getValue)); - - static List weeklyFrom( - List data, - double Function(T) getValue, - ) => - smooth(toWeekly( - data: data, getTime: (e) => e.timestamp, getValue: getValue)); - - static List monthlyFrom( - List data, - double Function(T) getValue, - ) => - smooth(toMonthly( - data: data, getTime: (e) => e.timestamp, getValue: getValue)); } diff --git a/lib/screens/monitoring/wind_speed/blocs/wind_speed_bloc.dart b/lib/screens/monitoring/wind_speed/blocs/wind_speed_bloc.dart index 05f0616..9a995a7 100644 --- a/lib/screens/monitoring/wind_speed/blocs/wind_speed_bloc.dart +++ b/lib/screens/monitoring/wind_speed/blocs/wind_speed_bloc.dart @@ -48,16 +48,32 @@ class WindSpeedBloc extends Bloc { final history = await _repository.getSensorHistory( 'anemometer/history', (json) => MyWindSpeed.fromJson(json), - orderByChild: 'timestamp', - limit: 500, ); - final dailyGraph = - TimeSeriesMapper.dailyFrom(history, (e) => e.speed); - final weekly = - TimeSeriesMapper.weeklyFrom(history, (e) => e.speed); - final monthly = - TimeSeriesMapper.monthlyFrom(history, (e) => e.speed); + // ✅ Ini saja yang benar + final dailyGraph = TimeSeriesMapper.smooth( + TimeSeriesMapper.toDaily( + data: history, + getTime: (e) => e.timestamp, + getValue: (e) => e.speed, + ), + ); + + final weekly = TimeSeriesMapper.smooth( + TimeSeriesMapper.toWeekly( + data: history, + getTime: (e) => e.timestamp, + getValue: (e) => e.speed, + ), + ); + + final monthly = TimeSeriesMapper.smooth( + TimeSeriesMapper.toMonthly( + data: history, + getTime: (e) => e.timestamp, + getValue: (e) => e.speed, + ), + ); // Cek kondisi awal dari history if (history.isNotEmpty) { @@ -125,11 +141,48 @@ class WindSpeedBloc extends Bloc { /// ========================= /// 📊 CHANGE PERIOD /// ========================= - void _onPeriodChanged( + Future _onPeriodChanged( WindSpeedPeriodChanged event, Emitter emit, - ) { + ) async { emit(state.copyWith(selectedPeriod: event.period)); + + final history = state.history; + + List raw; + + if (event.period == "Minggu Ini") { + raw = TimeSeriesMapper.toWeekly( + data: history, + getTime: (e) => e.timestamp, + getValue: (e) => e.speed, + ); + } else if (event.period == "Bulan Ini") { + raw = TimeSeriesMapper.toMonthly( + data: history, + getTime: (e) => e.timestamp, + getValue: (e) => e.speed, + ); + } else { + raw = TimeSeriesMapper.toDaily( + data: history, + getTime: (e) => e.timestamp, + getValue: (e) => e.speed, + ); + } + + /// 🔥 baru di-smooth + final updatedGraph = TimeSeriesMapper.smooth(raw); + + emit(state.copyWith( + dailySpeeds: + event.period == "Hari Ini" ? updatedGraph : state.dailySpeeds, + weeklySpeeds: + event.period == "Minggu Ini" ? updatedGraph : state.weeklySpeeds, + monthlySpeeds: + event.period == "Bulan Ini" ? updatedGraph : state.monthlySpeeds, + isLoading: false, + )); } @override diff --git a/packages/monitoring_repository/lib/monitoring_repository.dart b/packages/monitoring_repository/lib/monitoring_repository.dart index b69e0b2..e5ff71e 100644 --- a/packages/monitoring_repository/lib/monitoring_repository.dart +++ b/packages/monitoring_repository/lib/monitoring_repository.dart @@ -3,4 +3,3 @@ library monitoring_repository; export 'src/monitoring_repo.dart'; export 'src/firebase_monitoring_repo.dart'; export 'src/models/models.dart'; -export 'src/utils/timestamp_parser.dart'; diff --git a/packages/monitoring_repository/lib/src/firebase_monitoring_repo.dart b/packages/monitoring_repository/lib/src/firebase_monitoring_repo.dart index a8f54eb..f7cf78c 100644 --- a/packages/monitoring_repository/lib/src/firebase_monitoring_repo.dart +++ b/packages/monitoring_repository/lib/src/firebase_monitoring_repo.dart @@ -1,61 +1,72 @@ +// import 'dart:developer'; import 'dart:async'; +// import 'package:rxdart/rxdart.dart'; import 'package:firebase_database/firebase_database.dart'; -import 'monitoring_repo.dart'; -import 'models/has_timestamp.dart'; -import 'models/models.dart'; +import 'package:monitoring_repository/monitoring_repository.dart'; +/// === ambil data dari realtime database firebase === /// class FirebaseMonitoringRepo implements MonitoringRepository { final FirebaseDatabase _db = FirebaseDatabase.instance; + // Satu fungsi untuk semua jenis sensor + // Kamu cukup masukkan "path" database-nya saja @override Stream getSensorStream( String path, T Function(Map json) mapper, ) { + /// === ambil data mentah dari firebase === /// return _db.ref(path).onValue.map((event) { - final value = event.snapshot.value; - return mapper(value is Map ? value : {}); + final Object? value = event.snapshot.value; + + if (value is Map) { + return mapper(value); + } else { + // Jika data kosong atau bukan Map, berikan Map kosong agar mapper tidak crash + return mapper({}); + } }); } @override + // Jika ingin mengambil data sekali saja (bukan stream) Future getSensorSnapshot( String path, T Function(Map json) mapper, ) async { final snapshot = await _db.ref(path).get(); - return mapper(snapshot.value as Map? ?? {}); + final data = snapshot.value as Map? ?? {}; + return mapper(data); } @override - Future> getSensorHistory( + Future> getSensorHistory( String path, - T Function(Map json) mapper, { - String? orderByChild, - int limit = 500, - }) async { + T Function(Map json) mapper, + ) async { try { - Query query = _db.ref(path); + // Ambil data dari path history + final snapshot = await _db.ref(path).get(); - if (orderByChild != null) { - query = query.orderByChild(orderByChild).limitToLast(limit); - } else { - query = query.limitToLast(limit); + if (snapshot.exists && snapshot.value is Map) { + final Map data = snapshot.value as Map; + + final list = data.values.map((item) { + return mapper(item as Map); + }).toList(); + + // ✅ Sort by timestamp — Firebase push tidak jamin urutan + list.sort((a, b) { + if (a is MyWindSpeed && b is MyWindSpeed) { + return a.timestamp.compareTo(b.timestamp); + } + return 0; + }); + + return list; } - - final snapshot = await query.get(); - if (!snapshot.exists || snapshot.value is! Map) return []; - - final data = snapshot.value as Map; - final list = data.values - .whereType() - .map((item) => mapper(item)) - .toList(); - - // Sort generik — works untuk SEMUA sensor karena T extends HasTimestamp - list.sort((a, b) => a.timestamp.compareTo(b.timestamp)); - return list; - } catch (_) { + return []; + } catch (e) { return []; } } diff --git a/packages/monitoring_repository/lib/src/models/evaporasi.dart b/packages/monitoring_repository/lib/src/models/evaporasi.dart index caec82e..907cbc5 100644 --- a/packages/monitoring_repository/lib/src/models/evaporasi.dart +++ b/packages/monitoring_repository/lib/src/models/evaporasi.dart @@ -1,13 +1,7 @@ -import 'has_timestamp.dart'; -import '../utils/timestamp_parser.dart'; - -class Evaporasi implements HasTimestamp { +class Evaporasi { final double evaporasi; final double suhu; final double tinggiAir; - final String status; - - @override final DateTime timestamp; Evaporasi({ @@ -15,7 +9,6 @@ class Evaporasi implements HasTimestamp { required this.suhu, required this.tinggiAir, required this.timestamp, - this.status = '', }); static final empty = Evaporasi( @@ -26,22 +19,27 @@ class Evaporasi implements HasTimestamp { ); factory Evaporasi.fromJson(Map 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'] ?? 0).toDouble(); + final now = DateTime.now(); + DateTime timestamp = now; - // ✅ Prioritas: timestamp (Unix, kalau firmware sudah diupdate) - // → fallback ke waktu ("HH:MM:SS") - final rawTime = json['timestamp'] ?? json['waktu']; + final waktuStr = json['waktu'] as String?; + if (waktuStr != null) { + final parts = waktuStr.split(':'); + if (parts.length >= 2) { + 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; + timestamp = DateTime(now.year, now.month, now.day, jam, menit, detik); + } + } return Evaporasi( evaporasi: (json['evaporasi'] ?? 0).toDouble(), - suhu: suhu, - tinggiAir: tinggi, - status: json['status'] ?? '', - timestamp: TimestampParser.parse(rawTime), // ✅ pakai TimestampParser + suhu: (json['suhu_air'] ?? 0).toDouble(), + tinggiAir: (json['tinggi_air'] ?? 0).toDouble(), + + /// 🔥 bikin timestamp dari jam & menit + timestamp: timestamp, ); } } diff --git a/packages/monitoring_repository/lib/src/models/has_timestamp.dart b/packages/monitoring_repository/lib/src/models/has_timestamp.dart deleted file mode 100644 index 46a4e63..0000000 --- a/packages/monitoring_repository/lib/src/models/has_timestamp.dart +++ /dev/null @@ -1,5 +0,0 @@ -/// Semua model sensor wajib implement ini. -/// Memungkinkan sorting & TimeSeriesMapper yang generik. -abstract class HasTimestamp { - DateTime get timestamp; -} diff --git a/packages/monitoring_repository/lib/src/models/models.dart b/packages/monitoring_repository/lib/src/models/models.dart index c51ddde..ae80da6 100644 --- a/packages/monitoring_repository/lib/src/models/models.dart +++ b/packages/monitoring_repository/lib/src/models/models.dart @@ -1,4 +1,3 @@ export 'wind_speed.dart'; export 'evaporasi.dart'; export 'atmospheric_conditions.dart'; -export 'has_timestamp.dart'; diff --git a/packages/monitoring_repository/lib/src/models/wind_speed.dart b/packages/monitoring_repository/lib/src/models/wind_speed.dart index 90ab814..b35d05a 100644 --- a/packages/monitoring_repository/lib/src/models/wind_speed.dart +++ b/packages/monitoring_repository/lib/src/models/wind_speed.dart @@ -1,23 +1,23 @@ -import 'has_timestamp.dart'; -import '../utils/timestamp_parser.dart'; +class MyWindSpeed { + double speed; -class MyWindSpeed implements HasTimestamp { - final double speed; - - @override - final DateTime timestamp; + DateTime timestamp; MyWindSpeed({required this.speed, required this.timestamp}); static final empty = MyWindSpeed( speed: 0.0, + timestamp: DateTime.fromMillisecondsSinceEpoch(0), ); factory MyWindSpeed.fromJson(Map json) { return MyWindSpeed( speed: (json['speed'] ?? 0).toDouble(), - timestamp: TimestampParser.parse(json['timestamp']), + + timestamp: DateTime.fromMillisecondsSinceEpoch( + (json['timestamp'] ?? 0) * 1000, // kalau dari Unix detik + ).toLocal(), ); } } diff --git a/packages/monitoring_repository/lib/src/monitoring_repo.dart b/packages/monitoring_repository/lib/src/monitoring_repo.dart index 9d0afb5..555796a 100644 --- a/packages/monitoring_repository/lib/src/monitoring_repo.dart +++ b/packages/monitoring_repository/lib/src/monitoring_repo.dart @@ -1,22 +1,21 @@ -/// file monitoring_repo.dart di "C:\App_project\klimatologi\packages\monitoring_repository\lib\src\monitoring_repo.dart" - -import 'models/has_timestamp.dart'; +// import 'models/models.dart'; abstract class MonitoringRepository { + // fungsi untuk ambil data berkali/streaming Stream getSensorStream( String path, T Function(Map json) mapper, ); + // fungsi untuk ambil sensor sekali Future getSensorSnapshot( String path, T Function(Map json) mapper, ); - Future> getSensorHistory( + // Fungsi untuk ambil riwayat (List) + Future> getSensorHistory( String path, - T Function(Map json) mapper, { - String? orderByChild, - int limit = 500, - }); + T Function(Map json) mapper, + ); } diff --git a/packages/monitoring_repository/lib/src/utils/timestamp_parser.dart b/packages/monitoring_repository/lib/src/utils/timestamp_parser.dart deleted file mode 100644 index dd8c978..0000000 --- a/packages/monitoring_repository/lib/src/utils/timestamp_parser.dart +++ /dev/null @@ -1,46 +0,0 @@ -/// Mengubah berbagai format waktu Firebase → DateTime. -/// -/// Format yang didukung: -/// int Unix detik 1777807041 (anemometer) -/// int Unix milidetik 1777950497446 (sensor/atmospheric) -/// String "HH:MM:SS" "11:09:00" (Monitoring/evaporasi, intraday only) -/// String ISO 8601 "2025-03-05T14:26" (untuk masa depan) -class TimestampParser { - // Angka > ini dianggap milidetik (threshold: ~Nov 2001 dalam detik) - static const int _msThreshold = 10000000000; - - static DateTime parse(dynamic raw) { - if (raw == null) return DateTime.now(); - - if (raw is int) { - if (raw <= 0) return DateTime.now(); - if (raw > _msThreshold) { - return DateTime.fromMillisecondsSinceEpoch(raw).toLocal(); - } - return DateTime.fromMillisecondsSinceEpoch(raw * 1000).toLocal(); - } - - if (raw is double) return parse(raw.toInt()); - - if (raw is String) { - // "HH:MM:SS" → pakai tanggal hari ini (intraday only) - final m = RegExp(r'^(\d{1,2}):(\d{2}):(\d{2})$').firstMatch(raw); - if (m != null) { - final now = DateTime.now(); - return DateTime( - now.year, - now.month, - now.day, - int.parse(m.group(1)!), - int.parse(m.group(2)!), - int.parse(m.group(3)!), - ); - } - try { - return DateTime.parse(raw).toLocal(); - } catch (_) {} - } - - return DateTime.now(); - } -} From b5539715f3b6fdade7f81c45397290efa6f66d3f Mon Sep 17 00:00:00 2001 From: kleponijo <158453666+kleponijo@users.noreply.github.com> Date: Sat, 9 May 2026 15:43:52 +0700 Subject: [PATCH 08/22] Revert "Evaporasi list: show evaporasi + suhu with firebase timestamp" This reverts commit 2675ed7bd408c6f188645e73930b362beb6feb9f. --- .../evaporasi/views/evaporasi_screen.dart | 35 +++++-------------- 1 file changed, 9 insertions(+), 26 deletions(-) diff --git a/lib/screens/monitoring/evaporasi/views/evaporasi_screen.dart b/lib/screens/monitoring/evaporasi/views/evaporasi_screen.dart index e1ea08c..cc403a7 100644 --- a/lib/screens/monitoring/evaporasi/views/evaporasi_screen.dart +++ b/lib/screens/monitoring/evaporasi/views/evaporasi_screen.dart @@ -310,11 +310,8 @@ class EvaporasiScreen extends StatelessWidget { separatorBuilder: (_, __) => const Divider(height: 1), itemBuilder: (context, index) { final e = data[index]; - final dateLabel = DateFormat('dd MMM yyyy', 'id_ID') - .format(e.timestamp) + - ' • ' + - DateFormat('HH:mm:ss', 'id_ID').format(e.timestamp); - + final dateLabel = DateFormat('dd MMM yyyy HH:mm:ss', 'id_ID') + .format(e.timestamp); return Padding( padding: const EdgeInsets.symmetric(vertical: 10), child: Row( @@ -331,27 +328,13 @@ class EvaporasiScreen extends StatelessWidget { ), ), const SizedBox(width: 12), - Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text( - '${e.evaporasi.toStringAsFixed(1)} mm', - style: const TextStyle( - fontSize: 13, - fontWeight: FontWeight.w700, - color: Colors.blue, - ), - ), - const SizedBox(height: 4), - Text( - '${e.suhu.toStringAsFixed(1)} °C', - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - color: Colors.orange.shade700, - ), - ), - ], + Text( + '${e.evaporasi.toStringAsFixed(1)} mm', + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: Colors.blue, + ), ), ], ), From 0e90908b2d0be0c5d6c1954db46e272fff7c77eb Mon Sep 17 00:00:00 2001 From: kleponijo <158453666+kleponijo@users.noreply.github.com> Date: Sat, 9 May 2026 15:44:00 +0700 Subject: [PATCH 09/22] Revert "Fix evaporasi dashboard: firebase history list + sync chart labels" This reverts commit 42bac0c98054df4b68fa8de4179690bf2b04b5b2. --- .../evaporasi/blocs/evaporasi_bloc.dart | 30 ------ .../evaporasi/blocs/evaporasi_state.dart | 15 --- .../evaporasi/views/evaporasi_screen.dart | 101 ++---------------- .../views/widgets/evaporasi_chart_widget.dart | 23 ++-- .../widgets/evaporasi_period_selector.dart | 15 +-- 5 files changed, 21 insertions(+), 163 deletions(-) diff --git a/lib/screens/monitoring/evaporasi/blocs/evaporasi_bloc.dart b/lib/screens/monitoring/evaporasi/blocs/evaporasi_bloc.dart index 011dd9f..32cee33 100644 --- a/lib/screens/monitoring/evaporasi/blocs/evaporasi_bloc.dart +++ b/lib/screens/monitoring/evaporasi/blocs/evaporasi_bloc.dart @@ -34,8 +34,6 @@ class EvaporasiBloc extends Bloc { WatchEvaporasiStarted event, Emitter emit, ) async { - // chartLabels & listData untuk list/label di UI - emit(state.copyWith(isLoading: true)); final history = await _repository.getSensorHistory( @@ -43,13 +41,7 @@ class EvaporasiBloc extends Bloc { (json) => Evaporasi.fromJson(json), ); - // listData dipakai untuk tab/list di UI (default: ambil data yang sudah ada di Firebase) - final listData = List.from(history) - ..sort((a, b) => a.timestamp.compareTo(b.timestamp)); - - final dailyGraph = TimeSeriesMapper.toDaily( - data: history, getTime: (e) => e.timestamp, getValue: (e) => e.evaporasi, @@ -68,12 +60,8 @@ class EvaporasiBloc extends Bloc { emit(state.copyWith( history: history, - listData: listData, dailyValues: dailyGraph, dailyTemperatures: dailyTempGraph, - chartLabels: _buildChartLabels( - period: 'Hari Ini', - ), weatherStatus: status, willRain: rain, isLoading: false, @@ -167,7 +155,6 @@ class EvaporasiBloc extends Bloc { emit(state.copyWith( dailyValues: updated, dailyTemperatures: updatedTemp, - chartLabels: _buildChartLabels(period: event.period), isLoading: false, )); } @@ -204,7 +191,6 @@ class EvaporasiBloc extends Bloc { emit(state.copyWith( dailyValues: updated, dailyTemperatures: updatedTemp, - chartLabels: _buildChartLabels(period: 'Tanggal Khusus'), isLoading: false, )); } @@ -239,22 +225,6 @@ class EvaporasiBloc extends Bloc { return ('Buruk', true); } - List _buildChartLabels({required String period}) { - if (period == 'Minggu Ini') { - return const ['Sen', 'Sel', 'Rab', 'Kam', 'Jum', 'Sab', 'Min']; - } - - // Untuk Bulan Ini & Tanggal Khusus/ Hari Ini, biarkan chart pakai 0..23 / 0..days - final now = DateTime.now(); - if (period == 'Bulan Ini') { - final daysInMonth = DateTime(now.year, now.month + 1, 0).day; - return List.generate(daysInMonth, (i) => '${i + 1}'); - } - - // Hari Ini / Tanggal Khusus => 24 jam - return List.generate(24, (i) => '${i.toString().padLeft(2, '0')}:00'); - } - void _emitEvaporasiAlert(String status, bool willRain, double value) { final AlertSeverity severity; final String message; diff --git a/lib/screens/monitoring/evaporasi/blocs/evaporasi_state.dart b/lib/screens/monitoring/evaporasi/blocs/evaporasi_state.dart index fcbd8cf..640d2dd 100644 --- a/lib/screens/monitoring/evaporasi/blocs/evaporasi_state.dart +++ b/lib/screens/monitoring/evaporasi/blocs/evaporasi_state.dart @@ -12,15 +12,6 @@ class EvaporasiState extends Equatable { final List dailyValues; // untuk grafik evaporasi final List dailyTemperatures; // untuk grafik suhu - - /// Label X-axis sesuai agregasi period - /// Contoh: Hari Ini => ["00:00","01:00",...] - /// Minggu Ini => ["Sen","Sel",...] - /// Bulan Ini => ["1","2",...] - final List chartLabels; - - /// Data untuk list (timestamp asli dari firebase) - final List listData; final List history; final String weatherStatus; // Baik / Sedang / Buruk @@ -37,8 +28,6 @@ class EvaporasiState extends Equatable { this.viewMode = EvaporasiViewMode.period, this.dailyValues = const [], this.dailyTemperatures = const [], - this.chartLabels = const [], - this.listData = const [], this.history = const [], this.weatherStatus = "Baik", this.willRain = false, @@ -54,8 +43,6 @@ EvaporasiState copyWith({ EvaporasiViewMode? viewMode, List? dailyValues, List? dailyTemperatures, - List? chartLabels, - List? listData, List? history, String? weatherStatus, bool? willRain, @@ -71,8 +58,6 @@ EvaporasiState copyWith({ dailyValues: dailyValues ?? this.dailyValues, dailyTemperatures: dailyTemperatures ?? this.dailyTemperatures, history: history ?? this.history, - chartLabels: chartLabels ?? this.chartLabels, - listData: listData ?? this.listData, weatherStatus: weatherStatus ?? this.weatherStatus, willRain: willRain ?? this.willRain, isLoading: isLoading ?? this.isLoading, diff --git a/lib/screens/monitoring/evaporasi/views/evaporasi_screen.dart b/lib/screens/monitoring/evaporasi/views/evaporasi_screen.dart index cc403a7..eb3343a 100644 --- a/lib/screens/monitoring/evaporasi/views/evaporasi_screen.dart +++ b/lib/screens/monitoring/evaporasi/views/evaporasi_screen.dart @@ -53,8 +53,7 @@ class EvaporasiScreen extends StatelessWidget { style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), const SizedBox(height: 15), - - // Period selector with date picker +// Period selector with date picker Builder( builder: (context) { final state = context.watch().state; @@ -84,24 +83,16 @@ class EvaporasiScreen extends StatelessWidget { Builder( builder: (context) { final state = context.watch().state; - return Column( - children: [ - EvaporasiChartWidget( - dailyValues: state.dailyValues, - dailyTemperatures: state.dailyTemperatures, - period: state.viewMode == EvaporasiViewMode.customDate - ? "Tanggal Khusus" - : state.selectedPeriod, - chartLabels: state.chartLabels, - ), - const SizedBox(height: 20), - _evaporasiList(state), - const SizedBox(height: 10), - ], + return EvaporasiChartWidget( + dailyValues: state.dailyValues, + dailyTemperatures: state.dailyTemperatures, + period: state.viewMode == EvaporasiViewMode.customDate + ? "Tanggal Khusus" + : state.selectedPeriod, ); }, ), - const SizedBox(height: 10), + const SizedBox(height: 25), ExportPdfButton( onExport: () => PdfExportService.evaporasi( evaporasi: state.currentValue, @@ -270,82 +261,6 @@ class EvaporasiScreen extends StatelessWidget { ); } - /// ========================= - /// 🧾 LIST DATA EVAPORASI - /// ========================= - Widget _evaporasiList(EvaporasiState state) { - final data = state.listData; - if (data.isEmpty) { - return Container( - width: double.infinity, - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(20), - ), - child: const Text('Belum ada data evaporasi', - style: TextStyle(color: Colors.grey)), - ); - } - - return Container( - width: double.infinity, - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(20), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'List Data Evaporasi', - style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold), - ), - const SizedBox(height: 12), - ListView.separated( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemCount: data.length, - separatorBuilder: (_, __) => const Divider(height: 1), - itemBuilder: (context, index) { - final e = data[index]; - final dateLabel = DateFormat('dd MMM yyyy HH:mm:ss', 'id_ID') - .format(e.timestamp); - return Padding( - padding: const EdgeInsets.symmetric(vertical: 10), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Text( - dateLabel, - style: const TextStyle( - fontSize: 12, - color: Colors.black87, - ), - overflow: TextOverflow.ellipsis, - ), - ), - const SizedBox(width: 12), - Text( - '${e.evaporasi.toStringAsFixed(1)} mm', - style: const TextStyle( - fontSize: 13, - fontWeight: FontWeight.w700, - color: Colors.blue, - ), - ), - ], - ), - ); - }, - ), - ], - ), - ); - } - /// ========================= /// 📅 FORMAT DATE INFO (for custom date display) /// ========================= diff --git a/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_chart_widget.dart b/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_chart_widget.dart index af307b4..fe0a686 100644 --- a/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_chart_widget.dart +++ b/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_chart_widget.dart @@ -5,18 +5,14 @@ class EvaporasiChartWidget extends StatelessWidget { final List dailyValues; final List dailyTemperatures; final String period; - final List chartLabels; - const EvaporasiChartWidget({ super.key, required this.dailyValues, required this.dailyTemperatures, required this.period, - required this.chartLabels, }); - double _getMaxY(List data) { if (data.isEmpty) return 10; final max = data.reduce((a, b) => a > b ? a : b); @@ -97,7 +93,6 @@ class EvaporasiChartWidget extends StatelessWidget { _getBottomTitle(value), style: const TextStyle( color: Colors.grey, fontSize: 10), - ), ); }, @@ -232,14 +227,20 @@ class EvaporasiChartWidget extends StatelessWidget { } String _getBottomTitle(double value) { - final index = value.toInt(); - if (chartLabels.isEmpty) return ''; - if (index < 0 || index >= chartLabels.length) return ''; - return chartLabels[index]; + 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; diff --git a/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_period_selector.dart b/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_period_selector.dart index 45b79ff..ee5ea72 100644 --- a/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_period_selector.dart +++ b/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_period_selector.dart @@ -68,20 +68,7 @@ Widget _buildDatePickerButton(BuildContext context, EvaporasiViewMode viewMode, context.read().add( const EvaporasiViewModeChanged(EvaporasiViewMode.customDate), ); - - // Penting: pastikan bottom sheet masih punya context yang memiliki EvaporasiBloc - final bloc = context.read(); - showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: Colors.transparent, - builder: (sheetContext) { - return BlocProvider.value( - value: bloc, - child: const EvaporasiDatePicker(), - ); - }, - ); + showEvaporasiDatePicker(context); }, child: Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), From 345c2c34f652f3633532fd55f121c7dc6ce5936c Mon Sep 17 00:00:00 2001 From: kleponijo <158453666+kleponijo@users.noreply.github.com> Date: Sat, 9 May 2026 15:45:15 +0700 Subject: [PATCH 10/22] oke --- lib/screens/home/views/home_screen.dart | 15 ++ .../evaporasi/blocs/evaporasi_bloc.dart | 47 ++-- .../evaporasi/blocs/evaporasi_state.dart | 18 +- .../evaporasi/views/evaporasi_screen.dart | 40 ++- .../views/widgets/evaporasi_chart_widget.dart | 250 ------------------ pubspec.lock | 16 -- 6 files changed, 52 insertions(+), 334 deletions(-) delete mode 100644 lib/screens/monitoring/evaporasi/views/widgets/evaporasi_chart_widget.dart diff --git a/lib/screens/home/views/home_screen.dart b/lib/screens/home/views/home_screen.dart index a495092..1a831fb 100644 --- a/lib/screens/home/views/home_screen.dart +++ b/lib/screens/home/views/home_screen.dart @@ -2,6 +2,7 @@ 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'; @@ -9,6 +10,8 @@ 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 { @@ -97,6 +100,7 @@ class _HomeScreenState extends State height: 90, fit: BoxFit.contain, ), +<<<<<<< HEAD actions: [ // Icon lonceng dengan badge BlocBuilder( @@ -174,6 +178,17 @@ class _HomeScreenState extends State ), ), ], +======= + const SizedBox(width: 10), + ], + ), + actions: [ + IconButton( + icon: const Icon(Icons.notifications_none, color: Colors.black), + onPressed: () { + // Aksi notifikasi + }, +>>>>>>> parent of 6f38d3b (update evaporsi) ), ), ), diff --git a/lib/screens/monitoring/evaporasi/blocs/evaporasi_bloc.dart b/lib/screens/monitoring/evaporasi/blocs/evaporasi_bloc.dart index 32cee33..8cf3748 100644 --- a/lib/screens/monitoring/evaporasi/blocs/evaporasi_bloc.dart +++ b/lib/screens/monitoring/evaporasi/blocs/evaporasi_bloc.dart @@ -4,7 +4,10 @@ import 'package:equatable/equatable.dart'; import 'package:monitoring_repository/monitoring_repository.dart'; import '../../../../core/utils/time_series_mapper.dart'; +<<<<<<< HEAD import '../../../../blocs/notification_bloc/notification_bloc.dart'; +======= +>>>>>>> parent of 6f38d3b (update evaporsi) part 'evaporasi_event.dart'; part 'evaporasi_state.dart'; @@ -44,9 +47,10 @@ class EvaporasiBloc extends Bloc { final dailyGraph = TimeSeriesMapper.toDaily( data: history, getTime: (e) => e.timestamp, - getValue: (e) => e.evaporasi, + getValue: (e) => e.evaporasi, // ⚠️ sesuaikan nama field ); +<<<<<<< HEAD final dailyTempGraph = TimeSeriesMapper.toDaily( data: history, getTime: (e) => e.timestamp, @@ -58,12 +62,11 @@ class EvaporasiBloc extends Bloc { final (status, rain) = _computeWeatherStatus(lastValue); _emitEvaporasiAlert(status, rain, lastValue); +======= +>>>>>>> parent of 6f38d3b (update evaporsi) emit(state.copyWith( history: history, dailyValues: dailyGraph, - dailyTemperatures: dailyTempGraph, - weatherStatus: status, - willRain: rain, isLoading: false, )); @@ -81,25 +84,28 @@ class EvaporasiBloc extends Bloc { Emitter emit, ) { final updated = List.from(state.dailyValues); +<<<<<<< HEAD final updatedTemp = List.from(state.dailyTemperatures); +======= + +>>>>>>> parent of 6f38d3b (update evaporsi) final index = DateTime.now().hour; if (index < updated.length) { - updated[index] = event.data.evaporasi; - updatedTemp[index] = event.data.suhu; + updated[index] = event.data.evaporasi; // ⚠️ sesuaikan field } +<<<<<<< HEAD final (status, rain) = _computeWeatherStatus(event.data.evaporasi); _emitEvaporasiAlert(status, rain, event.data.evaporasi); +======= +>>>>>>> parent of 6f38d3b (update evaporsi) emit(state.copyWith( currentValue: event.data.evaporasi, temperature: event.data.suhu, waterLevel: event.data.tinggiAir, dailyValues: updated, - dailyTemperatures: updatedTemp, - weatherStatus: status, - willRain: rain, )); } @@ -114,7 +120,6 @@ class EvaporasiBloc extends Bloc { final history = state.history; List updated; - List updatedTemp; if (event.period == "Minggu Ini") { updated = TimeSeriesMapper.toWeekly( @@ -122,39 +127,26 @@ class EvaporasiBloc extends Bloc { getTime: (e) => e.timestamp, getValue: (e) => e.evaporasi, ); - updatedTemp = TimeSeriesMapper.toWeekly( - data: history, - getTime: (e) => e.timestamp, - getValue: (e) => e.suhu, - ); } else if (event.period == "Bulan Ini") { updated = TimeSeriesMapper.toMonthly( data: history, getTime: (e) => e.timestamp, getValue: (e) => e.evaporasi, ); - updatedTemp = TimeSeriesMapper.toMonthly( - data: history, - getTime: (e) => e.timestamp, - getValue: (e) => e.suhu, - ); } else { updated = TimeSeriesMapper.toDaily( data: history, getTime: (e) => e.timestamp, getValue: (e) => e.evaporasi, ); - updatedTemp = TimeSeriesMapper.toDaily( - data: history, - getTime: (e) => e.timestamp, - getValue: (e) => e.suhu, - ); } +<<<<<<< HEAD // Pertahankan status cuaca saat ini (tidak berubah karena hanya ganti periode) +======= +>>>>>>> parent of 6f38d3b (update evaporsi) emit(state.copyWith( dailyValues: updated, - dailyTemperatures: updatedTemp, isLoading: false, )); } @@ -215,6 +207,7 @@ class EvaporasiBloc extends Bloc { await _subscription?.cancel(); return super.close(); } +<<<<<<< HEAD /// ========================= /// 🌤️ HELPER: Status Cuaca @@ -252,6 +245,8 @@ class EvaporasiBloc extends Bloc { ), )); } +======= +>>>>>>> parent of 6f38d3b (update evaporsi) } /// INTERNAL EVENT diff --git a/lib/screens/monitoring/evaporasi/blocs/evaporasi_state.dart b/lib/screens/monitoring/evaporasi/blocs/evaporasi_state.dart index 640d2dd..72e4a5c 100644 --- a/lib/screens/monitoring/evaporasi/blocs/evaporasi_state.dart +++ b/lib/screens/monitoring/evaporasi/blocs/evaporasi_state.dart @@ -10,13 +10,9 @@ class EvaporasiState extends Equatable { final DateTime? selectedDate; // tanggal spesifik untuk custom date final EvaporasiViewMode viewMode; // period vs custom date - final List dailyValues; // untuk grafik evaporasi - final List dailyTemperatures; // untuk grafik suhu + final List dailyValues; // untuk grafik final List history; - final String weatherStatus; // Baik / Sedang / Buruk - final bool willRain; // true jika status Sedang/Buruk - final bool isLoading; const EvaporasiState({ @@ -27,10 +23,7 @@ class EvaporasiState extends Equatable { this.selectedDate, this.viewMode = EvaporasiViewMode.period, this.dailyValues = const [], - this.dailyTemperatures = const [], this.history = const [], - this.weatherStatus = "Baik", - this.willRain = false, this.isLoading = true, }); @@ -42,10 +35,7 @@ EvaporasiState copyWith({ DateTime? selectedDate, EvaporasiViewMode? viewMode, List? dailyValues, - List? dailyTemperatures, List? history, - String? weatherStatus, - bool? willRain, bool? isLoading, }) { return EvaporasiState( @@ -56,10 +46,7 @@ EvaporasiState copyWith({ selectedDate: selectedDate ?? this.selectedDate, viewMode: viewMode ?? this.viewMode, dailyValues: dailyValues ?? this.dailyValues, - dailyTemperatures: dailyTemperatures ?? this.dailyTemperatures, history: history ?? this.history, - weatherStatus: weatherStatus ?? this.weatherStatus, - willRain: willRain ?? this.willRain, isLoading: isLoading ?? this.isLoading, ); } @@ -73,10 +60,7 @@ EvaporasiState copyWith({ selectedDate, viewMode, dailyValues, - dailyTemperatures, history, - weatherStatus, - willRain, isLoading, ]; } diff --git a/lib/screens/monitoring/evaporasi/views/evaporasi_screen.dart b/lib/screens/monitoring/evaporasi/views/evaporasi_screen.dart index eb3343a..8897ef1 100644 --- a/lib/screens/monitoring/evaporasi/views/evaporasi_screen.dart +++ b/lib/screens/monitoring/evaporasi/views/evaporasi_screen.dart @@ -2,8 +2,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:intl/intl.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/widgets/export_pdf_button.dart'; @@ -47,6 +45,7 @@ class EvaporasiScreen extends StatelessWidget { const SizedBox(height: 25), _infoRow(state), const SizedBox(height: 25), +<<<<<<< HEAD _statusCard(state), const SizedBox(height: 25), const Text("Tren Evaporasi & Suhu", @@ -92,6 +91,9 @@ class EvaporasiScreen extends StatelessWidget { ); }, ), +======= + _trendSection(), +>>>>>>> parent of 6f38d3b (update evaporsi) const SizedBox(height: 25), ExportPdfButton( onExport: () => PdfExportService.evaporasi( @@ -193,36 +195,17 @@ class EvaporasiScreen extends StatelessWidget { } /// ========================= - /// 🌤️ STATUS CARD + /// 📈 TREND (SIMPLE PLACEHOLDER) /// ========================= - 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; - } - + Widget _trendSection() { return Container( width: double.infinity, - padding: const EdgeInsets.all(20), + height: 200, decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(20), - border: Border.all(color: statusColor.withOpacity(0.3), width: 1.5), ), +<<<<<<< HEAD child: Row( children: [ Icon(statusIcon, color: statusColor, size: 40), @@ -257,6 +240,13 @@ class EvaporasiScreen extends StatelessWidget { ), ), ], +======= + child: const Center( + child: Text( + "Grafik Evaporasi", + style: TextStyle(color: Colors.grey), + ), +>>>>>>> parent of 6f38d3b (update evaporsi) ), ); } diff --git a/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_chart_widget.dart b/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_chart_widget.dart deleted file mode 100644 index fe0a686..0000000 --- a/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_chart_widget.dart +++ /dev/null @@ -1,250 +0,0 @@ -import 'package:fl_chart/fl_chart.dart'; -import 'package:flutter/material.dart'; - -class EvaporasiChartWidget extends StatelessWidget { - final List dailyValues; - final List dailyTemperatures; - final String period; - - const EvaporasiChartWidget({ - super.key, - required this.dailyValues, - required this.dailyTemperatures, - required this.period, - }); - - double _getMaxY(List data) { - if (data.isEmpty) return 10; - final max = data.reduce((a, b) => a > b ? a : b); - return (max + 5).clamp(10, 100); - } - - List _safeData(List 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; - } -} - diff --git a/pubspec.lock b/pubspec.lock index ae5f51c..4867820 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -695,14 +695,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.27.7" - simple_gesture_detector: - dependency: transitive - description: - name: simple_gesture_detector - sha256: ba2cd5af24ff20a0b8d609cec3f40e5b0744d2a71804a2616ae086b9c19d19a3 - url: "https://pub.dev" - source: hosted - version: "0.2.1" sky_engine: dependency: transitive description: flutter @@ -748,14 +740,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" - table_calendar: - dependency: "direct main" - description: - name: table_calendar - sha256: "0c0c6219878b363a2d5f40c7afb159d845f253d061dc3c822aa0d5fe0f721982" - url: "https://pub.dev" - source: hosted - version: "3.2.0" term_glyph: dependency: transitive description: From 3113280b4270de175dd6d74ceb92933bdfa3caa0 Mon Sep 17 00:00:00 2001 From: kleponijo <158453666+kleponijo@users.noreply.github.com> Date: Sat, 9 May 2026 15:47:48 +0700 Subject: [PATCH 11/22] oke --- pubspec.lock | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/pubspec.lock b/pubspec.lock index 4867820..ae5f51c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -695,6 +695,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.27.7" + simple_gesture_detector: + dependency: transitive + description: + name: simple_gesture_detector + sha256: ba2cd5af24ff20a0b8d609cec3f40e5b0744d2a71804a2616ae086b9c19d19a3 + url: "https://pub.dev" + source: hosted + version: "0.2.1" sky_engine: dependency: transitive description: flutter @@ -740,6 +748,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + table_calendar: + dependency: "direct main" + description: + name: table_calendar + sha256: "0c0c6219878b363a2d5f40c7afb159d845f253d061dc3c822aa0d5fe0f721982" + url: "https://pub.dev" + source: hosted + version: "3.2.0" term_glyph: dependency: transitive description: From 652182a462546930d23b3047d7922586d0711dfe Mon Sep 17 00:00:00 2001 From: kleponijo <158453666+kleponijo@users.noreply.github.com> Date: Sat, 9 May 2026 15:49:11 +0700 Subject: [PATCH 12/22] Update pubspec.lock --- pubspec.lock | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/pubspec.lock b/pubspec.lock index 4867820..ae5f51c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -695,6 +695,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.27.7" + simple_gesture_detector: + dependency: transitive + description: + name: simple_gesture_detector + sha256: ba2cd5af24ff20a0b8d609cec3f40e5b0744d2a71804a2616ae086b9c19d19a3 + url: "https://pub.dev" + source: hosted + version: "0.2.1" sky_engine: dependency: transitive description: flutter @@ -740,6 +748,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + table_calendar: + dependency: "direct main" + description: + name: table_calendar + sha256: "0c0c6219878b363a2d5f40c7afb159d845f253d061dc3c822aa0d5fe0f721982" + url: "https://pub.dev" + source: hosted + version: "3.2.0" term_glyph: dependency: transitive description: From 5fbb846078f8a108f1e730b3c53b13a9bee9e855 Mon Sep 17 00:00:00 2001 From: kleponijo <158453666+kleponijo@users.noreply.github.com> Date: Sat, 9 May 2026 15:50:05 +0700 Subject: [PATCH 13/22] Update pubspec.lock --- pubspec.lock | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index ae5f51c..4867820 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -695,14 +695,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.27.7" - simple_gesture_detector: - dependency: transitive - description: - name: simple_gesture_detector - sha256: ba2cd5af24ff20a0b8d609cec3f40e5b0744d2a71804a2616ae086b9c19d19a3 - url: "https://pub.dev" - source: hosted - version: "0.2.1" sky_engine: dependency: transitive description: flutter @@ -748,14 +740,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" - table_calendar: - dependency: "direct main" - description: - name: table_calendar - sha256: "0c0c6219878b363a2d5f40c7afb159d845f253d061dc3c822aa0d5fe0f721982" - url: "https://pub.dev" - source: hosted - version: "3.2.0" term_glyph: dependency: transitive description: From f9abec81c349516f6c81165440e8675c7e087d9f Mon Sep 17 00:00:00 2001 From: Mochamad ongki ramadani Date: Sat, 9 May 2026 18:00:57 +0700 Subject: [PATCH 14/22] percobaan --- .../evaporasi/blocs/evaporasi_bloc.dart | 112 +++++---- .../evaporasi/blocs/evaporasi_state.dart | 35 ++- .../evaporasi/views/evaporasi_screen.dart | 224 ++++++++++++------ .../views/widgets/evaporasi_chart_widget.dart | 46 +++- .../widgets/evaporasi_date_search_bar.dart | 72 ++++++ .../lib/src/models/evaporasi.dart | 9 +- 6 files changed, 357 insertions(+), 141 deletions(-) create mode 100644 lib/screens/monitoring/evaporasi/views/widgets/evaporasi_date_search_bar.dart diff --git a/lib/screens/monitoring/evaporasi/blocs/evaporasi_bloc.dart b/lib/screens/monitoring/evaporasi/blocs/evaporasi_bloc.dart index 011dd9f..1f3a593 100644 --- a/lib/screens/monitoring/evaporasi/blocs/evaporasi_bloc.dart +++ b/lib/screens/monitoring/evaporasi/blocs/evaporasi_bloc.dart @@ -27,29 +27,24 @@ class EvaporasiBloc extends Bloc { on(_onViewModeChanged); } - /// ========================= - /// 🚀 START - /// ========================= Future _onStarted( WatchEvaporasiStarted event, Emitter emit, ) async { - // chartLabels & listData untuk list/label di UI - emit(state.copyWith(isLoading: true)); + // Ambil history yang akan dipakai untuk list + agregasi grafik. final history = await _repository.getSensorHistory( - 'evaporasi/history', + 'Monitoring/History', (json) => Evaporasi.fromJson(json), + orderByChild: null, + limit: 500, ); - // listData dipakai untuk tab/list di UI (default: ambil data yang sudah ada di Firebase) final listData = List.from(history) ..sort((a, b) => a.timestamp.compareTo(b.timestamp)); - final dailyGraph = TimeSeriesMapper.toDaily( - data: history, getTime: (e) => e.timestamp, getValue: (e) => e.evaporasi, @@ -61,7 +56,30 @@ class EvaporasiBloc extends Bloc { getValue: (e) => e.suhu, ); - // Hitung status dari data terakhir history jika ada + final weeklyGraph = TimeSeriesMapper.toWeekly( + data: history, + getTime: (e) => e.timestamp, + getValue: (e) => e.evaporasi, + ); + + final monthlyGraph = TimeSeriesMapper.toMonthly( + data: history, + getTime: (e) => e.timestamp, + getValue: (e) => e.evaporasi, + ); + + final weeklyTemp = TimeSeriesMapper.toWeekly( + data: history, + getTime: (e) => e.timestamp, + getValue: (e) => e.suhu, + ); + + final monthlyTemp = TimeSeriesMapper.toMonthly( + data: history, + getTime: (e) => e.timestamp, + getValue: (e) => e.suhu, + ); + final lastValue = history.isNotEmpty ? history.last.evaporasi : 0.0; final (status, rain) = _computeWeatherStatus(lastValue); _emitEvaporasiAlert(status, rain, lastValue); @@ -71,9 +89,11 @@ class EvaporasiBloc extends Bloc { listData: listData, dailyValues: dailyGraph, dailyTemperatures: dailyTempGraph, - chartLabels: _buildChartLabels( - period: 'Hari Ini', - ), + weeklyValues: weeklyGraph, + monthlyValues: monthlyGraph, + weeklyTemperatures: weeklyTemp, + monthlyTemperatures: monthlyTemp, + chartLabels: _buildChartLabels(period: 'Hari Ini'), weatherStatus: status, willRain: rain, isLoading: false, @@ -85,18 +105,16 @@ class EvaporasiBloc extends Bloc { .listen((data) => add(_EvaporasiRealtimeUpdated(data))); } - /// ========================= - /// ⚡ REALTIME - /// ========================= void _onRealtimeUpdated( _EvaporasiRealtimeUpdated event, Emitter emit, ) { + // Hanya update grafik daily (index jam saat ini). final updated = List.from(state.dailyValues); final updatedTemp = List.from(state.dailyTemperatures); final index = DateTime.now().hour; - if (index < updated.length) { + if (index >= 0 && index < updated.length) { updated[index] = event.data.evaporasi; updatedTemp[index] = event.data.suhu; } @@ -115,20 +133,18 @@ class EvaporasiBloc extends Bloc { )); } - /// ========================= - /// 📊 PERIOD - /// ========================= Future _onPeriodChanged( EvaporasiPeriodChanged event, Emitter emit, ) async { emit(state.copyWith(isLoading: true, selectedPeriod: event.period)); + final history = state.history; List updated; List updatedTemp; - if (event.period == "Minggu Ini") { + if (event.period == 'Minggu Ini') { updated = TimeSeriesMapper.toWeekly( data: history, getTime: (e) => e.timestamp, @@ -139,7 +155,7 @@ class EvaporasiBloc extends Bloc { getTime: (e) => e.timestamp, getValue: (e) => e.suhu, ); - } else if (event.period == "Bulan Ini") { + } else if (event.period == 'Bulan Ini') { updated = TimeSeriesMapper.toMonthly( data: history, getTime: (e) => e.timestamp, @@ -163,18 +179,17 @@ class EvaporasiBloc extends Bloc { ); } -// Pertahankan status cuaca saat ini (tidak berubah karena hanya ganti periode) emit(state.copyWith( dailyValues: updated, dailyTemperatures: updatedTemp, chartLabels: _buildChartLabels(period: event.period), isLoading: false, + // penting: jangan hilangkan list + listData: state.listData, + history: state.history, )); } - /// ========================= - /// 📅 DATE SELECTED (Custom Date) - /// ========================= Future _onDateSelected( EvaporasiDateSelected event, Emitter emit, @@ -206,18 +221,17 @@ class EvaporasiBloc extends Bloc { dailyTemperatures: updatedTemp, chartLabels: _buildChartLabels(period: 'Tanggal Khusus'), isLoading: false, + // jangan hilangkan list + listData: state.listData, + history: state.history, )); } - /// ========================= - /// 🔄 VIEW MODE CHANGED - /// ========================= Future _onViewModeChanged( EvaporasiViewModeChanged event, Emitter emit, ) async { if (event.mode == EvaporasiViewMode.period) { - // Kembali ke mode period - trigger period change add(EvaporasiPeriodChanged(state.selectedPeriod)); } else { emit(state.copyWith(viewMode: event.mode)); @@ -230,31 +244,12 @@ class EvaporasiBloc extends Bloc { return super.close(); } - /// ========================= - /// 🌤️ HELPER: Status Cuaca - /// ========================= static (String status, bool willRain) _computeWeatherStatus(double value) { if (value <= 5.0) return ('Baik', false); if (value <= 10.0) return ('Sedang', true); return ('Buruk', true); } - List _buildChartLabels({required String period}) { - if (period == 'Minggu Ini') { - return const ['Sen', 'Sel', 'Rab', 'Kam', 'Jum', 'Sab', 'Min']; - } - - // Untuk Bulan Ini & Tanggal Khusus/ Hari Ini, biarkan chart pakai 0..23 / 0..days - final now = DateTime.now(); - if (period == 'Bulan Ini') { - final daysInMonth = DateTime(now.year, now.month + 1, 0).day; - return List.generate(daysInMonth, (i) => '${i + 1}'); - } - - // Hari Ini / Tanggal Khusus => 24 jam - return List.generate(24, (i) => '${i.toString().padLeft(2, '0')}:00'); - } - void _emitEvaporasiAlert(String status, bool willRain, double value) { final AlertSeverity severity; final String message; @@ -268,7 +263,7 @@ class EvaporasiBloc extends Bloc { message = 'Evaporasi ${value.toStringAsFixed(1)} mm — status sedang, potensi hujan'; } else { - severity = AlertSeverity.info; // normal → bersihkan alert + severity = AlertSeverity.info; message = ''; } @@ -282,9 +277,23 @@ class EvaporasiBloc extends Bloc { ), )); } + + List _buildChartLabels({required String period}) { + if (period == 'Minggu Ini') { + return const ['Sen', 'Sel', 'Rab', 'Kam', 'Jum', 'Sab', 'Min']; + } + + final now = DateTime.now(); + if (period == 'Bulan Ini') { + final daysInMonth = DateTime(now.year, now.month + 1, 0).day; + return List.generate(daysInMonth, (i) => '${i + 1}'); + } + + // Hari Ini / Tanggal Khusus => 24 jam + return List.generate(24, (i) => '${i.toString().padLeft(2, '0')}:00'); + } } -/// INTERNAL EVENT class _EvaporasiRealtimeUpdated extends EvaporasiEvent { final Evaporasi data; @@ -293,3 +302,4 @@ class _EvaporasiRealtimeUpdated extends EvaporasiEvent { @override List get props => [data]; } + diff --git a/lib/screens/monitoring/evaporasi/blocs/evaporasi_state.dart b/lib/screens/monitoring/evaporasi/blocs/evaporasi_state.dart index fcbd8cf..0486273 100644 --- a/lib/screens/monitoring/evaporasi/blocs/evaporasi_state.dart +++ b/lib/screens/monitoring/evaporasi/blocs/evaporasi_state.dart @@ -10,8 +10,13 @@ class EvaporasiState extends Equatable { final DateTime? selectedDate; // tanggal spesifik untuk custom date final EvaporasiViewMode viewMode; // period vs custom date - final List dailyValues; // untuk grafik evaporasi + final List dailyValues; // untuk grafik evaporasi (default) + final List weeklyValues; + final List monthlyValues; + final List dailyTemperatures; // untuk grafik suhu + final List weeklyTemperatures; + final List monthlyTemperatures; /// Label X-axis sesuai agregasi period /// Contoh: Hari Ini => ["00:00","01:00",...] @@ -21,6 +26,7 @@ class EvaporasiState extends Equatable { /// Data untuk list (timestamp asli dari firebase) final List listData; + final List history; final String weatherStatus; // Baik / Sedang / Buruk @@ -32,20 +38,24 @@ class EvaporasiState extends Equatable { this.currentValue = 0.0, this.temperature = 0.0, this.waterLevel = 0.0, - this.selectedPeriod = "Hari Ini", + this.selectedPeriod = 'Hari Ini', this.selectedDate, this.viewMode = EvaporasiViewMode.period, this.dailyValues = const [], + this.weeklyValues = const [], + this.monthlyValues = const [], this.dailyTemperatures = const [], + this.weeklyTemperatures = const [], + this.monthlyTemperatures = const [], this.chartLabels = const [], this.listData = const [], this.history = const [], - this.weatherStatus = "Baik", + this.weatherStatus = 'Baik', this.willRain = false, this.isLoading = true, }); -EvaporasiState copyWith({ + EvaporasiState copyWith({ double? currentValue, double? temperature, double? waterLevel, @@ -53,7 +63,11 @@ EvaporasiState copyWith({ DateTime? selectedDate, EvaporasiViewMode? viewMode, List? dailyValues, + List? weeklyValues, + List? monthlyValues, List? dailyTemperatures, + List? weeklyTemperatures, + List? monthlyTemperatures, List? chartLabels, List? listData, List? history, @@ -69,10 +83,14 @@ EvaporasiState copyWith({ selectedDate: selectedDate ?? this.selectedDate, viewMode: viewMode ?? this.viewMode, dailyValues: dailyValues ?? this.dailyValues, + weeklyValues: weeklyValues ?? this.weeklyValues, + monthlyValues: monthlyValues ?? this.monthlyValues, dailyTemperatures: dailyTemperatures ?? this.dailyTemperatures, - history: history ?? this.history, + weeklyTemperatures: weeklyTemperatures ?? this.weeklyTemperatures, + monthlyTemperatures: monthlyTemperatures ?? this.monthlyTemperatures, chartLabels: chartLabels ?? this.chartLabels, listData: listData ?? this.listData, + history: history ?? this.history, weatherStatus: weatherStatus ?? this.weatherStatus, willRain: willRain ?? this.willRain, isLoading: isLoading ?? this.isLoading, @@ -88,10 +106,17 @@ EvaporasiState copyWith({ selectedDate, viewMode, dailyValues, + weeklyValues, + monthlyValues, dailyTemperatures, + weeklyTemperatures, + monthlyTemperatures, + chartLabels, + listData, history, weatherStatus, willRain, isLoading, ]; } + diff --git a/lib/screens/monitoring/evaporasi/views/evaporasi_screen.dart b/lib/screens/monitoring/evaporasi/views/evaporasi_screen.dart index e1ea08c..2b7e8b5 100644 --- a/lib/screens/monitoring/evaporasi/views/evaporasi_screen.dart +++ b/lib/screens/monitoring/evaporasi/views/evaporasi_screen.dart @@ -1,15 +1,21 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:intl/intl.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/widgets/export_pdf_button.dart'; -class EvaporasiScreen extends StatelessWidget { +class EvaporasiScreen extends StatefulWidget { const EvaporasiScreen({super.key}); + @override + State createState() => _EvaporasiScreenState(); +} + +class _EvaporasiScreenState extends State { @override Widget build(BuildContext context) { return Scaffold( @@ -49,9 +55,10 @@ class EvaporasiScreen extends StatelessWidget { const SizedBox(height: 25), _statusCard(state), const SizedBox(height: 25), - const Text("Tren Evaporasi & Suhu", - style: - TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + const Text( + "Tren Evaporasi & Suhu", + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), const SizedBox(height: 15), // Period selector with date picker @@ -61,7 +68,6 @@ class EvaporasiScreen extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Show selected date info when in custom mode if (state.viewMode == EvaporasiViewMode.customDate && state.selectedDate != null) Padding( @@ -80,28 +86,32 @@ class EvaporasiScreen extends StatelessWidget { ); }, ), + const SizedBox(height: 15), + + // Chart Builder( builder: (context) { final state = context.watch().state; - return Column( - children: [ - EvaporasiChartWidget( - dailyValues: state.dailyValues, - dailyTemperatures: state.dailyTemperatures, - period: state.viewMode == EvaporasiViewMode.customDate - ? "Tanggal Khusus" - : state.selectedPeriod, - chartLabels: state.chartLabels, - ), - const SizedBox(height: 20), - _evaporasiList(state), - const SizedBox(height: 10), - ], + return EvaporasiChartWidget( + dailyValues: state.dailyValues, + dailyTemperatures: state.dailyTemperatures, + period: state.viewMode == EvaporasiViewMode.customDate + ? "Tanggal Khusus" + : state.selectedPeriod, + chartLabels: state.chartLabels, ); }, ), + + const SizedBox(height: 20), + + // List data (mengikuti chart: period vs custom date) + _evaporasiList(state), + + const SizedBox(height: 10), + ExportPdfButton( onExport: () => PdfExportService.evaporasi( evaporasi: state.currentValue, @@ -224,6 +234,20 @@ class EvaporasiScreen extends StatelessWidget { break; } + // Teks peringatan khusus evaporasi (normal/sedang/tinggi) + final String warningText; + if (state.weatherStatus == 'Baik') { + warningText = + 'Normal — evaporasi stabil, risiko dampak rendah.'; + } else if (state.weatherStatus == 'Sedang') { + warningText = + 'Sedang — evaporasi mulai tinggi, pantau kondisi cuaca.'; + } else { + warningText = + 'Tinggi — evaporasi signifikan, berpotensi memengaruhi kondisi lingkungan.'; + } + + return Container( width: double.infinity, padding: const EdgeInsets.all(20), @@ -246,26 +270,32 @@ class EvaporasiScreen extends StatelessWidget { ), const SizedBox(height: 4), Text( - state.weatherStatus, + state.weatherStatus == 'Baik' + ? 'Normal' + : state.weatherStatus == 'Sedang' + ? 'Sedang' + : 'Tinggi', style: TextStyle( fontSize: 20, fontWeight: FontWeight.bold, color: statusColor, ), ), + if (state.willRain) - const Text( - "⚠️ Potensi hujan tinggi", - style: TextStyle( + Text( + warningText, + style: const TextStyle( fontSize: 12, color: Colors.red, fontWeight: FontWeight.w500, ), ), + ], ), ), -], + ], ), ); } @@ -274,7 +304,15 @@ class EvaporasiScreen extends StatelessWidget { /// 🧾 LIST DATA EVAPORASI /// ========================= Widget _evaporasiList(EvaporasiState state) { - final data = state.listData; + final data = state.viewMode == EvaporasiViewMode.customDate && state.selectedDate != null + ? state.history + .where((e) => + e.timestamp.year == state.selectedDate!.year && + e.timestamp.month == state.selectedDate!.month && + e.timestamp.day == state.selectedDate!.day) + .toList() + : state.history; + if (data.isEmpty) { return Container( width: double.infinity, @@ -283,8 +321,10 @@ class EvaporasiScreen extends StatelessWidget { color: Colors.white, borderRadius: BorderRadius.circular(20), ), - child: const Text('Belum ada data evaporasi', - style: TextStyle(color: Colors.grey)), + child: const Text( + 'Belum ada data evaporasi', + style: TextStyle(color: Colors.grey), + ), ); } @@ -303,60 +343,77 @@ class EvaporasiScreen extends StatelessWidget { style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold), ), const SizedBox(height: 12), - ListView.separated( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemCount: data.length, - separatorBuilder: (_, __) => const Divider(height: 1), - itemBuilder: (context, index) { - final e = data[index]; - final dateLabel = DateFormat('dd MMM yyyy', 'id_ID') - .format(e.timestamp) + - ' • ' + - DateFormat('HH:mm:ss', 'id_ID').format(e.timestamp); + SizedBox( + height: 280, + child: ListView.separated( + itemCount: data.length, + itemBuilder: (context, index) { + final e = data[index]; + final dateLabel = + '${DateFormat('dd MMM yyyy', 'id_ID').format(e.timestamp)} • ${DateFormat('HH:mm:ss', 'id_ID').format(e.timestamp)}'; - return Padding( - padding: const EdgeInsets.symmetric(vertical: 10), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Text( - dateLabel, - style: const TextStyle( - fontSize: 12, - color: Colors.black87, - ), - overflow: TextOverflow.ellipsis, - ), - ), - const SizedBox(width: 12), - Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text( - '${e.evaporasi.toStringAsFixed(1)} mm', + return Padding( + padding: const EdgeInsets.symmetric(vertical: 10), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + dateLabel, style: const TextStyle( - fontSize: 13, - fontWeight: FontWeight.w700, - color: Colors.blue, - ), - ), - const SizedBox(height: 4), - Text( - '${e.suhu.toStringAsFixed(1)} °C', - style: TextStyle( fontSize: 12, - fontWeight: FontWeight.w600, - color: Colors.orange.shade700, + color: Colors.black87, ), + overflow: TextOverflow.ellipsis, ), - ], - ), - ], - ), - ); - }, + ), + const SizedBox(width: 12), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + '${e.evaporasi.toStringAsFixed(1)} mm', + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: Colors.blue, + ), + ), + const SizedBox(height: 4), + Text( + 'Tinggi Air: ${e.tinggiAir.toStringAsFixed(1)} cm', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Colors.blue.shade700, + ), + ), + const SizedBox(height: 4), + Text( + 'Suhu: ${e.suhu.toStringAsFixed(1)} °C', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Colors.orange.shade700, + ), + ), + const SizedBox(height: 4), + Text( + _statusTextForHistory(e.evaporasi), + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + color: _statusColorForHistory(e.evaporasi), + ), + ), + ], + ), + ], + ), + ); + }, + separatorBuilder: (_, __) => const Divider(height: 1), + ), ), ], ), @@ -366,8 +423,22 @@ class EvaporasiScreen extends StatelessWidget { /// ========================= /// 📅 FORMAT DATE INFO (for custom date display) /// ========================= + String _statusTextForHistory(double evaporasi) { + if (evaporasi <= 5.0) return 'Status: Normal'; + if (evaporasi <= 10.0) return 'Status: Sedang'; + return 'Status: Tinggi'; + } + + Color _statusColorForHistory(double evaporasi) { + if (evaporasi <= 5.0) return Colors.green; + if (evaporasi <= 10.0) return Colors.orange; + return Colors.red; + } + String _formatDateInfo(DateTime date) { + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); final yesterday = today.subtract(const Duration(days: 1)); final selected = DateTime(date.year, date.month, date.day); @@ -381,3 +452,4 @@ class EvaporasiScreen extends StatelessWidget { } } } + diff --git a/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_chart_widget.dart b/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_chart_widget.dart index af307b4..8b6a8a7 100644 --- a/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_chart_widget.dart +++ b/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_chart_widget.dart @@ -72,18 +72,42 @@ class EvaporasiChartWidget extends StatelessWidget { 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)), + // dual axis: kiri untuk Evaporasi (mm), kanan untuk Suhu (°C) + leftTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 40, + interval: _getYAxisInterval(safeValues), + getTitlesWidget: (value, meta) { + return Text( + value.toStringAsFixed(0), + style: const TextStyle(color: Colors.blueGrey, fontSize: 10), + ); + }, + ), + ), + rightTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 40, + interval: _getYAxisInterval(safeTemps), + getTitlesWidget: (value, meta) { + return Text( + value.toStringAsFixed(0), + style: const TextStyle(color: Colors.orangeAccent, fontSize: 10), + ); + }, + ), + ), topTitles: const AxisTitles( sideTitles: SideTitles(showTitles: false)), - leftTitles: const AxisTitles( - sideTitles: SideTitles(showTitles: false)), bottomTitles: AxisTitles( sideTitles: SideTitles( showTitles: true, @@ -105,7 +129,7 @@ class EvaporasiChartWidget extends StatelessWidget { ), ), lineBarsData: [ - // === Garis Evaporasi === + // === Garis Evaporasi (kiri axis) === LineChartBarData( spots: safeValues.asMap().entries.map((e) { return FlSpot(e.key.toDouble(), e.value); @@ -143,7 +167,7 @@ class EvaporasiChartWidget extends StatelessWidget { ), ), ), - // === Garis Suhu === + // === Garis Suhu (kanan axis) === LineChartBarData( spots: safeTemps.asMap().entries.map((e) { return FlSpot(e.key.toDouble(), e.value); @@ -245,5 +269,15 @@ class EvaporasiChartWidget extends StatelessWidget { if (period == "Minggu Ini") return 1; return 1; } + + double _getYAxisInterval(List data) { + if (data.isEmpty) return 1; + final max = data.reduce((a, b) => a > b ? a : b); + if (max <= 10) return 2; + if (max <= 20) return 5; + if (max <= 30) return 10; + return 20; + } } + diff --git a/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_date_search_bar.dart b/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_date_search_bar.dart new file mode 100644 index 0000000..bf29d28 --- /dev/null +++ b/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_date_search_bar.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +import 'package:monitoring_repository/monitoring_repository.dart'; + +class EvaporasiDateSearchBar extends StatefulWidget { + final String initialQuery; + final ValueChanged onQueryChanged; + + const EvaporasiDateSearchBar({ + super.key, + required this.initialQuery, + required this.onQueryChanged, + }); + + + @override + State createState() => _EvaporasiDateSearchBarState(); +} + +class _EvaporasiDateSearchBarState extends State { + late final TextEditingController _controller; + DateTime? _selected; + + @override + void initState() { + super.initState(); + _controller = TextEditingController(text: widget.initialQuery); + } + + @override + void dispose() { + _controller.dispose(); + 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)', + prefixIcon: const Icon(Icons.search), + suffixIcon: _controller.text.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear), + onPressed: () => setState(() => _controller.clear()), + ) + : null, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), + isDense: true, + ), + onChanged: (v) { + widget.onQueryChanged(v); + setState(() {}); + }, + ); + } +} + diff --git a/packages/monitoring_repository/lib/src/models/evaporasi.dart b/packages/monitoring_repository/lib/src/models/evaporasi.dart index caec82e..fc706ae 100644 --- a/packages/monitoring_repository/lib/src/models/evaporasi.dart +++ b/packages/monitoring_repository/lib/src/models/evaporasi.dart @@ -32,9 +32,12 @@ class Evaporasi implements HasTimestamp { final suhu = (json['suhu'] ?? json['suhu_air'] ?? 0).toDouble(); final tinggi = (json['tinggi'] ?? json['tinggi_air'] ?? 0).toDouble(); - // ✅ Prioritas: timestamp (Unix, kalau firmware sudah diupdate) - // → fallback ke waktu ("HH:MM:SS") - final rawTime = json['timestamp'] ?? json['waktu']; + // ✅ Prioritas waktu sesuai firmware ESP32: + // 1) timestamp (Unix, kalau pernah dikirim) + // 2) datetime ("YYYY-MM-DD HH:MM:SS") + // 3) waktu ("HH:MM:SS") fallback (akan memakai tanggal hari ini) + final rawTime = json['timestamp'] ?? json['datetime'] ?? json['waktu']; + return Evaporasi( evaporasi: (json['evaporasi'] ?? 0).toDouble(), From 91786810c001a365fb475019a5696461f63d2617 Mon Sep 17 00:00:00 2001 From: kleponijo <158453666+kleponijo@users.noreply.github.com> Date: Sat, 9 May 2026 18:40:56 +0700 Subject: [PATCH 15/22] perbaikan authentication --- lib/app_view.dart | 32 +- .../authentication_bloc.dart | 11 +- .../auth/blocs/sign_in_bloc/sign_in_bloc.dart | 2 +- lib/screens/auth/views/sign_in_screen.dart | 223 ++++++---- lib/screens/auth/views/sign_up_screen.dart | 409 ++++++++---------- lib/screens/auth/views/welcome_screen.dart | 125 +++--- .../lib/src/firebase_user_repo.dart | 46 +- .../user_repository/lib/src/models/user.dart | 18 +- 8 files changed, 425 insertions(+), 441 deletions(-) diff --git a/lib/app_view.dart b/lib/app_view.dart index 3fd5d03..0320ddb 100644 --- a/lib/app_view.dart +++ b/lib/app_view.dart @@ -11,25 +11,23 @@ class MyAppView extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( - title: 'Klimatologi', - debugShowCheckedModeBanner: false, - theme: ThemeData( - colorScheme: ColorScheme.light( - background: Colors.grey.shade100, - onBackground: Colors.black, - primary: Colors.blue, - onPrimary: Colors.white + title: 'Klimatologi', + debugShowCheckedModeBanner: false, + theme: ThemeData( + colorScheme: ColorScheme.light( + surface: Colors.grey.shade100, + onSurface: Colors.black, + primary: Colors.blue, + onPrimary: Colors.white), ), - ), - home: BlocBuilder( - builder: ((context, state){ - if(state.status == AuthenticationStatus.authenticated){ - return HomeScreen(); + home: BlocBuilder( + builder: ((context, state) { + if (state.status == AuthenticationStatus.authenticated) { + return HomeScreen(); } else { - return WelcomeScreen(); + return WelcomeScreen(); } }), - ) - ); + )); } -} \ No newline at end of file +} diff --git a/lib/blocs/authentication_bloc/authentication_bloc.dart b/lib/blocs/authentication_bloc/authentication_bloc.dart index 7929c1e..9ff6e4a 100644 --- a/lib/blocs/authentication_bloc/authentication_bloc.dart +++ b/lib/blocs/authentication_bloc/authentication_bloc.dart @@ -7,20 +7,19 @@ import 'package:user_repository/user_repository.dart'; part 'authentication_event.dart'; part 'authentication_state.dart'; -class AuthenticationBloc extends Bloc { +class AuthenticationBloc + extends Bloc { final UserRepository userRepository; late final StreamSubscription _userSubscription; - AuthenticationBloc({ - required this.userRepository - }) : super(const AuthenticationState.unknown()) { + AuthenticationBloc({required this.userRepository}) + : super(const AuthenticationState.unknown()) { _userSubscription = userRepository.user.listen((user) { add(AuthenticationUserChanged(user)); }); - on((event, emit) { - if(event.user != MyUser.empty) { + if (event.user != null && event.user != MyUser.empty) { emit(AuthenticationState.authenticated(event.user!)); } else { emit(AuthenticationState.unauthenticated()); diff --git a/lib/screens/auth/blocs/sign_in_bloc/sign_in_bloc.dart b/lib/screens/auth/blocs/sign_in_bloc/sign_in_bloc.dart index ef52fb0..aa0fbfb 100644 --- a/lib/screens/auth/blocs/sign_in_bloc/sign_in_bloc.dart +++ b/lib/screens/auth/blocs/sign_in_bloc/sign_in_bloc.dart @@ -13,6 +13,7 @@ class SignInBloc extends Bloc { emit(SignInProcess()); try { await _userRepository.signIn(event.email, event.password); + emit(SignInSuccess()); } catch (e) { emit(SignInFailure()); } @@ -25,7 +26,6 @@ class SignInBloc extends Bloc { emit(SignInSuccess()); } catch (e) { emit(SignInFailure()); - emit(SignInInitial()); } }); diff --git a/lib/screens/auth/views/sign_in_screen.dart b/lib/screens/auth/views/sign_in_screen.dart index 84e359a..b4f00f1 100644 --- a/lib/screens/auth/views/sign_in_screen.dart +++ b/lib/screens/auth/views/sign_in_screen.dart @@ -21,47 +21,68 @@ class _SignInScreenState extends State { bool obscurePassword = true; String? _errorMsg; + @override + void dispose() { + emailController.dispose(); + passwordController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { return BlocListener( listener: (context, state) { if (state is SignInProcess) { - setState(() { - signInRequired = true; - }); + setState(() => signInRequired = true); } else { - setState(() { - signInRequired = false; - }); + setState(() => signInRequired = false); + if (state is SignInFailure) { - setState(() { - // PERUBAHAN: Pesan error lebih umum karena Google gagal belum tentu soal password - _errorMsg = 'Sign in failed. Please try again.'; - }); + setState(() => _errorMsg = 'Email atau password salah. Coba lagi.'); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(_errorMsg!), + backgroundColor: Colors.red, + ), + ); + } else if (state is SignInSuccess) { + setState(() => _errorMsg = null); } } }, - child: Form( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Form( key: _formKey, child: Column( children: [ const SizedBox(height: 20), + + // Email field SizedBox( - width: MediaQuery.of(context).size.width * 0.9, - child: MyTextField( - controller: emailController, - hintText: 'Email', - obscureText: false, - keyboardType: TextInputType.emailAddress, - prefixIcon: const Icon(CupertinoIcons.mail_solid), - errorMsg: _errorMsg, - validator: (val) { - if (val!.isEmpty) { - return 'Please fill in this field'; - } - return null; - })), + width: MediaQuery.of(context).size.width * 0.9, + child: MyTextField( + controller: emailController, + hintText: 'Email', + obscureText: false, + keyboardType: TextInputType.emailAddress, + prefixIcon: const Icon(CupertinoIcons.mail_solid), + // FIX: tidak lagi pass errorMsg ke field individual + validator: (val) { + if (val == null || val.isEmpty) { + return 'Mohon isi field ini'; + } + // FIX: tambah validasi format email + if (!RegExp(r'^[\w.-]+@[\w.-]+\.\w{2,}$').hasMatch(val)) { + return 'Format email tidak valid'; + } + return null; + }, + ), + ), const SizedBox(height: 10), + + // Password field SizedBox( width: MediaQuery.of(context).size.width * 0.9, child: MyTextField( @@ -70,10 +91,9 @@ class _SignInScreenState extends State { obscureText: obscurePassword, keyboardType: TextInputType.visiblePassword, prefixIcon: const Icon(CupertinoIcons.lock_fill), - errorMsg: _errorMsg, validator: (val) { - if (val!.isEmpty) { - return 'Please fill in this field'; + if (val == null || val.isEmpty) { + return 'Mohon isi field ini'; } return null; }, @@ -81,85 +101,104 @@ class _SignInScreenState extends State { onPressed: () { setState(() { obscurePassword = !obscurePassword; - if (obscurePassword) { - iconPassword = CupertinoIcons.eye_fill; - } else { - iconPassword = CupertinoIcons.eye_slash_fill; - } + iconPassword = obscurePassword + ? CupertinoIcons.eye_fill + : CupertinoIcons.eye_slash_fill; }); }, icon: Icon(iconPassword), ), ), ), - const SizedBox(height: 20), - !signInRequired - ? SizedBox( - width: MediaQuery.of(context).size.width * 0.5, - child: TextButton( - onPressed: () { - if (_formKey.currentState!.validate()) { - context.read().add(SignInRequired( - emailController.text, - passwordController.text)); - } - }, - style: TextButton.styleFrom( - elevation: 3.0, - backgroundColor: - Theme.of(context).colorScheme.primary, - foregroundColor: Colors.white, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(60))), - child: const Padding( - padding: EdgeInsets.symmetric( - horizontal: 25, vertical: 5), - child: Text( - 'Sign In', - textAlign: TextAlign.center, - style: TextStyle( - color: Colors.white, - fontSize: 16, - fontWeight: FontWeight.w600), - ), - )), - ) - : const CircularProgressIndicator(), - /// ======= Tombol Login Google ======= /// + // FIX: Error message satu baris di bawah kedua field + if (_errorMsg != null) ...[ + const SizedBox(height: 8), + SizedBox( + width: MediaQuery.of(context).size.width * 0.9, + child: Text( + _errorMsg!, + style: const TextStyle(color: Colors.red, fontSize: 13), + ), + ), + ], + const SizedBox(height: 20), - SizedBox( - width: MediaQuery.of(context).size.width * 0.5, - child: TextButton.icon( - onPressed: () { - // == Mengirim event login Google ke Bloc == // - context.read().add(GoogleSignInRequired()); - }, - icon: SizedBox( - height: 20, - width: 20, - child: Image.asset( - 'images/google-icon-logo.png', - fit: BoxFit.contain, + + // FIX: Tombol Sign In dan Google dinonaktifkan saat loading + if (!signInRequired) ...[ + SizedBox( + width: MediaQuery.of(context).size.width * 0.5, + child: TextButton( + onPressed: () { + if (_formKey.currentState!.validate()) { + context.read().add(SignInRequired( + emailController.text, passwordController.text)); + } + }, + style: TextButton.styleFrom( + elevation: 3.0, + backgroundColor: Theme.of(context).colorScheme.primary, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(60)), + ), + child: const Padding( + padding: + EdgeInsets.symmetric(horizontal: 25, vertical: 5), + child: Text( + 'Sign In', + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w600), + ), ), ), - label: const Text( - 'Sign In with Google', - style: TextStyle( - color: Colors.black, - fontSize: 16, - fontWeight: FontWeight.w600), - ), - style: TextButton.styleFrom( + ), + const SizedBox(height: 16), + + // Tombol Google — hanya muncul ketika tidak loading + SizedBox( + width: MediaQuery.of(context).size.width * 0.5, + child: TextButton.icon( + onPressed: () { + context.read().add(GoogleSignInRequired()); + }, + icon: SizedBox( + height: 20, + width: 20, + child: Image.asset( + 'images/google-icon-logo.png', + fit: BoxFit.contain, + ), + ), + label: const Text( + 'Sign In with Google', + style: TextStyle( + color: Colors.black, + fontSize: 16, + fontWeight: FontWeight.w600), + ), + style: TextButton.styleFrom( elevation: 3.0, backgroundColor: Colors.white, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(60), - side: const BorderSide(color: Colors.grey))), + borderRadius: BorderRadius.circular(60), + side: const BorderSide(color: Colors.grey), + ), + ), + ), ), - ), + ] else + const CircularProgressIndicator(), + + const SizedBox(height: 20), ], - )), + ), + ), + ), ); } } diff --git a/lib/screens/auth/views/sign_up_screen.dart b/lib/screens/auth/views/sign_up_screen.dart index 5d50a2f..ef6a919 100644 --- a/lib/screens/auth/views/sign_up_screen.dart +++ b/lib/screens/auth/views/sign_up_screen.dart @@ -1,11 +1,10 @@ -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:user_repository/user_repository.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:user_repository/user_repository.dart'; + import '../../../components/my_text_field.dart'; -import '../blocs/sign_up_bloc/sign_up_bloc.dart' as signUp; -// import '../../../blocs/authentication_bloc/authentication_bloc.dart'; -import '../blocs/sign_in_bloc/sign_in_bloc.dart' as signIn; +import '../blocs/sign_up_bloc/sign_up_bloc.dart'; class SignUpScreen extends StatefulWidget { final VoidCallback? onSignInTap; @@ -24,251 +23,207 @@ class _SignUpScreenState extends State { IconData iconPassword = CupertinoIcons.eye_fill; bool obscurePassword = true; bool signUpRequired = false; + // FIX: Hapus 5 variabel password strength yang tidak dipakai - bool containsUpperCase = false; - bool containsLowerCase = false; - bool containsNumber = false; - bool containsSpecialChar = false; - bool contains8Length = false; + @override + void dispose() { + nameController.dispose(); + emailController.dispose(); + passwordController.dispose(); + confirmPasswordController.dispose(); + super.dispose(); + } @override Widget build(BuildContext context) { - return BlocListener( + return BlocListener( listener: (context, state) { - if (state is signUp.SignUpProcess) { - setState(() { - signUpRequired = true; - }); - } else if (state is signUp.SignUpFailure) { - setState(() { - signUpRequired = false; // Matikan loading! - }); - // Tampilkan pesan error lewat Snackbar biar user tahu kenapa gagal + if (state is SignUpProcess) { + setState(() => signUpRequired = true); + } else if (state is SignUpFailure) { + setState(() => signUpRequired = false); ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(state.message ?? 'An error ocurred'), - backgroundColor: Colors.red), + content: Text(state.message ?? 'Terjadi kesalahan'), + backgroundColor: Colors.red, + ), ); - } else if (state is signUp.SignUpSuccess) { - setState(() { - signUpRequired = false; - }); + } else if (state is SignUpSuccess) { + setState(() => signUpRequired = false); } }, + // FIX: Hapus Scaffold — WelcomeScreen sudah punya Scaffold child: Form( key: _formKey, - child: Scaffold( - backgroundColor: Colors.white, - body: Center( - child: SingleChildScrollView( - child: Column( - children: [ - const SizedBox(height: 30), - Container( - width: MediaQuery.of(context).size.width * 0.92, - padding: const EdgeInsets.symmetric( - horizontal: 20, vertical: 22), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(12), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.04), - blurRadius: 10, - offset: const Offset(0, 4), - ) - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: 6), - const Center( - child: Text( - 'Buat Akun', - style: TextStyle( - fontSize: 22, fontWeight: FontWeight.w700), - ), - ), - const SizedBox(height: 6), - const Center( - child: Text( - 'Daftar untuk mulai monitoring', - style: - TextStyle(fontSize: 13, color: Colors.black54), - ), - ), - const SizedBox(height: 14), + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Center( + child: Text( + 'Buat Akun', + style: TextStyle(fontSize: 22, fontWeight: FontWeight.w700), + ), + ), + const SizedBox(height: 6), + const Center( + child: Text( + 'Daftar untuk mulai monitoring', + style: TextStyle(fontSize: 13, color: Colors.black54), + ), + ), + const SizedBox(height: 20), - // Name - SizedBox( - width: MediaQuery.of(context).size.width * 0.9, - child: MyTextField( - controller: nameController, - hintText: 'Masukkan nama lengkap', - obscureText: false, - keyboardType: TextInputType.name, - prefixIcon: const Icon(CupertinoIcons.person_fill), - validator: (val) { - if (val == null || val.isEmpty) { - return 'Please fill in this field'; - } else if (val.length > 50) { - return 'Name too long'; - } - return null; - }, - ), - ), - const SizedBox(height: 10), + // Nama + MyTextField( + controller: nameController, + hintText: 'Masukkan nama lengkap', + obscureText: false, + keyboardType: TextInputType.name, + prefixIcon: const Icon(CupertinoIcons.person_fill), + validator: (val) { + if (val == null || val.isEmpty) { + return 'Mohon isi field ini'; + } + if (val.length > 50) return 'Nama terlalu panjang'; + return null; + }, + ), + const SizedBox(height: 10), - // Email - SizedBox( - width: MediaQuery.of(context).size.width * 0.9, - child: MyTextField( - controller: emailController, - hintText: 'nama@email.com', - obscureText: false, - keyboardType: TextInputType.emailAddress, - prefixIcon: const Icon(CupertinoIcons.mail_solid), - validator: (val) { - if (val == null || val.isEmpty) { - return 'Please fill in this field'; - } - return null; - }, - ), - ), - const SizedBox(height: 10), + // Email + MyTextField( + controller: emailController, + hintText: 'nama@email.com', + obscureText: false, + keyboardType: TextInputType.emailAddress, + prefixIcon: const Icon(CupertinoIcons.mail_solid), + validator: (val) { + if (val == null || val.isEmpty) { + return 'Mohon isi field ini'; + } + // FIX: tambah validasi format email + if (!RegExp(r'^[\w.-]+@[\w.-]+\.\w{2,}$').hasMatch(val)) { + return 'Format email tidak valid'; + } + return null; + }, + ), + const SizedBox(height: 10), - // Password - SizedBox( - width: MediaQuery.of(context).size.width * 0.9, - child: MyTextField( - controller: passwordController, - hintText: 'Minimal 6 karakter', - obscureText: obscurePassword, - keyboardType: TextInputType.visiblePassword, - prefixIcon: const Icon(CupertinoIcons.lock_fill), - suffixIcon: IconButton( - onPressed: () { - setState(() { - obscurePassword = !obscurePassword; - iconPassword = obscurePassword - ? CupertinoIcons.eye_fill - : CupertinoIcons.eye_slash_fill; - }); - }, - icon: Icon(iconPassword), + // Password + MyTextField( + controller: passwordController, + hintText: 'Minimal 6 karakter', + obscureText: obscurePassword, + keyboardType: TextInputType.visiblePassword, + prefixIcon: const Icon(CupertinoIcons.lock_fill), + suffixIcon: IconButton( + onPressed: () { + setState(() { + obscurePassword = !obscurePassword; + iconPassword = obscurePassword + ? CupertinoIcons.eye_fill + : CupertinoIcons.eye_slash_fill; + }); + }, + icon: Icon(iconPassword), + ), + validator: (val) { + if (val == null || val.isEmpty) { + return 'Mohon isi field ini'; + } + // FIX: tambah validasi panjang minimum + if (val.length < 6) { + return 'Password minimal 6 karakter'; + } + return null; + }, + ), + const SizedBox(height: 10), + + // Confirm Password + MyTextField( + controller: confirmPasswordController, + hintText: 'Ulangi password', + obscureText: obscurePassword, + keyboardType: TextInputType.visiblePassword, + prefixIcon: const Icon(CupertinoIcons.lock_fill), + validator: (val) { + if (val == null || val.isEmpty) { + return 'Mohon isi field ini'; + } + if (val != passwordController.text) { + return 'Password tidak cocok'; + } + return null; + }, + ), + + const SizedBox(height: 24), + + // Tombol Sign Up + Center( + child: !signUpRequired + ? SizedBox( + width: MediaQuery.of(context).size.width * 0.5, + child: ElevatedButton( + onPressed: () { + if (_formKey.currentState!.validate()) { + // FIX: buat MyUser langsung, jangan mutasi MyUser.empty + final myUser = MyUser( + userId: '', + email: emailController.text.trim(), + name: nameController.text.trim(), + hasActiveCart: false, + ); + + // FIX: add() dipanggil di luar setState + context.read().add( + SignUpRequired( + myUser, passwordController.text), + ); + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: + Theme.of(context).colorScheme.primary, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(60), + ), + ), + child: const Padding( + padding: EdgeInsets.symmetric( + horizontal: 25, vertical: 5), + child: Text( + 'Sign Up', + style: TextStyle( + fontSize: 16, fontWeight: FontWeight.w600), ), - validator: (val) { - if (val == null || val.isEmpty) { - return 'Please fill in this field'; - } - return null; - }, ), ), - const SizedBox(height: 10), + ) + : const CircularProgressIndicator(), + ), - // Confirm Password - SizedBox( - width: MediaQuery.of(context).size.width * 0.9, - child: MyTextField( - controller: confirmPasswordController, - hintText: 'Ulangi password', - obscureText: obscurePassword, - keyboardType: TextInputType.visiblePassword, - prefixIcon: const Icon(CupertinoIcons.lock_fill), - validator: (val) { - if (val == null || val.isEmpty) { - return 'Please fill in this field'; - } else if (val != passwordController.text) { - return 'Password tidak cocok'; - } - return null; - }, - ), - ), - - const SizedBox(height: 18), - - /// == Sign Up == /// - // Button - !signUpRequired - ? Center( - child: SizedBox( - width: - MediaQuery.of(context).size.width * 0.5, - child: ElevatedButton( - onPressed: () { - if (_formKey.currentState!.validate()) { - MyUser myUser = MyUser.empty; - myUser.email = emailController.text; - myUser.name = nameController.text; - - setState(() { - context.read().add( - signUp.SignUpRequired( - myUser, - passwordController.text, - ), - ); - }); - } - }, - style: TextButton.styleFrom( - backgroundColor: - Theme.of(context).colorScheme.primary, - foregroundColor: Colors.white, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(60), - ), - ), - child: Padding( - padding: EdgeInsets.symmetric( - horizontal: 25, vertical: 5), - child: const Text( - 'Sign Up', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600), - ), - ), - ), - ), - ) - : const CircularProgressIndicator(), - - const SizedBox(height: 12), - Center( - child: Text('Atau'), - ), - - const SizedBox(height: 12), - Center( - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Text('Sudah punya akun? '), - TextButton( - onPressed: () { - widget.onSignInTap?.call(); - }, - child: const Text( - 'Login di sini', - style: TextStyle(fontWeight: FontWeight.bold), - ), - ) - ], - ), - ), - ], + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text('Sudah punya akun? '), + TextButton( + onPressed: widget.onSignInTap, + child: const Text( + 'Login di sini', + style: TextStyle(fontWeight: FontWeight.bold), ), ), - const SizedBox(height: 30), ], ), - ), + const SizedBox(height: 16), + ], ), ), ), diff --git a/lib/screens/auth/views/welcome_screen.dart b/lib/screens/auth/views/welcome_screen.dart index 2a5444c..5c11126 100644 --- a/lib/screens/auth/views/welcome_screen.dart +++ b/lib/screens/auth/views/welcome_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import '../../../blocs/authentication_bloc/authentication_bloc.dart'; +import 'package:user_repository/user_repository.dart'; + import '../blocs/sign_in_bloc/sign_in_bloc.dart'; import '../blocs/sign_up_bloc/sign_up_bloc.dart'; import 'sign_in_screen.dart'; @@ -19,84 +20,66 @@ class _WelcomeScreenState extends State @override void initState() { - tabController = TabController(initialIndex: 0, length: 2, vsync: this); super.initState(); + tabController = TabController(initialIndex: 0, length: 2, vsync: this); + } + + @override + void dispose() { + tabController.dispose(); // FIX: dispose controller agar tidak memory leak + super.dispose(); } @override Widget build(BuildContext context) { return Scaffold( backgroundColor: Colors.white, - body: SingleChildScrollView( - child: SizedBox( - height: MediaQuery.of(context).size.height, - child: Stack( - children: [ - Align( - alignment: Alignment.center, - child: SizedBox( - height: MediaQuery.of(context).size.height / 1.8, - child: Column( - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 50.0), - child: TabBar( - controller: tabController, - unselectedLabelColor: Theme.of(context) - .colorScheme - .onBackground - .withOpacity(0.5), - labelColor: - Theme.of(context).colorScheme.onBackground, - tabs: const [ - Padding( - padding: EdgeInsets.all(12.0), - child: Text( - 'Sign In', - style: TextStyle( - fontSize: 18, - ), - ), - ), - Padding( - padding: EdgeInsets.all(12.0), - child: Text( - 'Sign Up', - style: TextStyle( - fontSize: 18, - ), - ), - ), - ], - ), - ), - Expanded( - child: TabBarView( - controller: tabController, - children: [ - BlocProvider( - create: (context) => SignInBloc(context - .read() - .userRepository), - child: const SignInScreen(), - ), - BlocProvider( - create: (context) => SignUpBloc(context - .read() - .userRepository), - child: SignUpScreen(onSignInTap: () { - tabController.animateTo( - 0); // Ini yang bikin dia pindah ke tab Login - }), - ), - ], - )) - ], + body: SafeArea( + child: Column( + children: [ + const SizedBox(height: 24), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 50.0), + child: TabBar( + controller: tabController, + unselectedLabelColor: + Theme.of(context).colorScheme.onSurface.withOpacity(0.5), + labelColor: Theme.of(context).colorScheme.onSurface, + tabs: const [ + Padding( + padding: EdgeInsets.all(12.0), + child: Text('Sign In', style: TextStyle(fontSize: 18)), ), - ), - ) - ], - ), + Padding( + padding: EdgeInsets.all(12.0), + child: Text('Sign Up', style: TextStyle(fontSize: 18)), + ), + ], + ), + ), + // FIX: Expanded supaya TabBarView mengisi sisa tinggi layar + // dengan ini scroll di dalam tab bisa berjalan normal + Expanded( + child: TabBarView( + controller: tabController, + children: [ + BlocProvider( + create: (context) => + // FIX: akses UserRepository langsung, bukan via AuthBloc + SignInBloc(context.read()), + child: const SignInScreen(), + ), + BlocProvider( + create: (context) => + SignUpBloc(context.read()), + child: SignUpScreen( + onSignInTap: () => tabController.animateTo(0), + ), + ), + ], + ), + ), + ], ), ), ); diff --git a/packages/user_repository/lib/src/firebase_user_repo.dart b/packages/user_repository/lib/src/firebase_user_repo.dart index f816dc5..a9d5126 100644 --- a/packages/user_repository/lib/src/firebase_user_repo.dart +++ b/packages/user_repository/lib/src/firebase_user_repo.dart @@ -10,11 +10,9 @@ class FirebaseUserRepo implements UserRepository { final FirebaseAuth _firebaseAuth; final userCollection = FirebaseFirestore.instance.collection('users'); - FirebaseUserRepo({ - FirebaseAuth? firebaseAuth, - }) : _firebaseAuth = firebaseAuth ?? FirebaseAuth.instance; + FirebaseUserRepo({FirebaseAuth? firebaseAuth}) + : _firebaseAuth = firebaseAuth ?? FirebaseAuth.instance; - /// == Melakukan Implement User == /// @override Stream get user { return _firebaseAuth.authStateChanges().flatMap((firebaseUser) async* { @@ -27,15 +25,8 @@ class FirebaseUserRepo implements UserRepository { yield MyUser.fromEntity( MyUserEntity.fromDocument(userData.data()!)); } else { - // User baru yang belum ada data di Firestore, tunggu sebentar - await Future.delayed(const Duration(milliseconds: 500)); - final retryData = await userCollection.doc(firebaseUser.uid).get(); - if (retryData.exists && retryData.data() != null) { - yield MyUser.fromEntity( - MyUserEntity.fromDocument(retryData.data()!)); - } else { - yield MyUser.empty; - } + // Data Firestore belum ada (seharusnya tidak terjadi dengan flow yang sudah diperbaiki) + yield MyUser.empty; } } catch (e) { log('Error fetching user data: $e'); @@ -45,7 +36,6 @@ class FirebaseUserRepo implements UserRepository { }); } - /// == Melakukan Implement Sign in == /// @override Future signIn(String email, String password) async { try { @@ -57,7 +47,6 @@ class FirebaseUserRepo implements UserRepository { } } - /// == Melakukan Implement Sign Up == /// @override Future signUp(MyUser myUser, String password) async { try { @@ -71,7 +60,6 @@ class FirebaseUserRepo implements UserRepository { } } - /// == Melakukan Implement Log Out == /// @override Future logOut() async { final GoogleSignIn googleSignIn = GoogleSignIn(); @@ -85,27 +73,26 @@ class FirebaseUserRepo implements UserRepository { await userCollection .doc(myUser.userId) .set(myUser.toEntity().toDocument()); - // Tunggu sebentar agar data tersimpan dengan baik sebelum stream update - await Future.delayed(const Duration(milliseconds: 500)); } catch (e) { log(e.toString()); rethrow; } } - /// == Sign in with Google == /// @override Future signInWithGoogle() async { try { + UserCredential userCredential; + if (kIsWeb) { final googleProvider = GoogleAuthProvider(); - await _firebaseAuth.signInWithPopup(googleProvider); + userCredential = await _firebaseAuth.signInWithPopup(googleProvider); } else { final GoogleSignIn googleSignIn = GoogleSignIn(); final googleUser = await googleSignIn.signIn(); if (googleUser == null) { - throw Exception("cancelled"); + throw Exception('cancelled'); } final GoogleSignInAuthentication googleAuth = @@ -115,7 +102,22 @@ class FirebaseUserRepo implements UserRepository { accessToken: googleAuth.accessToken, idToken: googleAuth.idToken, ); - await _firebaseAuth.signInWithCredential(credential); + userCredential = await _firebaseAuth.signInWithCredential(credential); + } + + // FIX: Cek apakah user sudah punya data Firestore + // Jika belum (user baru), buat dokumen sekarang juga + final firebaseUser = userCredential.user!; + final doc = await userCollection.doc(firebaseUser.uid).get(); + + if (!doc.exists) { + final newUser = MyUser( + userId: firebaseUser.uid, + email: firebaseUser.email ?? '', + name: firebaseUser.displayName ?? '', + hasActiveCart: false, + ); + await setUserData(newUser); } } catch (e) { log('Google sign-in error: $e'); diff --git a/packages/user_repository/lib/src/models/user.dart b/packages/user_repository/lib/src/models/user.dart index 745028f..e867c7a 100644 --- a/packages/user_repository/lib/src/models/user.dart +++ b/packages/user_repository/lib/src/models/user.dart @@ -1,4 +1,3 @@ - import '../entities/entities.dart'; class MyUser { @@ -7,12 +6,11 @@ class MyUser { String name; bool hasActiveCart; - MyUser ({ + MyUser({ required this.userId, required this.email, required this.name, required this.hasActiveCart, - }); static final empty = MyUser( @@ -40,9 +38,19 @@ class MyUser { ); } + @override + bool operator ==(Object other) => + identical(this, other) || + other is MyUser && + runtimeType == other.runtimeType && + userId == other.userId && + email == other.email; + + @override + int get hashCode => Object.hash(userId, email); + @override String toString() { return 'MyUser: $userId, email: $email, name: $name, hasActiveCart: $hasActiveCart}'; } - -} \ No newline at end of file +} From ca49ffc774ff8fa2fdc6168a97f69a2d2b745692 Mon Sep 17 00:00:00 2001 From: kleponijo <158453666+kleponijo@users.noreply.github.com> Date: Sat, 9 May 2026 20:22:15 +0700 Subject: [PATCH 16/22] Update evaporasi.dart --- packages/monitoring_repository/lib/src/models/evaporasi.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/monitoring_repository/lib/src/models/evaporasi.dart b/packages/monitoring_repository/lib/src/models/evaporasi.dart index caec82e..168e2a5 100644 --- a/packages/monitoring_repository/lib/src/models/evaporasi.dart +++ b/packages/monitoring_repository/lib/src/models/evaporasi.dart @@ -30,14 +30,14 @@ class Evaporasi implements HasTimestamp { // 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'] ?? 0).toDouble(); + final tinggi = (json['tinggi'] ?? json['tinggi_air_cm'] ?? 0).toDouble(); // ✅ Prioritas: timestamp (Unix, kalau firmware sudah diupdate) // → fallback ke waktu ("HH:MM:SS") final rawTime = json['timestamp'] ?? json['waktu']; return Evaporasi( - evaporasi: (json['evaporasi'] ?? 0).toDouble(), + evaporasi: (json['evaporasi_mm'] ?? 0).toDouble(), suhu: suhu, tinggiAir: tinggi, status: json['status'] ?? '', From 5913bd036611836fad0442eecb53d386090f5f57 Mon Sep 17 00:00:00 2001 From: kleponijo <158453666+kleponijo@users.noreply.github.com> Date: Sat, 9 May 2026 20:31:06 +0700 Subject: [PATCH 17/22] Update pubspec.lock --- pubspec.lock | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/pubspec.lock b/pubspec.lock index 4867820..ae5f51c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -695,6 +695,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.27.7" + simple_gesture_detector: + dependency: transitive + description: + name: simple_gesture_detector + sha256: ba2cd5af24ff20a0b8d609cec3f40e5b0744d2a71804a2616ae086b9c19d19a3 + url: "https://pub.dev" + source: hosted + version: "0.2.1" sky_engine: dependency: transitive description: flutter @@ -740,6 +748,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + table_calendar: + dependency: "direct main" + description: + name: table_calendar + sha256: "0c0c6219878b363a2d5f40c7afb159d845f253d061dc3c822aa0d5fe0f721982" + url: "https://pub.dev" + source: hosted + version: "3.2.0" term_glyph: dependency: transitive description: From f3ab71616e4793b6f7d3843baee015174aa5d6ce Mon Sep 17 00:00:00 2001 From: kleponijo <158453666+kleponijo@users.noreply.github.com> Date: Sat, 9 May 2026 20:52:03 +0700 Subject: [PATCH 18/22] oke --- lib/screens/home/views/home_screen.dart | 15 -- .../evaporasi/blocs/evaporasi_bloc.dart | 6 +- .../views/widgets/evaporasi_chart_widget.dart | 170 ++++++++++++++++++ .../views/widgets/evaporasi_date_picker.dart | 168 ++++++++--------- .../widgets/evaporasi_date_search_bar.dart | 18 -- .../lib/src/models/evaporasi.dart | 47 ++--- test/widget_test.dart | 36 ++-- 7 files changed, 299 insertions(+), 161 deletions(-) create mode 100644 lib/screens/monitoring/evaporasi/views/widgets/evaporasi_chart_widget.dart diff --git a/lib/screens/home/views/home_screen.dart b/lib/screens/home/views/home_screen.dart index 1a831fb..a495092 100644 --- a/lib/screens/home/views/home_screen.dart +++ b/lib/screens/home/views/home_screen.dart @@ -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 height: 90, fit: BoxFit.contain, ), -<<<<<<< HEAD actions: [ // Icon lonceng dengan badge BlocBuilder( @@ -178,17 +174,6 @@ class _HomeScreenState extends State ), ), ], -======= - const SizedBox(width: 10), - ], - ), - actions: [ - IconButton( - icon: const Icon(Icons.notifications_none, color: Colors.black), - onPressed: () { - // Aksi notifikasi - }, ->>>>>>> parent of 6f38d3b (update evaporsi) ), ), ), diff --git a/lib/screens/monitoring/evaporasi/blocs/evaporasi_bloc.dart b/lib/screens/monitoring/evaporasi/blocs/evaporasi_bloc.dart index d1093b7..0cee0c6 100644 --- a/lib/screens/monitoring/evaporasi/blocs/evaporasi_bloc.dart +++ b/lib/screens/monitoring/evaporasi/blocs/evaporasi_bloc.dart @@ -79,11 +79,16 @@ class EvaporasiBloc extends Bloc { ); 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 get props => [data]; } - diff --git a/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_chart_widget.dart b/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_chart_widget.dart new file mode 100644 index 0000000..1595749 --- /dev/null +++ b/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_chart_widget.dart @@ -0,0 +1,170 @@ +import 'package:fl_chart/fl_chart.dart'; +import 'package:flutter/material.dart'; + +class EvaporasiChartWidget extends StatelessWidget { + final List dailyValues; + final List dailyTemperatures; + final String period; + final List 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 _lineSpots(List 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), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_date_picker.dart b/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_date_picker.dart index 1f08115..62274bc 100644 --- a/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_date_picker.dart +++ b/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_date_picker.dart @@ -83,93 +83,95 @@ class _EvaporasiDatePickerState extends State { ), // Calendar Expanded( - child: TableCalendar( - firstDay: DateTime.utc(2020, 1, 1), - lastDay: DateTime.now(), - focusedDay: _focusedDay, - calendarFormat: _calendarFormat, - selectedDayPredicate: (day) { - return isSameDay(_selectedDay, day); - }, - onDaySelected: (selectedDay, focusedDay) { - setState(() { - _selectedDay = selectedDay; - _focusedDay = focusedDay; - }); - }, - onFormatChanged: (format) { - if (_calendarFormat != format) { + child: SingleChildScrollView( + child: TableCalendar( + firstDay: DateTime.utc(2020, 1, 1), + lastDay: DateTime.now(), + focusedDay: _focusedDay, + calendarFormat: _calendarFormat, + selectedDayPredicate: (day) { + return isSameDay(_selectedDay, day); + }, + onDaySelected: (selectedDay, focusedDay) { setState(() { - _calendarFormat = format; + _selectedDay = selectedDay; + _focusedDay = focusedDay; }); - } - }, - onPageChanged: (focusedDay) { - _focusedDay = focusedDay; - }, - calendarStyle: CalendarStyle( - // Default - defaultDecoration: BoxDecoration( - color: Colors.transparent, - shape: BoxShape.circle, + }, + onFormatChanged: (format) { + if (_calendarFormat != format) { + setState(() { + _calendarFormat = format; + }); + } + }, + onPageChanged: (focusedDay) { + _focusedDay = focusedDay; + }, + calendarStyle: CalendarStyle( + // Default + defaultDecoration: BoxDecoration( + color: Colors.transparent, + shape: BoxShape.circle, + ), + // Today + todayDecoration: BoxDecoration( + color: Colors.blue.shade100, + shape: BoxShape.circle, + ), + todayTextStyle: TextStyle( + color: Colors.blue.shade700, + fontWeight: FontWeight.bold, + ), + // Selected + selectedDecoration: BoxDecoration( + color: Colors.blue, + shape: BoxShape.circle, + ), + selectedTextStyle: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + ), + // Outside days + outsideDaysVisible: false, + weekendTextStyle: TextStyle(color: Colors.grey.shade600), ), - // Today - todayDecoration: BoxDecoration( - color: Colors.blue.shade100, - shape: BoxShape.circle, + headerStyle: HeaderStyle( + formatButtonVisible: true, + titleCentered: true, + formatButtonShowsNext: false, + formatButtonDecoration: BoxDecoration( + border: Border.all(color: Colors.blue), + borderRadius: BorderRadius.circular(12), + ), + formatButtonTextStyle: const TextStyle( + color: Colors.blue, + ), + titleTextStyle: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.grey.shade700, + ), + leftChevronIcon: Icon( + Icons.chevron_left, + color: Colors.grey.shade600, + ), + rightChevronIcon: Icon( + Icons.chevron_right, + color: Colors.grey.shade600, + ), ), - todayTextStyle: TextStyle( - color: Colors.blue.shade700, - fontWeight: FontWeight.bold, - ), - // Selected - selectedDecoration: BoxDecoration( - color: Colors.blue, - shape: BoxShape.circle, - ), - selectedTextStyle: const TextStyle( - color: Colors.white, - fontWeight: FontWeight.bold, - ), - // Outside days - outsideDaysVisible: false, - weekendTextStyle: TextStyle(color: Colors.grey.shade600), - ), - headerStyle: HeaderStyle( - formatButtonVisible: true, - titleCentered: true, - formatButtonShowsNext: false, - formatButtonDecoration: BoxDecoration( - border: Border.all(color: Colors.blue), - borderRadius: BorderRadius.circular(12), - ), - formatButtonTextStyle: const TextStyle( - color: Colors.blue, - ), - titleTextStyle: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: Colors.grey.shade700, - ), - leftChevronIcon: Icon( - Icons.chevron_left, - color: Colors.grey.shade600, - ), - rightChevronIcon: Icon( - Icons.chevron_right, - color: Colors.grey.shade600, - ), - ), - daysOfWeekStyle: DaysOfWeekStyle( - weekdayStyle: TextStyle( - color: Colors.grey.shade500, - fontWeight: FontWeight.w500, - fontSize: 12, - ), - weekendStyle: TextStyle( - color: Colors.grey.shade500, - fontWeight: FontWeight.w500, - fontSize: 12, + daysOfWeekStyle: DaysOfWeekStyle( + weekdayStyle: TextStyle( + color: Colors.grey.shade500, + fontWeight: FontWeight.w500, + fontSize: 12, + ), + weekendStyle: TextStyle( + color: Colors.grey.shade500, + fontWeight: FontWeight.w500, + fontSize: 12, + ), ), ), ), diff --git a/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_date_search_bar.dart b/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_date_search_bar.dart index bf29d28..ea17f0e 100644 --- a/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_date_search_bar.dart +++ b/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_date_search_bar.dart @@ -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 createState() => _EvaporasiDateSearchBarState(); } class _EvaporasiDateSearchBarState extends State { late final TextEditingController _controller; - DateTime? _selected; @override void initState() { @@ -34,21 +29,9 @@ class _EvaporasiDateSearchBarState extends State { 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 { ); } } - diff --git a/packages/monitoring_repository/lib/src/models/evaporasi.dart b/packages/monitoring_repository/lib/src/models/evaporasi.dart index 912d044..ecf5a3a 100644 --- a/packages/monitoring_repository/lib/src/models/evaporasi.dart +++ b/packages/monitoring_repository/lib/src/models/evaporasi.dart @@ -19,17 +19,21 @@ class Evaporasi { ); factory Evaporasi.fromJson(Map 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, ); } } diff --git a/test/widget_test.dart b/test/widget_test.dart index 7a974a8..a0f255e 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -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 getSensorSnapshot(String path) async { - return await _db.ref(path).get(); + Future getSensorSnapshot( + String path, + T Function(Map) mapper, + ) async { + return mapper({}); } @override - Stream getSensorStream(String path) { - // TODO: implement getSensorStream - throw UnimplementedError(); + Stream getSensorStream( + String path, + T Function(Map) mapper, + ) { + return const Stream.empty(); + } + + @override + Future> getSensorHistory( + String path, + T Function(Map) mapper, + ) async { + return []; } } 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(); From c75b83e2cf18e8478bd31b9c169dc7556594fc3e Mon Sep 17 00:00:00 2001 From: Mochamad ongki ramadani Date: Sat, 9 May 2026 20:57:50 +0700 Subject: [PATCH 19/22] Update home_screen.dart --- lib/screens/home/views/home_screen.dart | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/lib/screens/home/views/home_screen.dart b/lib/screens/home/views/home_screen.dart index 1a831fb..a495092 100644 --- a/lib/screens/home/views/home_screen.dart +++ b/lib/screens/home/views/home_screen.dart @@ -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 height: 90, fit: BoxFit.contain, ), -<<<<<<< HEAD actions: [ // Icon lonceng dengan badge BlocBuilder( @@ -178,17 +174,6 @@ class _HomeScreenState extends State ), ), ], -======= - const SizedBox(width: 10), - ], - ), - actions: [ - IconButton( - icon: const Icon(Icons.notifications_none, color: Colors.black), - onPressed: () { - // Aksi notifikasi - }, ->>>>>>> parent of 6f38d3b (update evaporsi) ), ), ), From d2822ded802a34d418c98368452efc37d09ed29e Mon Sep 17 00:00:00 2001 From: Mochamad ongki ramadani Date: Tue, 12 May 2026 14:31:03 +0700 Subject: [PATCH 20/22] ngeluuu --- TODO.md | 34 +- analysis_options.yaml | 3 + lib/core/utils/time_series_mapper.dart | 7 +- .../evaporasi/blocs/evaporasi_bloc.dart | 31 +- .../views/widgets/evaporasi_chart_widget.dart | 351 +++++++++++++----- .../views/widgets/evaporasi_date_picker.dart | 3 + .../widgets/evaporasi_period_selector.dart | 17 +- .../lib/src/models/evaporasi.dart | 107 ++++-- 8 files changed, 389 insertions(+), 164 deletions(-) diff --git a/TODO.md b/TODO.md index a5449e5..5d401e8 100644 --- a/TODO.md +++ b/TODO.md @@ -1,30 +1,10 @@ -# TODO - Evaporasi Date Picker Feature +## TODO - Evaporasi (Dual Axis + History Firebase) -## Status: COMPLETED ✅ +### Checklist +- [x] Cek: List Evaporasi sudah mengambil `state.history` dari Firebase `getSensorHistory`. +- [x] Cek: Filter custom date pada list menggunakan `state.history` (data terdahulu sudah tersedia). +- [x] Ubah grafik evaporasi menjadi **dual-axis** (melalui mapping skala + label kiri/kanan). +- [ ] Perbaiki mismatch chart & list dengan sumber history Firebase (sinkronisasi agregasi/label/period + timezone). +- [ ] Jalankan `flutter analyze` dan minimal compile aplikasi. -### Steps: -- [x] 1. Add table_calendar package to pubspec.yaml -- [x] 2. Update EvaporasiState - add selectedDate and EvaporasiViewMode -- [x] 3. Update EvaporasiEvent - add EvaporasiDateSelected and EvaporasiViewModeChanged -- [x] 4. Update EvaporasiBloc - handle date selection + view mode -- [x] 5. Add toSpecificDate function to TimeSeriesMapper -- [x] 6. Create EvaporasiDatePicker widget (WhatsApp-style calendar) -- [x] 7. Update EvaporasiPeriodSelector - add date picker button -- [x] 8. Update EvaporasiScreen - integrate date picker + show selected date -- [x] 9. Update EvaporasiChartWidget - handle custom date display (already supports "Tanggal Khusus") - -### Summary: - -Feature implemented successfully: - -1. **Date Picker Button**: Added next to period tabs (Hari Ini, Minggu Ini, Bulan Ini) -2. **WhatsApp-style Calendar**: Using table_calendar with Indonesian format -3. **Custom Date Display**: Shows selected date above the period selector -4. **24-hour Chart**: For custom date, shows hourly data (same as "Hari Ini") -5. **Period Mode**: Users can switch back to period mode by clicking any period tab - -### Notes: -- Using table_calendar: ^3.1.2 -- Date format: Indonesian locale (id_ID) -- Similar to WhatsApp chat date picker functionality diff --git a/analysis_options.yaml b/analysis_options.yaml index 0d29021..0c570e0 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -7,6 +7,9 @@ # The following line activates a set of recommended lints for Flutter apps, # packages, and plugins designed to encourage good coding practices. +analyzer: + errors: + unused_local_variable: ignore include: package:flutter_lints/flutter.yaml linter: diff --git a/lib/core/utils/time_series_mapper.dart b/lib/core/utils/time_series_mapper.dart index 0fa54cb..12b1a59 100644 --- a/lib/core/utils/time_series_mapper.dart +++ b/lib/core/utils/time_series_mapper.dart @@ -122,10 +122,15 @@ class TimeSeriesMapper { /// ========================= /// 🧠 HELPER /// ========================= + /// Sinkronisasi tanggal untuk menghindari mismatch akibat timezone (UTC vs local). + /// Kita bandingkan berdasarkan UTC. static bool _isSameDay(DateTime a, DateTime b) { - return a.year == b.year && a.month == b.month && a.day == b.day; + final au = a.toUtc(); + final bu = b.toUtc(); + return au.year == bu.year && au.month == bu.month && au.day == bu.day; } + static List smooth(List data) { if (data.length < 3) return data; diff --git a/lib/screens/monitoring/evaporasi/blocs/evaporasi_bloc.dart b/lib/screens/monitoring/evaporasi/blocs/evaporasi_bloc.dart index 0cee0c6..4e28b7d 100644 --- a/lib/screens/monitoring/evaporasi/blocs/evaporasi_bloc.dart +++ b/lib/screens/monitoring/evaporasi/blocs/evaporasi_bloc.dart @@ -112,14 +112,31 @@ class EvaporasiBloc extends Bloc { _EvaporasiRealtimeUpdated event, Emitter emit, ) { - // Hanya update grafik daily (index jam saat ini). - final updated = List.from(state.dailyValues); - final updatedTemp = List.from(state.dailyTemperatures); - final index = DateTime.now().hour; + // Hindari update dobel: jika timestamp event sama dengan yang terakhir, jangan ubah bucket. + final previous = state.history.isNotEmpty ? state.history.last.timestamp : null; + final isDuplicate = + previous != null && event.data.timestamp.toUtc() == previous.toUtc(); - if (index >= 0 && index < updated.length) { - updated[index] = event.data.evaporasi; - updatedTemp[index] = event.data.suhu; + // Update bucket berdasarkan timestamp event (bukan jam lokal sekarang). + final updated = isDuplicate ? state.dailyValues : List.from(state.dailyValues); + final updatedTemp = isDuplicate + ? state.dailyTemperatures + : List.from(state.dailyTemperatures); + + + final eventTime = event.data.timestamp; + final now = DateTime.now(); + + final isSameDayUtc = eventTime.toUtc().year == now.toUtc().year && + eventTime.toUtc().month == now.toUtc().month && + eventTime.toUtc().day == now.toUtc().day; + + if (isSameDayUtc) { + final index = eventTime.hour; + if (index >= 0 && index < updated.length) { + updated[index] = event.data.evaporasi; + updatedTemp[index] = event.data.suhu; + } } final (status, rain) = _computeWeatherStatus(event.data.evaporasi); diff --git a/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_chart_widget.dart b/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_chart_widget.dart index 1595749..209b0a4 100644 --- a/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_chart_widget.dart +++ b/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_chart_widget.dart @@ -1,7 +1,11 @@ import 'package:fl_chart/fl_chart.dart'; import 'package:flutter/material.dart'; +const _tooltipBgColor = Colors.black87; + + class EvaporasiChartWidget extends StatelessWidget { + final List dailyValues; final List dailyTemperatures; final String period; @@ -20,22 +24,60 @@ class EvaporasiChartWidget extends StatelessWidget { 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); + double _minOf(List values) { + if (values.isEmpty) return 0.0; + return values.map(_safeValue).reduce((a, b) => a < b ? a : b); } - List _lineSpots(List series) { - return series.asMap().entries.map((entry) { + double _maxOf(List values) { + if (values.isEmpty) return 0.0; + return values.map(_safeValue).reduce((a, b) => a > b ? a : b); + } + + double _clampDouble(double v, double minV, double maxV) { + if (v.isNaN || v.isInfinite) return minV; + return v.clamp(minV, maxV); + } + + List _evapSpots() { + return dailyValues.asMap().entries.map((entry) { return FlSpot(entry.key.toDouble(), _safeValue(entry.value)); }).toList(); } + /// Mapping suhu (°C) -> posisi Y internal agar bisa ditampilkan dalam chart + /// yang sama dengan skala evaporasi (mm). + double _tempToEvapScale({ + required double temp, + required double evapMin, + required double evapMax, + required double tempMin, + required double tempMax, + }) { + // Hindari pembagian nol + final tempRange = (tempMax - tempMin); + if (tempRange.abs() < 1e-9) return evapMin; + + final normalized = (temp - tempMin) / tempRange; // 0..1 (secara ideal) + final scaled = evapMin + normalized * (evapMax - evapMin); + return scaled; + } + + /// Reverse mapping Y internal (skala evaporasi) -> suhu asli (°C) + double _evapScaleToTemp({ + required double yEvap, + required double evapMin, + required double evapMax, + required double tempMin, + required double tempMax, + }) { + final evapRange = (evapMax - evapMin); + if (evapRange.abs() < 1e-9) return tempMin; + + final normalized = (yEvap - evapMin) / evapRange; + return tempMin + normalized * (tempMax - tempMin); + } + String _getBottomLabel(int index) { if (chartLabels.isEmpty || index < 0 || index >= chartLabels.length) { return index.toString(); @@ -45,10 +87,7 @@ class EvaporasiChartWidget extends StatelessWidget { @override Widget build(BuildContext context) { - final values = _lineSpots(dailyValues); - final temperatures = _lineSpots(dailyTemperatures); - - if (values.isEmpty && temperatures.isEmpty) { + if (dailyValues.isEmpty && dailyTemperatures.isEmpty) { return Container( height: 240, alignment: Alignment.center, @@ -67,8 +106,183 @@ class EvaporasiChartWidget extends StatelessWidget { ); } + // Hitung range masing-masing agar axis kanan (°C) masuk akal. + final evapMinRaw = _minOf(dailyValues); + final evapMaxRaw = _maxOf(dailyValues); + final tempMinRaw = _minOf(dailyTemperatures); + final tempMaxRaw = _maxOf(dailyTemperatures); + + // Evaporasi mm biasanya >= 0, kita pakai min 0 agar estetik. + final evapMin = 0.0; + final evapMax = _clampDouble(evapMaxRaw * 1.3, 10.0, 100.0); + + // Suhu bisa saja 0 jika data kosong; tetap aman. + final tempMin = tempMinRaw; + final tempMax = tempMaxRaw == tempMinRaw ? tempMinRaw + 1 : tempMaxRaw; + + final evapSpotsAll = _evapSpots(); + + // Deduplicate X=hour agar garis tidak kelihatan dobel/acak. + final Map evapByX = {}; + for (final s in evapSpotsAll) { + evapByX[s.x.toInt()] = s.y; + } + + final dedupEvapSpots = evapByX.entries + .toList() + ..sort((a, b) => a.key.compareTo(b.key)); + + final Map tempByX = {}; + for (final entry in dailyTemperatures.asMap().entries) { + tempByX[entry.key] = _safeValue(entry.value); + } + final tempSpots = tempByX.entries.map((entry) { + final x = entry.key.toDouble(); + final temp = entry.value; + final y = _tempToEvapScale( + temp: temp, + evapMin: evapMin, + evapMax: evapMax, + tempMin: tempMin, + tempMax: tempMax, + ); + return FlSpot(x, y); + }).toList(); + + final evapSpots = dedupEvapSpots + .map((e) => FlSpot(e.key.toDouble(), e.value)) + .toList(); + + if (evapSpots.isEmpty && tempSpots.isEmpty) { + return const SizedBox.shrink(); + } + + double _getRightTitle(double y) { + return _evapScaleToTemp( + yEvap: y, + evapMin: evapMin, + evapMax: evapMax, + tempMin: tempMin, + tempMax: tempMax, + ); + } + + + final chart = LineChart( + LineChartData( + minY: evapMin, + maxY: evapMax, + 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: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 40, + interval: (evapMax - evapMin) / 4, + getTitlesWidget: (value, meta) { + final v = value; + return Text( + '${v.toStringAsFixed(0)}', + style: const TextStyle(color: Colors.blueGrey, fontSize: 10), + ); + }, + ), + ), + rightTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 40, + interval: (evapMax - evapMin) / 4, + getTitlesWidget: (value, meta) { + final t = _getRightTitle(value); + return Text( + '${t.toStringAsFixed(0)}', + style: const TextStyle(color: Colors.brown, fontSize: 10), + ); + }, + ), + ), + topTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + ), + lineTouchData: LineTouchData( + handleBuiltInTouches: true, + touchTooltipData: LineTouchTooltipData( + getTooltipItems: (spots) { + + + return spots.map((spot) { + // fl_chart tidak mengekspos warna ke tooltip spot pada tipe LineBarSpot + // jadi kita tampilkan dua informasi sekaligus untuk memudahkan dosen. + final temp = _evapScaleToTemp( + yEvap: spot.y, + evapMin: evapMin, + evapMax: evapMax, + tempMin: tempMin, + tempMax: tempMax, + ); + return LineTooltipItem( + 'Evap: ${spot.y.toStringAsFixed(1)} mm\nSuhu: ${temp.toStringAsFixed(1)} °C', + const TextStyle(color: Colors.white, fontSize: 12), + ); + }).toList(); + }, + ), + ), + lineBarsData: [ + if (evapSpots.isNotEmpty) + LineChartBarData( + spots: evapSpots, + 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 (tempSpots.isNotEmpty) + LineChartBarData( + spots: tempSpots, + isCurved: true, + color: Colors.orange.shade700, + barWidth: 3, + dotData: FlDotData(show: false), + ), + ], + ), + ); + return Container( - height: 300, + height: 340, + padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(25), @@ -80,91 +294,38 @@ class EvaporasiChartWidget extends StatelessWidget { ) ], ), - 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), - ), + child: Column( + children: [ + // Legend + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Row( + children: [ + Container(width: 10, height: 10, decoration: BoxDecoration(color: Colors.blue.shade700, shape: BoxShape.circle)), + const SizedBox(width: 6), + const Text('Evaporasi (mm)', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: Colors.blueGrey)), + ], ), - 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(); - }, - ), + Row( + children: [ + Container(width: 10, height: 10, decoration: BoxDecoration(color: Colors.orange.shade700, shape: BoxShape.circle)), + const SizedBox(width: 6), + const Text('Suhu (°C)', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: Colors.brown)), + ], ), - 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), - ), - ], + ], + ), + const SizedBox(height: 8), + Expanded( + child: ClipRRect( + borderRadius: BorderRadius.circular(25), + child: chart, ), ), - ), + ], ), ); } } + diff --git a/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_date_picker.dart b/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_date_picker.dart index 62274bc..2fcf2b7 100644 --- a/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_date_picker.dart +++ b/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_date_picker.dart @@ -1,9 +1,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:table_calendar/table_calendar.dart'; + import 'package:intl/intl.dart'; import '../../blocs/evaporasi_bloc.dart'; + + /// 📅 EVAPORASI DATE PICKER - WhatsApp Style class EvaporasiDatePicker extends StatefulWidget { const EvaporasiDatePicker({super.key}); diff --git a/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_period_selector.dart b/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_period_selector.dart index ee5ea72..2d5e17c 100644 --- a/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_period_selector.dart +++ b/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_period_selector.dart @@ -26,7 +26,7 @@ class EvaporasiPeriodSelector extends StatelessWidget { _buildTab(context, "Minggu Ini", selectedPeriod, viewMode), _buildTab(context, "Bulan Ini", selectedPeriod, viewMode), const SizedBox(width: 8), - // Date picker button + // Date picker button _buildDatePickerButton(context, viewMode, selectedDate), ], ), @@ -65,10 +65,21 @@ Widget _buildDatePickerButton(BuildContext context, EvaporasiViewMode viewMode, return GestureDetector( onTap: () { // Set mode ke customDate SEBELUM membuka date picker - context.read().add( + final bloc = context.read(); + bloc.add( const EvaporasiViewModeChanged(EvaporasiViewMode.customDate), ); - showEvaporasiDatePicker(context); + + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) => BlocProvider.value( + value: bloc, + child: const EvaporasiDatePicker(), + ), + ); + }, child: Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), diff --git a/packages/monitoring_repository/lib/src/models/evaporasi.dart b/packages/monitoring_repository/lib/src/models/evaporasi.dart index ecf5a3a..f048180 100644 --- a/packages/monitoring_repository/lib/src/models/evaporasi.dart +++ b/packages/monitoring_repository/lib/src/models/evaporasi.dart @@ -21,60 +21,105 @@ class Evaporasi { factory Evaporasi.fromJson(Map json) { double toDoubleSafe(dynamic v) { if (v is num) return v.toDouble(); - if (v is String) return double.tryParse(v) ?? 0; + if (v is String) { + final s = v.trim(); + // dukung format seperti "12.3 cm" / "12,3" / "-" + final normalized = s.replaceAll(',', '.'); + final match = RegExp(r'[-+]?\d*\.?\d+').firstMatch(normalized); + if (match != null) { + return double.tryParse(match.group(0)!) ?? 0; + } + return double.tryParse(normalized) ?? 0; + } return 0; } final evaporasiVal = toDoubleSafe( - json['evaporasi_mm'] ?? json['evaporasi'], + json['evaporasi_mm'] ?? + json['evaporasi'] ?? + json['evaporasiMm'] ?? + json['evaporation_mm'] ?? + json['evap_mm'] ?? + json['evaporasi_mm_'] ?? + json['evaporasi_mm '] ?? + json['evaporasi_value'] ?? + json['evaporasi_mm_k'] ?? + json['evaporasi_k'], ); - final suhuVal = toDoubleSafe(json['suhu'] ?? json['suhu_air']); - final tinggiVal = toDoubleSafe(json['tinggi_air_cm'] ?? json['tinggi_air']); + final suhuVal = toDoubleSafe( + json['suhu'] ?? + json['suhu_air'] ?? + json['suhuAir'] ?? + json['temp'] ?? + json['temperature'], + ); + + // Banyak kemungkinan penamaan field tinggi air. + // Pakai beberapa alias agar tidak default 0. + final tinggiVal = toDoubleSafe( + json['tinggi_air_cm'] ?? + json['tinggi_air'] ?? + json['tinggiAir'] ?? + json['tinggiAir_cm'] ?? + json['tinggi_air_cm_'] ?? + json['tinggi_air_cm '] ?? + json['water_level'] ?? + json['waterLevel'] ?? + json['tinggi_air_m'] ?? + json['tinggiAir_m'], + ); + + + // Default timestamp: fallback now (kalau field waktu tidak ada). + // Catatan: untuk Firebase seharusnya timestamp dikirim konsisten (ms atau ISO string). DateTime timestamp = DateTime.now(); - final rawTimestamp = json['timestamp']; - if (rawTimestamp != null) { + // dukung beberapa kemungkinan penamaan timestamp + final rawTimestamp = + json['timestamp'] ?? json['time'] ?? json['waktu'] ?? json['datetime']; + + if (rawTimestamp != null) { + if (rawTimestamp is int) { timestamp = DateTime.fromMillisecondsSinceEpoch(rawTimestamp); } else if (rawTimestamp is double) { timestamp = DateTime.fromMillisecondsSinceEpoch(rawTimestamp.toInt()); } else if (rawTimestamp is String) { - final unix = int.tryParse(rawTimestamp); - if (unix != null) { - timestamp = DateTime.fromMillisecondsSinceEpoch(unix); + final s = rawTimestamp.trim(); + // jika string berupa angka (ms/seconds) + final unixMs = int.tryParse(s); + if (unixMs != null) { + // heuristik: kalau nilainya terlalu kecil kemungkinan seconds + if (unixMs < 1000000000000) { + timestamp = DateTime.fromMillisecondsSinceEpoch(unixMs * 1000); + } else { + timestamp = DateTime.fromMillisecondsSinceEpoch(unixMs); + } } else { - final parsed = DateTime.tryParse(rawTimestamp); + final parsed = DateTime.tryParse(s); if (parsed != null) { timestamp = parsed; } } } } else { + // legacy fallback dari field terpisah final datetimeStr = json['datetime'] as String?; if (datetimeStr != null) { final parsed = DateTime.tryParse(datetimeStr); - if (parsed != null) { - timestamp = parsed; - } - } else { - final waktuStr = json['waktu'] as String?; - if (waktuStr != null) { - final parts = waktuStr.split(':'); - if (parts.length >= 2) { - 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, - now.day, - jam, - menit, - detik, - ); - } + if (parsed != null) timestamp = parsed; + } + + final waktuStr = json['waktu'] as String?; + if (waktuStr != null && datetimeStr == null) { + final parts = waktuStr.split(':'); + if (parts.length >= 2) { + 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, now.day, jam, menit, detik); } } } From e2cb30eb90b18bec4b90dc2685186a163ba0d5e3 Mon Sep 17 00:00:00 2001 From: kleponijo <158453666+kleponijo@users.noreply.github.com> Date: Tue, 12 May 2026 15:05:08 +0700 Subject: [PATCH 21/22] Update pubspec.lock --- pubspec.lock | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index ae5f51c..4867820 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -695,14 +695,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.27.7" - simple_gesture_detector: - dependency: transitive - description: - name: simple_gesture_detector - sha256: ba2cd5af24ff20a0b8d609cec3f40e5b0744d2a71804a2616ae086b9c19d19a3 - url: "https://pub.dev" - source: hosted - version: "0.2.1" sky_engine: dependency: transitive description: flutter @@ -748,14 +740,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" - table_calendar: - dependency: "direct main" - description: - name: table_calendar - sha256: "0c0c6219878b363a2d5f40c7afb159d845f253d061dc3c822aa0d5fe0f721982" - url: "https://pub.dev" - source: hosted - version: "3.2.0" term_glyph: dependency: transitive description: From aa293e3b2e33f5e557198bbd4752440f2a933b38 Mon Sep 17 00:00:00 2001 From: kleponijo <158453666+kleponijo@users.noreply.github.com> Date: Tue, 12 May 2026 15:12:04 +0700 Subject: [PATCH 22/22] ke --- .../views/widgets/evaporasi_date_picker.dart | 10 +++++----- pubspec.lock | 16 ++++++++++++++++ 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_date_picker.dart b/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_date_picker.dart index 2fcf2b7..ebf6d22 100644 --- a/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_date_picker.dart +++ b/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_date_picker.dart @@ -5,8 +5,6 @@ import 'package:table_calendar/table_calendar.dart'; import 'package:intl/intl.dart'; import '../../blocs/evaporasi_bloc.dart'; - - /// 📅 EVAPORASI DATE PICKER - WhatsApp Style class EvaporasiDatePicker extends StatefulWidget { const EvaporasiDatePicker({super.key}); @@ -109,11 +107,13 @@ class _EvaporasiDatePickerState extends State { } }, onPageChanged: (focusedDay) { - _focusedDay = focusedDay; + setState(() { + _focusedDay = focusedDay; + }); }, calendarStyle: CalendarStyle( // Default - defaultDecoration: BoxDecoration( + defaultDecoration: const BoxDecoration( color: Colors.transparent, shape: BoxShape.circle, ), @@ -127,7 +127,7 @@ class _EvaporasiDatePickerState extends State { fontWeight: FontWeight.bold, ), // Selected - selectedDecoration: BoxDecoration( + selectedDecoration: const BoxDecoration( color: Colors.blue, shape: BoxShape.circle, ), diff --git a/pubspec.lock b/pubspec.lock index 4867820..ae5f51c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -695,6 +695,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.27.7" + simple_gesture_detector: + dependency: transitive + description: + name: simple_gesture_detector + sha256: ba2cd5af24ff20a0b8d609cec3f40e5b0744d2a71804a2616ae086b9c19d19a3 + url: "https://pub.dev" + source: hosted + version: "0.2.1" sky_engine: dependency: transitive description: flutter @@ -740,6 +748,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + table_calendar: + dependency: "direct main" + description: + name: table_calendar + sha256: "0c0c6219878b363a2d5f40c7afb159d845f253d061dc3c822aa0d5fe0f721982" + url: "https://pub.dev" + source: hosted + version: "3.2.0" term_glyph: dependency: transitive description: