diff --git a/composeApp/src/androidMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/AppStorage.android.kt b/composeApp/src/androidMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/AppStorage.android.kt index 4ae7867..77a96f6 100644 --- a/composeApp/src/androidMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/AppStorage.android.kt +++ b/composeApp/src/androidMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/AppStorage.android.kt @@ -1,9 +1,14 @@ package com.digitoolsolutions.app.torquevaultkmp import android.content.Context +import android.content.SharedPreferences import com.digitoolsolutions.app.torquevaultkmp.data.storage.AppStorage import kotlin.reflect.KClass import androidx.core.content.edit +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.filterNotNull class AppStoragePlatform(context: Context) : AppStorage { private val prefs = context.getSharedPreferences("app_prefs", Context.MODE_PRIVATE) @@ -30,4 +35,22 @@ class AppStoragePlatform(context: Context) : AppStorage { override fun remove(key: String) { prefs.edit { remove(key) } } + + @Suppress("UNCHECKED_CAST") + override fun observe(key: String, type: KClass): Flow = callbackFlow { + val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, changedKey -> + if (changedKey == key) { + val value: T? = when (type) { + String::class -> prefs.getString(key, null) as T? + Int::class -> prefs.getInt(key, -1) as T? + Boolean::class -> prefs.getBoolean(key, false) as T? + else -> throw IllegalArgumentException("Unsupported type") + } + value?.let { trySend(it) } + } + } + prefs.registerOnSharedPreferenceChangeListener(listener) + load(key, type)?.let { trySend(it) } + awaitClose { prefs.unregisterOnSharedPreferenceChangeListener(listener) } + }.filterNotNull() } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/repository/DeviceRepository.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/repository/DeviceRepository.kt index 017805b..9a5bccd 100644 --- a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/repository/DeviceRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/repository/DeviceRepository.kt @@ -11,6 +11,9 @@ import kotlinx.coroutines.flow.Flow class DeviceRepository( private val dbQueries: AppDatabaseQueries ) { + companion object { + const val PAGE_SIZE = 20L + } suspend fun saveDevice(id: String, name: String) { dbQueries.insertDevice(id, name) } @@ -20,7 +23,10 @@ class DeviceRepository( .asFlow() .mapToList(Dispatchers.IO) - suspend fun getDevicesPaged(limit: Long, lastId: String?): List { + suspend fun getAllDeviceIdsFlow(): Flow> { + return dbQueries.getAllDeviceIds().asFlow().mapToList(Dispatchers.IO) + } + suspend fun getDevicesPaged(limit: Long = PAGE_SIZE, lastId: String? = null): List { return if (lastId == null) { dbQueries.getDevicesFirstPage(limit).executeAsList() } else { diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/storage/AppStorage.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/storage/AppStorage.kt index a434e12..eedfbca 100644 --- a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/storage/AppStorage.kt +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/storage/AppStorage.kt @@ -1,13 +1,18 @@ package com.digitoolsolutions.app.torquevaultkmp.data.storage +import kotlinx.coroutines.flow.Flow import kotlin.reflect.KClass interface AppStorage { fun save(key: String, value: T) fun load(key: String, type: KClass): T? fun remove(key: String) + fun observe(key: String, type: KClass): Flow } inline fun AppStorage.load(key: String): T? { return load(key, T::class) +} +inline fun AppStorage.observe(key: String): Flow { + return observe(key, T::class) } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/bonded/BondedScreen.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/bonded/BondedScreen.kt index 29194f7..8c42c17 100644 --- a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/bonded/BondedScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/bonded/BondedScreen.kt @@ -51,6 +51,9 @@ fun BondedScreen( val devices by viewModel.bondedDevices.collectAsState() val isLoading by viewModel.isLoading.collectAsState() + LaunchedEffect(Unit) { + viewModel.refresh() + } BondedContent( devices = devices, isLoading = isLoading, diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/bonded/BondedViewModel.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/bonded/BondedViewModel.kt index 6ab027e..163c76b 100644 --- a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/bonded/BondedViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/bonded/BondedViewModel.kt @@ -18,7 +18,7 @@ class BondedViewModel( val bondedDevices: StateFlow> = _bondedDevices.asStateFlow() private var lastId: String? = null - private val pageSize = 20L + private val pageSize = DeviceRepository.PAGE_SIZE private var isLastPage = false var isLoading = MutableStateFlow(false) private set diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/scanner/ScannerScreen.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/scanner/ScannerScreen.kt index aebe1b6..4446291 100644 --- a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/scanner/ScannerScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/scanner/ScannerScreen.kt @@ -105,7 +105,6 @@ fun ScannerScreen( } else { viewModel.disconnect(deviceId) } - viewModel.addBondedDevice(deviceId, device.name ?: "No name") } ) } diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/scanner/ScannerViewModel.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/scanner/ScannerViewModel.kt index 8feee17..8aa0f92 100644 --- a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/scanner/ScannerViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/scanner/ScannerViewModel.kt @@ -9,6 +9,7 @@ import com.digitoolsolutions.app.torquevaultkmp.data.repository.ScannerRepositor import com.digitoolsolutions.app.torquevaultkmp.data.storage.AppStorage import com.digitoolsolutions.app.torquevaultkmp.data.storage.ReferKeys import com.digitoolsolutions.app.torquevaultkmp.data.storage.load +import com.digitoolsolutions.app.torquevaultkmp.data.storage.observe import com.juul.kable.Advertisement import com.juul.kable.Peripheral import com.juul.kable.State @@ -22,10 +23,12 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.cancel import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlin.time.Duration.Companion.seconds @@ -39,7 +42,9 @@ class ScannerViewModel( private val deviceRepository: DeviceRepository, private val scannerRepository: ScannerRepository ) : ViewModel() { - + companion object { + private val SCAN_TIMEOUT = 1.seconds + } private val _devices = MutableStateFlow>(emptyList()) val devices: StateFlow> = _devices.asStateFlow() @@ -47,10 +52,9 @@ class ScannerViewModel( val isScanning: StateFlow = _isScanning.asStateFlow() private val _connectedDevices = MutableStateFlow>(emptyMap()) val connectedDevices: StateFlow> = _connectedDevices.asStateFlow() - private val _workOrders = MutableStateFlow>(emptyList()) - private val _autoConnect = MutableStateFlow(storage.load(ReferKeys.DEVICE_AUTO_CONNECT) ?: false) - val autoConnect: StateFlow = _autoConnect.asStateFlow() + private val _autoConnect: StateFlow = storage.observe(ReferKeys.DEVICE_AUTO_CONNECT) + .stateIn(viewModelScope, SharingStarted.Eagerly, false) private val _onlyWithName = MutableStateFlow(true) private var scanJob: Job? = null @@ -59,36 +63,21 @@ class ScannerViewModel( private val advertisementMap = mutableMapOf>() - companion object { - private val SCAN_TIMEOUT = 1.seconds - } - - fun fetchWorkOrders(peripheral: Peripheral, woID: String = "0", action: String = "next") { + /** Bonded device */ + private val bondedIds = mutableSetOf() // Cache + suspend fun preloadBondedIds() { viewModelScope.launch { - try { - val orders = scannerRepository.getAbleWorkOrders(woID = woID, action = action) - _workOrders.value = orders - sendWorkOrdersToDevice(peripheral,orders) - } catch (e: Exception) { - Napier.e("Failed to load work orders", e) + deviceRepository.getAllDeviceIdsFlow().collect { ids -> + bondedIds.clear() + bondedIds.addAll(ids) } } } + fun isBonded(id: String): Boolean = bondedIds.contains(id) - private fun sendWorkOrdersToDevice(peripheral: Peripheral, workOrders: List) { + init { viewModelScope.launch { - if (workOrders.isEmpty()) { - bleManager.sendCommand(peripheral, Helper.buildUartCommand(Helper.CMD_EMPTY_WO)) - return@launch - } - - val formatted = workOrders.joinToString(separator = "\r\n") { wo -> - val torque = wo.services?.values?.firstOrNull()?.torqueRequired ?: 0.0 - val actionCode = if (wo.status.lowercase() == "open") 0 else 1 - val wheelsMask = wo.computeWheelsNuts() - "$${wo.id},${actionCode},${wo.make},${wo.licensePlate},$torque,ft-lb,$wheelsMask*" - } - bleManager.sendCommand(peripheral, Helper.buildUartCommand(formatted)) + preloadBondedIds() } } @@ -106,11 +95,15 @@ class ScannerViewModel( } .collect { advertisement -> if (_onlyWithName.value && advertisement.name == null) return@collect - - val now = TimeSource.Monotonic.markNow() - advertisementMap[advertisement.identifier.toString()] = advertisement to now - startCleanupJob() - updateDeviceList() + val bleId: String = advertisement.identifier.toString() + if(_autoConnect.value && isBonded(bleId)){ + connect(advertisement) + } else { + val now = TimeSource.Monotonic.markNow() + advertisementMap[advertisement.identifier.toString()] = advertisement to now + startCleanupJob() + updateDeviceList() + } } } } @@ -165,6 +158,8 @@ class ScannerViewModel( launch { peripheral.state.collect { state -> _connectedDevices.value += (deviceIdentifier to state) + /** Add bonded device */ + addBondedDevice(deviceIdentifier, adv.name ?: "No name") if (state is State.Disconnected) { this@coroutineScope.cancel(">>>>> Device disconnected") } @@ -191,7 +186,6 @@ class ScannerViewModel( } connectionJobs[deviceIdentifier] = job } - fun disconnect(deviceIdentifier: String) { viewModelScope.launch { bleManager.disconnect(deviceIdentifier) @@ -200,7 +194,32 @@ class ScannerViewModel( _connectedDevices.value -= deviceIdentifier } } - + fun fetchWorkOrders(peripheral: Peripheral, woID: String = "0", action: String = "next") { + viewModelScope.launch { + try { + val orders = scannerRepository.getAbleWorkOrders(woID = woID, action = action) + _workOrders.value = orders + sendWorkOrdersToDevice(peripheral,orders) + } catch (e: Exception) { + Napier.e("Failed to load work orders", e) + } + } + } + private fun sendWorkOrdersToDevice(peripheral: Peripheral, workOrders: List) { + viewModelScope.launch { + if (workOrders.isEmpty()) { + bleManager.sendCommand(peripheral, Helper.buildUartCommand(Helper.CMD_EMPTY_WO)) + return@launch + } + val formatted = workOrders.joinToString(separator = "\r\n") { wo -> + val torque = wo.services?.values?.firstOrNull()?.torqueRequired ?: 0.0 + val actionCode = if (wo.status.lowercase() == "open") 0 else 1 + val wheelsMask = wo.computeWheelsNuts() + "$${wo.id},${actionCode},${wo.make},${wo.licensePlate},$torque,ft-lb,$wheelsMask*" + } + bleManager.sendCommand(peripheral, Helper.buildUartCommand(formatted)) + } + } private fun handleMessage(peripheral: Peripheral, msg: String, deviceIdentifier: String) { val decoded = Helper.decodeRxData(msg) ?: return val data = decoded.data diff --git a/composeApp/src/commonMain/sqldelight/com/digitoolsolutions/app/torquevaultkmp/data/storage/db/AppDatabase.sq b/composeApp/src/commonMain/sqldelight/com/digitoolsolutions/app/torquevaultkmp/data/storage/db/AppDatabase.sq index cb5b0c5..184b8c3 100644 --- a/composeApp/src/commonMain/sqldelight/com/digitoolsolutions/app/torquevaultkmp/data/storage/db/AppDatabase.sq +++ b/composeApp/src/commonMain/sqldelight/com/digitoolsolutions/app/torquevaultkmp/data/storage/db/AppDatabase.sq @@ -24,6 +24,9 @@ VALUES (?, ?); getAllDevices: SELECT * FROM Device; +getAllDeviceIds: +SELECT id FROM Device; + getDevicesFirstPage: SELECT * FROM Device ORDER BY id ASC LIMIT :limit; diff --git a/composeApp/src/iosMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/AppStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/AppStorage.ios.kt index c5469ab..419e78b 100644 --- a/composeApp/src/iosMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/AppStorage.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/AppStorage.ios.kt @@ -1,7 +1,14 @@ package com.digitoolsolutions.app.torquevaultkmp import com.digitoolsolutions.app.torquevaultkmp.data.storage.AppStorage +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.map +import platform.Foundation.NSNotificationCenter import platform.Foundation.NSUserDefaults +import platform.Foundation.NSUserDefaultsDidChangeNotification import kotlin.reflect.KClass class AppStoragePlatform : AppStorage { @@ -28,4 +35,19 @@ class AppStoragePlatform : AppStorage { override fun remove(key: String) { defaults.removeObjectForKey(key) } + + override fun observe(key: String, type: KClass): Flow = callbackFlow { + val observer = NSNotificationCenter.defaultCenter.addObserverForName( + name = NSUserDefaultsDidChangeNotification, + `object` = null, + queue = null + ) { _ -> + load(key, type)?.let { trySend(it) } + } + // emit initial value + load(key, type)?.let { trySend(it) } + awaitClose { + NSNotificationCenter.defaultCenter.removeObserver(observer) + } + }.filterNotNull().map { it } }