coba
This commit is contained in:
parent
f976556d99
commit
4fd56dc5f9
|
|
@ -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<WindSpeedEvent, WindSpeedState> {
|
|||
on<WatchWindSpeedStarted>(_onStarted, transformer: restartable());
|
||||
on<_WindSpeedRealtimeUpdated>(_onRealtimeUpdated);
|
||||
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 {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getString(_kDeviceIdKey) ?? _kDefaultDeviceId;
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════
|
||||
// START
|
||||
// START — sekarang fetch dengan keys untuk delete support
|
||||
// ════════════════════════════════════════════════════════════
|
||||
Future<void> _onStarted(
|
||||
WatchWindSpeedStarted event,
|
||||
Emitter<WindSpeedState> 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<WindSpeedEvent, WindSpeedState> {
|
|||
}
|
||||
|
||||
_emitWindAlert(newValue);
|
||||
|
||||
emit(state.copyWith(
|
||||
currentSpeed: newValue,
|
||||
dailySpeeds: updated,
|
||||
|
|
@ -168,7 +146,6 @@ class WindSpeedBloc extends Bloc<WindSpeedEvent, WindSpeedState> {
|
|||
}
|
||||
|
||||
final updatedGraph = TimeSeriesMapper.smooth(raw);
|
||||
|
||||
emit(state.copyWith(
|
||||
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,
|
||||
monthlySpeeds:
|
||||
event.period == 'Bulan Ini' ? updatedGraph : state.monthlySpeeds,
|
||||
isLoading: false,
|
||||
));
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════
|
||||
// DATE FILTER CHANGED ← baru
|
||||
// DATE FILTER
|
||||
// ════════════════════════════════════════════════════════════
|
||||
void _onDateFilterChanged(
|
||||
WindSpeedDateFilterChanged event,
|
||||
Emitter<WindSpeedState> 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));
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════
|
||||
// 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(
|
||||
filteredHistory: filtered,
|
||||
selectedDate: date,
|
||||
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<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) {
|
||||
if (speed >= _kWindDanger) return 'Bahaya';
|
||||
if (speed >= _kWindWarning) return 'Waspada';
|
||||
|
|
@ -236,15 +404,13 @@ class WindSpeedBloc extends Bloc<WindSpeedEvent, WindSpeedState> {
|
|||
message = '';
|
||||
}
|
||||
|
||||
_notificationBloc.add(SensorAlertAdded(
|
||||
SensorAlert(
|
||||
_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<WindSpeedEvent, WindSpeedState> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Internal event — tidak diekspos ke luar
|
||||
class _WindSpeedRealtimeUpdated extends WindSpeedEvent {
|
||||
final MyWindSpeed data;
|
||||
const _WindSpeedRealtimeUpdated(this.data);
|
||||
|
|
|
|||
|
|
@ -30,3 +30,49 @@ class WindSpeedDateFilterChanged extends WindSpeedEvent {
|
|||
@override
|
||||
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; // 0–23
|
||||
final int endHour; // 0–23, harus >= startHour
|
||||
const WindSpeedDeleteByHourRangeRequested({
|
||||
required this.date,
|
||||
required this.startHour,
|
||||
required this.endHour,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [date, startHour, endHour];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<MyWindSpeed> filteredHistory; // setelah difilter tanggal
|
||||
final DateTime? selectedDate; // null = tampilkan semua
|
||||
|
||||
/// Map { Firebase push-key → data } — dipakai untuk operasi delete
|
||||
final Map<String, MyWindSpeed> 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<MyWindSpeed>? filteredHistory,
|
||||
DateTime? selectedDate,
|
||||
bool clearSelectedDate = false,
|
||||
Map<String, MyWindSpeed>? 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<Object?> get props => [
|
||||
isLoading,
|
||||
isDeleting,
|
||||
deleteError,
|
||||
currentSpeed,
|
||||
alertLevel,
|
||||
selectedPeriod,
|
||||
|
|
@ -69,5 +86,6 @@ class WindSpeedState extends Equatable {
|
|||
history,
|
||||
filteredHistory,
|
||||
selectedDate,
|
||||
historyMap,
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
Widget build(BuildContext context) {
|
||||
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) {
|
||||
if (state.isLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
|
|
@ -876,6 +903,26 @@ class _WindSpeedScreenState extends State<WindSpeedScreen> {
|
|||
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<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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ──────────────────────────────────────
|
||||
@override
|
||||
Future<Map<String, dynamic>> getAnemometerSettings() async {
|
||||
|
|
|
|||
|
|
@ -43,4 +43,13 @@ abstract class MonitoringRepository {
|
|||
Future<void> sendRemoteRestart(String deviceId);
|
||||
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue