diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 448aacf..5d20ff9 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -33,6 +33,15 @@ Clear Scanner CAN'T SEE YOUR DEVICE? - 1. Make sure the device is connected to a power source and powered on.\n\n2. Make sure the appropriate firmware and SoftDevice are flashed. + 1. Make sure the device is connected to a power source and powered on.\n\n2. Make sure the appropriate firmware and Soft Device are flashed. + Disconnected + Device disconnected successfully. + The device is not available.\nPlease check if it is turned on and try again. + The device got out of range, or has been turned off. + The device is not supported. + Cancel + Retry + Connecting… + It should just take a moment… diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/di/Modules.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/di/Modules.kt index c30a4d1..3c772be 100644 --- a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/di/Modules.kt +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/di/Modules.kt @@ -63,5 +63,5 @@ val viewModelModule = module { factoryOf(::SettingViewModel) factoryOf(::BondedViewModel) factoryOf(::LogViewModel) - factory { ScannerViewModel(get(), get(), get(), get()) } + single { ScannerViewModel(get(), get(), get(), get()) } } diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/domain/model/ConnectionReason.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/domain/model/ConnectionReason.kt new file mode 100644 index 0000000..15afd7e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/domain/model/ConnectionReason.kt @@ -0,0 +1,5 @@ +package com.digitoolsolutions.app.torquevaultkmp.domain.model + +enum class ConnectionReason { + USER, TIMEOUT, LINK_LOSS, MISSING_SERVICE +} diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/kable/BleManager.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/kable/BleManager.kt index 41f7c5e..496328e 100644 --- a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/kable/BleManager.kt +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/kable/BleManager.kt @@ -24,8 +24,13 @@ class BleManager { } private val peripherals = mutableMapOf() + private val advertisements = mutableMapOf() private val mutex = Mutex() + fun getPeripheral(identifier: String): Peripheral? = peripherals[identifier] + fun getAdvertisement(identifier: String): Advertisement? = advertisements[identifier] + fun getAdvertisementName(identifier: String): String = advertisements[identifier]?.name.toString() + fun scanDevices(): Flow = Scanner { filters { match { @@ -37,26 +42,25 @@ class BleManager { level = Logging.Level.Events format = Logging.Format.Multiline } - }.advertisements + }.advertisements.map { + advertisements[it.identifier.toString()] = it + it + } suspend fun connect(advertisement: Advertisement): Peripheral { val identifier = advertisement.identifier.toString() Napier.d(">>>>> Connecting to $identifier") - val peripheral = mutex.withLock { + return mutex.withLock { peripherals.getOrPut(identifier) { Peripheral(advertisement) } } - // Connect outside the lock to allow parallel connections for different devices - peripheral.connect() - return peripheral } suspend fun disconnect(identifier: String) { mutex.withLock { peripherals.remove(identifier)?.disconnect() } - Napier.d(">>>>> $identifier disconnected") } suspend fun disconnectAll() { @@ -65,7 +69,6 @@ class BleManager { peripherals.clear() } } - suspend fun sendCommand(peripheral: Peripheral, command: String) { Napier.d(">>>>> Sent to uart: $command") peripheral.write(RX_CHAR, command.encodeToByteArray()) diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/MainScreen.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/MainScreen.kt index 2007e54..a0336ee 100644 --- a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/MainScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/MainScreen.kt @@ -10,24 +10,29 @@ import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController +import androidx.navigation.navArgument import com.digitoolsolutions.app.torquevaultkmp.AppViewModel import com.digitoolsolutions.app.torquevaultkmp.screens.auth.AuthDestination import com.digitoolsolutions.app.torquevaultkmp.screens.auth.LoginScreen -import com.digitoolsolutions.app.torquevaultkmp.screens.bluetooth.BluetoothRequirementWrapper import com.digitoolsolutions.app.torquevaultkmp.screens.bonded.BondedDestination import com.digitoolsolutions.app.torquevaultkmp.screens.bonded.BondedScreen +import com.digitoolsolutions.app.torquevaultkmp.screens.communication.CommunicationDestination +import com.digitoolsolutions.app.torquevaultkmp.screens.communication.UartCommunicationScreen import com.digitoolsolutions.app.torquevaultkmp.screens.home.HomeDestination import com.digitoolsolutions.app.torquevaultkmp.screens.home.HomeScreen import com.digitoolsolutions.app.torquevaultkmp.screens.logs.LogDestination import com.digitoolsolutions.app.torquevaultkmp.screens.logs.LogScreen import com.digitoolsolutions.app.torquevaultkmp.screens.scanner.ScannerDestination import com.digitoolsolutions.app.torquevaultkmp.screens.scanner.ScannerScreen +import com.digitoolsolutions.app.torquevaultkmp.screens.scanner.ScannerViewModel import com.digitoolsolutions.app.torquevaultkmp.screens.settings.SettingDestination import com.digitoolsolutions.app.torquevaultkmp.screens.settings.SettingScreen import org.jetbrains.compose.resources.painterResource @@ -35,13 +40,16 @@ import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel import torquevaultkmp.composeapp.generated.resources.Res import torquevaultkmp.composeapp.generated.resources.btn_login +import kotlin.uuid.ExperimentalUuidApi +@OptIn(ExperimentalUuidApi::class) @Composable fun MainScreen( isDarkTheme: Boolean, onThemeToggle: (Boolean) -> Unit, isLoggedIn: Boolean, - viewModel: AppViewModel = koinViewModel() + viewModel: AppViewModel = koinViewModel(), + scannerViewModel: ScannerViewModel = koinViewModel(), ) { val destinations = listOf( HomeDestination, @@ -53,6 +61,7 @@ fun MainScreen( val navController = rememberNavController() val navBackStackEntry by navController.currentBackStackEntryAsState() val currentDestination = navBackStackEntry?.destination + val noNavbar = listOf(AuthDestination.route, CommunicationDestination.route) val sessionExpired by viewModel.sessionExpired @@ -74,10 +83,14 @@ fun MainScreen( ) } + LaunchedEffect(Unit) { + scannerViewModel.startScan() + } + Scaffold( modifier = Modifier.fillMaxSize(), bottomBar = { - if (currentDestination?.route != AuthDestination.route) + if (currentDestination?.route !in noNavbar) NavigationBar { destinations.forEach { dest -> NavigationBarItem( @@ -120,10 +133,17 @@ fun MainScreen( BondedScreen(navController) } composable(ScannerDestination.route) { - ScannerScreen(onDeviceClick = { - // Handle device click, maybe navigate to details or connect + ScannerScreen(onDeviceClick = { device -> + navController.navigate(CommunicationDestination.createRoute(device.identifier.toString())) }) } + composable( + route = CommunicationDestination.route, + arguments = listOf(navArgument("deviceId") { type = NavType.StringType }) + ) { backStackEntry -> + val deviceId = backStackEntry.savedStateHandle.get("deviceId") ?: "" + UartCommunicationScreen(navController, deviceId) + } composable(LogDestination.route) { LogScreen(navController) } composable(SettingDestination.route) { SettingScreen(navController, isDarkTheme, onThemeToggle) diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/communication/CommunicationDestination.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/communication/CommunicationDestination.kt new file mode 100644 index 0000000..c6feb8e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/communication/CommunicationDestination.kt @@ -0,0 +1,14 @@ +package com.digitoolsolutions.app.torquevaultkmp.screens.communication + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Bluetooth +import com.digitoolsolutions.app.torquevaultkmp.screens.AppIcon +import com.digitoolsolutions.app.torquevaultkmp.screens.Destinations + +object CommunicationDestination: Destinations( + label = "Communication", + icon = AppIcon.Vector(Icons.Outlined.Bluetooth), + route = "communication/{deviceId}" +) { + fun createRoute(deviceId: String) = "communication/$deviceId" +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/communication/UartCommunicationScreen.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/communication/UartCommunicationScreen.kt new file mode 100644 index 0000000..8100651 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/communication/UartCommunicationScreen.kt @@ -0,0 +1,103 @@ +package com.digitoolsolutions.app.torquevaultkmp.screens.communication + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import com.digitoolsolutions.app.torquevaultkmp.components.AppBar +import com.digitoolsolutions.app.torquevaultkmp.domain.model.ConnectionReason +import com.digitoolsolutions.app.torquevaultkmp.screens.communication.views.DeviceConnected +import com.digitoolsolutions.app.torquevaultkmp.screens.communication.views.DeviceConnecting +import com.digitoolsolutions.app.torquevaultkmp.screens.communication.views.DeviceDisconnected +import com.digitoolsolutions.app.torquevaultkmp.screens.scanner.ScannerViewModel +import com.juul.kable.State +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.koinInject +import org.koin.compose.viewmodel.koinViewModel +import torquevaultkmp.composeapp.generated.resources.Res +import torquevaultkmp.composeapp.generated.resources.action_cancel +import torquevaultkmp.composeapp.generated.resources.action_retry + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun UartCommunicationScreen( + navController: NavController, + deviceId: String, + viewModel: ScannerViewModel = koinInject() +) { + val messages by viewModel.rxMessages.collectAsState() + val connectedDevices by viewModel.connectedDevices.collectAsState() + val connectionReasons by viewModel.connectionReasons.collectAsState() + + val connectionState = remember(connectedDevices, deviceId) { + connectedDevices[deviceId] ?: State.Disconnected() + } + val reason = remember(connectionReasons, deviceId) { + connectionReasons[deviceId] ?: ConnectionReason.LINK_LOSS + } + val deviceName = remember(connectedDevices) { viewModel.getDeviceName(deviceId) } + + LaunchedEffect(deviceId) { + viewModel.clearMessages() + viewModel.connectById(deviceId) + } + + Scaffold( + topBar = { + AppBar( + title = { Text(deviceName) }, + onNavigationButtonClick = { + viewModel.disconnect(deviceId) + navController.popBackStack() + }, + ) + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + when (connectionState) { + is State.Connecting -> { + DeviceConnecting ( + modifier = Modifier.padding(16.dp), + ) { padding -> + Button( + onClick = { viewModel.disconnect(deviceId) }, + modifier = Modifier.padding(padding), + ) { + Text(text = stringResource(Res.string.action_cancel)) + } + } + } + is State.Disconnected -> { + DeviceDisconnected( + reason = reason, + modifier = Modifier.padding(16.dp), + ) { padding -> + Button( + onClick = { viewModel.connectById(deviceId) }, + modifier = Modifier.padding(padding), + ) { + Text(text = stringResource(Res.string.action_retry)) + } + } + } + is State.Connected -> { + DeviceConnected(messages = messages, onSend = { msg -> + viewModel.sendRawCommand(deviceId, msg) + }) + } + else -> { + Text(text = "Another State") + } + } + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/communication/views/DeviceConnected.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/communication/views/DeviceConnected.kt new file mode 100644 index 0000000..a132087 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/communication/views/DeviceConnected.kt @@ -0,0 +1,86 @@ +package com.digitoolsolutions.app.torquevaultkmp.screens.communication.views + +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp + +@Composable +internal fun DeviceConnected( + messages: List, + onSend: (msg: String) -> Unit +) { + var txInput by rememberSaveable { mutableStateOf("") } + val listState = rememberLazyListState() + + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp) + .imePadding(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .border(1.dp, Color.Gray, RoundedCornerShape(4.dp)) + .padding(8.dp) + ) { + items(messages) { message -> + Text(message) + } + } + + LaunchedEffect(messages.size) { + if (messages.isNotEmpty()) { + listState.animateScrollToItem(messages.size - 1) + } + } + + Spacer(Modifier.height(8.dp)) + + Row(modifier = Modifier.fillMaxWidth()) { + OutlinedTextField( + value = txInput, + onValueChange = { txInput = it }, + label = { Text("Manual Command") }, + modifier = Modifier.weight(1f) + ) + Spacer(Modifier.width(8.dp)) + Button( + onClick = { + onSend(txInput) + txInput = "" + }, + modifier = Modifier.align(Alignment.CenterVertically) + ) { + Text("Send") + } + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/communication/views/DeviceConnecting.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/communication/views/DeviceConnecting.kt new file mode 100644 index 0000000..b7e97ba --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/communication/views/DeviceConnecting.kt @@ -0,0 +1,78 @@ +package com.digitoolsolutions.app.torquevaultkmp.screens.communication.views + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.BluetoothAudio +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.digitoolsolutions.app.torquevaultkmp.components.CircularIcon +import org.jetbrains.compose.resources.stringResource +import torquevaultkmp.composeapp.generated.resources.Res +import torquevaultkmp.composeapp.generated.resources.device_connecting +import torquevaultkmp.composeapp.generated.resources.device_explanation + +@Composable +internal fun DeviceConnecting( + modifier: Modifier = Modifier, + content: @Composable ColumnScope.(PaddingValues) -> Unit = {} +) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally + ) { + OutlinedCard( + modifier = Modifier + .widthIn(max = 460.dp), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + CircularIcon(imageVector = Icons.Default.BluetoothAudio) + + Text( + text = stringResource(Res.string.device_connecting), + style = MaterialTheme.typography.titleMedium + ) + + Text( + text = stringResource(Res.string.device_explanation), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyMedium + ) + } + } + + content(PaddingValues(top = 16.dp)) + } +} + +@Preview(showBackground = true) +@Composable +private fun DeviceConnectingView_Preview() { + DeviceConnecting { padding -> + Button( + onClick = {}, + modifier = Modifier.padding(padding) + ) { + Text(text = "Cancel") + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/communication/views/DeviceDisconnected.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/communication/views/DeviceDisconnected.kt new file mode 100644 index 0000000..3def5ca --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/communication/views/DeviceDisconnected.kt @@ -0,0 +1,93 @@ +package com.digitoolsolutions.app.torquevaultkmp.screens.communication.views + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.BluetoothDisabled +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.digitoolsolutions.app.torquevaultkmp.components.CircularIcon +import com.digitoolsolutions.app.torquevaultkmp.domain.model.ConnectionReason +import org.jetbrains.compose.resources.stringResource +import torquevaultkmp.composeapp.generated.resources.Res +import torquevaultkmp.composeapp.generated.resources.device_disconnected +import torquevaultkmp.composeapp.generated.resources.device_reason_link_loss +import torquevaultkmp.composeapp.generated.resources.device_reason_missing_service +import torquevaultkmp.composeapp.generated.resources.device_reason_timeout +import torquevaultkmp.composeapp.generated.resources.device_reason_user + +@Composable +internal fun DeviceDisconnected( + reason: ConnectionReason, + modifier: Modifier = Modifier, + content: @Composable ColumnScope.(PaddingValues) -> Unit = {}, +) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally + ) { + OutlinedCard( + modifier = Modifier + .widthIn(max = 460.dp), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + CircularIcon(imageVector = Icons.Default.BluetoothDisabled) + + Text( + text = stringResource(Res.string.device_disconnected), + style = MaterialTheme.typography.titleMedium + ) + + val text = when (reason) { + ConnectionReason.USER -> stringResource(Res.string.device_reason_user) + ConnectionReason.LINK_LOSS -> stringResource(Res.string.device_reason_link_loss) + ConnectionReason.MISSING_SERVICE -> stringResource(Res.string.device_reason_missing_service) + ConnectionReason.TIMEOUT -> stringResource(Res.string.device_reason_timeout) + } + + Text( + text = text, + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyMedium + ) + } + } + + content(PaddingValues(top = 16.dp)) + } +} + +@Preview(showBackground = true) +@Composable +private fun DeviceDisconnectedView_Preview() { + DeviceDisconnected( + reason = ConnectionReason.MISSING_SERVICE, + content = { padding -> + Button( + onClick = {}, + modifier = Modifier.padding(padding) + ) { + Text(text = "Retry") + } + } + ) +} \ No newline at end of file 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 4446291..c256e05 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 @@ -18,6 +18,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.navigation.NavController import com.digitoolsolutions.app.torquevaultkmp.components.AppBar import com.digitoolsolutions.app.torquevaultkmp.components.CircularIcon import com.digitoolsolutions.app.torquevaultkmp.components.RssiIcon @@ -27,6 +28,7 @@ import com.juul.kable.Advertisement import com.juul.kable.State import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource +import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel import torquevaultkmp.composeapp.generated.resources.Res import torquevaultkmp.composeapp.generated.resources.baseline_filter_list_24 @@ -40,13 +42,14 @@ import kotlin.uuid.ExperimentalUuidApi @OptIn(ExperimentalMaterial3Api::class, ExperimentalUuidApi::class) @Composable fun ScannerScreen( - viewModel: ScannerViewModel = koinViewModel(), + viewModel: ScannerViewModel = koinInject(), onDeviceClick: (Advertisement) -> Unit ) { val devices by viewModel.devices.collectAsState() val connectedDevices by viewModel.connectedDevices.collectAsState() val isScanning by viewModel.isScanning.collectAsState() val pullToRefreshState = rememberPullToRefreshState() + val autoConnect by viewModel.isAutoConnect.collectAsState() Scaffold( topBar = { @@ -101,7 +104,11 @@ fun ScannerScreen( connectionState = connectionState, onClick = { if (connectionState == null || connectionState is State.Disconnected) { - viewModel.connect(device) + if (!autoConnect) { + onDeviceClick(device) + } else { + viewModel.connect(device) + } } else { viewModel.disconnect(deviceId) } @@ -113,9 +120,9 @@ fun ScannerScreen( } } - LaunchedEffect(Unit) { - viewModel.startScan() - } +// LaunchedEffect(Unit) { +// viewModel.startScan() +// } } @OptIn(ExperimentalUuidApi::class) 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 8aa0f92..d669635 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 @@ -10,6 +10,7 @@ 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.digitoolsolutions.app.torquevaultkmp.domain.model.ConnectionReason import com.juul.kable.Advertisement import com.juul.kable.Peripheral import com.juul.kable.State @@ -31,6 +32,8 @@ import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.TimeoutCancellationException import kotlin.time.Duration.Companion.seconds import kotlin.time.TimeSource import kotlin.uuid.ExperimentalUuidApi @@ -44,6 +47,7 @@ class ScannerViewModel( ) : ViewModel() { companion object { private val SCAN_TIMEOUT = 1.seconds + private val CONNECTION_TIMEOUT = 15.seconds } private val _devices = MutableStateFlow>(emptyList()) val devices: StateFlow> = _devices.asStateFlow() @@ -52,11 +56,19 @@ class ScannerViewModel( val isScanning: StateFlow = _isScanning.asStateFlow() private val _connectedDevices = MutableStateFlow>(emptyMap()) val connectedDevices: StateFlow> = _connectedDevices.asStateFlow() + + private val _connectionReasons = MutableStateFlow>(emptyMap()) + val connectionReasons: StateFlow> = _connectionReasons.asStateFlow() + + private val _rxMessages = MutableStateFlow>(emptyList()) + val rxMessages: StateFlow> = _rxMessages.asStateFlow() + private val _workOrders = MutableStateFlow>(emptyList()) private val _autoConnect: StateFlow = storage.observe(ReferKeys.DEVICE_AUTO_CONNECT) .stateIn(viewModelScope, SharingStarted.Eagerly, false) - private val _onlyWithName = MutableStateFlow(true) + val isAutoConnect: StateFlow = _autoConnect + private val _onlyWithName = MutableStateFlow(true) private var scanJob: Job? = null private var cleanupJob: Job? = null private val connectionJobs = mutableMapOf() @@ -89,8 +101,8 @@ class ScannerViewModel( advertisementMap.clear() bleManager.scanDevices() - .catch { - _isScanning.value = false + .catch { + _isScanning.value = false stopCleanupJob() } .collect { advertisement -> @@ -145,6 +157,37 @@ class ScannerViewModel( startScan() } + fun connectById(deviceId: String) { + val adv = bleManager.getAdvertisement(deviceId) + if (adv != null) { + connect(adv) + } else { + Napier.e("Cannot connect: Advertisement not found for $deviceId") + } + } + fun getDeviceName(identifier: String): String { + return bleManager.getAdvertisementName(identifier) + } + fun getPeripheral(identifier: String): Peripheral? { + return bleManager.getPeripheral(identifier) + } + fun clearMessages() { + _rxMessages.value = emptyList() + } + + fun sendRawCommand(deviceIdentifier: String, command: String) { + val peripheral = bleManager.getPeripheral(deviceIdentifier) + if (peripheral != null) { + sendCommand(peripheral, command) + } + } + fun sendCommand(peripheral: Peripheral, command: String) { + viewModelScope.launch { + bleManager.sendCommand(peripheral, Helper.buildUartCommand(command)) + _rxMessages.value += "Sent: $command" + } + } + fun connect(adv: Advertisement) { val deviceIdentifier = adv.identifier.toString() if (connectionJobs[deviceIdentifier]?.isActive == true) return @@ -152,17 +195,25 @@ class ScannerViewModel( val job = viewModelScope.launch { try { val peripheral = bleManager.connect(adv) - + coroutineScope { // Tracking connection state + var hasEmitted = false launch { peripheral.state.collect { state -> _connectedDevices.value += (deviceIdentifier to state) - /** Add bonded device */ - addBondedDevice(deviceIdentifier, adv.name ?: "No name") - if (state is State.Disconnected) { + if (state is State.Connected) { + /** Add bonded device */ + addBondedDevice(deviceIdentifier, adv.name ?: "No name") + _connectionReasons.value -= deviceIdentifier + } + if (state is State.Disconnected && hasEmitted) { + if (_connectionReasons.value[deviceIdentifier] == null) { + _connectionReasons.value += (deviceIdentifier to ConnectionReason.LINK_LOSS) + } this@coroutineScope.cancel(">>>>> Device disconnected") } + hasEmitted = true } } @@ -172,9 +223,17 @@ class ScannerViewModel( handleMessage(peripheral, value, deviceIdentifier) } } + + withTimeout(CONNECTION_TIMEOUT) { + _connectionReasons.value -= deviceIdentifier + peripheral.connect() + } } } catch (e: Exception) { - if (e !is CancellationException) { + if (e is TimeoutCancellationException) { + Napier.e(">>>>> Connection timed out for $deviceIdentifier") + _connectionReasons.value += (deviceIdentifier to ConnectionReason.TIMEOUT) + } else if (e !is CancellationException) { Napier.e(">>>>> Connection failed for $deviceIdentifier", e) } } finally { @@ -187,7 +246,9 @@ class ScannerViewModel( connectionJobs[deviceIdentifier] = job } fun disconnect(deviceIdentifier: String) { + Napier.d(">>>>> disconnecting $deviceIdentifier") viewModelScope.launch { + _connectionReasons.value += (deviceIdentifier to ConnectionReason.USER) bleManager.disconnect(deviceIdentifier) connectionJobs[deviceIdentifier]?.cancel() connectionJobs.remove(deviceIdentifier) @@ -208,7 +269,7 @@ class ScannerViewModel( private fun sendWorkOrdersToDevice(peripheral: Peripheral, workOrders: List) { viewModelScope.launch { if (workOrders.isEmpty()) { - bleManager.sendCommand(peripheral, Helper.buildUartCommand(Helper.CMD_EMPTY_WO)) + sendCommand(peripheral, Helper.CMD_EMPTY_WO) return@launch } val formatted = workOrders.joinToString(separator = "\r\n") { wo -> @@ -217,10 +278,12 @@ class ScannerViewModel( val wheelsMask = wo.computeWheelsNuts() "$${wo.id},${actionCode},${wo.make},${wo.licensePlate},$torque,ft-lb,$wheelsMask*" } - bleManager.sendCommand(peripheral, Helper.buildUartCommand(formatted)) + + sendCommand(peripheral, formatted) } } private fun handleMessage(peripheral: Peripheral, msg: String, deviceIdentifier: String) { + _rxMessages.value += "Received: $msg" val decoded = Helper.decodeRxData(msg) ?: return val data = decoded.data val woId = decoded.id @@ -237,12 +300,10 @@ class ScannerViewModel( var command = Helper.CMD_NOT_AVAILABLE_WO if (res) command = Helper.CMD_RECEIVED_WO - bleManager.sendCommand(peripheral, Helper.buildUartCommand(command)) + sendCommand(peripheral, command) } catch (e: Exception) { Napier.e(">>>>> Failed to handle message: $msg", e) - bleManager.sendCommand( - peripheral, Helper.buildUartCommand(Helper.CMD_NOT_AVAILABLE_WO) - ) + sendCommand(peripheral, Helper.CMD_NOT_AVAILABLE_WO) } } else -> { @@ -265,7 +326,7 @@ class ScannerViewModel( Napier.e("An error occurred while sending the measurement", e, tag = "ScannerViewModel") } finally { // Currently, always send UPLOADED_WO regardless of success or failure - bleManager.sendCommand(peripheral, Helper.buildUartCommand(Helper.CMD_UPLOADED_WO)) + sendCommand(peripheral, Helper.CMD_UPLOADED_WO) } } Helper.TorqueAction.RE_TORQUE -> { @@ -275,7 +336,7 @@ class ScannerViewModel( Napier.e("An error occurred while sending the re-measurement", e, tag = "ScannerViewModel") } finally { // Currently, always send UPLOADED_WO regardless of success or failure - bleManager.sendCommand(peripheral, Helper.buildUartCommand(Helper.CMD_UPLOADED_WO)) + sendCommand(peripheral, Helper.CMD_UPLOADED_WO) } } Helper.TorqueAction.CANCEL -> { @@ -285,7 +346,7 @@ class ScannerViewModel( Napier.e("An error occurred while cancel work order", e, tag = "ScannerViewModel") } finally { // Currently, always send UPLOADED_WO regardless of success or failure - bleManager.sendCommand(peripheral, Helper.buildUartCommand(Helper.CMD_UPLOADED_WO)) + sendCommand(peripheral, Helper.CMD_UPLOADED_WO) } } else -> Napier.w(">>>>> Unknown action: $act")