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<DeviceSettingsSaved>(_onSettingsSaved);
on<DeviceLogsRefreshed>(_onLogsRefreshed); on<DeviceLogsRefreshed>(_onLogsRefreshed);
on<DeviceRestartRequested>(_onRestartRequested); 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 // Reset state
void _onReset( void _onReset(
ResetDeviceSetupEvent event, ResetDeviceSetupEvent event,

View File

@ -50,3 +50,14 @@ class DeviceSettingsSaved extends DeviceSetupEvent {}
class DeviceLogsRefreshed extends DeviceSetupEvent {} class DeviceLogsRefreshed extends DeviceSetupEvent {}
class DeviceRestartRequested 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 double radiusM;
final int intervalRealtimeMs; final int intervalRealtimeMs;
final int intervalHistoryMs; final int intervalHistoryMs;
/// Jumlah magnet pada anemometer. 1 = default, 3 = resolusi lebih tinggi.
/// Disimpan di Firebase: /anemometer/settings/magnet_count
final int magnetCount; final int magnetCount;
final List<Map<String, dynamic>> logs; final List<Map<String, dynamic>> logs;
final bool logsLoading; final bool logsLoading;
final Set<String> selectedLogKeys;
final bool isSelecting;
const DeviceSetupState({ const DeviceSetupState({
this.status = DeviceSetupStatus.idle, this.status = DeviceSetupStatus.idle,
@ -47,8 +45,13 @@ class DeviceSetupState {
this.magnetCount = 1, // default 1 magnet this.magnetCount = 1, // default 1 magnet
this.logs = const [], this.logs = const [],
this.logsLoading = false, this.logsLoading = false,
this.selectedLogKeys = const {},
this.isSelecting = false,
}); });
bool get allSelected =>
logs.isNotEmpty && selectedLogKeys.length == logs.length;
DeviceSetupState copyWith({ DeviceSetupState copyWith({
DeviceSetupStatus? status, DeviceSetupStatus? status,
String? errorMessage, String? errorMessage,
@ -62,6 +65,8 @@ class DeviceSetupState {
int? magnetCount, int? magnetCount,
List<Map<String, dynamic>>? logs, List<Map<String, dynamic>>? logs,
bool? logsLoading, bool? logsLoading,
Set<String>? selectedLogKeys,
bool? isSelecting,
}) { }) {
return DeviceSetupState( return DeviceSetupState(
status: status ?? this.status, status: status ?? this.status,
@ -76,6 +81,8 @@ class DeviceSetupState {
magnetCount: magnetCount ?? this.magnetCount, magnetCount: magnetCount ?? this.magnetCount,
logs: logs ?? this.logs, logs: logs ?? this.logs,
logsLoading: logsLoading ?? this.logsLoading, 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final bloc = context.read<DeviceSetupBloc>(); final bloc = context.read<DeviceSetupBloc>();
final fmt = DateFormat('dd MMM HH:mm:ss', 'id_ID'); final fmt = DateFormat('dd MMM HH:mm:ss', 'id_ID');
final isSelecting = state.isSelecting;
final selectedCount = state.selectedLogKeys.length;
return Column( return Column(
children: [ children: [
@ -597,9 +638,15 @@ class _LogsTab extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text('Log Device', Text(
style: const TextStyle( isSelecting ? '$selectedCount dipilih' : 'Log Device',
fontSize: 15, fontWeight: FontWeight.bold)), style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
color:
isSelecting ? Colors.blue.shade700 : Colors.black87,
),
),
Text( Text(
state.deviceId, state.deviceId,
style: 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 // restart esp
IconButton.filledTonal( IconButton.filledTonal(
onPressed: () => onPressed: () =>
@ -663,8 +757,10 @@ class _LogsTab extends StatelessWidget {
itemCount: state.logs.length, itemCount: state.logs.length,
itemBuilder: (ctx, i) { itemBuilder: (ctx, i) {
final log = state.logs[i]; final log = state.logs[i];
final key = log['_key'] as String;
final msg = log['msg'] as String; final msg = log['msg'] as String;
final ts = log['timestamp'] as DateTime; final ts = log['timestamp'] as DateTime;
final isSelected = state.selectedLogKeys.contains(key);
// Warna berdasarkan konten pesan // Warna berdasarkan konten pesan
final isOta = msg.contains('OTA') || msg.contains('FW='); final isOta = msg.contains('OTA') || msg.contains('FW=');
@ -683,46 +779,95 @@ class _LogsTab extends StatelessWidget {
? Colors.blue.shade50 ? Colors.blue.shade50
: Colors.green.shade50; : Colors.green.shade50;
return Container( return GestureDetector(
margin: const EdgeInsets.only(bottom: 8), // Long press masuk mode pilih sekaligus pilih item ini
padding: const EdgeInsets.all(12), onLongPress: isSelecting
decoration: BoxDecoration( ? null
color: bg, : () {
borderRadius: BorderRadius.circular(10), bloc.add(LogSelectModeToggled());
border: bloc.add(LogItemToggled(key));
Border.all(color: color.withValues(alpha: 0.25)), },
), onTap: isSelecting
child: Row( ? () => bloc.add(LogItemToggled(key))
crossAxisAlignment: CrossAxisAlignment.start, : null,
children: [ child: AnimatedContainer(
Icon( duration: const Duration(milliseconds: 150),
isError margin: const EdgeInsets.only(bottom: 8),
? Icons.error_outline_rounded padding: const EdgeInsets.all(12),
: isOta decoration: BoxDecoration(
? Icons.system_update_rounded color: isSelected ? Colors.blue.shade100 : bg,
: Icons.check_circle_outline_rounded, borderRadius: BorderRadius.circular(10),
size: 16, border: Border.all(
color: color, color: isSelected
? Colors.blue.shade400
: color.withValues(alpha: 0.25),
width: isSelected ? 1.5 : 1,
), ),
const SizedBox(width: 10), ),
Expanded( child: Row(
child: Column( crossAxisAlignment: CrossAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start, children: [
children: [ // Checkbox atau ikon
Text(msg, 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
? Icons.system_update_rounded
: Icons.check_circle_outline_rounded,
size: 16,
color: color,
),
),
// Konten
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
msg,
style: TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12,
color: color, color: isSelected
fontFamily: 'monospace')), ? Colors.blue.shade900
const SizedBox(height: 2), : color,
Text(fmt.format(ts), fontFamily: 'monospace',
),
),
const SizedBox(height: 2),
Text(
fmt.format(ts),
style: TextStyle( style: TextStyle(
fontSize: 10, 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 list = raw.entries.map((e) {
final v = e.value as Map<dynamic, dynamic>; final v = e.value as Map<dynamic, dynamic>;
return { return {
'_key': e.key.toString(),
'msg': v['msg'] ?? '', 'msg': v['msg'] ?? '',
'timestamp': DateTime.fromMillisecondsSinceEpoch( 'timestamp': DateTime.fromMillisecondsSinceEpoch(
(v['timestamp'] ?? 0) * 1000, (v['timestamp'] ?? 0) * 1000,
@ -146,6 +147,15 @@ class FirebaseMonitoringRepo implements MonitoringRepository {
return []; 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 @override
Future<void> sendRemoteRestart(String deviceId) async { Future<void> sendRemoteRestart(String deviceId) async {
await _db.ref('anemometer/$deviceId/command/restart').set(true); 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> sendRemoteRestart(String deviceId);
Future<void> deleteDeviceLogs(String deviceId, List<String> keys);
} }