Filter log by device

This commit is contained in:
2026-06-12 21:14:11 +07:00
parent c462373762
commit 54db47dc45
17 changed files with 362 additions and 206 deletions
@@ -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<Log> {
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()
}
}
}
}
}
@@ -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
}
}
}
}
@@ -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<String> =
peripheral.observe(TX_CHAR).map { it.decodeToString() }
@@ -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<String>("deviceId") ?: ""
UartCommunicationScreen(navController, deviceId)
val cancellable = backStackEntry.savedStateHandle.get<Boolean>("cancellable") ?: true
UartCommunicationScreen(navController, deviceId, cancellable)
}
composable(LogDestination.route) { LogScreen() }
composable(SettingDestination.route) {
@@ -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
@@ -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"
}
@@ -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)
})
}
@@ -30,7 +30,7 @@ import androidx.compose.ui.unit.dp
@Composable
internal fun DeviceConnected(
messages: List<String>,
histories: List<String>,
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)
}
}
@@ -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<HomeViewModel>()
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()
// )
}
@@ -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),
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(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)
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,
)
}
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
// )
},
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"))
}
@@ -17,7 +17,7 @@ class LogViewModel(
private val _logs = MutableStateFlow<List<Log>>(emptyList())
val logs: StateFlow<List<Log>> = _logs.asStateFlow()
private val _activeFilter = MutableStateFlow(LogFilter.All)
private val _activeFilter = MutableStateFlow<LogFilter>(LogFilter.All)
val activeFilter: StateFlow<LogFilter> = _activeFilter.asStateFlow()
private var lastTimestamp: Long? = null
@@ -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)
if (it !is LogFilter.Device || it.deviceId != null) {
onDismissRequest()
}
},
onClear = {
onFilterSelected(LogFilter.All)
@@ -87,11 +107,18 @@ internal fun FilterContent(
)
LogFilter.entries.forEach { filter ->
val isSelected = if (filter is LogFilter.Device) {
activeFilter is LogFilter.Device
} else {
activeFilter == filter
}
Column {
Row(
modifier = Modifier
.fillMaxWidth()
.selectable(
selected = activeFilter == filter,
selected = isSelected,
onClick = { onFilterSelected(filter) },
role = Role.RadioButton
)
@@ -99,7 +126,7 @@ internal fun FilterContent(
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(
selected = activeFilter == filter,
selected = isSelected,
onClick = { onFilterSelected(filter) }
)
Text(
@@ -108,6 +135,101 @@ internal fun FilterContent(
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<DeviceRepository>()
var expanded by remember { mutableStateOf(false) }
var devices by remember { mutableStateOf(emptyList<Device>()) }
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
}
)
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 = {}
)
}
}
}
}
@@ -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
@@ -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(),
text = "${device.identifier}",
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
)
}
}
},
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)
}
@@ -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<List<Advertisement>>(emptyList())
val devices: StateFlow<List<Advertisement>> = _devices.asStateFlow()
private val _isScanning = MutableStateFlow(false)
val isScanning: StateFlow<Boolean> = _isScanning.asStateFlow()
private val _connectedDevices = MutableStateFlow<Map<String, State>>(emptyMap())
val connectedDevices: StateFlow<Map<String, State>> = _connectedDevices.asStateFlow()
private val _connectedDevices = MutableStateFlow<Map<String, ConnectedDevice>>(emptyMap())
val connectedDevices: StateFlow<Map<String, ConnectedDevice>> = _connectedDevices.asStateFlow()
private val _connectionReasons = MutableStateFlow<Map<String, ConnectionReason>>(emptyMap())
val connectionReasons: StateFlow<Map<String, ConnectionReason>> = _connectionReasons.asStateFlow()
private val _rxMessages = MutableStateFlow<List<String>>(emptyList())
val rxMessages: StateFlow<List<String>> = _rxMessages.asStateFlow()
private val _rxHistories = MutableStateFlow<Map<String,List<String>>>(emptyMap())
private val _workOrders = MutableStateFlow<List<WorkOrder>>(emptyList())
private val _autoConnect: StateFlow<Boolean> = storage.observe<Boolean>(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<List<String>> =
_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
@@ -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,
@@ -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)
}
}
}