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