update delete log

bisa select dan hapus log
This commit is contained in:
kleponijo 2026-05-29 19:05:16 +07:00
parent 1178fc335d
commit f9a74b9b4e
6 changed files with 289 additions and 42 deletions

View File

@ -37,6 +37,10 @@ class DeviceSetupBloc extends Bloc<DeviceSetupEvent, DeviceSetupState> {
on<DeviceSettingsSaved>(_onSettingsSaved);
on<DeviceLogsRefreshed>(_onLogsRefreshed);
on<DeviceRestartRequested>(_onRestartRequested);
on<LogSelectModeToggled>(_onSelectModeToggled);
on<LogItemToggled>(_onLogItemToggled);
on<LogSelectAllToggled>(_onLogSelectAllToggled);
on<LogsDeleteRequested>(_onLogsDeleteRequested);
}
//
@ -256,6 +260,74 @@ class DeviceSetupBloc extends Bloc<DeviceSetupEvent, DeviceSetupState> {
}
}
void _onSelectModeToggled(
LogSelectModeToggled event,
Emitter<DeviceSetupState> emit,
) {
emit(state.copyWith(
isSelecting: !state.isSelecting,
selectedLogKeys: {}, // reset pilihan saat toggle mode
));
}
void _onLogItemToggled(
LogItemToggled event,
Emitter<DeviceSetupState> emit,
) {
final current = Set<String>.from(state.selectedLogKeys);
if (current.contains(event.key)) {
current.remove(event.key);
} else {
current.add(event.key);
}
emit(state.copyWith(selectedLogKeys: current));
}
void _onLogSelectAllToggled(
LogSelectAllToggled event,
Emitter<DeviceSetupState> emit,
) {
if (state.allSelected) {
// Sudah semua terpilih batalkan semua
emit(state.copyWith(selectedLogKeys: {}));
} else {
// Pilih semua ambil semua '_key' dari logs
final allKeys = state.logs.map((e) => e['_key'] as String).toSet();
emit(state.copyWith(selectedLogKeys: allKeys));
}
}
Future<void> _onLogsDeleteRequested(
LogsDeleteRequested event,
Emitter<DeviceSetupState> emit,
) async {
if (state.selectedLogKeys.isEmpty) return;
emit(state.copyWith(logsLoading: true));
try {
await _repository.deleteDeviceLogs(
state.deviceId,
state.selectedLogKeys.toList(),
);
// Refresh logs setelah delete
final logs = await _repository.getDeviceLogs(state.deviceId);
emit(state.copyWith(
logs: logs,
logsLoading: false,
selectedLogKeys: {},
isSelecting: false,
));
} catch (e) {
emit(state.copyWith(
logsLoading: false,
status: DeviceSetupStatus.settingsError,
errorMessage: 'Gagal hapus log: $e',
));
}
}
// Reset state
void _onReset(
ResetDeviceSetupEvent event,

View File

@ -50,3 +50,14 @@ class DeviceSettingsSaved extends DeviceSetupEvent {}
class DeviceLogsRefreshed extends DeviceSetupEvent {}
class DeviceRestartRequested extends DeviceSetupEvent {}
class LogSelectModeToggled extends DeviceSetupEvent {}
class LogItemToggled extends DeviceSetupEvent {
final String key;
LogItemToggled(this.key);
}
class LogSelectAllToggled extends DeviceSetupEvent {}
class LogsDeleteRequested extends DeviceSetupEvent {}

View File

@ -26,13 +26,11 @@ class DeviceSetupState {
final double radiusM;
final int intervalRealtimeMs;
final int intervalHistoryMs;
/// Jumlah magnet pada anemometer. 1 = default, 3 = resolusi lebih tinggi.
/// Disimpan di Firebase: /anemometer/settings/magnet_count
final int magnetCount;
final List<Map<String, dynamic>> logs;
final bool logsLoading;
final Set<String> selectedLogKeys;
final bool isSelecting;
const DeviceSetupState({
this.status = DeviceSetupStatus.idle,
@ -47,8 +45,13 @@ class DeviceSetupState {
this.magnetCount = 1, // default 1 magnet
this.logs = const [],
this.logsLoading = false,
this.selectedLogKeys = const {},
this.isSelecting = false,
});
bool get allSelected =>
logs.isNotEmpty && selectedLogKeys.length == logs.length;
DeviceSetupState copyWith({
DeviceSetupStatus? status,
String? errorMessage,
@ -62,6 +65,8 @@ class DeviceSetupState {
int? magnetCount,
List<Map<String, dynamic>>? logs,
bool? logsLoading,
Set<String>? selectedLogKeys,
bool? isSelecting,
}) {
return DeviceSetupState(
status: status ?? this.status,
@ -76,6 +81,8 @@ class DeviceSetupState {
magnetCount: magnetCount ?? this.magnetCount,
logs: logs ?? this.logs,
logsLoading: logsLoading ?? this.logsLoading,
selectedLogKeys: selectedLogKeys ?? this.selectedLogKeys,
isSelecting: isSelecting ?? this.isSelecting,
);
}
}

View File

@ -581,10 +581,51 @@ class _LogsTab extends StatelessWidget {
);
}
void _showDeleteConfirmDialog(BuildContext context, DeviceSetupBloc bloc) {
final count = state.selectedLogKeys.length;
showDialog(
context: context,
builder: (ctx) => AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
title: Row(children: [
Icon(Icons.delete_outline_rounded, color: Colors.red.shade600),
const SizedBox(width: 8),
const Text('Hapus Log'),
]),
content: Text(
'Hapus $count log yang dipilih?\n\n'
'Data akan dihapus permanen dari Firebase dan tidak bisa dikembalikan.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: Text('Batal', style: TextStyle(color: Colors.grey.shade600)),
),
ElevatedButton.icon(
onPressed: () {
Navigator.pop(ctx);
bloc.add(LogsDeleteRequested());
},
icon: const Icon(Icons.delete_rounded, size: 16),
label: const Text('Hapus'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red.shade600,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10)),
),
),
],
),
);
}
@override
Widget build(BuildContext context) {
final bloc = context.read<DeviceSetupBloc>();
final fmt = DateFormat('dd MMM HH:mm:ss', 'id_ID');
final isSelecting = state.isSelecting;
final selectedCount = state.selectedLogKeys.length;
return Column(
children: [
@ -597,9 +638,15 @@ class _LogsTab extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Log Device',
style: const TextStyle(
fontSize: 15, fontWeight: FontWeight.bold)),
Text(
isSelecting ? '$selectedCount dipilih' : 'Log Device',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
color:
isSelecting ? Colors.blue.shade700 : Colors.black87,
),
),
Text(
state.deviceId,
style:
@ -609,6 +656,53 @@ class _LogsTab extends StatelessWidget {
),
),
if (isSelecting) ...[
// Pilih semua
TextButton(
onPressed: () => bloc.add(LogSelectAllToggled()),
child: Text(
state.allSelected ? 'Batal semua' : 'Pilih semua',
style: TextStyle(fontSize: 12, color: Colors.blue.shade700),
),
),
// Hapus terpilih
IconButton.filledTonal(
onPressed: selectedCount == 0
? null
: () => _showDeleteConfirmDialog(context, bloc),
icon: const Icon(Icons.delete_rounded),
tooltip: 'Hapus yang dipilih',
style: IconButton.styleFrom(
backgroundColor: selectedCount > 0
? Colors.red.shade50
: Colors.grey.shade100,
foregroundColor: selectedCount > 0
? Colors.red.shade700
: Colors.grey.shade400,
),
),
// Batalkan mode pilih
IconButton(
onPressed: () => bloc.add(LogSelectModeToggled()),
icon: const Icon(Icons.close_rounded),
tooltip: 'Batalkan',
),
] else ...[
// Tombol pilih / select mode
IconButton.filledTonal(
onPressed: state.logs.isEmpty
? null
: () => bloc.add(LogSelectModeToggled()),
icon: const Icon(Icons.checklist_rounded),
tooltip: 'Pilih log',
style: IconButton.styleFrom(
backgroundColor: Colors.blue.shade50,
foregroundColor: Colors.blue.shade700,
),
),
const SizedBox(width: 4),
],
// restart esp
IconButton.filledTonal(
onPressed: () =>
@ -663,8 +757,10 @@ class _LogsTab extends StatelessWidget {
itemCount: state.logs.length,
itemBuilder: (ctx, i) {
final log = state.logs[i];
final key = log['_key'] as String;
final msg = log['msg'] as String;
final ts = log['timestamp'] as DateTime;
final isSelected = state.selectedLogKeys.contains(key);
// Warna berdasarkan konten pesan
final isOta = msg.contains('OTA') || msg.contains('FW=');
@ -683,19 +779,58 @@ class _LogsTab extends StatelessWidget {
? Colors.blue.shade50
: Colors.green.shade50;
return Container(
return GestureDetector(
// Long press masuk mode pilih sekaligus pilih item ini
onLongPress: isSelecting
? null
: () {
bloc.add(LogSelectModeToggled());
bloc.add(LogItemToggled(key));
},
onTap: isSelecting
? () => bloc.add(LogItemToggled(key))
: null,
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: bg,
color: isSelected ? Colors.blue.shade100 : bg,
borderRadius: BorderRadius.circular(10),
border:
Border.all(color: color.withValues(alpha: 0.25)),
border: Border.all(
color: isSelected
? Colors.blue.shade400
: color.withValues(alpha: 0.25),
width: isSelected ? 1.5 : 1,
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
// Checkbox atau ikon
if (isSelecting)
Padding(
padding:
const EdgeInsets.only(right: 10, top: 1),
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 150),
child: isSelected
? Icon(Icons.check_circle_rounded,
key: const ValueKey('checked'),
size: 20,
color: Colors.blue.shade700)
: Icon(
Icons.radio_button_unchecked_rounded,
key: const ValueKey('unchecked'),
size: 20,
color: Colors.grey.shade400),
),
)
else
Padding(
padding:
const EdgeInsets.only(right: 10, top: 1),
child: Icon(
isError
? Icons.error_outline_rounded
: isOta
@ -704,26 +839,36 @@ class _LogsTab extends StatelessWidget {
size: 16,
color: color,
),
const SizedBox(width: 10),
),
// Konten
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(msg,
Text(
msg,
style: TextStyle(
fontSize: 12,
color: color,
fontFamily: 'monospace')),
color: isSelected
? Colors.blue.shade900
: color,
fontFamily: 'monospace',
),
),
const SizedBox(height: 2),
Text(fmt.format(ts),
Text(
fmt.format(ts),
style: TextStyle(
fontSize: 10,
color: Colors.grey.shade500)),
color: Colors.grey.shade500),
),
],
),
),
],
),
),
);
},
),

View File

@ -129,6 +129,7 @@ class FirebaseMonitoringRepo implements MonitoringRepository {
final list = raw.entries.map((e) {
final v = e.value as Map<dynamic, dynamic>;
return {
'_key': e.key.toString(),
'msg': v['msg'] ?? '',
'timestamp': DateTime.fromMillisecondsSinceEpoch(
(v['timestamp'] ?? 0) * 1000,
@ -146,6 +147,15 @@ class FirebaseMonitoringRepo implements MonitoringRepository {
return [];
}
@override
Future<void> deleteDeviceLogs(String deviceId, List<String> keys) async {
// Hapus semua key sekaligus pakai Map null (multi-path delete)
final Map<String, dynamic> updates = {
for (final key in keys) 'anemometer/$deviceId/logs/$key': null,
};
await _db.ref().update(updates);
}
@override
Future<void> sendRemoteRestart(String deviceId) async {
await _db.ref('anemometer/$deviceId/command/restart').set(true);

View File

@ -41,4 +41,6 @@ abstract class MonitoringRepository {
});
Future<void> sendRemoteRestart(String deviceId);
Future<void> deleteDeviceLogs(String deviceId, List<String> keys);
}