From 4fd56dc5f911f9f3ba913228610a3eeb19bb17d5 Mon Sep 17 00:00:00 2001 From: kleponijo Date: Thu, 25 Jun 2026 10:54:05 +0700 Subject: [PATCH] coba --- .../wind_speed/blocs/wind_speed_bloc.dart | 299 +++++++++++---- .../wind_speed/blocs/wind_speed_event.dart | 46 +++ .../wind_speed/blocs/wind_speed_state.dart | 18 + .../widgets/wind_speed_history_list.dart | 29 ++ .../wind_speed/views/wind_speed_screen.dart | 356 +++++++++++++++++- .../lib/src/firebase_monitoring_repo.dart | 37 ++ .../lib/src/monitoring_repo.dart | 9 + 7 files changed, 726 insertions(+), 68 deletions(-) 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 d85d92d..3a7e40d 100644 --- a/lib/screens/monitoring/wind_speed/blocs/wind_speed_bloc.dart +++ b/lib/screens/monitoring/wind_speed/blocs/wind_speed_bloc.dart @@ -10,10 +10,8 @@ import '../../../../blocs/notification_bloc/notification_bloc.dart'; part 'wind_speed_event.dart'; part 'wind_speed_state.dart'; -/// Threshold kecepatan angin (satuan m/s) -/// Referensi: Beaufort scale & BMKG -const double _kWindWarning = 25.0; // Waspada: 8–12.5 m/s (~29–45 km/h) -const double _kWindDanger = 45.5; // Bahaya: > 12.5 m/s (> 45 km/h) +const double _kWindWarning = 25.0; +const double _kWindDanger = 45.5; const _kDeviceIdKey = 'selected_device_id'; const _kDefaultDeviceId = 'esp_lapangan'; @@ -31,70 +29,51 @@ class WindSpeedBloc extends Bloc { on(_onStarted, transformer: restartable()); on<_WindSpeedRealtimeUpdated>(_onRealtimeUpdated); on(_onPeriodChanged); - on(_onDateFilterChanged); // ← baru + on(_onDateFilterChanged); + // Delete handlers + on(_onDeleteAll); + on(_onDeleteByDate); + on(_onDeleteByDateRange); + on(_onDeleteByHourRange); } - // ── Baca device ID dari SharedPreferences ───────────────── Future _getDeviceId() async { final prefs = await SharedPreferences.getInstance(); return prefs.getString(_kDeviceIdKey) ?? _kDefaultDeviceId; } // ════════════════════════════════════════════════════════════ - // START + // START — sekarang fetch dengan keys untuk delete support // ════════════════════════════════════════════════════════════ Future _onStarted( WatchWindSpeedStarted event, Emitter emit, ) async { emit(state.copyWith(isLoading: true)); - - // Baca device ID aktif (dari SharedPreferences, diset di DeviceSetupBloc) final deviceId = await _getDeviceId(); - final history = await _repository.getSensorHistory( + final historyMap = await _repository.getSensorHistoryWithKeys( 'anemometer/$deviceId/history', (json) => MyWindSpeed.fromJson(json), ); - 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 history = _sortedList(historyMap); + final graphs = _buildGraphs(history); - if (history.isNotEmpty) { - _emitWindAlert(history.last.speed); - } + if (history.isNotEmpty) _emitWindAlert(history.last.speed); emit(state.copyWith( + historyMap: historyMap, history: history, - filteredHistory: history, // awal = semua data - dailySpeeds: dailyGraph, - weeklySpeeds: weekly, - monthlySpeeds: monthly, + filteredHistory: history, + dailySpeeds: graphs['daily']!, + weeklySpeeds: graphs['weekly']!, + monthlySpeeds: graphs['monthly']!, isLoading: false, alertLevel: history.isNotEmpty ? _getAlertLevel(history.last.speed) : 'Normal', )); - // Subscribe realtime dari path device aktif await _subscription?.cancel(); _subscription = _repository .getSensorStream( @@ -128,7 +107,6 @@ class WindSpeedBloc extends Bloc { } _emitWindAlert(newValue); - emit(state.copyWith( currentSpeed: newValue, dailySpeeds: updated, @@ -168,7 +146,6 @@ class WindSpeedBloc extends Bloc { } final updatedGraph = TimeSeriesMapper.smooth(raw); - emit(state.copyWith( dailySpeeds: event.period == 'Hari Ini' ? updatedGraph : state.dailySpeeds, @@ -176,45 +153,236 @@ class WindSpeedBloc extends Bloc { event.period == 'Minggu Ini' ? updatedGraph : state.weeklySpeeds, monthlySpeeds: event.period == 'Bulan Ini' ? updatedGraph : state.monthlySpeeds, - isLoading: false, )); } // ════════════════════════════════════════════════════════════ - // DATE FILTER CHANGED ← baru + // DATE FILTER // ════════════════════════════════════════════════════════════ void _onDateFilterChanged( WindSpeedDateFilterChanged event, Emitter emit, ) { final date = event.date; - final allHistory = state.history; - if (date == null) { - // Reset: tampilkan semua emit(state.copyWith( - filteredHistory: allHistory, + filteredHistory: state.history, clearSelectedDate: true, )); return; } - // Filter data yang tanggalnya sama dengan [date] - final filtered = allHistory.where((item) { - return item.timestamp.year == date.year && - item.timestamp.month == date.month && - item.timestamp.day == date.day; - }).toList(); + final filtered = + state.history.where((e) => _isSameDate(e.timestamp, date)).toList(); - emit(state.copyWith( - filteredHistory: filtered, - selectedDate: date, - )); + emit(state.copyWith(filteredHistory: filtered, selectedDate: date)); + } + + // ════════════════════════════════════════════════════════════ + // DELETE ALL + // ════════════════════════════════════════════════════════════ + Future _onDeleteAll( + WindSpeedDeleteAllRequested event, + Emitter emit, + ) async { + emit(state.copyWith(isDeleting: true, clearDeleteError: true)); + try { + final deviceId = await _getDeviceId(); + await _repository.deleteAllHistory('anemometer/$deviceId/history'); + final emptyGraphs = _buildGraphs(const []); + emit(state.copyWith( + isDeleting: false, + historyMap: const {}, + history: const [], + filteredHistory: const [], + clearSelectedDate: true, + dailySpeeds: emptyGraphs['daily'], + weeklySpeeds: emptyGraphs['weekly'], + monthlySpeeds: emptyGraphs['monthly'], + )); + } catch (e) { + emit(state.copyWith(isDeleting: false, deleteError: e.toString())); + } + } + + // ════════════════════════════════════════════════════════════ + // DELETE BY DATE + // ════════════════════════════════════════════════════════════ + Future _onDeleteByDate( + WindSpeedDeleteByDateRequested event, + Emitter emit, + ) async { + emit(state.copyWith(isDeleting: true, clearDeleteError: true)); + try { + final deviceId = await _getDeviceId(); + + final keys = state.historyMap.entries + .where((e) => _isSameDate(e.value.timestamp, event.date)) + .map((e) => e.key) + .toList(); + + if (keys.isNotEmpty) { + await _repository.deleteHistoryByKeys( + 'anemometer/$deviceId/history', keys); + } + + _emitAfterDelete(emit, _removeKeys(state.historyMap, keys)); + } catch (e) { + emit(state.copyWith(isDeleting: false, deleteError: e.toString())); + } + } + + // ════════════════════════════════════════════════════════════ + // DELETE BY DATE RANGE + // ════════════════════════════════════════════════════════════ + Future _onDeleteByDateRange( + WindSpeedDeleteByDateRangeRequested event, + Emitter emit, + ) async { + emit(state.copyWith(isDeleting: true, clearDeleteError: true)); + try { + final deviceId = await _getDeviceId(); + + final startDay = + DateTime(event.start.year, event.start.month, event.start.day); + final endInclusive = + DateTime(event.end.year, event.end.month, event.end.day, 23, 59, 59); + + final keys = state.historyMap.entries + .where((e) { + final t = e.value.timestamp; + return !t.isBefore(startDay) && !t.isAfter(endInclusive); + }) + .map((e) => e.key) + .toList(); + + if (keys.isNotEmpty) { + await _repository.deleteHistoryByKeys( + 'anemometer/$deviceId/history', keys); + } + + _emitAfterDelete(emit, _removeKeys(state.historyMap, keys)); + } catch (e) { + emit(state.copyWith(isDeleting: false, deleteError: e.toString())); + } + } + + // ════════════════════════════════════════════════════════════ + // DELETE BY HOUR RANGE + // ════════════════════════════════════════════════════════════ + Future _onDeleteByHourRange( + WindSpeedDeleteByHourRangeRequested event, + Emitter emit, + ) async { + emit(state.copyWith(isDeleting: true, clearDeleteError: true)); + try { + final deviceId = await _getDeviceId(); + + final keys = state.historyMap.entries + .where((e) { + final t = e.value.timestamp; + return _isSameDate(t, event.date) && + t.hour >= event.startHour && + t.hour <= event.endHour; + }) + .map((e) => e.key) + .toList(); + + if (keys.isNotEmpty) { + await _repository.deleteHistoryByKeys( + 'anemometer/$deviceId/history', keys); + } + + // Re-apply filter aktif jika ada + final newMap = _removeKeys(state.historyMap, keys); + final newHistory = _sortedList(newMap); + final newFiltered = state.selectedDate != null + ? newHistory + .where((e) => _isSameDate(e.timestamp, state.selectedDate!)) + .toList() + : newHistory; + final graphs = _buildGraphs(newHistory); + + emit(state.copyWith( + isDeleting: false, + historyMap: newMap, + history: newHistory, + filteredHistory: newFiltered, + dailySpeeds: graphs['daily'], + weeklySpeeds: graphs['weekly'], + monthlySpeeds: graphs['monthly'], + )); + } catch (e) { + emit(state.copyWith(isDeleting: false, deleteError: e.toString())); + } } // ════════════════════════════════════════════════════════════ // HELPERS // ════════════════════════════════════════════════════════════ + + /// Emit state setelah delete all/date/range (selalu reset filter) + void _emitAfterDelete( + Emitter emit, + Map newMap, + ) { + final newHistory = _sortedList(newMap); + final graphs = _buildGraphs(newHistory); + emit(state.copyWith( + isDeleting: false, + historyMap: newMap, + history: newHistory, + filteredHistory: newHistory, + clearSelectedDate: true, + dailySpeeds: graphs['daily'], + weeklySpeeds: graphs['weekly'], + monthlySpeeds: graphs['monthly'], + )); + } + + Map> _buildGraphs(List history) { + return { + 'daily': TimeSeriesMapper.smooth( + TimeSeriesMapper.toDaily( + data: history, + getTime: (e) => e.timestamp, + getValue: (e) => e.speed, + ), + ), + 'weekly': TimeSeriesMapper.smooth( + TimeSeriesMapper.toWeekly( + data: history, + getTime: (e) => e.timestamp, + getValue: (e) => e.speed, + ), + ), + 'monthly': TimeSeriesMapper.smooth( + TimeSeriesMapper.toMonthly( + data: history, + getTime: (e) => e.timestamp, + getValue: (e) => e.speed, + ), + ), + }; + } + + bool _isSameDate(DateTime a, DateTime b) => + a.year == b.year && a.month == b.month && a.day == b.day; + + Map _removeKeys( + Map map, + List keys, + ) { + final newMap = Map.from(map); + for (final k in keys) newMap.remove(k); + return newMap; + } + + List _sortedList(Map map) { + return map.values.toList() + ..sort((a, b) => a.timestamp.compareTo(b.timestamp)); + } + String _getAlertLevel(double speed) { if (speed >= _kWindDanger) return 'Bahaya'; if (speed >= _kWindWarning) return 'Waspada'; @@ -236,15 +404,13 @@ class WindSpeedBloc extends Bloc { message = ''; } - _notificationBloc.add(SensorAlertAdded( - SensorAlert( - sensorId: 'wind_speed', - sensorName: 'Anemometer', - message: message, - severity: severity, - timestamp: DateTime.now(), - ), - )); + _notificationBloc.add(SensorAlertAdded(SensorAlert( + sensorId: 'wind_speed', + sensorName: 'Anemometer', + message: message, + severity: severity, + timestamp: DateTime.now(), + ))); } @override @@ -254,7 +420,6 @@ class WindSpeedBloc extends Bloc { } } -/// Internal event — tidak diekspos ke luar class _WindSpeedRealtimeUpdated extends WindSpeedEvent { final MyWindSpeed data; const _WindSpeedRealtimeUpdated(this.data); diff --git a/lib/screens/monitoring/wind_speed/blocs/wind_speed_event.dart b/lib/screens/monitoring/wind_speed/blocs/wind_speed_event.dart index 38430af..e641a5e 100644 --- a/lib/screens/monitoring/wind_speed/blocs/wind_speed_event.dart +++ b/lib/screens/monitoring/wind_speed/blocs/wind_speed_event.dart @@ -30,3 +30,49 @@ class WindSpeedDateFilterChanged extends WindSpeedEvent { @override List get props => [date]; } + +// ═══════════════════════════════════════════════════════════ +// DELETE EVENTS +// ═══════════════════════════════════════════════════════════ + +/// Hapus SEMUA riwayat dari Firebase +class WindSpeedDeleteAllRequested extends WindSpeedEvent { + const WindSpeedDeleteAllRequested(); +} + +/// Hapus riwayat pada tanggal tertentu +class WindSpeedDeleteByDateRequested extends WindSpeedEvent { + final DateTime date; + const WindSpeedDeleteByDateRequested(this.date); + + @override + List get props => [date]; +} + +/// Hapus riwayat dalam rentang tanggal (inklusif) +class WindSpeedDeleteByDateRangeRequested extends WindSpeedEvent { + final DateTime start; + final DateTime end; + const WindSpeedDeleteByDateRangeRequested({ + required this.start, + required this.end, + }); + + @override + List get props => [start, end]; +} + +/// Hapus riwayat dalam rentang jam pada tanggal tertentu +class WindSpeedDeleteByHourRangeRequested extends WindSpeedEvent { + final DateTime date; + final int startHour; // 0–23 + final int endHour; // 0–23, harus >= startHour + const WindSpeedDeleteByHourRangeRequested({ + required this.date, + required this.startHour, + required this.endHour, + }); + + @override + List get props => [date, startHour, endHour]; +} diff --git a/lib/screens/monitoring/wind_speed/blocs/wind_speed_state.dart b/lib/screens/monitoring/wind_speed/blocs/wind_speed_state.dart index 218aa97..60b281d 100644 --- a/lib/screens/monitoring/wind_speed/blocs/wind_speed_state.dart +++ b/lib/screens/monitoring/wind_speed/blocs/wind_speed_state.dart @@ -2,6 +2,8 @@ part of 'wind_speed_bloc.dart'; class WindSpeedState extends Equatable { final bool isLoading; + final bool isDeleting; + final String? deleteError; final double currentSpeed; final String alertLevel; final String selectedPeriod; @@ -16,8 +18,13 @@ class WindSpeedState extends Equatable { final List filteredHistory; // setelah difilter tanggal final DateTime? selectedDate; // null = tampilkan semua + /// Map { Firebase push-key → data } — dipakai untuk operasi delete + final Map historyMap; + const WindSpeedState({ this.isLoading = false, + this.isDeleting = false, + this.deleteError, this.currentSpeed = 0.0, this.alertLevel = 'Normal', this.selectedPeriod = 'Hari Ini', @@ -27,10 +34,14 @@ class WindSpeedState extends Equatable { this.history = const [], this.filteredHistory = const [], this.selectedDate, + this.historyMap = const {}, }); WindSpeedState copyWith({ bool? isLoading, + bool? isDeleting, + String? deleteError, + bool clearDeleteError = false, double? currentSpeed, String? alertLevel, String? selectedPeriod, @@ -41,9 +52,12 @@ class WindSpeedState extends Equatable { List? filteredHistory, DateTime? selectedDate, bool clearSelectedDate = false, + Map? historyMap, }) { return WindSpeedState( isLoading: isLoading ?? this.isLoading, + isDeleting: isDeleting ?? this.isDeleting, + deleteError: clearDeleteError ? null : (deleteError ?? this.deleteError), currentSpeed: currentSpeed ?? this.currentSpeed, alertLevel: alertLevel ?? this.alertLevel, selectedPeriod: selectedPeriod ?? this.selectedPeriod, @@ -54,12 +68,15 @@ class WindSpeedState extends Equatable { filteredHistory: filteredHistory ?? this.filteredHistory, selectedDate: clearSelectedDate ? null : (selectedDate ?? this.selectedDate), + historyMap: historyMap ?? this.historyMap, ); } @override List get props => [ isLoading, + isDeleting, + deleteError, currentSpeed, alertLevel, selectedPeriod, @@ -69,5 +86,6 @@ class WindSpeedState extends Equatable { history, filteredHistory, selectedDate, + historyMap, ]; } diff --git a/lib/screens/monitoring/wind_speed/views/widgets/wind_speed_history_list.dart b/lib/screens/monitoring/wind_speed/views/widgets/wind_speed_history_list.dart index 911c3ed..d2f5b85 100644 --- a/lib/screens/monitoring/wind_speed/views/widgets/wind_speed_history_list.dart +++ b/lib/screens/monitoring/wind_speed/views/widgets/wind_speed_history_list.dart @@ -12,6 +12,8 @@ class WindSpeedHistoryList extends StatelessWidget { final DateTime? selectedDate; final VoidCallback onPickDate; final VoidCallback onClearDate; + final VoidCallback onDeleteTap; + final bool isDeleting; const WindSpeedHistoryList({ super.key, @@ -19,6 +21,8 @@ class WindSpeedHistoryList extends StatelessWidget { required this.selectedDate, required this.onPickDate, required this.onClearDate, + required this.onDeleteTap, + this.isDeleting = false, }); // ── Grouping per tanggal ───────────────────────────────────── @@ -46,6 +50,8 @@ class WindSpeedHistoryList extends StatelessWidget { totalCount: history.length, onPickDate: onPickDate, onClearDate: onClearDate, + onDeleteTap: onDeleteTap, // ← baru + isDeleting: isDeleting, ), const SizedBox(height: 12), @@ -98,12 +104,16 @@ class _HeaderBar extends StatelessWidget { final int totalCount; final VoidCallback onPickDate; final VoidCallback onClearDate; + final VoidCallback onDeleteTap; + final bool isDeleting; const _HeaderBar({ required this.selectedDate, required this.totalCount, required this.onPickDate, required this.onClearDate, + required this.onDeleteTap, + required this.isDeleting, }); @override @@ -148,6 +158,25 @@ class _HeaderBar extends StatelessWidget { color: Colors.blue.shade700, onTap: onPickDate, ), + const SizedBox(width: 4), + + // ── DELETE BUTTON ← baru ────────────────────────────── + isDeleting + ? const Padding( + padding: EdgeInsets.all(10), + child: SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2.5), + ), + ) + : IconButton( + visualDensity: VisualDensity.compact, + icon: const Icon(Icons.delete_sweep_outlined, + color: Colors.redAccent), + tooltip: 'Hapus riwayat', + onPressed: totalCount == 0 ? null : onDeleteTap, + ), ], ); } diff --git a/lib/screens/monitoring/wind_speed/views/wind_speed_screen.dart b/lib/screens/monitoring/wind_speed/views/wind_speed_screen.dart index 60d8795..55cab91 100644 --- a/lib/screens/monitoring/wind_speed/views/wind_speed_screen.dart +++ b/lib/screens/monitoring/wind_speed/views/wind_speed_screen.dart @@ -601,6 +601,24 @@ class _WindSpeedScreenState extends State { } } + // ════════════════════════════════════════════════════════════ +// Delete bottom sheet +// ════════════════════════════════════════════════════════════ + void _showDeleteSheet(BuildContext context) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (_) => BlocProvider.value( + value: context.read(), + child: const _DeleteSheet(), + ), + ); + } + @override Widget build(BuildContext context) { return Scaffold( @@ -634,7 +652,16 @@ class _WindSpeedScreenState extends State { ), ], ), - body: BlocBuilder( + body: BlocConsumer( + listenWhen: (prev, curr) => + curr.deleteError != null && prev.deleteError != curr.deleteError, + listener: (context, state) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text('Gagal menghapus: ${state.deleteError}'), + backgroundColor: Colors.red.shade700, + behavior: SnackBarBehavior.floating, + )); + }, builder: (context, state) { if (state.isLoading) { return const Center(child: CircularProgressIndicator()); @@ -876,6 +903,26 @@ class _WindSpeedScreenState extends State { icon: Icons.calendar_month_rounded, onTap: () => _pickDate(context, state), ), + // ── Tombol Delete ────────────────────────────────────────── + const SizedBox(width: 4), + state.isDeleting + ? const Padding( + padding: EdgeInsets.all(10), + child: SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2.5), + ), + ) + : IconButton( + visualDensity: VisualDensity.compact, + icon: const Icon(Icons.delete_sweep_outlined, + color: Colors.redAccent), + tooltip: 'Hapus riwayat', + onPressed: state.history.isEmpty + ? null + : () => _showDeleteSheet(context), + ), ], ), @@ -1129,3 +1176,310 @@ class _HistoryTile extends StatelessWidget { ); } } + +// ════════════════════════════════════════════════════════════ +// Delete Bottom Sheet +// ════════════════════════════════════════════════════════════ +class _DeleteSheet extends StatefulWidget { + const _DeleteSheet(); + + @override + State<_DeleteSheet> createState() => _DeleteSheetState(); +} + +class _DeleteSheetState extends State<_DeleteSheet> { + int _mode = 0; // 0=all, 1=date, 2=range, 3=hour + DateTime? _byDate; + DateTime? _rangeStart; + DateTime? _rangeEnd; + DateTime? _hourDate; + int _startHour = 0; + int _endHour = 23; + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.fromLTRB( + 16, 16, 16, MediaQuery.of(context).viewInsets.bottom + 24), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row(children: [ + const Icon(Icons.delete_sweep, color: Colors.redAccent), + const SizedBox(width: 10), + const Text('Hapus Riwayat Angin', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + const Spacer(), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.pop(context)), + ]), + const Divider(), + const SizedBox(height: 4), + _modeCard(0, Icons.select_all_rounded, 'Hapus Semua', + 'Menghapus seluruh riwayat dari Firebase'), + _modeCard(1, Icons.event_outlined, 'Berdasarkan Tanggal', + 'Hapus data pada satu tanggal tertentu'), + _modeCard(2, Icons.date_range_outlined, 'Rentang Tanggal', + 'Hapus data dalam rentang tanggal (inklusif)'), + _modeCard(3, Icons.schedule_outlined, 'Rentang Jam', + 'Hapus data berdasarkan jam pada suatu tanggal'), + if (_mode > 0) ...[ + const SizedBox(height: 12), + const Divider(), + const SizedBox(height: 8), + AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + child: _buildSubInput(), + ), + ], + const SizedBox(height: 20), + SizedBox( + width: double.infinity, + height: 48, + child: FilledButton.icon( + style: FilledButton.styleFrom( + backgroundColor: Colors.red, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10)), + ), + onPressed: _valid ? _confirm : null, + icon: const Icon(Icons.delete_forever), + label: const Text('Hapus Sekarang', + style: + TextStyle(fontSize: 15, fontWeight: FontWeight.bold)), + ), + ), + if (_mode == 0) + const Padding( + padding: EdgeInsets.only(top: 8), + child: Center( + child: Text('⚠️ Tindakan ini tidak dapat dibatalkan', + style: TextStyle(fontSize: 11, color: Colors.deepOrange)), + ), + ), + ], + ), + ), + ); + } + + Widget _modeCard(int value, IconData icon, String title, String sub) { + final sel = _mode == value; + return GestureDetector( + onTap: () => setState(() => _mode = value), + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + margin: const EdgeInsets.symmetric(vertical: 3), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: sel ? Colors.red.withOpacity(0.07) : Colors.transparent, + border: Border.all( + color: sel ? Colors.red.shade300 : Colors.grey.shade200), + borderRadius: BorderRadius.circular(10), + ), + child: Row(children: [ + Icon(icon, size: 20, color: sel ? Colors.red : Colors.grey), + const SizedBox(width: 12), + Expanded( + child: + Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(title, + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 13, + color: sel ? Colors.red : Colors.black87)), + Text(sub, + style: TextStyle(fontSize: 11, color: Colors.grey.shade600)), + ]), + ), + if (sel) const Icon(Icons.check_circle, color: Colors.red, size: 18), + ]), + ), + ); + } + + Widget _buildSubInput() { + switch (_mode) { + case 1: + return _datePicker( + 'Pilih Tanggal', _byDate, (d) => setState(() => _byDate = d), + key: const ValueKey('by_date')); + case 2: + return Column(key: const ValueKey('range'), children: [ + _datePicker('Tanggal Mulai', _rangeStart, + (d) => setState(() => _rangeStart = d)), + const SizedBox(height: 8), + _datePicker( + 'Tanggal Akhir', _rangeEnd, (d) => setState(() => _rangeEnd = d)), + if (_rangeStart != null && + _rangeEnd != null && + _rangeEnd!.isBefore(_rangeStart!)) + const Padding( + padding: EdgeInsets.only(top: 4), + child: Text('Tanggal akhir harus ≥ tanggal mulai', + style: TextStyle(color: Colors.red, fontSize: 11)), + ), + ]); + case 3: + return Column( + key: const ValueKey('hour'), + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _datePicker('Pilih Tanggal', _hourDate, + (d) => setState(() => _hourDate = d)), + const SizedBox(height: 12), + const Text('Rentang Jam', + style: TextStyle(fontWeight: FontWeight.w600, fontSize: 13)), + const SizedBox(height: 6), + Row(children: [ + Expanded( + child: _hourDrop('Jam Mulai', _startHour, + (v) => setState(() => _startHour = v))), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 10), + child: Text('—', style: TextStyle(fontSize: 18))), + Expanded( + child: _hourDrop('Jam Akhir', _endHour, + (v) => setState(() => _endHour = v))), + ]), + if (_startHour > _endHour) + const Padding( + padding: EdgeInsets.only(top: 4), + child: Text('Jam mulai harus ≤ jam akhir', + style: TextStyle(color: Colors.red, fontSize: 11)), + ), + ]); + default: + return const SizedBox.shrink(); + } + } + + Widget _datePicker( + String hint, DateTime? value, ValueChanged onPick, + {Key? key}) { + return GestureDetector( + key: key, + onTap: () async { + final d = await showDatePicker( + context: context, + initialDate: value ?? DateTime.now(), + firstDate: DateTime(2020), + lastDate: DateTime.now(), + confirmText: 'Pilih', + cancelText: 'Batal', + ); + if (d != null) onPick(d); + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + decoration: BoxDecoration( + color: Colors.grey.shade50, + border: Border.all( + color: + value != null ? Colors.red.shade300 : Colors.grey.shade300), + borderRadius: BorderRadius.circular(8), + ), + child: Row(children: [ + Icon(Icons.calendar_month_outlined, + size: 16, + color: value != null ? Colors.red : Colors.grey.shade400), + const SizedBox(width: 8), + Text( + value != null + ? DateFormat('d MMMM yyyy', 'id_ID').format(value) + : hint, + style: TextStyle( + fontSize: 13, + color: value != null ? Colors.black87 : Colors.grey.shade400), + ), + ]), + ), + ); + } + + Widget _hourDrop(String label, int value, ValueChanged onChange) { + return DropdownButtonFormField( + value: value, + decoration: InputDecoration( + labelText: label, + labelStyle: const TextStyle(fontSize: 11), + contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), + isDense: true, + ), + items: List.generate( + 24, + (i) => DropdownMenuItem( + value: i, + child: Text('${i.toString().padLeft(2, '0')}:00', + style: const TextStyle(fontSize: 13)), + ), + ), + onChanged: (v) => onChange(v!), + ); + } + + bool get _valid { + switch (_mode) { + case 0: + return true; + case 1: + return _byDate != null; + case 2: + return _rangeStart != null && + _rangeEnd != null && + !_rangeEnd!.isBefore(_rangeStart!); + case 3: + return _hourDate != null && _startHour <= _endHour; + default: + return false; + } + } + + void _confirm() { + final bloc = context.read(); + if (_mode == 0) { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Hapus Semua?'), + content: + const Text('Seluruh riwayat akan dihapus permanen dari Firebase. ' + 'Tindakan ini tidak dapat dibatalkan.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Batal')), + FilledButton( + style: FilledButton.styleFrom(backgroundColor: Colors.red), + onPressed: () { + Navigator.pop(ctx); + Navigator.pop(context); + bloc.add(const WindSpeedDeleteAllRequested()); + }, + child: const Text('Ya, Hapus Semua'), + ), + ], + ), + ); + return; + } + switch (_mode) { + case 1: + bloc.add(WindSpeedDeleteByDateRequested(_byDate!)); + break; + case 2: + bloc.add(WindSpeedDeleteByDateRangeRequested( + start: _rangeStart!, end: _rangeEnd!)); + break; + case 3: + bloc.add(WindSpeedDeleteByHourRangeRequested( + date: _hourDate!, startHour: _startHour, endHour: _endHour)); + break; + } + Navigator.pop(context); + } +} diff --git a/packages/monitoring_repository/lib/src/firebase_monitoring_repo.dart b/packages/monitoring_repository/lib/src/firebase_monitoring_repo.dart index b7566fe..7cb137c 100644 --- a/packages/monitoring_repository/lib/src/firebase_monitoring_repo.dart +++ b/packages/monitoring_repository/lib/src/firebase_monitoring_repo.dart @@ -61,6 +61,43 @@ class FirebaseMonitoringRepo implements MonitoringRepository { } } + @override + Future> getSensorHistoryWithKeys( + String path, + T Function(Map json) mapper, + ) async { + try { + final snapshot = await _db.ref(path).get(); + if (snapshot.exists && snapshot.value is Map) { + final data = snapshot.value as Map; + return { + for (final entry in data.entries) + entry.key.toString(): mapper( + entry.value is Map ? entry.value as Map : {}, + ), + }; + } + return {}; + } catch (_) { + return {}; + } + } + + @override + Future deleteHistoryByKeys(String path, List keys) async { + if (keys.isEmpty) return; + // Firebase multi-path delete: set tiap path ke null dalam satu update + final Map updates = { + for (final key in keys) '$path/$key': null, + }; + await _db.ref().update(updates); + } + + @override + Future deleteAllHistory(String path) async { + await _db.ref(path).remove(); + } + // ── Anemometer Settings ────────────────────────────────────── @override Future> getAnemometerSettings() async { diff --git a/packages/monitoring_repository/lib/src/monitoring_repo.dart b/packages/monitoring_repository/lib/src/monitoring_repo.dart index 4d495db..542001a 100644 --- a/packages/monitoring_repository/lib/src/monitoring_repo.dart +++ b/packages/monitoring_repository/lib/src/monitoring_repo.dart @@ -43,4 +43,13 @@ abstract class MonitoringRepository { Future sendRemoteRestart(String deviceId); Future deleteDeviceLogs(String deviceId, List keys); + + Future> getSensorHistoryWithKeys( + String path, + T Function(Map json) mapper, + ); + + Future deleteHistoryByKeys(String path, List keys); + + Future deleteAllHistory(String path); }