This commit is contained in:
kleponijo 2026-06-25 10:54:05 +07:00
parent f976556d99
commit 4fd56dc5f9
7 changed files with 726 additions and 68 deletions

View File

@ -10,10 +10,8 @@ import '../../../../blocs/notification_bloc/notification_bloc.dart';
part 'wind_speed_event.dart'; part 'wind_speed_event.dart';
part 'wind_speed_state.dart'; part 'wind_speed_state.dart';
/// Threshold kecepatan angin (satuan m/s) const double _kWindWarning = 25.0;
/// Referensi: Beaufort scale & BMKG const double _kWindDanger = 45.5;
const double _kWindWarning = 25.0; // Waspada: 812.5 m/s (~2945 km/h)
const double _kWindDanger = 45.5; // Bahaya: > 12.5 m/s (> 45 km/h)
const _kDeviceIdKey = 'selected_device_id'; const _kDeviceIdKey = 'selected_device_id';
const _kDefaultDeviceId = 'esp_lapangan'; const _kDefaultDeviceId = 'esp_lapangan';
@ -31,70 +29,51 @@ class WindSpeedBloc extends Bloc<WindSpeedEvent, WindSpeedState> {
on<WatchWindSpeedStarted>(_onStarted, transformer: restartable()); on<WatchWindSpeedStarted>(_onStarted, transformer: restartable());
on<_WindSpeedRealtimeUpdated>(_onRealtimeUpdated); on<_WindSpeedRealtimeUpdated>(_onRealtimeUpdated);
on<WindSpeedPeriodChanged>(_onPeriodChanged); on<WindSpeedPeriodChanged>(_onPeriodChanged);
on<WindSpeedDateFilterChanged>(_onDateFilterChanged); // baru on<WindSpeedDateFilterChanged>(_onDateFilterChanged);
// Delete handlers
on<WindSpeedDeleteAllRequested>(_onDeleteAll);
on<WindSpeedDeleteByDateRequested>(_onDeleteByDate);
on<WindSpeedDeleteByDateRangeRequested>(_onDeleteByDateRange);
on<WindSpeedDeleteByHourRangeRequested>(_onDeleteByHourRange);
} }
// Baca device ID dari SharedPreferences
Future<String> _getDeviceId() async { Future<String> _getDeviceId() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
return prefs.getString(_kDeviceIdKey) ?? _kDefaultDeviceId; return prefs.getString(_kDeviceIdKey) ?? _kDefaultDeviceId;
} }
// //
// START // START sekarang fetch dengan keys untuk delete support
// //
Future<void> _onStarted( Future<void> _onStarted(
WatchWindSpeedStarted event, WatchWindSpeedStarted event,
Emitter<WindSpeedState> emit, Emitter<WindSpeedState> emit,
) async { ) async {
emit(state.copyWith(isLoading: true)); emit(state.copyWith(isLoading: true));
// Baca device ID aktif (dari SharedPreferences, diset di DeviceSetupBloc)
final deviceId = await _getDeviceId(); final deviceId = await _getDeviceId();
final history = await _repository.getSensorHistory( final historyMap = await _repository.getSensorHistoryWithKeys(
'anemometer/$deviceId/history', 'anemometer/$deviceId/history',
(json) => MyWindSpeed.fromJson(json), (json) => MyWindSpeed.fromJson(json),
); );
final dailyGraph = TimeSeriesMapper.smooth( final history = _sortedList(historyMap);
TimeSeriesMapper.toDaily( final graphs = _buildGraphs(history);
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,
),
);
if (history.isNotEmpty) { if (history.isNotEmpty) _emitWindAlert(history.last.speed);
_emitWindAlert(history.last.speed);
}
emit(state.copyWith( emit(state.copyWith(
historyMap: historyMap,
history: history, history: history,
filteredHistory: history, // awal = semua data filteredHistory: history,
dailySpeeds: dailyGraph, dailySpeeds: graphs['daily']!,
weeklySpeeds: weekly, weeklySpeeds: graphs['weekly']!,
monthlySpeeds: monthly, monthlySpeeds: graphs['monthly']!,
isLoading: false, isLoading: false,
alertLevel: alertLevel:
history.isNotEmpty ? _getAlertLevel(history.last.speed) : 'Normal', history.isNotEmpty ? _getAlertLevel(history.last.speed) : 'Normal',
)); ));
// Subscribe realtime dari path device aktif
await _subscription?.cancel(); await _subscription?.cancel();
_subscription = _repository _subscription = _repository
.getSensorStream( .getSensorStream(
@ -128,7 +107,6 @@ class WindSpeedBloc extends Bloc<WindSpeedEvent, WindSpeedState> {
} }
_emitWindAlert(newValue); _emitWindAlert(newValue);
emit(state.copyWith( emit(state.copyWith(
currentSpeed: newValue, currentSpeed: newValue,
dailySpeeds: updated, dailySpeeds: updated,
@ -168,7 +146,6 @@ class WindSpeedBloc extends Bloc<WindSpeedEvent, WindSpeedState> {
} }
final updatedGraph = TimeSeriesMapper.smooth(raw); final updatedGraph = TimeSeriesMapper.smooth(raw);
emit(state.copyWith( emit(state.copyWith(
dailySpeeds: dailySpeeds:
event.period == 'Hari Ini' ? updatedGraph : state.dailySpeeds, event.period == 'Hari Ini' ? updatedGraph : state.dailySpeeds,
@ -176,45 +153,236 @@ class WindSpeedBloc extends Bloc<WindSpeedEvent, WindSpeedState> {
event.period == 'Minggu Ini' ? updatedGraph : state.weeklySpeeds, event.period == 'Minggu Ini' ? updatedGraph : state.weeklySpeeds,
monthlySpeeds: monthlySpeeds:
event.period == 'Bulan Ini' ? updatedGraph : state.monthlySpeeds, event.period == 'Bulan Ini' ? updatedGraph : state.monthlySpeeds,
isLoading: false,
)); ));
} }
// //
// DATE FILTER CHANGED baru // DATE FILTER
// //
void _onDateFilterChanged( void _onDateFilterChanged(
WindSpeedDateFilterChanged event, WindSpeedDateFilterChanged event,
Emitter<WindSpeedState> emit, Emitter<WindSpeedState> emit,
) { ) {
final date = event.date; final date = event.date;
final allHistory = state.history;
if (date == null) { if (date == null) {
// Reset: tampilkan semua
emit(state.copyWith( emit(state.copyWith(
filteredHistory: allHistory, filteredHistory: state.history,
clearSelectedDate: true, clearSelectedDate: true,
)); ));
return; return;
} }
// Filter data yang tanggalnya sama dengan [date] final filtered =
final filtered = allHistory.where((item) { state.history.where((e) => _isSameDate(e.timestamp, date)).toList();
return item.timestamp.year == date.year &&
item.timestamp.month == date.month &&
item.timestamp.day == date.day;
}).toList();
emit(state.copyWith( emit(state.copyWith(filteredHistory: filtered, selectedDate: date));
filteredHistory: filtered, }
selectedDate: date,
)); //
// DELETE ALL
//
Future<void> _onDeleteAll(
WindSpeedDeleteAllRequested event,
Emitter<WindSpeedState> 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<void> _onDeleteByDate(
WindSpeedDeleteByDateRequested event,
Emitter<WindSpeedState> 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<void> _onDeleteByDateRange(
WindSpeedDeleteByDateRangeRequested event,
Emitter<WindSpeedState> 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<void> _onDeleteByHourRange(
WindSpeedDeleteByHourRangeRequested event,
Emitter<WindSpeedState> 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 // HELPERS
// //
/// Emit state setelah delete all/date/range (selalu reset filter)
void _emitAfterDelete(
Emitter<WindSpeedState> emit,
Map<String, MyWindSpeed> 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<String, List<double>> _buildGraphs(List<MyWindSpeed> 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<String, MyWindSpeed> _removeKeys(
Map<String, MyWindSpeed> map,
List<String> keys,
) {
final newMap = Map<String, MyWindSpeed>.from(map);
for (final k in keys) newMap.remove(k);
return newMap;
}
List<MyWindSpeed> _sortedList(Map<String, MyWindSpeed> map) {
return map.values.toList()
..sort((a, b) => a.timestamp.compareTo(b.timestamp));
}
String _getAlertLevel(double speed) { String _getAlertLevel(double speed) {
if (speed >= _kWindDanger) return 'Bahaya'; if (speed >= _kWindDanger) return 'Bahaya';
if (speed >= _kWindWarning) return 'Waspada'; if (speed >= _kWindWarning) return 'Waspada';
@ -236,15 +404,13 @@ class WindSpeedBloc extends Bloc<WindSpeedEvent, WindSpeedState> {
message = ''; message = '';
} }
_notificationBloc.add(SensorAlertAdded( _notificationBloc.add(SensorAlertAdded(SensorAlert(
SensorAlert( sensorId: 'wind_speed',
sensorId: 'wind_speed', sensorName: 'Anemometer',
sensorName: 'Anemometer', message: message,
message: message, severity: severity,
severity: severity, timestamp: DateTime.now(),
timestamp: DateTime.now(), )));
),
));
} }
@override @override
@ -254,7 +420,6 @@ class WindSpeedBloc extends Bloc<WindSpeedEvent, WindSpeedState> {
} }
} }
/// Internal event tidak diekspos ke luar
class _WindSpeedRealtimeUpdated extends WindSpeedEvent { class _WindSpeedRealtimeUpdated extends WindSpeedEvent {
final MyWindSpeed data; final MyWindSpeed data;
const _WindSpeedRealtimeUpdated(this.data); const _WindSpeedRealtimeUpdated(this.data);

View File

@ -30,3 +30,49 @@ class WindSpeedDateFilterChanged extends WindSpeedEvent {
@override @override
List<Object?> get props => [date]; List<Object?> 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<Object?> 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<Object?> get props => [start, end];
}
/// Hapus riwayat dalam rentang jam pada tanggal tertentu
class WindSpeedDeleteByHourRangeRequested extends WindSpeedEvent {
final DateTime date;
final int startHour; // 023
final int endHour; // 023, harus >= startHour
const WindSpeedDeleteByHourRangeRequested({
required this.date,
required this.startHour,
required this.endHour,
});
@override
List<Object?> get props => [date, startHour, endHour];
}

View File

@ -2,6 +2,8 @@ part of 'wind_speed_bloc.dart';
class WindSpeedState extends Equatable { class WindSpeedState extends Equatable {
final bool isLoading; final bool isLoading;
final bool isDeleting;
final String? deleteError;
final double currentSpeed; final double currentSpeed;
final String alertLevel; final String alertLevel;
final String selectedPeriod; final String selectedPeriod;
@ -16,8 +18,13 @@ class WindSpeedState extends Equatable {
final List<MyWindSpeed> filteredHistory; // setelah difilter tanggal final List<MyWindSpeed> filteredHistory; // setelah difilter tanggal
final DateTime? selectedDate; // null = tampilkan semua final DateTime? selectedDate; // null = tampilkan semua
/// Map { Firebase push-key data } dipakai untuk operasi delete
final Map<String, MyWindSpeed> historyMap;
const WindSpeedState({ const WindSpeedState({
this.isLoading = false, this.isLoading = false,
this.isDeleting = false,
this.deleteError,
this.currentSpeed = 0.0, this.currentSpeed = 0.0,
this.alertLevel = 'Normal', this.alertLevel = 'Normal',
this.selectedPeriod = 'Hari Ini', this.selectedPeriod = 'Hari Ini',
@ -27,10 +34,14 @@ class WindSpeedState extends Equatable {
this.history = const [], this.history = const [],
this.filteredHistory = const [], this.filteredHistory = const [],
this.selectedDate, this.selectedDate,
this.historyMap = const {},
}); });
WindSpeedState copyWith({ WindSpeedState copyWith({
bool? isLoading, bool? isLoading,
bool? isDeleting,
String? deleteError,
bool clearDeleteError = false,
double? currentSpeed, double? currentSpeed,
String? alertLevel, String? alertLevel,
String? selectedPeriod, String? selectedPeriod,
@ -41,9 +52,12 @@ class WindSpeedState extends Equatable {
List<MyWindSpeed>? filteredHistory, List<MyWindSpeed>? filteredHistory,
DateTime? selectedDate, DateTime? selectedDate,
bool clearSelectedDate = false, bool clearSelectedDate = false,
Map<String, MyWindSpeed>? historyMap,
}) { }) {
return WindSpeedState( return WindSpeedState(
isLoading: isLoading ?? this.isLoading, isLoading: isLoading ?? this.isLoading,
isDeleting: isDeleting ?? this.isDeleting,
deleteError: clearDeleteError ? null : (deleteError ?? this.deleteError),
currentSpeed: currentSpeed ?? this.currentSpeed, currentSpeed: currentSpeed ?? this.currentSpeed,
alertLevel: alertLevel ?? this.alertLevel, alertLevel: alertLevel ?? this.alertLevel,
selectedPeriod: selectedPeriod ?? this.selectedPeriod, selectedPeriod: selectedPeriod ?? this.selectedPeriod,
@ -54,12 +68,15 @@ class WindSpeedState extends Equatable {
filteredHistory: filteredHistory ?? this.filteredHistory, filteredHistory: filteredHistory ?? this.filteredHistory,
selectedDate: selectedDate:
clearSelectedDate ? null : (selectedDate ?? this.selectedDate), clearSelectedDate ? null : (selectedDate ?? this.selectedDate),
historyMap: historyMap ?? this.historyMap,
); );
} }
@override @override
List<Object?> get props => [ List<Object?> get props => [
isLoading, isLoading,
isDeleting,
deleteError,
currentSpeed, currentSpeed,
alertLevel, alertLevel,
selectedPeriod, selectedPeriod,
@ -69,5 +86,6 @@ class WindSpeedState extends Equatable {
history, history,
filteredHistory, filteredHistory,
selectedDate, selectedDate,
historyMap,
]; ];
} }

View File

@ -12,6 +12,8 @@ class WindSpeedHistoryList extends StatelessWidget {
final DateTime? selectedDate; final DateTime? selectedDate;
final VoidCallback onPickDate; final VoidCallback onPickDate;
final VoidCallback onClearDate; final VoidCallback onClearDate;
final VoidCallback onDeleteTap;
final bool isDeleting;
const WindSpeedHistoryList({ const WindSpeedHistoryList({
super.key, super.key,
@ -19,6 +21,8 @@ class WindSpeedHistoryList extends StatelessWidget {
required this.selectedDate, required this.selectedDate,
required this.onPickDate, required this.onPickDate,
required this.onClearDate, required this.onClearDate,
required this.onDeleteTap,
this.isDeleting = false,
}); });
// Grouping per tanggal // Grouping per tanggal
@ -46,6 +50,8 @@ class WindSpeedHistoryList extends StatelessWidget {
totalCount: history.length, totalCount: history.length,
onPickDate: onPickDate, onPickDate: onPickDate,
onClearDate: onClearDate, onClearDate: onClearDate,
onDeleteTap: onDeleteTap, // baru
isDeleting: isDeleting,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
@ -98,12 +104,16 @@ class _HeaderBar extends StatelessWidget {
final int totalCount; final int totalCount;
final VoidCallback onPickDate; final VoidCallback onPickDate;
final VoidCallback onClearDate; final VoidCallback onClearDate;
final VoidCallback onDeleteTap;
final bool isDeleting;
const _HeaderBar({ const _HeaderBar({
required this.selectedDate, required this.selectedDate,
required this.totalCount, required this.totalCount,
required this.onPickDate, required this.onPickDate,
required this.onClearDate, required this.onClearDate,
required this.onDeleteTap,
required this.isDeleting,
}); });
@override @override
@ -148,6 +158,25 @@ class _HeaderBar extends StatelessWidget {
color: Colors.blue.shade700, color: Colors.blue.shade700,
onTap: onPickDate, 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,
),
], ],
); );
} }

View File

@ -601,6 +601,24 @@ class _WindSpeedScreenState extends State<WindSpeedScreen> {
} }
} }
//
// 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<WindSpeedBloc>(),
child: const _DeleteSheet(),
),
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
@ -634,7 +652,16 @@ class _WindSpeedScreenState extends State<WindSpeedScreen> {
), ),
], ],
), ),
body: BlocBuilder<WindSpeedBloc, WindSpeedState>( body: BlocConsumer<WindSpeedBloc, WindSpeedState>(
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) { builder: (context, state) {
if (state.isLoading) { if (state.isLoading) {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
@ -876,6 +903,26 @@ class _WindSpeedScreenState extends State<WindSpeedScreen> {
icon: Icons.calendar_month_rounded, icon: Icons.calendar_month_rounded,
onTap: () => _pickDate(context, state), 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<DateTime> 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<int> onChange) {
return DropdownButtonFormField<int>(
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<WindSpeedBloc>();
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);
}
}

View File

@ -61,6 +61,43 @@ class FirebaseMonitoringRepo implements MonitoringRepository {
} }
} }
@override
Future<Map<String, T>> getSensorHistoryWithKeys<T>(
String path,
T Function(Map<dynamic, dynamic> json) mapper,
) async {
try {
final snapshot = await _db.ref(path).get();
if (snapshot.exists && snapshot.value is Map) {
final data = snapshot.value as Map<dynamic, dynamic>;
return {
for (final entry in data.entries)
entry.key.toString(): mapper(
entry.value is Map ? entry.value as Map<dynamic, dynamic> : {},
),
};
}
return {};
} catch (_) {
return {};
}
}
@override
Future<void> deleteHistoryByKeys(String path, List<String> keys) async {
if (keys.isEmpty) return;
// Firebase multi-path delete: set tiap path ke null dalam satu update
final Map<String, dynamic> updates = {
for (final key in keys) '$path/$key': null,
};
await _db.ref().update(updates);
}
@override
Future<void> deleteAllHistory(String path) async {
await _db.ref(path).remove();
}
// Anemometer Settings // Anemometer Settings
@override @override
Future<Map<String, dynamic>> getAnemometerSettings() async { Future<Map<String, dynamic>> getAnemometerSettings() async {

View File

@ -43,4 +43,13 @@ abstract class MonitoringRepository {
Future<void> sendRemoteRestart(String deviceId); Future<void> sendRemoteRestart(String deviceId);
Future<void> deleteDeviceLogs(String deviceId, List<String> keys); Future<void> deleteDeviceLogs(String deviceId, List<String> keys);
Future<Map<String, T>> getSensorHistoryWithKeys<T>(
String path,
T Function(Map<dynamic, dynamic> json) mapper,
);
Future<void> deleteHistoryByKeys(String path, List<String> keys);
Future<void> deleteAllHistory(String path);
} }