parent
1178fc335d
commit
f9a74b9b4e
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -41,4 +41,6 @@ abstract class MonitoringRepository {
|
|||
});
|
||||
|
||||
Future<void> sendRemoteRestart(String deviceId);
|
||||
|
||||
Future<void> deleteDeviceLogs(String deviceId, List<String> keys);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue