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: