From f211eed812f977e2d81bff7fa8a3221eb8c41b2d Mon Sep 17 00:00:00 2001 From: Vineyro Date: Thu, 25 May 2023 12:47:13 +0700 Subject: [PATCH] Add filters --- .../ble/app/ui/screen/ble/BleListContract.kt | 40 ++- .../ble/app/ui/screen/ble/BleListScreen.kt | 211 ++++++++++- .../ble/app/ui/screen/ble/BleListViewModel.kt | 90 ++++- .../llc/arma/ble/app/ui/screen/ble/Filter.kt | 337 ++++++++++++++++++ .../llc/arma/ble/data/BleRepositoryImpl.kt | 31 +- .../java/llc/arma/ble/domain/model/BleInfo.kt | 5 +- 6 files changed, 694 insertions(+), 20 deletions(-) create mode 100644 app/src/main/java/llc/arma/ble/app/ui/screen/ble/Filter.kt diff --git a/app/src/main/java/llc/arma/ble/app/ui/screen/ble/BleListContract.kt b/app/src/main/java/llc/arma/ble/app/ui/screen/ble/BleListContract.kt index bc942a0..d86c23c 100644 --- a/app/src/main/java/llc/arma/ble/app/ui/screen/ble/BleListContract.kt +++ b/app/src/main/java/llc/arma/ble/app/ui/screen/ble/BleListContract.kt @@ -11,19 +11,55 @@ class BleListContract { sealed class Event : ViewEvent { + object OnResetFilter : Event() + + object OnHideFilter : Event() + + object OnShowFilter : Event() + data class OnConnectToBle( val bleAddress: String ) : Event() + data class OnRssiRangeChanged( + val rssi: ClosedFloatingPointRange + ) : Event() + + data class OnMacFilterChanged( + val mac: String + ) : Event() + + data class OnNameFilterChanged( + val name: String + ) : Event() + + data class OnTypeChanged( + val type: BleInfo.Type? + ) : Event() + } data class State( val connectedBleList: List, - val bleList: List - ) : ViewState + val bleList: List, + val filter: Filter + ) : ViewState { + + data class Filter( + val name: String = "", + val mac: String = "", + val rssi: ClosedFloatingPointRange = (-100f)..(-30f), + val bleType: BleInfo.Type? = null + ) + + } sealed class Effect : ViewSideEffect { + object ShowFilter : Effect() + + object HideFilter : Effect() + sealed class Navigation : Effect() { data class NavigateToBle( diff --git a/app/src/main/java/llc/arma/ble/app/ui/screen/ble/BleListScreen.kt b/app/src/main/java/llc/arma/ble/app/ui/screen/ble/BleListScreen.kt index e6e2462..ea79261 100644 --- a/app/src/main/java/llc/arma/ble/app/ui/screen/ble/BleListScreen.kt +++ b/app/src/main/java/llc/arma/ble/app/ui/screen/ble/BleListScreen.kt @@ -1,5 +1,6 @@ package llc.arma.ble.app.ui.screen.ble +import android.os.SystemClock import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* @@ -7,11 +8,16 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.ContentAlpha import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.* import androidx.compose.material3.* 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.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha @@ -19,8 +25,11 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import llc.arma.ble.app.ui.common.rememberBottomDialogState import llc.arma.ble.domain.model.BleInfo import llc.arma.ble.domain.model.ConnectedBleInfo @@ -33,19 +42,80 @@ fun BleListScreen( val viewModel = hiltViewModel() val state = viewModel.viewState.value + val bottomDialog = rememberBottomDialogState() + LaunchedEffect("effect"){ viewModel.effect.onEach { when(it){ is BleListContract.Effect.Navigation -> onNavigationEvent(it) + is BleListContract.Effect.HideFilter -> launch { + bottomDialog.hide() + } + is BleListContract.Effect.ShowFilter -> launch { + bottomDialog.show { + Filter( + filter = viewModel.viewState.value.filter, + onEvent = { + viewModel.setEvent(it) + } + ) + } + } } }.launchIn(this) } Column { - CenterAlignedTopAppBar( + TopAppBar( title = { Text(text = "Arma BLE") + }, + actions = { + + Row( + modifier = Modifier + .padding(horizontal = 8.dp) + .align(Alignment.CenterVertically) + ) { + + Text(text = "${state.bleList.size}") + + Spacer(modifier = Modifier.width(12.dp)) + + Text(text = "${state.bleList.filter { + it.batteryLevel == 100 + }.filterNot { SystemClock.elapsedRealtime() - it.scanTime > 10_000 }.size}") + + Text(text = " | ") + + Text( + text = "${state.bleList.filter { SystemClock.elapsedRealtime() - it.scanTime > 10_000 }.size}", + color = LocalContentColor.current.copy(alpha = ContentAlpha.disabled) + ) + + Text(text = " | ") + + Text( + text = "${state.bleList.filter { it.batteryLevel < 100 }.size}", + color = MaterialTheme.colorScheme.error + ) + + } + + IconButton( + onClick = { + viewModel.setEvent(BleListContract.Event.OnShowFilter) + } + ) { + + Icon( + imageVector = Icons.Rounded.FilterAlt, + contentDescription = null + ) + + } + } ) @@ -71,7 +141,14 @@ fun BleListScreen( } - items(items = state.bleList) { + val filteredData = state.bleList.filter { + (it.type == state.filter.bleType || state.filter.bleType == null) && + it.name.contains(state.filter.name) && + it.serial.contains(state.filter.mac) && + state.filter.rssi.contains(it.rssi?.toFloat() ?: Float.MIN_VALUE) + } + + items(items = filteredData.sortedBy { it.name }.reversed()) { BleItem( ble = it, @@ -113,14 +190,48 @@ private fun BleItem( onClick: () -> Unit ){ + val color = if(ble.batteryLevel < 100){ + MaterialTheme.colorScheme.errorContainer + } else { + MaterialTheme.colorScheme.background + } + + val highAlpha = ContentAlpha.high + val disabledAlpha = ContentAlpha.disabled + + var alpha by remember { + mutableStateOf( + if(SystemClock.elapsedRealtime() - ble.scanTime > 10_000){ + disabledAlpha + } else { + highAlpha + } + ) + } + + LaunchedEffect(ble.scanTime) { + while(true) { + alpha = if(SystemClock.elapsedRealtime() - ble.scanTime > 10_000){ + disabledAlpha + } else { + highAlpha + } + + delay(800) + } + } + Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(16.dp)) + .background(color) .clickable { onClick() } .padding(vertical = 8.dp, horizontal = 16.dp) + .alpha(alpha) + ) { ItemIcon { @@ -158,10 +269,20 @@ private fun BleItem( contentDescription = null ) - Text( - style = MaterialTheme.typography.bodyMedium, - text = ble.rssi.toString() + " dBm" - ) + Box { + + Text( + style = MaterialTheme.typography.bodyMedium, + text = "-999 dBm", + modifier = Modifier.alpha(0f) + ) + + Text( + style = MaterialTheme.typography.bodyMedium, + text = ble.rssi.toString() + " dBm" + ) + + } } @@ -170,19 +291,87 @@ private fun BleItem( modifier = Modifier.alpha(0.7f) ) { + val color = if(ble.batteryLevel < 100){ + MaterialTheme.colorScheme.error + } else { + LocalContentColor.current + } + Icon( modifier = Modifier.size(16.dp), imageVector = Icons.Rounded.BatteryFull, - contentDescription = null + contentDescription = null, + tint = color ) - Text( - style = MaterialTheme.typography.bodyMedium, - text = ble.batteryLevel.toString() + " %" - ) + Box { + + Text( + style = MaterialTheme.typography.bodyMedium, + text = "100 %", + modifier = Modifier.alpha(0f) + ) + + Text( + style = MaterialTheme.typography.bodyMedium, + text = ble.batteryLevel.toString() + " %", + color = color + ) + + } + } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.alpha(0.7f) + ) { + + val color = if(ble.batteryLevel < 100){ + MaterialTheme.colorScheme.error + } else { + LocalContentColor.current + } + + Icon( + modifier = Modifier.size(16.dp), + imageVector = Icons.Rounded.ArrowRightAlt, + contentDescription = null, + tint = color + ) + + Box { + + Text( + style = MaterialTheme.typography.bodyMedium, + text = "99s", + modifier = Modifier.alpha(0f) + ) + + Text( + style = MaterialTheme.typography.bodyMedium, + text = ">1m", + modifier = Modifier.alpha(0f) + ) + + val lastAdv = ((SystemClock.elapsedRealtime() - ble.scanTime) / 1_000) + + Text( + style = MaterialTheme.typography.bodyMedium, + text = if(lastAdv > 60){ + ">1m" + } else { + "${lastAdv}s" + } + ) + + } + + + } + + } } diff --git a/app/src/main/java/llc/arma/ble/app/ui/screen/ble/BleListViewModel.kt b/app/src/main/java/llc/arma/ble/app/ui/screen/ble/BleListViewModel.kt index 5332fd7..19a7af9 100644 --- a/app/src/main/java/llc/arma/ble/app/ui/screen/ble/BleListViewModel.kt +++ b/app/src/main/java/llc/arma/ble/app/ui/screen/ble/BleListViewModel.kt @@ -23,7 +23,7 @@ class BleListViewModel @Inject constructor( it.fold( onSuccess = { setState { - BleListContract.State( + copy( connectedBleList = emptyList(), bleList = it ) @@ -38,11 +38,18 @@ class BleListViewModel @Inject constructor( } - override fun setInitialState(): BleListContract.State = BleListContract.State(emptyList(), emptyList()) + override fun setInitialState(): BleListContract.State = BleListContract.State(emptyList(), emptyList(), BleListContract.State.Filter()) override fun handleEvents(event: BleListContract.Event) { when(event){ is BleListContract.Event.OnConnectToBle -> reduce(viewState.value, event) + is BleListContract.Event.OnHideFilter -> reduce(viewState.value, event) + is BleListContract.Event.OnMacFilterChanged -> reduce(viewState.value, event) + is BleListContract.Event.OnNameFilterChanged -> reduce(viewState.value, event) + is BleListContract.Event.OnResetFilter -> reduce(viewState.value, event) + is BleListContract.Event.OnRssiRangeChanged -> reduce(viewState.value, event) + is BleListContract.Event.OnShowFilter -> reduce(viewState.value, event) + is BleListContract.Event.OnTypeChanged -> reduce(viewState.value, event) } } @@ -55,4 +62,83 @@ class BleListViewModel @Inject constructor( } } + private fun reduce( + state: BleListContract.State, + event: BleListContract.Event.OnHideFilter + ) { + setEffect { + BleListContract.Effect.HideFilter + } + } + + private fun reduce( + state: BleListContract.State, + event: BleListContract.Event.OnMacFilterChanged + ) { + setState { + copy( + filter = this.filter.copy(mac = event.mac) + ) + } + } + + private fun reduce( + state: BleListContract.State, + event: BleListContract.Event.OnNameFilterChanged + ) { + setState { + copy( + filter = this.filter.copy(name = event.name) + ) + } + } + + private fun reduce( + state: BleListContract.State, + event: BleListContract.Event.OnResetFilter + ) { + + setState { + copy( + filter = BleListContract.State.Filter() + ) + } + + setEffect { + BleListContract.Effect.HideFilter + } + + } + + private fun reduce( + state: BleListContract.State, + event: BleListContract.Event.OnRssiRangeChanged + ) { + setState { + copy( + filter = this.filter.copy(rssi = event.rssi) + ) + } + } + + private fun reduce( + state: BleListContract.State, + event: BleListContract.Event.OnTypeChanged + ) { + setState { + copy( + filter = this.filter.copy(bleType = event.type) + ) + } + } + + private fun reduce( + state: BleListContract.State, + event: BleListContract.Event.OnShowFilter + ) { + setEffect { + BleListContract.Effect.ShowFilter + } + } + } \ No newline at end of file diff --git a/app/src/main/java/llc/arma/ble/app/ui/screen/ble/Filter.kt b/app/src/main/java/llc/arma/ble/app/ui/screen/ble/Filter.kt new file mode 100644 index 0000000..30e8927 --- /dev/null +++ b/app/src/main/java/llc/arma/ble/app/ui/screen/ble/Filter.kt @@ -0,0 +1,337 @@ +package llc.arma.ble.app.ui.screen.ble + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +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.padding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Bluetooth +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.Search +import androidx.compose.material.icons.rounded.ShortText +import androidx.compose.material.icons.rounded.SignalCellularAlt +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RangeSlider +import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import llc.arma.ble.domain.model.BleInfo + +private val BleInfo.Type?.localized: String + get() { + return when(this){ + BleInfo.Type.BEACON -> "Маяк" + BleInfo.Type.THERMOMETER -> "Термодатчик" + null -> "Все" + } + } + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun Filter( + filter: BleListContract.State.Filter, + onEvent: (BleListContract.Event) -> Unit +) { + + Column( + + ) { + + Text( + modifier = Modifier.padding(horizontal = 12.dp), + text = "Фильтр", + style = MaterialTheme.typography.titleLarge + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Column( + modifier = Modifier.padding(horizontal = 12.dp) + ) { + + Spacer(modifier = Modifier.height(8.dp)) + + Row( + verticalAlignment = Alignment.CenterVertically + ) { + + Icon( + imageVector = Icons.Rounded.Bluetooth, + contentDescription = null, + modifier = Modifier.padding(12.dp) + ) + + var expanded by remember { mutableStateOf(false) } + + ExposedDropdownMenuBox( + modifier = Modifier.fillMaxWidth().padding(end = 8.dp), + expanded = expanded, + onExpandedChange = { + expanded = it + } + ) { + + OutlinedTextField( + modifier = Modifier.menuAnchor().fillMaxWidth(), + readOnly = true, + value = filter.bleType.localized, + onValueChange = { }, + label = { Text("Тип") }, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon( + expanded = expanded + ) + } + ) + + ExposedDropdownMenu( + modifier = Modifier.background(MaterialTheme.colorScheme.background).fillMaxWidth(), + expanded = expanded, + onDismissRequest = { + expanded = false + } + ) { + + mutableListOf(null).apply { + addAll(BleInfo.Type.values()) + }.forEach { selectionOption -> + DropdownMenuItem( + onClick = { + onEvent( + BleListContract.Event.OnTypeChanged( + selectionOption + ) + ) + expanded = false + }, + text = { + Text(text = selectionOption.localized) + } + ) + } + } + } + + } + + Row( + verticalAlignment = Alignment.CenterVertically + ){ + + Icon( + imageVector = Icons.Rounded.Search, + contentDescription = null, + modifier = Modifier.padding(12.dp) + ) + + OutlinedTextField( + value = filter.name, + singleLine = true, + onValueChange = { + onEvent(BleListContract.Event.OnNameFilterChanged(it)) + }, + label = { + Text(text = "Имя") + }, + trailingIcon = { + + if(filter.name.isNotEmpty()) { + + IconButton( + onClick = { onEvent(BleListContract.Event.OnNameFilterChanged("")) } + ) { + Icon( + imageVector = Icons.Rounded.Close, + contentDescription = null + ) + } + + } + + }, + modifier = Modifier + .padding(end = 8.dp) + .fillMaxWidth() + + ) + + } + + Spacer(modifier = Modifier.height(8.dp)) + + Row( + verticalAlignment = Alignment.CenterVertically + ) { + + Icon( + imageVector = Icons.Rounded.ShortText, + contentDescription = null, + modifier = Modifier.padding(12.dp) + ) + + OutlinedTextField( + value = filter.mac, + singleLine = true, + onValueChange = { + onEvent(BleListContract.Event.OnMacFilterChanged(it)) + }, + label = { + Text(text = "Mac") + }, + trailingIcon = { + + if (filter.mac.isNotEmpty()) { + + IconButton( + onClick = { onEvent(BleListContract.Event.OnMacFilterChanged("")) } + ) { + Icon( + imageVector = Icons.Rounded.Close, + contentDescription = null + ) + } + + } + + }, + modifier = Modifier + .padding(end = 8.dp) + .fillMaxWidth() + ) + + } + + Spacer(modifier = Modifier.height(12.dp)) + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .padding(end = 8.dp) + ) { + + Icon( + imageVector = Icons.Rounded.SignalCellularAlt, + contentDescription = null, + modifier = Modifier.padding(12.dp) + ) + + Column() { + + RangeSlider( + value = filter.rssi, + onValueChange = { + onEvent(BleListContract.Event.OnRssiRangeChanged(it)) + }, + valueRange = (-100f)..(-30f), + steps = 69, + colors = SliderDefaults.colors( + activeTickColor = MaterialTheme.colorScheme.primary, + inactiveTickColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.38f) + ) + ) + + Row() { + + Text(text = filter.rssi.start.toInt().toString() + " dBm") + + Spacer(modifier = Modifier.weight(1f)) + + Text(text = filter.rssi.endInclusive.toInt().toString() + " dBm") + + } + + } + + } + + Spacer(modifier = Modifier.height(20.dp)) + + Box( + modifier = Modifier + ) { + + Surface( + modifier = Modifier + .fillMaxWidth() + .height(50.dp), + shape = CircleShape, + color = MaterialTheme.colorScheme.primaryContainer, + onClick = { + onEvent(BleListContract.Event.OnHideFilter) + } + ) { + + Box(modifier = Modifier.fillMaxSize()) { + + Text( + modifier = Modifier.align(Alignment.Center), + color = MaterialTheme.colorScheme.background, + style = MaterialTheme.typography.labelLarge, + text = "Применить" + ) + + } + + } + + } + + Spacer(modifier = Modifier.height(8.dp)) + + Box( + modifier = Modifier + ) { + + Surface( + modifier = Modifier + .fillMaxWidth() + .height(50.dp), + shape = CircleShape, + color = MaterialTheme.colorScheme.secondaryContainer, + onClick = { + onEvent(BleListContract.Event.OnResetFilter) + } + ) { + + Box(modifier = Modifier.fillMaxSize()) { + + Text( + modifier = Modifier.align(Alignment.Center), + color = MaterialTheme.colorScheme.onSecondaryContainer, + style = MaterialTheme.typography.labelLarge, + text = "Сбросить" + ) + + } + + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + } + + } + +} \ No newline at end of file diff --git a/app/src/main/java/llc/arma/ble/data/BleRepositoryImpl.kt b/app/src/main/java/llc/arma/ble/data/BleRepositoryImpl.kt index 21df44c..c7c8086 100644 --- a/app/src/main/java/llc/arma/ble/data/BleRepositoryImpl.kt +++ b/app/src/main/java/llc/arma/ble/data/BleRepositoryImpl.kt @@ -64,7 +64,8 @@ class BleRepositoryImpl @Inject constructor( serial = device.address, batteryLevel = batteryLevel ?: 0, rssi = rssi, - type = type + type = type, + scanTime = timestampNanos / 1_000_000 ) } @@ -94,7 +95,7 @@ class BleRepositoryImpl @Inject constructor( (this[idx].toUInt() and 0xFFu) private val deviceCache = mutableMapOf() - val resultList = mutableMapOf() + val resultList: MutableMap = Collections.synchronizedMap(mutableMapOf()) override fun getConnectedBle(): List { if(checkPermission()) { @@ -152,6 +153,32 @@ class BleRepositoryImpl @Inject constructor( } + override fun onBatchScanResults(results: MutableList) { + super.onBatchScanResults(results) + + if (checkPermission()) { + + results.forEach { result -> + + if (result.scanRecord?.deviceName?.contains("ArmA") == true) { + + resultList[result.device.address] = result.info + + deviceCache[result.device.address] = result + + } + + } + + } else { + CoroutineScope(Dispatchers.IO).launch { + send( + Result.failure(BleException.PermissionDenied) + ) + } + } + + } } val bleScanner = diff --git a/app/src/main/java/llc/arma/ble/domain/model/BleInfo.kt b/app/src/main/java/llc/arma/ble/domain/model/BleInfo.kt index 6a72a27..ac85156 100644 --- a/app/src/main/java/llc/arma/ble/domain/model/BleInfo.kt +++ b/app/src/main/java/llc/arma/ble/domain/model/BleInfo.kt @@ -1,13 +1,12 @@ package llc.arma.ble.domain.model -import java.util.UUID - data class BleInfo( val name: String, val serial: String, val batteryLevel: Int, val rssi: Int?, - val type: Type + val type: Type, + val scanTime: Long ){ enum class Type {