parent
1178fc335d
commit
f9a74b9b4e
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 {}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
],
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue