diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/repository/LogRepository.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/repository/LogRepository.kt index 2005a89..15a2293 100644 --- a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/repository/LogRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/repository/LogRepository.kt @@ -14,7 +14,7 @@ class LogRepository( private val dbQueries: AppDatabaseQueries ) { fun addLog(title: String, content: String, deviceId: String? = null) { - val source = if (deviceId == null) LogFilter.System.name else LogFilter.Device.name + val source = if (deviceId == null) LogFilter.SYSTEM else LogFilter.DEVICE dbQueries.insertLog( title = title, content = content, @@ -40,13 +40,24 @@ class LogRepository( suspend fun getLogsByFilterPaged(filter: LogFilter, limit: Long, lastTimestamp: Long?, lastId: Long?): List { return when (filter) { LogFilter.All -> getLogsPaged(limit, lastTimestamp, lastId) - LogFilter.System, LogFilter.Device -> { + LogFilter.System -> { if (lastTimestamp == null || lastId == null) { dbQueries.getLogsByTypeFirstPage(filter.name, limit).executeAsList() } else { dbQueries.getLogsByTypeNextPage(source = filter.name, lastTimestamp = lastTimestamp, lastId = lastId, limit = limit).executeAsList() } } + is LogFilter.Device -> { + if (filter.deviceId != null) { + getLogsByDevicePaged(filter.deviceId, limit, lastTimestamp, lastId) + } else { + if (lastTimestamp == null || lastId == null) { + dbQueries.getLogsByTypeFirstPage(filter.name, limit).executeAsList() + } else { + dbQueries.getLogsByTypeNextPage(source = filter.name, lastTimestamp = lastTimestamp, lastId = lastId, limit = limit).executeAsList() + } + } + } } } diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/domain/model/LogFilter.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/domain/model/LogFilter.kt index b388327..0402430 100644 --- a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/domain/model/LogFilter.kt +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/domain/model/LogFilter.kt @@ -1,13 +1,24 @@ package com.digitoolsolutions.app.torquevaultkmp.domain.model -enum class LogFilter { - All, - System, - Device; +sealed class LogFilter(val name: String) { + data object All : LogFilter(ALL) + data object System : LogFilter(SYSTEM) + data class Device(val deviceId: String? = null, val deviceName: String? = null) : LogFilter(DEVICE) companion object { + const val ALL = "All" + const val SYSTEM = "System" + const val DEVICE = "Device" + + val entries = listOf(All, System, Device()) + fun fromString(value: String): LogFilter { - return entries.find { it.name == value } ?: All + return when (value) { + ALL -> All + SYSTEM -> System + DEVICE -> Device() + else -> All + } } } } 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 496328e..92a8dee 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 @@ -71,7 +71,8 @@ class BleManager { } suspend fun sendCommand(peripheral: Peripheral, command: String) { Napier.d(">>>>> Sent to uart: $command") - peripheral.write(RX_CHAR, command.encodeToByteArray()) + val cmd = command.replace("\\r\\n", "\r\n") + peripheral.write(RX_CHAR, cmd.encodeToByteArray()) } fun observeRx(peripheral: Peripheral): Flow = peripheral.observe(TX_CHAR).map { it.decodeToString() } 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 61317ba..d134007 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 @@ -127,7 +127,9 @@ fun MainScreen( ) { composable(AuthDestination.route) { LoginScreen(navController) } composable(HomeDestination.route) { - HomeScreen(navController) + HomeScreen(onDeviceClicked = {device -> + navController.navigate(CommunicationDestination.createRoute(device.identifier.toString(), cancellable = false)) + }) } composable(BondedDestination.route) { BondedScreen() @@ -139,10 +141,14 @@ fun MainScreen( } composable( route = CommunicationDestination.route, - arguments = listOf(navArgument("deviceId") { type = NavType.StringType }) + arguments = listOf( + navArgument("deviceId") { type = NavType.StringType }, + navArgument("cancellable") { type = NavType.BoolType; defaultValue = true } + ) ) { backStackEntry -> val deviceId = backStackEntry.savedStateHandle.get("deviceId") ?: "" - UartCommunicationScreen(navController, deviceId) + val cancellable = backStackEntry.savedStateHandle.get("cancellable") ?: true + UartCommunicationScreen(navController, deviceId, cancellable) } composable(LogDestination.route) { LogScreen() } composable(SettingDestination.route) { diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/bonded/view/BondedDeviceItem.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/bonded/view/BondedDeviceItem.kt index deb9675..9cd4723 100644 --- a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/bonded/view/BondedDeviceItem.kt +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/bonded/view/BondedDeviceItem.kt @@ -1,6 +1,5 @@ package com.digitoolsolutions.app.torquevaultkmp.screens.bonded.view -import androidx.compose.animation.core.spring import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background 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 index c6feb8e..93f659b 100644 --- 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 @@ -8,7 +8,7 @@ import com.digitoolsolutions.app.torquevaultkmp.screens.Destinations object CommunicationDestination: Destinations( label = "Communication", icon = AppIcon.Vector(Icons.Outlined.Bluetooth), - route = "communication/{deviceId}" + route = "communication/{deviceId}?cancellable={cancellable}" ) { - fun createRoute(deviceId: String) = "communication/$deviceId" + fun createRoute(deviceId: String, cancellable: Boolean = true) = "communication/$deviceId?cancellable=$cancellable" } \ 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 index 8100651..c6cd4d8 100644 --- 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 @@ -1,11 +1,17 @@ package com.digitoolsolutions.app.torquevaultkmp.screens.communication +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.DeleteForever 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.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.unit.dp import androidx.navigation.NavController import com.digitoolsolutions.app.torquevaultkmp.components.AppBar @@ -17,7 +23,6 @@ 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 @@ -27,14 +32,16 @@ import torquevaultkmp.composeapp.generated.resources.action_retry fun UartCommunicationScreen( navController: NavController, deviceId: String, + cancellable: Boolean = true, viewModel: ScannerViewModel = koinInject() ) { - val messages by viewModel.rxMessages.collectAsState() + val focusManager = LocalFocusManager.current + val histories by viewModel.historyFlow(deviceId).collectAsState(initial = emptyList()) val connectedDevices by viewModel.connectedDevices.collectAsState() val connectionReasons by viewModel.connectionReasons.collectAsState() val connectionState = remember(connectedDevices, deviceId) { - connectedDevices[deviceId] ?: State.Disconnected() + connectedDevices[deviceId]?.state ?: State.Disconnected() } val reason = remember(connectionReasons, deviceId) { connectionReasons[deviceId] ?: ConnectionReason.LINK_LOSS @@ -42,8 +49,8 @@ fun UartCommunicationScreen( val deviceName = remember(connectedDevices) { viewModel.getDeviceName(deviceId) } LaunchedEffect(deviceId) { - viewModel.clearMessages() - viewModel.connectById(deviceId) +// viewModel.clearMessages(deviceId) + if(cancellable) viewModel.connectById(deviceId) } Scaffold( @@ -51,16 +58,31 @@ fun UartCommunicationScreen( AppBar( title = { Text(deviceName) }, onNavigationButtonClick = { - viewModel.disconnect(deviceId) +// if(cancellable) viewModel.disconnect(deviceId) navController.popBackStack() }, + actions = { + IconButton(onClick = { viewModel.clearMessages(deviceId) }) { + Icon( + Icons.Rounded.DeleteForever, + contentDescription = "Clear history", + tint = Color.Red + ) + } + } ) } ) { padding -> Column( modifier = Modifier .fillMaxSize() - .padding(padding), + .pointerInput(Unit) { + detectTapGestures(onTap = { + focusManager.clearFocus() + }) + } + .padding(padding) + .imePadding(), horizontalAlignment = Alignment.CenterHorizontally, ) { when (connectionState) { @@ -90,7 +112,7 @@ fun UartCommunicationScreen( } } is State.Connected -> { - DeviceConnected(messages = messages, onSend = { msg -> + DeviceConnected(histories = histories, onSend = { msg -> viewModel.sendRawCommand(deviceId, msg) }) } 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 index a132087..81a6cc0 100644 --- 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 @@ -30,7 +30,7 @@ import androidx.compose.ui.unit.dp @Composable internal fun DeviceConnected( - messages: List, + histories: List, onSend: (msg: String) -> Unit ) { var txInput by rememberSaveable { mutableStateOf("") } @@ -51,14 +51,14 @@ internal fun DeviceConnected( .border(1.dp, Color.Gray, RoundedCornerShape(4.dp)) .padding(8.dp) ) { - items(messages) { message -> - Text(message) + items(histories) { history -> + Text(history) } } - LaunchedEffect(messages.size) { - if (messages.isNotEmpty()) { - listState.animateScrollToItem(messages.size - 1) + LaunchedEffect(histories.size) { + if (histories.isNotEmpty()) { + listState.animateScrollToItem(histories.size - 1) } } diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/home/HomeScreen.kt index 072b2f8..36747e1 100644 --- a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/home/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/home/HomeScreen.kt @@ -1,82 +1,43 @@ package com.digitoolsolutions.app.torquevaultkmp.screens.home import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth 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.material.icons.Icons -import androidx.compose.material.icons.filled.Bluetooth -import androidx.compose.material.icons.outlined.SyncAlt -import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedCard import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.SwipeToDismissBox -import androidx.compose.material3.SwipeToDismissBoxDefaults -import androidx.compose.material3.SwipeToDismissBoxValue import androidx.compose.material3.Text -import androidx.compose.material3.rememberSwipeToDismissBoxState import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -//import androidx.compose.ui.platform.LocalContext -//import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.navigation.NavController import com.digitoolsolutions.app.torquevaultkmp.components.AppBar -import com.digitoolsolutions.app.torquevaultkmp.screens.auth.AuthDestination import com.digitoolsolutions.app.torquevaultkmp.screens.home.views.HomeEmpty import com.digitoolsolutions.app.torquevaultkmp.screens.home.views.HomeItem -import com.digitoolsolutions.app.torquevaultkmp.utils.ForceLogoutException -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch +import com.digitoolsolutions.app.torquevaultkmp.screens.scanner.ScannerViewModel +import com.juul.kable.Advertisement 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.home_title -//import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -//import androidx.navigation.NavController -//import com.digitoolsolutions.android.components.ui.AppBar -//import com.digitoolsolutions.android.components.ui.CircularIcon -//import com.digitoolsolutions.android.home.view.HomeEmpty -//import com.digitoolsolutions.android.home.view.HomeItem -//import com.digitoolsolutions.android.scanner.viewmodel.ScannerViewModel -//import com.digitoolsolutions.android.uart.spec.ConnectionState -//import com.digitoolsolutions.android.uart.spec.UartDevice import kotlin.uuid.ExperimentalUuidApi @OptIn(ExperimentalMaterial3Api::class, ExperimentalUuidApi::class) @Composable fun HomeScreen( - navController: NavController, -// bleScannerViewModel: ScannerViewModel + onDeviceClicked: (Advertisement) -> Unit, + viewModel: HomeViewModel = koinViewModel(), + scannerViewModel: ScannerViewModel = koinInject() ) { val snackbarHostState = remember { SnackbarHostState() } -// val connectedDevice by bleScannerViewModel.connectedDevices.collectAsState(initial = emptyList()) - val viewModel = koinViewModel() - val workOrders by viewModel.workOrders.collectAsState() - - LaunchedEffect(Unit) { - viewModel.loadWorkOrders() - } + val connectedDevices by scannerViewModel.connectedDevices.collectAsState() Scaffold( modifier = Modifier.imePadding(), @@ -94,23 +55,17 @@ fun HomeScreen( .padding(padding) .padding(horizontal = 16.dp, vertical = 12.dp), ) { - if (workOrders.isEmpty()) + if (connectedDevices.isEmpty()) HomeEmpty() else LazyColumn { - items(workOrders) { workOrder -> - HomeItem(workOrder) + connectedDevices.forEach { (_, connectedDevice) -> + val (device, connectionState) = connectedDevice + item { + HomeItem(device, connectionState, onDeviceClicked = {onDeviceClicked(device)}) + } } } } } } - -@Preview(showBackground = true) -@Composable -private fun HomeScreenPreview() { -// HomeScreen( -// navController = NavController(LocalContext.current), -//// bleScannerViewModel = hiltViewModel() -// ) -} diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/home/views/HomeItem.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/home/views/HomeItem.kt index 8bdc199..68c2f1d 100644 --- a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/home/views/HomeItem.kt +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/home/views/HomeItem.kt @@ -1,14 +1,16 @@ package com.digitoolsolutions.app.torquevaultkmp.screens.home.views -import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.SyncAlt +import androidx.compose.material.icons.filled.Bluetooth +import androidx.compose.material.icons.rounded.SyncAlt import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedCard import androidx.compose.material3.Text @@ -16,60 +18,71 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.digitoolsolutions.app.torquevaultkmp.components.CircularIcon -import com.digitoolsolutions.app.torquevaultkmp.domain.model.WorkOrder - -//import com.digitoolsolutions.android.components.ui.CircularIcon -//import com.digitoolsolutions.android.uart.spec.ConnectionState -//import com.digitoolsolutions.android.uart.spec.UartDevice - +import com.juul.kable.Advertisement +import com.juul.kable.State +sealed class Status(val color: Color, val text: String) { + object Disconnected : Status(Color.Red, "Disconnected") + object Connecting : Status(Color.Gray, "Connecting") + object Connected : Status(Color(0xFF388E3C), "Connected") + object Disconnecting : Status(Color.Yellow, "Disconnecting") +} @Composable -//fun HomeItem(device: UartDevice, modifier: Modifier = Modifier) { -fun HomeItem(device: WorkOrder, modifier: Modifier = Modifier) { +fun HomeItem(device: Advertisement, connectionState: State?, onDeviceClicked: () -> Unit, modifier: Modifier = Modifier) { OutlinedCard( - modifier = modifier.fillMaxWidth().padding(vertical = 4.dp), + modifier = modifier.fillMaxWidth().padding(vertical = 4.dp).clickable { + onDeviceClicked() + }, elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) ) { - Row ( - modifier = Modifier.padding(16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - CircularIcon(Icons.Outlined.SyncAlt) - Spacer(modifier = Modifier.width(16.dp)) - Column(modifier = Modifier.weight(1f)) { -// device.name?.let { -// Text( -// text = it, -// style = MaterialTheme.typography.titleMedium -// ) -// } -// Text( -// text = device.identifier, -// style = MaterialTheme.typography.bodyMedium, -// color = MaterialTheme.colorScheme.onSurfaceVariant -// ) - Text(text=device.id) - } - Spacer(modifier = Modifier.width(16.dp)) -// val statusColor = when (device.status) { -// ConnectionState.CONNECTING -> MaterialTheme.colorScheme.onSurfaceVariant -// ConnectionState.READY -> Color.Green -// else -> MaterialTheme.colorScheme.error -// } -// Text( -// text = device.status.name, -// style = MaterialTheme.typography.bodyMedium, -// color = statusColor -// ) + val status = when (connectionState) { + is State.Disconnected -> Status.Disconnected + is State.Connecting -> Status.Connecting + is State.Connected -> Status.Connected + is State.Disconnecting -> Status.Disconnecting + else -> null } + ListItem( + headlineContent = { + Text( + text = device.name ?: "No name", + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + supportingContent = { + Text( + text = "${device.identifier}", + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + leadingContent = { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + CircularIcon( + imageVector = if(connectionState is State.Connected) Icons.Rounded.SyncAlt else Icons.Default.Bluetooth, + backgroundColor = if (connectionState is State.Connected) Color(0xFF0082FC) else MaterialTheme.colorScheme.secondaryContainer, + iconTint = if (connectionState is State.Connected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSecondaryContainer, + ) + } + }, + trailingContent = { + if (connectionState is State.Connecting) { + CircularProgressIndicator(modifier = Modifier.size(24.dp), strokeWidth = 3.dp, color = Color.Blue) + } else { + if(status != null) + Text( + text = status.text, + style = MaterialTheme.typography.bodyMedium, + color = status.color + ) + } + } + ) } } - -@Preview(showBackground = true) -@Composable -private fun ItemPreview() { -// HomeItem(device = UartDevice("AA:BB:CC:DD:EE", "Device 1")) -} diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/logs/LogViewModel.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/logs/LogViewModel.kt index 968522b..af0e288 100644 --- a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/logs/LogViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/logs/LogViewModel.kt @@ -17,7 +17,7 @@ class LogViewModel( private val _logs = MutableStateFlow>(emptyList()) val logs: StateFlow> = _logs.asStateFlow() - private val _activeFilter = MutableStateFlow(LogFilter.All) + private val _activeFilter = MutableStateFlow(LogFilter.All) val activeFilter: StateFlow = _activeFilter.asStateFlow() private var lastTimestamp: Long? = null diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/logs/view/FilterBottomSheet.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/logs/view/FilterBottomSheet.kt index 40a5dd6..f53906d 100644 --- a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/logs/view/FilterBottomSheet.kt +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/logs/view/FilterBottomSheet.kt @@ -8,27 +8,45 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.ExposedDropdownMenuAnchorType import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.RadioButton import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.semantics.Role import androidx.compose.ui.unit.dp +import com.digitoolsolutions.app.torquevaultkmp.data.repository.DeviceRepository +import com.digitoolsolutions.app.torquevaultkmp.data.storage.db.Device import com.digitoolsolutions.app.torquevaultkmp.domain.model.LogFilter +import com.digitoolsolutions.app.torquevaultkmp.utils.Helper +import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource +import org.koin.compose.koinInject import torquevaultkmp.composeapp.generated.resources.Res import torquevaultkmp.composeapp.generated.resources.clear @@ -59,7 +77,9 @@ fun FilterBottomSheet( activeFilter = activeFilter, onFilterSelected = { onFilterSelected(it) - onDismissRequest() + if (it !is LogFilter.Device || it.deviceId != null) { + onDismissRequest() + } }, onClear = { onFilterSelected(LogFilter.All) @@ -87,25 +107,127 @@ internal fun FilterContent( ) LogFilter.entries.forEach { filter -> - Row( - modifier = Modifier - .fillMaxWidth() - .selectable( - selected = activeFilter == filter, - onClick = { onFilterSelected(filter) }, - role = Role.RadioButton + val isSelected = if (filter is LogFilter.Device) { + activeFilter is LogFilter.Device + } else { + activeFilter == filter + } + + Column { + Row( + modifier = Modifier + .fillMaxWidth() + .selectable( + selected = isSelected, + onClick = { onFilterSelected(filter) }, + role = Role.RadioButton + ) + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = isSelected, + onClick = { onFilterSelected(filter) } ) - .padding(horizontal = 16.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - RadioButton( - selected = activeFilter == filter, - onClick = { onFilterSelected(filter) } + Text( + text = filter.name, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(start = 8.dp) + ) + } + + if (filter is LogFilter.Device && isSelected) { + DeviceSelector( + selectedDeviceName = if((activeFilter as? LogFilter.Device)?.deviceName != null) + "${activeFilter.deviceName} - ${Helper.formatDeviceId(activeFilter.deviceId)}" + else "Select device", + onDeviceSelected = { device -> + onFilterSelected(LogFilter.Device(device.id, device.name)) + }, + modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp) + ) + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DeviceSelector( + selectedDeviceName: String, + onDeviceSelected: (Device) -> Unit, + modifier: Modifier = Modifier +) { + val deviceRepository = koinInject() + var expanded by remember { mutableStateOf(false) } + var devices by remember { mutableStateOf(emptyList()) } + var isLoading by remember { mutableStateOf(false) } + var isLastPage by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + + val loadMore = { + if (!isLoading && !isLastPage) { + scope.launch { + isLoading = true + val lastId = devices.lastOrNull()?.id + val newDevices = deviceRepository.getDevicesPaged(lastId = lastId) + if (newDevices.size < DeviceRepository.PAGE_SIZE) { + isLastPage = true + } + devices = devices + newDevices + isLoading = false + } + } + } + + LaunchedEffect(Unit) { + if (devices.isEmpty()) { + loadMore() + } + } + + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = !expanded }, + modifier = modifier.fillMaxWidth() + ) { + OutlinedTextField( + value = selectedDeviceName, + onValueChange = {}, + readOnly = true, + label = { Text("Device") }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + colors = ExposedDropdownMenuDefaults.outlinedTextFieldColors(), + modifier = Modifier.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable, true).fillMaxWidth() + ) + + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + devices.forEachIndexed { index, device -> + DropdownMenuItem( + text = { Text("${device.name} - ${device.id.takeLast(12)}") }, + onClick = { + onDeviceSelected(device) + expanded = false + } ) - Text( - text = filter.name, - style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.padding(start = 8.dp) + if (index == devices.size - 1 && !isLastPage) { + LaunchedEffect(Unit) { + loadMore() + } + } + } + if (isLoading) { + DropdownMenuItem( + text = { + Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + CircularProgressIndicator(modifier = Modifier.size(24.dp)) + } + }, + onClick = {} ) } } diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/logs/view/LogItem.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/logs/view/LogItem.kt index 0e68881..3461fb2 100644 --- a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/logs/view/LogItem.kt +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/logs/view/LogItem.kt @@ -36,7 +36,7 @@ fun LogItem(log: Log, modifier: Modifier = Modifier) { text = log.title, style = MaterialTheme.typography.titleMedium ) - val sourceText = if (log.source == LogFilter.Device.name) { + val sourceText = if (log.source == LogFilter.DEVICE) { log.deviceId ?: "Unknown Device" } else { log.source 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 c256e05..a1ee36c 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 @@ -15,25 +15,19 @@ import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color 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 import com.digitoolsolutions.app.torquevaultkmp.components.WarningView -import com.digitoolsolutions.app.torquevaultkmp.kable.BleManager 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 -import torquevaultkmp.composeapp.generated.resources.baseline_warning_24 -import torquevaultkmp.composeapp.generated.resources.filter_title import torquevaultkmp.composeapp.generated.resources.no_device_guide_info import torquevaultkmp.composeapp.generated.resources.scan_empty_title import torquevaultkmp.composeapp.generated.resources.scanner_title @@ -88,7 +82,7 @@ fun ScannerScreen( title = stringResource(Res.string.scan_empty_title), hint = stringResource(Res.string.no_device_guide_info), hintTextAlign = TextAlign.Justify, - ){} + ) } else { LazyColumn( modifier = Modifier.fillMaxSize(), @@ -97,8 +91,8 @@ fun ScannerScreen( ) { items(devices) { device -> val deviceId = device.identifier.toString() - val connectionState = connectedDevices[deviceId] - + val connectedDevice = connectedDevices[deviceId] + val connectionState = connectedDevice?.state DeviceListItem( device = device, connectionState = connectionState, @@ -132,14 +126,6 @@ fun DeviceListItem( connectionState: State?, onClick: () -> Unit ) { - val statusText = when (connectionState) { - is State.Connecting -> "Connecting..." - is State.Connected -> "Connected" - is State.Disconnecting -> "Disconnecting..." - is State.Disconnected -> "Disconnected" - null -> "" - } - OutlinedCard( modifier = Modifier .fillMaxWidth() @@ -160,20 +146,11 @@ fun DeviceListItem( ) }, supportingContent = { - Column { - Text( - text = device.identifier.toString(), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - if (statusText.isNotEmpty()) { - Text( - text = statusText, - style = MaterialTheme.typography.bodySmall, - color = if (connectionState is State.Connected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline - ) - } - } + Text( + text = "${device.identifier}", + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) }, leadingContent = { CircularIcon( @@ -184,7 +161,7 @@ fun DeviceListItem( }, trailingContent = { if (connectionState is State.Connecting) { - CircularProgressIndicator(modifier = Modifier.size(24.dp), strokeWidth = 2.dp) + CircularProgressIndicator(modifier = Modifier.size(24.dp), strokeWidth = 3.dp, color = Color.Blue) } else { RssiIcon(rssi = device.rssi) } 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 62caabe..fd9c392 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,7 +9,6 @@ import com.digitoolsolutions.app.torquevaultkmp.data.repository.LogRepository import com.digitoolsolutions.app.torquevaultkmp.data.repository.ScannerRepository 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 @@ -18,7 +17,6 @@ import com.juul.kable.State import com.digitoolsolutions.app.torquevaultkmp.domain.model.WorkOrder import com.digitoolsolutions.app.torquevaultkmp.domain.model.computeWheelsNuts import com.digitoolsolutions.app.torquevaultkmp.utils.Helper -import com.juul.kable.ExperimentalApi import io.github.aakira.napier.Napier import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -33,13 +31,20 @@ import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.isActive +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map import kotlin.time.Duration.Companion.seconds import kotlin.time.TimeSource import kotlin.uuid.ExperimentalUuidApi +data class ConnectedDevice( + val advertisement: Advertisement, + val state: State +) @OptIn(ExperimentalUuidApi::class) class ScannerViewModel( private val bleManager: BleManager, @@ -51,21 +56,19 @@ class ScannerViewModel( companion object { private val SCAN_TIMEOUT = 1.seconds private val CONNECTION_TIMEOUT = 5.seconds + private const val MAX_LOG_ENTRIES = 50 } private val _devices = MutableStateFlow>(emptyList()) val devices: StateFlow> = _devices.asStateFlow() private val _isScanning = MutableStateFlow(false) val isScanning: StateFlow = _isScanning.asStateFlow() - private val _connectedDevices = MutableStateFlow>(emptyMap()) - val connectedDevices: StateFlow> = _connectedDevices.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 _rxHistories = MutableStateFlow>>(emptyMap()) private val _workOrders = MutableStateFlow>(emptyList()) private val _autoConnect: StateFlow = storage.observe(ReferKeys.DEVICE_AUTO_CONNECT) .stateIn(viewModelScope, SharingStarted.Eagerly, false) @@ -175,8 +178,23 @@ class ScannerViewModel( fun getPeripheral(identifier: String): Peripheral? { return bleManager.getPeripheral(identifier) } - fun clearMessages() { - _rxMessages.value = emptyList() + + fun historyFlow(deviceId: String): Flow> = + _rxHistories.map { it[deviceId] ?: emptyList() } + fun clearMessages(deviceId: String? = null) { + if (deviceId == null) { + _rxHistories.value = emptyMap() + } else { + _rxHistories.update { it - deviceId } + } + } + + private fun appendHistory(deviceId: String, message: String) { + _rxHistories.update { currentMap -> + val currentLogs = currentMap[deviceId] ?: emptyList() + val newLogs = (currentLogs + message).takeLast(MAX_LOG_ENTRIES) + currentMap + (deviceId to newLogs) + } } fun sendRawCommand(deviceIdentifier: String, command: String) { @@ -187,10 +205,11 @@ class ScannerViewModel( } fun sendCommand(peripheral: Peripheral, command: String) { + val deviceId = peripheral.identifier.toString() viewModelScope.launch { bleManager.sendCommand(peripheral, Helper.buildUartCommand(command)) - _rxMessages.value += "Sent: $command" - logRepository.addLog(title = "Communicate - Sent", content = command, peripheral.identifier.toString()) + appendHistory(deviceId, "Sent: $command") + logRepository.addLog(title = "Communicate - Sent", content = command, deviceId) } } @@ -207,7 +226,7 @@ class ScannerViewModel( var hasEmitted = false launch { peripheral.state.collect { state -> - _connectedDevices.value += (deviceIdentifier to state) + _connectedDevices.value += (deviceIdentifier to ConnectedDevice(adv, state)) if (state is State.Connected) { /** Add bonded device */ addBondedDevice(deviceIdentifier, adv.name ?: "No name") @@ -293,7 +312,7 @@ class ScannerViewModel( } } private fun handleMessage(peripheral: Peripheral, msg: String, deviceIdentifier: String) { - _rxMessages.value += "Received: $msg" + appendHistory(deviceIdentifier, "Received: $msg") logRepository.addLog(title = "Communicate - Received", content = msg, deviceId = deviceIdentifier) val decoded = Helper.decodeRxData(msg) ?: return val data = decoded.data diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/settings/SettingScreen.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/settings/SettingScreen.kt index d72b795..88116ef 100644 --- a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/settings/SettingScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/settings/SettingScreen.kt @@ -1,5 +1,6 @@ package com.digitoolsolutions.app.torquevaultkmp.screens.settings +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -27,6 +28,8 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -94,7 +97,7 @@ fun SettingContent( onLogout: () -> Unit, onChangeAutoConnect: (Boolean) -> Unit ) { -// val context = LocalContext.current + val focusManager = LocalFocusManager.current val snackbarHostState = remember { SnackbarHostState() } val scope = rememberCoroutineScope() // remember the initial serverUrl to check if it has changed when saving @@ -125,7 +128,16 @@ fun SettingContent( snackbarHost = { SnackbarHost(snackbarHostState) }) { padding -> Column ( - modifier = Modifier.fillMaxSize().imePadding().padding(padding).padding(16.dp), + modifier = Modifier + .fillMaxSize() + .imePadding() + .padding(padding) + .padding(16.dp) + .pointerInput(Unit) { + detectTapGestures(onTap = { + focusManager.clearFocus() + }) + }, ) { Row( verticalAlignment = Alignment.CenterVertically, diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/utils/Helper.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/utils/Helper.kt index 77900c5..5ab624a 100644 --- a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/utils/Helper.kt +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/utils/Helper.kt @@ -52,4 +52,12 @@ object Helper { } return result } + fun formatDeviceId(deviceId: String?): String { + if(deviceId == null) return "" + return if (deviceId.contains(":")) { + deviceId + } else { + deviceId.takeLast(12) + } + } }