From c5413cdf95f98599357cab6c56f5e6009b2ee5c2 Mon Sep 17 00:00:00 2001 From: kleponijo <158453666+kleponijo@users.noreply.github.com> Date: Fri, 22 May 2026 11:07:24 +0700 Subject: [PATCH] oke --- .../device_setup/blocs/device_setup_bloc.dart | 133 ++- .../blocs/device_setup_event.dart | 41 + .../blocs/device_setup_state.dart | 50 + .../views/device_setup_screen.dart | 1029 +++++++++++++---- .../wind_speed/blocs/wind_speed_bloc.dart | 23 +- .../lib/src/firebase_monitoring_repo.dart | 68 +- .../lib/src/monitoring_repo.dart | 26 +- 7 files changed, 1117 insertions(+), 253 deletions(-) diff --git a/lib/screens/monitoring/device_setup/blocs/device_setup_bloc.dart b/lib/screens/monitoring/device_setup/blocs/device_setup_bloc.dart index 5a42d4c..a722682 100644 --- a/lib/screens/monitoring/device_setup/blocs/device_setup_bloc.dart +++ b/lib/screens/monitoring/device_setup/blocs/device_setup_bloc.dart @@ -1,26 +1,129 @@ import 'package:bloc/bloc.dart'; import 'package:dio/dio.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:monitoring_repository/monitoring_repository.dart'; part 'device_setup_event.dart'; part 'device_setup_state.dart'; +const _kDeviceIdKey = 'selected_device_id'; + class DeviceSetupBloc extends Bloc { - DeviceSetupBloc() : super(const DeviceSetupState()) { + final MonitoringRepository _repository; + + final Dio _dio = Dio(BaseOptions( + connectTimeout: const Duration(seconds: 5), + receiveTimeout: const Duration(seconds: 10), + sendTimeout: const Duration(seconds: 5), + )); + + DeviceSetupBloc({required MonitoringRepository repository}) + : _repository = repository, + super(const DeviceSetupState()) { on(_onCheckConnection); on(_onSendCredentials); on(_onReset); + // Settings + on(_onSettingsStarted); + on(_onDeviceIdChanged); + on((e, emit) => emit(state.copyWith(kFaktor: e.value))); + on((e, emit) => emit(state.copyWith(radiusM: e.value))); + on( + (e, emit) => emit(state.copyWith(intervalRealtimeMs: e.ms))); + on( + (e, emit) => emit(state.copyWith(intervalHistoryMs: e.ms))); + on(_onSettingsSaved); + on(_onLogsRefreshed); } - // ── Dio instance khusus untuk komunikasi ke ESP ────────────── - // Timeout pendek karena ESP di jaringan lokal, harusnya cepat. - // Kalau timeout → berarti HP belum konek ke hotspot ESP. - final Dio _dio = Dio( - BaseOptions( - connectTimeout: const Duration(seconds: 5), - receiveTimeout: const Duration(seconds: 10), - sendTimeout: const Duration(seconds: 5), - ), - ); + // ════════════════════════════════════════════════════════════ + // Settings + // ════════════════════════════════════════════════════════════ + + Future _onSettingsStarted( + DeviceSettingsStarted event, + Emitter emit, + ) async { + emit(state.copyWith(status: DeviceSetupStatus.settingsLoading)); + try { + // Baca device ID tersimpan lokal + final prefs = await SharedPreferences.getInstance(); + final savedId = prefs.getString(_kDeviceIdKey) ?? state.deviceId; + + // Baca settings dari Firebase + final s = await _repository.getAnemometerSettings(); + + // Baca logs + final logs = await _repository.getDeviceLogs(savedId); + + emit(state.copyWith( + status: DeviceSetupStatus.settingsLoaded, + deviceId: savedId, + kFaktor: s['k_faktor'] as double, + radiusM: s['radius_m'] as double, + intervalRealtimeMs: s['interval_realtime_ms'] as int, + intervalHistoryMs: s['interval_history_ms'] as int, + logs: logs, + )); + } catch (e) { + emit(state.copyWith( + status: DeviceSetupStatus.settingsError, + errorMessage: 'Gagal memuat settings: $e', + )); + } + } + + Future _onDeviceIdChanged( + DeviceIdChanged event, + Emitter emit, + ) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_kDeviceIdKey, event.deviceId); + + // Muat log device baru + emit(state.copyWith( + deviceId: event.deviceId, + logsLoading: true, + )); + final logs = await _repository.getDeviceLogs(event.deviceId); + emit(state.copyWith(logs: logs, logsLoading: false)); + } + + Future _onSettingsSaved( + DeviceSettingsSaved event, + Emitter emit, + ) async { + emit(state.copyWith(status: DeviceSetupStatus.settingsSaving)); + try { + await _repository.updateAnemometerSettings( + kFaktor: state.kFaktor, + radiusM: state.radiusM, + intervalRealtimeMs: state.intervalRealtimeMs, + intervalHistoryMs: state.intervalHistoryMs, + ); + emit(state.copyWith(status: DeviceSetupStatus.settingsSaved)); + await Future.delayed(const Duration(seconds: 2)); + emit(state.copyWith(status: DeviceSetupStatus.settingsLoaded)); + } catch (e) { + emit(state.copyWith( + status: DeviceSetupStatus.settingsError, + errorMessage: 'Gagal menyimpan: $e', + )); + } + } + + Future _onLogsRefreshed( + DeviceLogsRefreshed event, + Emitter emit, + ) async { + emit(state.copyWith(logsLoading: true)); + final logs = await _repository.getDeviceLogs(state.deviceId); + emit(state.copyWith(logs: logs, logsLoading: false)); + } + + // ════════════════════════════════════════════════════════════ + // WiFi Setup (existing logic, tidak berubah) + // ════════════════════════════════════════════════════════════ // ── Cek koneksi ke ESP ──────────────────────────────────────── Future _onCheckConnection( @@ -137,6 +240,12 @@ class DeviceSetupBloc extends Bloc { ResetDeviceSetupEvent event, Emitter emit, ) { - emit(const DeviceSetupState()); + emit(DeviceSetupState( + deviceId: state.deviceId, + kFaktor: state.kFaktor, + radiusM: state.radiusM, + intervalRealtimeMs: state.intervalRealtimeMs, + intervalHistoryMs: state.intervalHistoryMs, + )); } } diff --git a/lib/screens/monitoring/device_setup/blocs/device_setup_event.dart b/lib/screens/monitoring/device_setup/blocs/device_setup_event.dart index 9a198c0..f1378cd 100644 --- a/lib/screens/monitoring/device_setup/blocs/device_setup_event.dart +++ b/lib/screens/monitoring/device_setup/blocs/device_setup_event.dart @@ -15,3 +15,44 @@ class SendWifiCredentialsEvent extends DeviceSetupEvent { /// Reset state ke awal (misalnya saat user keluar screen) class ResetDeviceSetupEvent extends DeviceSetupEvent {} + +// ── Sensor Settings events (new) ───────────────────────────── + +/// Load device ID dari SharedPreferences + settings dari Firebase +class DeviceSettingsStarted extends DeviceSetupEvent {} + +/// User pilih/ubah device ID (misal: esp_percobaan → esp_lapangan) +class DeviceIdChanged extends DeviceSetupEvent { + final String deviceId; + DeviceIdChanged(this.deviceId); +} + +/// User ubah k_faktor di form +class KFaktorChanged extends DeviceSetupEvent { + final double value; + KFaktorChanged(this.value); +} + +/// User ubah radius_m di form +class RadiusChanged extends DeviceSetupEvent { + final double value; + RadiusChanged(this.value); +} + +/// User ubah interval realtime (ms) +class IntervalRealtimeChanged extends DeviceSetupEvent { + final int ms; + IntervalRealtimeChanged(this.ms); +} + +/// User ubah interval history (ms) +class IntervalHistoryChanged extends DeviceSetupEvent { + final int ms; + IntervalHistoryChanged(this.ms); +} + +/// Simpan settings ke Firebase +class DeviceSettingsSaved extends DeviceSetupEvent {} + +/// Refresh logs dari Firebase +class DeviceLogsRefreshed extends DeviceSetupEvent {} diff --git a/lib/screens/monitoring/device_setup/blocs/device_setup_state.dart b/lib/screens/monitoring/device_setup/blocs/device_setup_state.dart index ec58f45..e3bb71d 100644 --- a/lib/screens/monitoring/device_setup/blocs/device_setup_state.dart +++ b/lib/screens/monitoring/device_setup/blocs/device_setup_state.dart @@ -8,6 +8,12 @@ enum DeviceSetupStatus { sending, // sedang kirim SSID+password ke ESP success, // ESP berhasil terima & akan restart failure, // gagal (timeout, ESP tidak respond, dll) + // Settings + settingsLoading, + settingsLoaded, + settingsSaving, + settingsSaved, + settingsError, } class DeviceSetupState { @@ -15,12 +21,42 @@ class DeviceSetupState { final String? errorMessage; final String? successMessage; final String espIp; + // ── Sensor settings ────────────────────────────────────────── + /// ID device aktif — menentukan path Firebase yang dibaca app. + /// Default 'esp_lapangan' (ESP utama di lapangan). + /// Disimpan lokal di SharedPreferences. + final String deviceId; + + /// Konstanta kalibrasi. Default 50.0 (hasil estimasi vs AWS). + final double kFaktor; + + /// Jari-jari lengan anemometer dalam meter. Default 0.08 (8 cm). + final double radiusM; + + /// Interval pengiriman realtime ke Firebase (ms). Default 1000 = 1 detik. + final int intervalRealtimeMs; + + /// Interval push history ke Firebase (ms). Default 3600000 = 1 jam. + final int intervalHistoryMs; + + // ── Logs ───────────────────────────────────────────────────── + final List> logs; + final bool logsLoading; const DeviceSetupState({ this.status = DeviceSetupStatus.idle, this.errorMessage, this.successMessage, this.espIp = '192.168.4.1', // default IP ESP saat AP mode + // Settings — default sama dengan cfg_config.h di ESP + this.deviceId = 'esp_lapangan', + this.kFaktor = 50.0, + this.radiusM = 0.08, + this.intervalRealtimeMs = 1000, + this.intervalHistoryMs = 3600000, + // Logs + this.logs = const [], + this.logsLoading = false, }); DeviceSetupState copyWith({ @@ -28,12 +64,26 @@ class DeviceSetupState { String? errorMessage, String? successMessage, String? espIp, + String? deviceId, + double? kFaktor, + double? radiusM, + int? intervalRealtimeMs, + int? intervalHistoryMs, + List>? logs, + bool? logsLoading, }) { return DeviceSetupState( status: status ?? this.status, errorMessage: errorMessage, successMessage: successMessage, espIp: espIp ?? this.espIp, + deviceId: deviceId ?? this.deviceId, + kFaktor: kFaktor ?? this.kFaktor, + radiusM: radiusM ?? this.radiusM, + intervalRealtimeMs: intervalRealtimeMs ?? this.intervalRealtimeMs, + intervalHistoryMs: intervalHistoryMs ?? this.intervalHistoryMs, + logs: logs ?? this.logs, + logsLoading: logsLoading ?? this.logsLoading, ); } } diff --git a/lib/screens/monitoring/device_setup/views/device_setup_screen.dart b/lib/screens/monitoring/device_setup/views/device_setup_screen.dart index ada34d6..85ac403 100644 --- a/lib/screens/monitoring/device_setup/views/device_setup_screen.dart +++ b/lib/screens/monitoring/device_setup/views/device_setup_screen.dart @@ -1,7 +1,31 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import '../blocs/device_setup_bloc.dart'; +import 'package:intl/intl.dart'; +import 'package:monitoring_repository/monitoring_repository.dart'; import 'package:app_settings/app_settings.dart'; +import '../blocs/device_setup_bloc.dart'; + +// ── Opsi device yang tersedia ───────────────────────────────── +const _kDeviceOptions = ['esp_lapangan', 'esp_percobaan']; + +// ── Opsi interval realtime ──────────────────────────────────── +const _kRealtimeOptions = { + '1 detik': 1000, + '5 detik': 5000, + '30 detik': 30000, + '1 menit': 60000, + '1 jam': 3600000, +}; + +// ── Opsi interval history ───────────────────────────────────── +const _kHistoryOptions = { + '10 menit': 600000, + '30 menit': 1800000, + '1 jam': 3600000, + '6 jam': 21600000, + '24 jam': 86400000, +}; class DeviceSetupScreen extends StatefulWidget { const DeviceSetupScreen({super.key}); @@ -10,183 +34,173 @@ class DeviceSetupScreen extends StatefulWidget { State createState() => _DeviceSetupScreenState(); } -class _DeviceSetupScreenState extends State { +class _DeviceSetupScreenState extends State + with SingleTickerProviderStateMixin { + late final TabController _tabController; + + // WiFi form controllers final _ssidController = TextEditingController(); final _passwordController = TextEditingController(); final _formKey = GlobalKey(); bool _obscurePassword = true; + // Settings form controllers + final _kFaktorController = TextEditingController(); + final _radiusController = TextEditingController(); + String? _customDeviceId; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 3, vsync: this); + } + @override void dispose() { + _tabController.dispose(); _ssidController.dispose(); _passwordController.dispose(); + _kFaktorController.dispose(); + _radiusController.dispose(); super.dispose(); } + // ── Sync text controllers dengan state saat settings loaded ── + void _syncControllers(DeviceSetupState state) { + if (_kFaktorController.text != state.kFaktor.toString()) { + _kFaktorController.text = state.kFaktor.toStringAsFixed(2); + } + if (_radiusController.text != state.radiusM.toString()) { + _radiusController.text = state.radiusM.toStringAsFixed(3); + } + } + @override Widget build(BuildContext context) { return BlocProvider( - create: (_) => DeviceSetupBloc(), - child: Scaffold( - appBar: AppBar( - title: const Text('Setup WiFi Anemometer'), - centerTitle: true, - elevation: 0, - ), - body: BlocConsumer( - listener: (context, state) { - if (state.status == DeviceSetupStatus.success) { - _showResultDialog(context, - success: true, message: state.successMessage ?? ''); - } else if (state.status == DeviceSetupStatus.failure) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(state.errorMessage ?? 'Terjadi kesalahan.'), - backgroundColor: Colors.red.shade700, - behavior: SnackBarBehavior.floating, - ), - ); - } - }, - builder: (context, state) { - return SingleChildScrollView( - padding: const EdgeInsets.all(20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // ── Info Banner ─────────────────────────────── - _InfoBanner(status: state.status), - const SizedBox(height: 24), - - // ── Step 1: Panduan konek hotspot ───────────── - _StepCard( - step: 1, - title: 'Nyalakan Perangkat Anemometer', - description: - 'Pastikan ESP32 anemometer sudah menyala dan belum pernah ' - 'dikonfigurasi WiFi-nya. LED akan berkedip menandakan ' - 'mode hotspot aktif.', - isDone: state.status == DeviceSetupStatus.connected || - state.status == DeviceSetupStatus.sending || - state.status == DeviceSetupStatus.success, - ), - const SizedBox(height: 12), - - _StepCard( - step: 2, - title: 'Sambungkan HP ke Hotspot ESP', - description: - 'Buka Pengaturan WiFi di HP kamu, lalu sambungkan ke:\n\n' - '📶 Anemometer-Setup\n\n' - 'Hotspot ini tidak punya password. Setelah tersambung, ' - 'kembali ke aplikasi ini.', - isDone: state.status == DeviceSetupStatus.connected || - state.status == DeviceSetupStatus.sending || - state.status == DeviceSetupStatus.success, - trailing: _OpenWifiSettingsButton(status: state.status), - ), - const SizedBox(height: 12), - - // ── Step 3: Cek koneksi ─────────────────────── - _StepCard( - step: 3, - title: 'Verifikasi Koneksi ke ESP', - description: state.status == DeviceSetupStatus.notConnected - ? state.errorMessage ?? 'Belum terhubung ke ESP.' - : state.status == DeviceSetupStatus.connected || - state.status == DeviceSetupStatus.sending || - state.status == DeviceSetupStatus.success - ? '✅ HP sudah terhubung ke ESP. Lanjutkan ke langkah berikutnya.' - : 'Tekan tombol di bawah untuk memverifikasi koneksi ke ESP.', - isDone: state.status == DeviceSetupStatus.connected || - state.status == DeviceSetupStatus.sending || - state.status == DeviceSetupStatus.success, - trailing: state.status == DeviceSetupStatus.connected || - state.status == DeviceSetupStatus.sending || - state.status == DeviceSetupStatus.success - ? null - : _CheckConnectionButton(state: state), - ), - const SizedBox(height: 12), - - // ── Step 4: Isi form WiFi ───────────────────── - _StepCard( - step: 4, - title: 'Masukkan Kredensial WiFi', - description: 'Isi SSID dan password WiFi yang ingin ' - 'digunakan oleh anemometer.', - isDone: state.status == DeviceSetupStatus.success, - child: state.status == DeviceSetupStatus.connected || - state.status == DeviceSetupStatus.sending - ? _WifiForm( - ssidController: _ssidController, - passwordController: _passwordController, - formKey: _formKey, - obscurePassword: _obscurePassword, - onToggleObscure: () => setState( - () => _obscurePassword = !_obscurePassword), - onSubmit: state.status == DeviceSetupStatus.sending - ? null - : () { - if (_formKey.currentState!.validate()) { - context.read().add( - SendWifiCredentialsEvent( - ssid: _ssidController.text, - password: - _passwordController.text, - ), - ); - } - }, - isLoading: - state.status == DeviceSetupStatus.sending, - ) - : const SizedBox.shrink(), - ), + create: (_) => DeviceSetupBloc( + repository: context.read(), + )..add(DeviceSettingsStarted()), + child: BlocConsumer( + listener: (context, state) { + // WiFi success/failure dialog + if (state.status == DeviceSetupStatus.success && + state.successMessage != null) { + _showResultDialog(context, + success: true, message: state.successMessage!); + } + if (state.status == DeviceSetupStatus.failure && + state.errorMessage != null) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(state.errorMessage!), + backgroundColor: Colors.red.shade700, + behavior: SnackBarBehavior.floating, + )); + } + // Settings saved snackbar + if (state.status == DeviceSetupStatus.settingsSaved) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: const Row(children: [ + Icon(Icons.check_circle, color: Colors.white), + SizedBox(width: 10), + Text('Settings disimpan! ESP akan baca dalam ~5 menit.'), + ]), + backgroundColor: Colors.green.shade600, + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10)), + )); + } + // Sync controllers saat data loaded + if (state.status == DeviceSetupStatus.settingsLoaded) { + _syncControllers(state); + } + }, + builder: (context, state) { + return Scaffold( + backgroundColor: Colors.grey.shade100, + appBar: AppBar( + title: const Text('Pengaturan Perangkat', + style: TextStyle(fontWeight: FontWeight.bold)), + centerTitle: true, + backgroundColor: Colors.transparent, + foregroundColor: Colors.black, + elevation: 0, + bottom: TabBar( + controller: _tabController, + labelColor: Colors.blue.shade700, + unselectedLabelColor: Colors.grey.shade500, + indicatorColor: Colors.blue.shade700, + indicatorWeight: 3, + tabs: const [ + Tab(icon: Icon(Icons.tune_rounded), text: 'Sensor'), + Tab(icon: Icon(Icons.wifi_rounded), text: 'WiFi ESP'), + Tab(icon: Icon(Icons.terminal_rounded), text: 'Log'), ], ), - ); - }, - ), + ), + body: TabBarView( + controller: _tabController, + children: [ + _SensorSettingsTab( + state: state, + kFaktorController: _kFaktorController, + radiusController: _radiusController, + customDeviceId: _customDeviceId, + onCustomDeviceIdChanged: (v) => + setState(() => _customDeviceId = v), + ), + _WifiSetupTab( + state: state, + ssidController: _ssidController, + passwordController: _passwordController, + formKey: _formKey, + obscurePassword: _obscurePassword, + onToggleObscure: () => + setState(() => _obscurePassword = !_obscurePassword), + onShowResultDialog: (success, msg) => _showResultDialog( + context, + success: success, + message: msg), + ), + _LogsTab(state: state), + ], + ), + ); + }, ), ); } - void _showResultDialog( - BuildContext context, { - required bool success, - required String message, - }) { + void _showResultDialog(BuildContext context, + {required bool success, required String message}) { showDialog( context: context, barrierDismissible: false, - builder: (dialogContext) => AlertDialog( + builder: (ctx) => AlertDialog( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - title: Row( - children: [ - Icon( - success ? Icons.check_circle_rounded : Icons.error_rounded, - color: success ? Colors.green : Colors.red, - ), - const SizedBox(width: 8), - Text(success ? 'Berhasil!' : 'Gagal'), - ], - ), + title: Row(children: [ + Icon( + success ? Icons.check_circle_rounded : Icons.error_rounded, + color: success ? Colors.green : Colors.red, + ), + const SizedBox(width: 8), + Text(success ? 'Berhasil!' : 'Gagal'), + ]), content: Text(message), actions: [ - if (!success) // ← tambah tombol Coba Lagi jika gagal + if (!success) TextButton( onPressed: () { - Navigator.of(dialogContext).pop(); // tutup dialog - context.read().add( - ResetDeviceSetupEvent()); // kembali ke wind_speed_screen + Navigator.pop(ctx); + context.read().add(ResetDeviceSetupEvent()); }, child: const Text('Coba Lagi'), ), TextButton( onPressed: () { - Navigator.of(dialogContext).pop(); - Navigator.of(context).pop(); + Navigator.pop(ctx); + if (success) Navigator.pop(context); }, child: Text(success ? 'Selesai' : 'Tutup'), ), @@ -196,6 +210,680 @@ class _DeviceSetupScreenState extends State { } } +// ═══════════════════════════════════════════════════════════════ +// Tab 1 — Sensor Settings +// ═══════════════════════════════════════════════════════════════ +class _SensorSettingsTab extends StatelessWidget { + final DeviceSetupState state; + final TextEditingController kFaktorController; + final TextEditingController radiusController; + final String? customDeviceId; + final ValueChanged onCustomDeviceIdChanged; + + const _SensorSettingsTab({ + required this.state, + required this.kFaktorController, + required this.radiusController, + required this.customDeviceId, + required this.onCustomDeviceIdChanged, + }); + + @override + Widget build(BuildContext context) { + final bloc = context.read(); + final isSaving = state.status == DeviceSetupStatus.settingsSaving; + final isLoading = state.status == DeviceSetupStatus.settingsLoading; + + if (isLoading) { + return const Center(child: CircularProgressIndicator()); + } + + return SingleChildScrollView( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ── Info card ──────────────────────────────────────── + _infoCard( + 'Settings disimpan ke Firebase. ESP membaca ulang setiap 5 menit. ' + 'Tidak perlu upload ulang firmware.', + ), + const SizedBox(height: 24), + + // ── Device ID ──────────────────────────────────────── + _sectionTitle('Perangkat Aktif'), + const SizedBox(height: 10), + _DeviceIdSelector( + currentId: state.deviceId, + onChanged: (id) => bloc.add(DeviceIdChanged(id)), + ), + const SizedBox(height: 24), + + // ── Kalibrasi ──────────────────────────────────────── + _sectionTitle('Kalibrasi'), + const SizedBox(height: 10), + _SettingsCard(children: [ + _NumericField( + controller: kFaktorController, + label: 'K-Faktor', + hint: 'Contoh: 50.0', + suffix: '×', + helper: 'Konstanta kalibrasi vs AWS. Awal: 50.0', + onChanged: (v) { + final d = double.tryParse(v); + if (d != null && d > 0) bloc.add(KFaktorChanged(d)); + }, + ), + const Divider(height: 24), + _NumericField( + controller: radiusController, + label: 'Jari-jari Lengan', + hint: 'Contoh: 0.08', + suffix: 'm', + helper: 'Jarak pusat ke ujung cup anemometer. Default: 0.08 m', + onChanged: (v) { + final d = double.tryParse(v); + if (d != null && d > 0) bloc.add(RadiusChanged(d)); + }, + ), + ]), + const SizedBox(height: 24), + + // ── Interval Realtime ───────────────────────────────── + _sectionTitle('Interval Pengiriman Realtime'), + const SizedBox(height: 4), + Text( + 'Seberapa sering data rata-rata dikirim ke Firebase. ' + 'Lebih pendek = lebih akurat, lebih boros data.', + style: TextStyle(fontSize: 12, color: Colors.grey.shade500), + ), + const SizedBox(height: 10), + _ChipSelector( + options: _kRealtimeOptions, + selectedMs: state.intervalRealtimeMs, + onSelected: (ms) => bloc.add(IntervalRealtimeChanged(ms)), + ), + const SizedBox(height: 24), + + // ── Interval History ────────────────────────────────── + _sectionTitle('Interval History (Rata-rata Jangka Panjang)'), + const SizedBox(height: 4), + Text( + 'Seberapa sering rata-rata data di-push ke history Firebase. ' + 'Ideal: 1 jam untuk rekaman harian.', + style: TextStyle(fontSize: 12, color: Colors.grey.shade500), + ), + const SizedBox(height: 10), + _ChipSelector( + options: _kHistoryOptions, + selectedMs: state.intervalHistoryMs, + onSelected: (ms) => bloc.add(IntervalHistoryChanged(ms)), + ), + const SizedBox(height: 32), + + // ── Rumus preview ───────────────────────────────────── + _FormulaPreview( + kFaktor: state.kFaktor, + radiusM: state.radiusM, + ), + const SizedBox(height: 32), + + // ── Tombol Simpan ───────────────────────────────────── + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: + isSaving ? null : () => bloc.add(DeviceSettingsSaved()), + icon: isSaving + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, color: Colors.white), + ) + : const Icon(Icons.save_rounded), + label: Text(isSaving ? 'Menyimpan...' : 'Simpan ke Firebase'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.blue.shade700, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14)), + textStyle: + const TextStyle(fontSize: 15, fontWeight: FontWeight.bold), + ), + ), + ), + const SizedBox(height: 20), + ], + ), + ); + } + + Widget _sectionTitle(String text) => Text( + text, + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold), + ); + + Widget _infoCard(String text) => Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: Colors.blue.shade50, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.blue.shade100), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.info_outline_rounded, + size: 18, color: Colors.blue.shade600), + const SizedBox(width: 10), + Expanded( + child: Text(text, + style: TextStyle(fontSize: 12, color: Colors.blue.shade800)), + ), + ], + ), + ); +} + +// ═══════════════════════════════════════════════════════════════ +// Tab 2 — WiFi Setup (existing, wrapped) +// ═══════════════════════════════════════════════════════════════ +class _WifiSetupTab extends StatelessWidget { + final DeviceSetupState state; + final TextEditingController ssidController; + final TextEditingController passwordController; + final GlobalKey formKey; + final bool obscurePassword; + final VoidCallback onToggleObscure; + final void Function(bool, String) onShowResultDialog; + + const _WifiSetupTab({ + required this.state, + required this.ssidController, + required this.passwordController, + required this.formKey, + required this.obscurePassword, + required this.onToggleObscure, + required this.onShowResultDialog, + }); + + @override + Widget build(BuildContext context) { + final bloc = context.read(); + + return SingleChildScrollView( + padding: const EdgeInsets.all(20), + child: Column( + children: [ + _InfoBanner(status: state.status), + const SizedBox(height: 20), + _StepCard( + step: 1, + title: 'Nyalakan Anemometer', + description: + 'Pastikan ESP32 sudah menyala dan dalam mode AP (hotspot). ' + 'LED berkedip menandakan mode hotspot aktif.', + isDone: _isConnectedOrBeyond(state.status), + ), + const SizedBox(height: 12), + _StepCard( + step: 2, + title: 'Sambungkan HP ke Hotspot ESP', + description: 'Buka Pengaturan WiFi → sambungkan ke:\n\n' + '📶 Anemometer-Setup\n\n' + 'Tidak ada password. Kembali ke app setelah tersambung.', + isDone: _isConnectedOrBeyond(state.status), + trailing: _isConnectedOrBeyond(state.status) + ? null + : TextButton.icon( + onPressed: () => + AppSettings.openAppSettings(type: AppSettingsType.wifi), + icon: const Icon(Icons.settings_rounded, size: 16), + label: const Text('Buka Setting'), + ), + ), + const SizedBox(height: 12), + _StepCard( + step: 3, + title: 'Verifikasi Koneksi ke ESP', + description: _step3Description(state), + isDone: _isConnectedOrBeyond(state.status), + trailing: _isConnectedOrBeyond(state.status) + ? null + : ElevatedButton.icon( + onPressed: state.status == DeviceSetupStatus.checkingConn + ? null + : () => bloc.add(CheckEspConnectionEvent()), + icon: state.status == DeviceSetupStatus.checkingConn + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2)) + : const Icon(Icons.wifi_find_rounded, size: 16), + label: Text( + state.status == DeviceSetupStatus.checkingConn + ? 'Mengecek...' + : 'Cek Koneksi', + ), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 14, vertical: 8), + textStyle: const TextStyle(fontSize: 13)), + ), + ), + const SizedBox(height: 12), + _StepCard( + step: 4, + title: 'Masukkan Kredensial WiFi', + description: 'Isi SSID dan password WiFi untuk anemometer.', + isDone: state.status == DeviceSetupStatus.success, + child: _isConnectedOrBeyond(state.status) && + state.status != DeviceSetupStatus.success + ? _WifiForm( + ssidController: ssidController, + passwordController: passwordController, + formKey: formKey, + obscurePassword: obscurePassword, + onToggleObscure: onToggleObscure, + isLoading: state.status == DeviceSetupStatus.sending, + onSubmit: state.status == DeviceSetupStatus.sending + ? null + : () { + if (formKey.currentState!.validate()) { + bloc.add(SendWifiCredentialsEvent( + ssid: ssidController.text, + password: passwordController.text, + )); + } + }, + ) + : const SizedBox.shrink(), + ), + ], + ), + ); + } + + bool _isConnectedOrBeyond(DeviceSetupStatus s) => + s == DeviceSetupStatus.connected || + s == DeviceSetupStatus.sending || + s == DeviceSetupStatus.success; + + String _step3Description(DeviceSetupState state) { + if (state.status == DeviceSetupStatus.notConnected) { + return state.errorMessage ?? 'Belum terhubung ke ESP.'; + } + if (_isConnectedOrBeyond(state.status)) { + return '✅ HP sudah terhubung ke ESP.'; + } + return 'Tekan tombol untuk memverifikasi koneksi ke ESP.'; + } +} + +// ═══════════════════════════════════════════════════════════════ +// Tab 3 — Logs +// ═══════════════════════════════════════════════════════════════ +class _LogsTab extends StatelessWidget { + final DeviceSetupState state; + const _LogsTab({required this.state}); + + @override + Widget build(BuildContext context) { + final bloc = context.read(); + final fmt = DateFormat('dd MMM HH:mm:ss', 'id_ID'); + + return Column( + children: [ + // ── Header ───────────────────────────────────────────── + Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 8), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Log Device', + style: const TextStyle( + fontSize: 15, fontWeight: FontWeight.bold)), + Text( + state.deviceId, + style: + TextStyle(fontSize: 12, color: Colors.grey.shade500), + ), + ], + ), + ), + IconButton.filledTonal( + onPressed: state.logsLoading + ? null + : () => bloc.add(DeviceLogsRefreshed()), + icon: state.logsLoading + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2)) + : const Icon(Icons.refresh_rounded), + tooltip: 'Refresh logs', + ), + ], + ), + ), + + // ── List ─────────────────────────────────────────────── + Expanded( + child: state.logs.isEmpty + ? Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.terminal_rounded, + size: 48, color: Colors.grey.shade300), + const SizedBox(height: 10), + Text('Belum ada log', + style: TextStyle(color: Colors.grey.shade400)), + const SizedBox(height: 4), + Text('Boot ESP untuk melihat log pertama', + style: TextStyle( + fontSize: 12, color: Colors.grey.shade400)), + ], + ), + ) + : ListView.builder( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 20), + itemCount: state.logs.length, + itemBuilder: (ctx, i) { + final log = state.logs[i]; + final msg = log['msg'] as String; + final ts = log['timestamp'] as DateTime; + + // Warna berdasarkan konten pesan + final isOta = msg.contains('OTA') || msg.contains('FW='); + final isError = msg.contains('GAGAL') || + msg.contains('error') || + msg.contains('Error'); + + final color = isError + ? Colors.red.shade600 + : isOta + ? Colors.blue.shade600 + : Colors.green.shade600; + final bg = isError + ? Colors.red.shade50 + : isOta + ? Colors.blue.shade50 + : Colors.green.shade50; + + return Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: bg, + borderRadius: BorderRadius.circular(10), + border: + Border.all(color: color.withValues(alpha: 0.25)), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + isError + ? Icons.error_outline_rounded + : isOta + ? Icons.system_update_rounded + : Icons.check_circle_outline_rounded, + size: 16, + color: color, + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(msg, + style: TextStyle( + fontSize: 12, + color: color, + fontFamily: 'monospace')), + const SizedBox(height: 2), + Text(fmt.format(ts), + style: TextStyle( + fontSize: 10, + color: Colors.grey.shade500)), + ], + ), + ), + ], + ), + ); + }, + ), + ), + ], + ); + } +} + +// ═══════════════════════════════════════════════════════════════ +// Sub-widgets +// ═══════════════════════════════════════════════════════════════ + +class _DeviceIdSelector extends StatelessWidget { + final String currentId; + final ValueChanged onChanged; + const _DeviceIdSelector({required this.currentId, required this.onChanged}); + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(14), + boxShadow: [ + BoxShadow(color: Colors.black.withValues(alpha: 0.05), blurRadius: 6), + ], + ), + child: Column( + children: [ + ..._kDeviceOptions.map((id) { + final selected = id == currentId; + return RadioListTile( + value: id, + groupValue: currentId, + title: Text(id, + style: TextStyle( + fontWeight: + selected ? FontWeight.bold : FontWeight.normal, + fontFamily: 'monospace')), + subtitle: Text( + id == 'esp_lapangan' + ? 'ESP utama di lapangan (outdoor)' + : 'ESP untuk percobaan (indoor/lab)', + style: TextStyle(fontSize: 11, color: Colors.grey.shade500), + ), + activeColor: Colors.blue.shade700, + onChanged: (v) { + if (v != null) onChanged(v); + }, + ); + }), + // Custom ID + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), + child: TextField( + decoration: InputDecoration( + labelText: 'Custom Device ID', + hintText: 'Contoh: esp_atap_gedung', + prefixIcon: const Icon(Icons.devices_rounded, size: 18), + border: + OutlineInputBorder(borderRadius: BorderRadius.circular(10)), + contentPadding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + isDense: true, + ), + onSubmitted: (v) { + if (v.trim().isNotEmpty) onChanged(v.trim()); + }, + ), + ), + ], + ), + ); + } +} + +class _ChipSelector extends StatelessWidget { + final Map options; + final int selectedMs; + final ValueChanged onSelected; + const _ChipSelector( + {required this.options, + required this.selectedMs, + required this.onSelected}); + + @override + Widget build(BuildContext context) { + return Wrap( + spacing: 8, + runSpacing: 8, + children: options.entries.map((e) { + final selected = e.value == selectedMs; + return ChoiceChip( + label: Text(e.key), + selected: selected, + selectedColor: Colors.blue.shade700, + labelStyle: TextStyle( + color: selected ? Colors.white : Colors.black87, + fontWeight: selected ? FontWeight.bold : FontWeight.normal, + ), + onSelected: (_) => onSelected(e.value), + ); + }).toList(), + ); + } +} + +class _NumericField extends StatelessWidget { + final TextEditingController controller; + final String label; + final String hint; + final String suffix; + final String helper; + final ValueChanged onChanged; + + const _NumericField({ + required this.controller, + required this.label, + required this.hint, + required this.suffix, + required this.helper, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + return TextField( + controller: controller, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'[0-9.]'))], + decoration: InputDecoration( + labelText: label, + hintText: hint, + suffixText: suffix, + helperText: helper, + helperMaxLines: 2, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)), + contentPadding: + const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + isDense: true, + ), + onChanged: onChanged, + ); + } +} + +class _SettingsCard extends StatelessWidget { + final List children; + const _SettingsCard({required this.children}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(14), + boxShadow: [ + BoxShadow(color: Colors.black.withValues(alpha: 0.05), blurRadius: 6), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: children), + ); + } +} + +class _FormulaPreview extends StatelessWidget { + final double kFaktor; + final double radiusM; + const _FormulaPreview({required this.kFaktor, required this.radiusM}); + + @override + Widget build(BuildContext context) { + // Contoh: 1 pulsa per detik + final exampleRps = 1.0; + final exampleSpeed = 2 * 3.14159 * radiusM * exampleRps * kFaktor; + + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.grey.shade900, + borderRadius: BorderRadius.circular(14), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row(children: [ + Icon(Icons.functions_rounded, + size: 16, color: Colors.amber.shade300), + const SizedBox(width: 8), + Text('Preview Rumus', + style: TextStyle( + fontSize: 12, + color: Colors.amber.shade300, + fontWeight: FontWeight.bold)), + ]), + const SizedBox(height: 10), + Text( + 'speed = 2π × radius × RPS × k_faktor', + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade400, + fontFamily: 'monospace'), + ), + const SizedBox(height: 6), + Text( + ' = 2π × ${radiusM.toStringAsFixed(3)}m × RPS × ${kFaktor.toStringAsFixed(1)}', + style: const TextStyle( + fontSize: 12, color: Colors.white70, fontFamily: 'monospace'), + ), + const SizedBox(height: 10), + Text( + '@ 1 pulsa/detik → ${exampleSpeed.toStringAsFixed(3)} m/s', + style: TextStyle( + fontSize: 13, + color: Colors.green.shade300, + fontWeight: FontWeight.bold, + fontFamily: 'monospace'), + ), + ], + ), + ); + } +} + // ══════════════════════════════════════════════════════════════════ // Widgets internal // ══════════════════════════════════════════════════════════════════ @@ -346,59 +1034,6 @@ class _StepCard extends StatelessWidget { } } -class _OpenWifiSettingsButton extends StatelessWidget { - final DeviceSetupStatus status; - const _OpenWifiSettingsButton({required this.status}); - - @override - Widget build(BuildContext context) { - // Tidak perlu tampil kalau sudah connected - if (status == DeviceSetupStatus.connected || - status == DeviceSetupStatus.sending || - status == DeviceSetupStatus.success) { - return const SizedBox.shrink(); - } - return TextButton.icon( - onPressed: () { - // Buka pengaturan WiFi sistem HP - // Butuh package: app_settings (opsional) - // AppSettings.openWIFISettings(); - AppSettings.openAppSettings(type: AppSettingsType.wifi); - }, - icon: const Icon(Icons.settings_rounded, size: 16), - label: const Text('Buka Setting'), - ); - } -} - -class _CheckConnectionButton extends StatelessWidget { - final DeviceSetupState state; - const _CheckConnectionButton({required this.state}); - - @override - Widget build(BuildContext context) { - final isChecking = state.status == DeviceSetupStatus.checkingConn; - return ElevatedButton.icon( - onPressed: isChecking - ? null - : () => - context.read().add(CheckEspConnectionEvent()), - icon: isChecking - ? const SizedBox( - width: 14, - height: 14, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.wifi_find_rounded, size: 16), - label: Text(isChecking ? 'Mengecek...' : 'Cek Koneksi'), - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), - textStyle: const TextStyle(fontSize: 13), - ), - ); - } -} - class _WifiForm extends StatelessWidget { final TextEditingController ssidController; final TextEditingController passwordController; diff --git a/lib/screens/monitoring/wind_speed/blocs/wind_speed_bloc.dart b/lib/screens/monitoring/wind_speed/blocs/wind_speed_bloc.dart index 30936df..eda80ab 100644 --- a/lib/screens/monitoring/wind_speed/blocs/wind_speed_bloc.dart +++ b/lib/screens/monitoring/wind_speed/blocs/wind_speed_bloc.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:bloc/bloc.dart'; import 'package:equatable/equatable.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'package:monitoring_repository/monitoring_repository.dart'; import 'package:bloc_concurrency/bloc_concurrency.dart'; import '../../../../core/utils/time_series_mapper.dart'; @@ -13,6 +14,8 @@ part 'wind_speed_state.dart'; /// Referensi: Beaufort scale & BMKG const double _kWindWarning = 8.0; // Waspada: 8–12.5 m/s (~29–45 km/h) const double _kWindDanger = 12.5; // Bahaya: > 12.5 m/s (> 45 km/h) +const _kDeviceIdKey = 'selected_device_id'; +const _kDefaultDeviceId = 'esp_lapangan'; class WindSpeedBloc extends Bloc { final MonitoringRepository _repository; @@ -31,6 +34,12 @@ class WindSpeedBloc extends Bloc { on(_onDateFilterChanged); // ← baru } + // ── Baca device ID dari SharedPreferences ───────────────── + Future _getDeviceId() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getString(_kDeviceIdKey) ?? _kDefaultDeviceId; + } + // ════════════════════════════════════════════════════════════ // START // ════════════════════════════════════════════════════════════ @@ -40,6 +49,9 @@ class WindSpeedBloc extends Bloc { ) async { emit(state.copyWith(isLoading: true)); + // Baca device ID aktif (dari SharedPreferences, diset di DeviceSetupBloc) + final deviceId = await _getDeviceId(); + final history = await _repository.getSensorHistory( 'anemometer/esp_percobaan/history', (json) => MyWindSpeed.fromJson(json), @@ -82,15 +94,14 @@ class WindSpeedBloc extends Bloc { history.isNotEmpty ? _getAlertLevel(history.last.speed) : 'Normal', )); + // Subscribe realtime dari path device aktif await _subscription?.cancel(); _subscription = _repository .getSensorStream( - 'anemometer/esp_percobaan/realtime', - (json) => MyWindSpeed.fromJson(json), - ) - .listen((data) { - add(_WindSpeedRealtimeUpdated(data)); - }); + 'anemometer/$deviceId/realtime', + (json) => MyWindSpeed.fromJson(json), + ) + .listen((data) => add(_WindSpeedRealtimeUpdated(data))); } // ════════════════════════════════════════════════════════════ diff --git a/packages/monitoring_repository/lib/src/firebase_monitoring_repo.dart b/packages/monitoring_repository/lib/src/firebase_monitoring_repo.dart index d5d2925..f13be47 100644 --- a/packages/monitoring_repository/lib/src/firebase_monitoring_repo.dart +++ b/packages/monitoring_repository/lib/src/firebase_monitoring_repo.dart @@ -1,6 +1,4 @@ -// import 'dart:developer'; import 'dart:async'; -// import 'package:rxdart/rxdart.dart'; import 'package:firebase_database/firebase_database.dart'; import 'package:monitoring_repository/monitoring_repository.dart'; @@ -15,57 +13,46 @@ class FirebaseMonitoringRepo implements MonitoringRepository { String path, T Function(Map json) mapper, ) { - /// === ambil data mentah dari firebase === /// return _db.ref(path).onValue.map((event) { final Object? value = event.snapshot.value; - - if (value is Map) { - return mapper(value); - } else { - // Jika data kosong atau bukan Map, berikan Map kosong agar mapper tidak crash - return mapper({}); - } + return mapper(value is Map ? value : {}); }); } + // ── Snapshot ───────────────────────────────────────────────── @override - // Jika ingin mengambil data sekali saja (bukan stream) Future getSensorSnapshot( String path, T Function(Map json) mapper, ) async { final snapshot = await _db.ref(path).get(); - final data = snapshot.value as Map? ?? {}; - return mapper(data); + return mapper( + snapshot.value is Map ? snapshot.value as Map : {}, + ); } + // ── History ────────────────────────────────────────────────── @override Future> getSensorHistory( String path, T Function(Map json) mapper, ) async { try { - // Ambil data dari path history final snapshot = await _db.ref(path).get(); - if (snapshot.exists && snapshot.value is Map) { - final Map data = snapshot.value as Map; - - final list = data.values.map((item) { - return mapper(item as Map); - }).toList(); - - // ✅ Sort by timestamp — Firebase push tidak selalu menghasilkan urutan kronologis. + final data = snapshot.value as Map; + final list = data.values + .map((item) => mapper(item as Map)) + .toList(); list.sort((a, b) { try { - final aTimestamp = (a as dynamic).timestamp as DateTime; - final bTimestamp = (b as dynamic).timestamp as DateTime; - return aTimestamp.compareTo(bTimestamp); + final aT = (a as dynamic).timestamp as DateTime; + final bT = (b as dynamic).timestamp as DateTime; + return aT.compareTo(bT); } catch (_) { return 0; } }); - return list; } return []; @@ -74,29 +61,40 @@ class FirebaseMonitoringRepo implements MonitoringRepository { } } - // ── Baca settings dari Firebase ────────────────────────────── + // ── Anemometer Settings ────────────────────────────────────── + @override Future> getAnemometerSettings() async { + // Default sama dengan cfg_config.h di ESP + const defaults = { + 'k_faktor': 50.0, + 'radius_m': 0.08, + 'interval_realtime_ms': 1000, + 'interval_history_ms': 3600000, + }; try { final snapshot = await _db.ref('anemometer/settings').get(); if (snapshot.exists && snapshot.value is Map) { final raw = snapshot.value as Map; return { - 'k_faktor': (raw['k_faktor'] ?? 50.0).toDouble(), - 'interval_realtime_ms': (raw['interval_realtime_ms'] ?? 1000) as int, - 'interval_history_ms': (raw['interval_history_ms'] ?? 3600000) as int, + 'k_faktor': (raw['k_faktor'] ?? defaults['k_faktor']).toDouble(), + 'radius_m': (raw['radius_m'] ?? defaults['radius_m']).toDouble(), + 'interval_realtime_ms': + (raw['interval_realtime_ms'] ?? defaults['interval_realtime_ms']) + as int, + 'interval_history_ms': + (raw['interval_history_ms'] ?? defaults['interval_history_ms']) + as int, }; } } catch (_) {} - return { - 'k_faktor': 50.0, - 'interval_realtime_ms': 1000, - 'interval_history_ms': 3600000, - }; + + return defaults; } // ── Tulis settings ke Firebase (dari app) ──────────────────── Future updateAnemometerSettings({ double? kFaktor, + double? radiusM, int? intervalRealtimeMs, int? intervalHistoryMs, }) async { diff --git a/packages/monitoring_repository/lib/src/monitoring_repo.dart b/packages/monitoring_repository/lib/src/monitoring_repo.dart index 555796a..62e06ce 100644 --- a/packages/monitoring_repository/lib/src/monitoring_repo.dart +++ b/packages/monitoring_repository/lib/src/monitoring_repo.dart @@ -1,7 +1,5 @@ -// import 'models/models.dart'; - abstract class MonitoringRepository { - // fungsi untuk ambil data berkali/streaming + // ── Stream & Snapshot ──────────────────────────────────────── Stream getSensorStream( String path, T Function(Map json) mapper, @@ -18,4 +16,26 @@ abstract class MonitoringRepository { String path, T Function(Map json) mapper, ); + + // ── Anemometer Settings ────────────────────────────────────── + /// Baca semua settings dari /anemometer/settings/ + /// Return map dengan keys: + /// k_faktor (double), radius_m (double), + /// interval_realtime_ms (int), interval_history_ms (int) + Future> getAnemometerSettings(); + + /// Tulis settings ke /anemometer/settings/ (field yang null tidak ditulis) + Future updateAnemometerSettings({ + double? kFaktor, + double? radiusM, + int? intervalRealtimeMs, + int? intervalHistoryMs, + }); + + // ── Device Logs ────────────────────────────────────────────── + /// Ambil N log terakhir dari /anemometer/{deviceId}/logs + Future>> getDeviceLogs( + String deviceId, { + int limit = 50, + }); }