Add filters

This commit is contained in:
Vineyro 2023-05-25 12:47:13 +07:00
parent e7e8823c9a
commit f211eed812
6 changed files with 694 additions and 20 deletions

View File

@ -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<Float>
) : 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<ConnectedBleInfo>,
val bleList: List<BleInfo>
) : ViewState
val bleList: List<BleInfo>,
val filter: Filter
) : ViewState {
data class Filter(
val name: String = "",
val mac: String = "",
val rssi: ClosedFloatingPointRange<Float> = (-100f)..(-30f),
val bleType: BleInfo.Type? = null
)
}
sealed class Effect : ViewSideEffect {
object ShowFilter : Effect()
object HideFilter : Effect()
sealed class Navigation : Effect() {
data class NavigateToBle(

View File

@ -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<BleListViewModel>()
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,6 +269,14 @@ private fun BleItem(
contentDescription = null
)
Box {
Text(
style = MaterialTheme.typography.bodyMedium,
text = "-999 dBm",
modifier = Modifier.alpha(0f)
)
Text(
style = MaterialTheme.typography.bodyMedium,
text = ble.rssi.toString() + " dBm"
@ -165,24 +284,94 @@ private fun BleItem(
}
}
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.BatteryFull,
contentDescription = null
contentDescription = null,
tint = color
)
Box {
Text(
style = MaterialTheme.typography.bodyMedium,
text = "100 %",
modifier = Modifier.alpha(0f)
)
Text(
style = MaterialTheme.typography.bodyMedium,
text = ble.batteryLevel.toString() + " %"
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"
}
)
}
}
}
}

View File

@ -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
}
}
}

View File

@ -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<BleInfo.Type?>(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))
}
}
}

View File

@ -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<String, ScanResult>()
val resultList = mutableMapOf<String, BleInfo>()
val resultList: MutableMap<String, BleInfo> = Collections.synchronizedMap(mutableMapOf<String, BleInfo>())
override fun getConnectedBle(): List<ConnectedBleInfo> {
if(checkPermission()) {
@ -152,6 +153,32 @@ class BleRepositoryImpl @Inject constructor(
}
override fun onBatchScanResults(results: MutableList<ScanResult>) {
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 =

View File

@ -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 {