Base functions

This commit is contained in:
Vineyro 2023-03-24 17:01:40 +07:00
parent 13a0dc8818
commit c53b111dde
37 changed files with 2940 additions and 165 deletions

View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="deploymentTargetDropDown">
<runningDeviceTargetSelectedWithDropDown>
<Target>
<type value="RUNNING_DEVICE_TARGET" />
<deviceKey>
<Key>
<type value="SERIAL_NUMBER" />
<value value="55381e0a" />
</Key>
</deviceKey>
</Target>
</runningDeviceTargetSelectedWithDropDown>
<timeTargetWasSelectedWithDropDown value="2023-03-22T08:48:05.794601500Z" />
</component>
</project>

View File

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings"> <component name="GradleSettings">
<option name="linkedExternalProjectsSettings"> <option name="linkedExternalProjectsSettings">
<GradleProjectSettings> <GradleProjectSettings>

6
.idea/vcs.xml Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

View File

@ -55,7 +55,7 @@ dependencies {
implementation 'androidx.activity:activity-compose:1.3.1' implementation 'androidx.activity:activity-compose:1.3.1'
implementation "androidx.compose.ui:ui:$compose_version" implementation "androidx.compose.ui:ui:$compose_version"
implementation "androidx.compose.ui:ui-tooling-preview:$compose_version" implementation "androidx.compose.ui:ui-tooling-preview:$compose_version"
implementation 'androidx.compose.material3:material3:1.0.0-alpha11' implementation 'androidx.compose.material3:material3:1.1.0-beta01'
testImplementation 'junit:junit:4.13.2' testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
@ -73,6 +73,12 @@ dependencies {
kapt('com.google.dagger:hilt-android-compiler:2.45') kapt('com.google.dagger:hilt-android-compiler:2.45')
kapt("androidx.hilt:hilt-compiler:1.0.0") kapt("androidx.hilt:hilt-compiler:1.0.0")
implementation "androidx.datastore:datastore-preferences:1.0.0" implementation "com.google.accompanist:accompanist-permissions:0.26.3-beta"
implementation "com.chargemap.compose:numberpicker:1.0.3"
implementation "com.patrykandpatrick.vico:core:1.6.4"
implementation "com.patrykandpatrick.vico:compose:1.6.4"
implementation "com.patrykandpatrick.vico:compose-m3:1.6.4"
} }

View File

@ -4,6 +4,7 @@
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" /> <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" /> <uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />

View File

@ -4,9 +4,11 @@ import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.core.view.WindowCompat
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import llc.arma.ble.app.ui.screen.main.MainScreen import llc.arma.ble.app.ui.screen.main.MainScreen
import llc.arma.ble.app.ui.theme.BleTheme import llc.arma.ble.app.ui.theme.BleTheme
@ -15,10 +17,13 @@ import llc.arma.ble.app.ui.theme.BleTheme
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false)
setContent { setContent {
BleTheme { BleTheme {
Surface( Surface(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize().navigationBarsPadding(),
color = MaterialTheme.colorScheme.background color = MaterialTheme.colorScheme.background
) { ) {
MainScreen() MainScreen()

View File

@ -0,0 +1,37 @@
package llc.arma.ble.app.ui.mapper
import llc.arma.ble.app.ui.model.BleView
import llc.arma.ble.domain.model.Ble
import javax.inject.Inject
class BleMapper @Inject constructor(
private val txMapper: TxMapper
) : Mapper<Ble, BleView> {
override fun map(input: Ble): BleView {
return when(input){
is Ble.Beacon -> {
BleView.Beacon(
info = input.info,
state = BleView.BleState(
tx = txMapper.map(input.state.tx)
)
)
}
is Ble.Thermometer -> {
BleView.Thermometer(
info = input.info,
state = BleView.BleState(
tx = txMapper.map(input.state.tx)
),
thermometerState = BleView.Thermometer.ThermometerState(
temperature = BleView.Thermometer.ThermometerState.TemperatureState(input.thermometerState.temperature, false),
historyInterval = input.thermometerState.historyInterval,
saveHistory = input.thermometerState.saveHistory
)
)
}
}
}
}

View File

@ -0,0 +1,37 @@
package llc.arma.ble.app.ui.mapper
import llc.arma.ble.app.ui.model.BleView
import llc.arma.ble.domain.model.Ble
import javax.inject.Inject
class BleViewMapper @Inject constructor(
private val txMapper: TxViewMapper
) : Mapper<BleView, Ble> {
override fun map(input: BleView): Ble {
return when(input){
is BleView.Beacon -> {
Ble.Beacon(
info = input.info,
state = Ble.BleState(
tx = txMapper.map(input.state.tx)
)
)
}
is BleView.Thermometer -> {
Ble.Thermometer(
info = input.info,
state = Ble.BleState(
tx = txMapper.map(input.state.tx)
),
thermometerState = Ble.Thermometer.ThermometerState(
temperature = input.thermometerState.temperature.value,
historyInterval = input.thermometerState.historyInterval,
saveHistory = input.thermometerState.saveHistory
)
)
}
}
}
}

View File

@ -0,0 +1,9 @@
package llc.arma.ble.app.ui.mapper
interface Mapper<I, O> {
fun map(input: I): O
fun map(input: List<I>): List<O> = input.map { map(it) }
}

View File

@ -0,0 +1,23 @@
package llc.arma.ble.app.ui.mapper
import llc.arma.ble.app.ui.model.BleView
import llc.arma.ble.domain.model.Ble
import javax.inject.Inject
class TxMapper @Inject constructor() : Mapper<Ble.BleState.TX, BleView.BleState.TX> {
override fun map(input: Ble.BleState.TX): BleView.BleState.TX {
return when(input){
Ble.BleState.TX.MINUS_40 -> BleView.BleState.TX.MINUS_40
Ble.BleState.TX.MINUS_20 -> BleView.BleState.TX.MINUS_20
Ble.BleState.TX.MINUS_16 -> BleView.BleState.TX.MINUS_16
Ble.BleState.TX.MINUS_12 -> BleView.BleState.TX.MINUS_12
Ble.BleState.TX.MINUS_8 -> BleView.BleState.TX.MINUS_8
Ble.BleState.TX.MINUS_4 -> BleView.BleState.TX.MINUS_4
Ble.BleState.TX.ZERO -> BleView.BleState.TX.ZERO
Ble.BleState.TX.PLUS_3 -> BleView.BleState.TX.PLUS_3
Ble.BleState.TX.PLUS_4 -> BleView.BleState.TX.PLUS_4
}
}
}

View File

@ -0,0 +1,24 @@
package llc.arma.ble.app.ui.mapper
import llc.arma.ble.app.ui.model.BleView
import llc.arma.ble.domain.model.Ble
import javax.inject.Inject
class TxViewMapper @Inject constructor() : Mapper<BleView.BleState.TX, Ble.BleState.TX> {
override fun map(input: BleView.BleState.TX): Ble.BleState.TX {
return when(input){
BleView.BleState.TX.MINUS_40 -> Ble.BleState.TX.MINUS_40
BleView.BleState.TX.MINUS_20 -> Ble.BleState.TX.MINUS_20
BleView.BleState.TX.MINUS_16 -> Ble.BleState.TX.MINUS_16
BleView.BleState.TX.MINUS_12 -> Ble.BleState.TX.MINUS_12
BleView.BleState.TX.MINUS_8 -> Ble.BleState.TX.MINUS_8
BleView.BleState.TX.MINUS_4 -> Ble.BleState.TX.MINUS_4
BleView.BleState.TX.ZERO -> Ble.BleState.TX.ZERO
BleView.BleState.TX.PLUS_3 -> Ble.BleState.TX.PLUS_3
BleView.BleState.TX.PLUS_4 -> Ble.BleState.TX.PLUS_4
}
}
}

View File

@ -0,0 +1,62 @@
package llc.arma.ble.app.ui.model
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import llc.arma.ble.domain.model.BleInfo
sealed class BleView(
val info: BleInfo
) {
class Beacon(
info: BleInfo,
val state: BleState
) : BleView(info)
class Thermometer(
info: BleInfo,
val state: BleState,
val thermometerState: ThermometerState
) : BleView(info) {
class ThermometerState(
temperature: TemperatureState,
saveHistory: Boolean,
historyInterval: Long
) {
class TemperatureState(
val value: Float,
val loading: Boolean
)
var temperature by mutableStateOf(temperature)
var saveHistory by mutableStateOf(saveHistory)
var historyInterval by mutableStateOf(historyInterval)
}
}
class BleState(
tx: TX
){
var tx by mutableStateOf(tx)
enum class TX(val value: Int) {
MINUS_40(-40),
MINUS_20(-20),
MINUS_16(-16),
MINUS_12(-12),
MINUS_8(-8),
MINUS_4(-4),
ZERO(0),
PLUS_3(3),
PLUS_4(4)
}
}
}

View File

@ -0,0 +1,154 @@
package llc.arma.ble.app.ui.screen
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import llc.arma.ble.domain.model.BleInfo
@Composable
fun BleInfoView(
bleInfo: BleInfo
) {
Surface(
modifier = Modifier.padding(bottom = 16.dp),
shape = RoundedCornerShape(24.dp),
color = MaterialTheme.colorScheme.surfaceVariant
) {
Column(
modifier = Modifier.padding(8.dp)
) {
Column() {
BleInfoItem(
icon = {
Icon(
imageVector = when(bleInfo.type){
BleInfo.Type.BEACON -> Icons.Rounded.Nfc
BleInfo.Type.THERMOMETER -> Icons.Rounded.Thermostat
},
contentDescription = null
)
},
title = "Тип метки",
subtitle = when(bleInfo.type){
BleInfo.Type.BEACON -> "Маяк"
BleInfo.Type.THERMOMETER -> "Термодатчик"
}
)
SpecDivider()
BleInfoItem(
icon = {
Icon(
imageVector = Icons.Rounded.ShortText,
contentDescription = null
)
},
title = "Наименование",
subtitle = bleInfo.name
)
SpecDivider()
BleInfoItem(
icon = {
Icon(
imageVector = Icons.Rounded.Key,
contentDescription = null
)
},
title = "Адрес",
subtitle = bleInfo.serial
)
SpecDivider()
BleInfoItem(
icon = {
Icon(
imageVector = Icons.Rounded.BatteryFull,
contentDescription = null
)
},
title = "Заряд аккумулятора",
subtitle = "${bleInfo.batteryLevel} %"
)
}
}
}
}
@Composable
private fun ColumnScope.SpecDivider(){
Spacer(modifier = Modifier.height(12.dp))
Divider()
Spacer(modifier = Modifier.height(12.dp))
}
@Composable
private fun BleInfoItem(
icon: @Composable () -> Unit,
title: String,
subtitle: String
){
Row(
modifier = Modifier.padding(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Surface(
modifier = Modifier.size(40.dp),
shape = CircleShape
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
){
icon()
}
}
Spacer(modifier = Modifier.width(12.dp))
Column(
modifier = Modifier.weight(1f)
) {
Text(
text = title
)
Text(
color = MaterialTheme.colorScheme.secondary,
style = MaterialTheme.typography.bodyMedium,
text = subtitle
)
}
}
}

View File

@ -9,6 +9,10 @@ class BeaconContract {
sealed class Event : ViewEvent { sealed class Event : ViewEvent {
data class OnBleChanged(
val ble: Ble.Beacon
) : Event()
data class OnTxChanged(val tx: Int) : Event() data class OnTxChanged(val tx: Int) : Event()
object OnNavigateUpClicked : Event() object OnNavigateUpClicked : Event()

View File

@ -1,21 +1,33 @@
package llc.arma.ble.app.ui.screen.beacon package llc.arma.ble.app.ui.screen.beacon
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.ArrowBack import androidx.compose.material.icons.rounded.ArrowBack
import androidx.compose.material.icons.rounded.KeyboardArrowDown
import androidx.compose.material.icons.rounded.KeyboardArrowRight
import androidx.compose.material.icons.rounded.Refresh
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import llc.arma.ble.app.ui.screen.BleInfoView
import llc.arma.ble.domain.model.Ble
import llc.arma.ble.domain.model.BleInfo
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun BeaconScreen( fun BeaconScreen(
ble: Ble.Beacon,
onNavigationEvent: (BeaconContract.Effect.Navigation) -> Unit onNavigationEvent: (BeaconContract.Effect.Navigation) -> Unit
) { ) {
@ -30,6 +42,10 @@ fun BeaconScreen(
}.launchIn(this) }.launchIn(this)
} }
LaunchedEffect(ble){
viewModel.setEvent(BeaconContract.Event.OnBleChanged(ble))
}
Column { Column {
CenterAlignedTopAppBar( CenterAlignedTopAppBar(
@ -47,12 +63,12 @@ fun BeaconScreen(
) )
}, },
title = { title = {
if (state is BeaconContract.State.Display) Text(text = state.beacon.info.name)
} }
) )
when(state){ when(state){
is BeaconContract.State.Display -> DisplayState() is BeaconContract.State.Display -> DisplayState(state.beacon)
is BeaconContract.State.Loading -> LoadingState() is BeaconContract.State.Loading -> LoadingState()
} }
@ -73,11 +89,78 @@ private fun LoadingState(){
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun DisplayState(){ private fun DisplayState(ble: Ble.Beacon){
Column {
LazyColumn(
modifier = Modifier.weight(1f),
content = {
item {
Box(
modifier = Modifier.padding(
vertical = 8.dp,
horizontal = 8.dp
)
) {
BleInfoView(bleInfo = ble.info)
}
}
item {
Box(
modifier = Modifier.padding(
vertical = 8.dp,
horizontal = 8.dp
)
){
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
.clickable { }
.padding(8.dp)
) {
Column(
modifier = Modifier.weight(1f)
) {
Text(
text = "Мощность"
)
Text(
color = MaterialTheme.colorScheme.secondary,
style = MaterialTheme.typography.bodyMedium,
text = "-40 db"
)
}
Icon(
imageVector = Icons.Rounded.KeyboardArrowDown,
contentDescription = null
)
}
}
}
}
)
Surface( Surface(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(8.dp)
.height(50.dp), .height(50.dp),
shape = CircleShape, shape = CircleShape,
color = MaterialTheme.colorScheme.primaryContainer, color = MaterialTheme.colorScheme.primaryContainer,
@ -99,4 +182,6 @@ private fun DisplayState(){
} }
}
} }

View File

@ -8,32 +8,20 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import llc.arma.ble.app.ui.common.BaseViewModel import llc.arma.ble.app.ui.common.BaseViewModel
import llc.arma.ble.domain.model.Ble import llc.arma.ble.domain.model.Ble
import llc.arma.ble.domain.model.BleInfo
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class BeaconViewModel @Inject constructor( class BeaconViewModel @Inject constructor(
savedStateHandle: SavedStateHandle
) : BaseViewModel<BeaconContract.State, BeaconContract.Event, BeaconContract.Effect>() { ) : BaseViewModel<BeaconContract.State, BeaconContract.Event, BeaconContract.Effect>() {
init {
savedStateHandle.get<String>("serial")?.let {
CoroutineScope(Dispatchers.IO).launch {
delay(5000)
setState {
BeaconContract.State.Display(Ble.Beacon())
}
}
}
}
override fun setInitialState() = BeaconContract.State.Loading override fun setInitialState() = BeaconContract.State.Loading
override fun handleEvents(event: BeaconContract.Event) { override fun handleEvents(event: BeaconContract.Event) {
when(event){ when(event){
is BeaconContract.Event.OnNavigateUpClicked -> reduce(viewState.value, event) is BeaconContract.Event.OnNavigateUpClicked -> reduce(viewState.value, event)
is BeaconContract.Event.OnTxChanged -> reduce(viewState.value, event) is BeaconContract.Event.OnTxChanged -> reduce(viewState.value, event)
is BeaconContract.Event.OnBleChanged -> reduce(viewState.value, event)
} }
} }
@ -51,4 +39,15 @@ class BeaconViewModel @Inject constructor(
} }
private fun reduce(
state: BeaconContract.State,
event: BeaconContract.Event.OnBleChanged
) {
setState {
BeaconContract.State.Display(
event.ble
)
}
}
} }

View File

@ -1,21 +1,30 @@
package llc.arma.ble.app.ui.screen.ble package llc.arma.ble.app.ui.screen.ble
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Text import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.BatteryFull
import androidx.compose.material.icons.rounded.NetworkCell
import androidx.compose.material.icons.rounded.Nfc
import androidx.compose.material.icons.rounded.Thermostat
import androidx.compose.material3.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import llc.arma.ble.domain.model.BleInfo import llc.arma.ble.domain.model.BleInfo
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun BleListScreen( fun BleListScreen(
onNavigationEvent: (BleListContract.Effect.Navigation) -> Unit onNavigationEvent: (BleListContract.Effect.Navigation) -> Unit
@ -34,7 +43,14 @@ fun BleListScreen(
Column { Column {
CenterAlignedTopAppBar(
title = {
Text(text = "Arma BLE")
}
)
LazyColumn( LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) { ) {
@ -56,24 +72,102 @@ fun BleListScreen(
} }
@Composable @Composable
fun BleItem( private fun ItemIcon(
image: @Composable BoxScope.() -> Unit
){
Surface(
modifier = Modifier.size(40.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
shape = CircleShape
) {
Box(modifier = Modifier.fillMaxSize()) {
image()
}
}
}
@Composable
private fun BleItem(
ble: BleInfo, ble: BleInfo,
onClick: () -> Unit onClick: () -> Unit
){ ){
Row( Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.clickable { onClick() } .clickable { onClick() }
.padding(vertical = 8.dp, horizontal = 16.dp)
) { ) {
Text(text = ble.rssi.toString()) ItemIcon {
Icon(
modifier = Modifier.align(Alignment.Center),
imageVector = when(ble.type){
BleInfo.Type.BEACON -> Icons.Rounded.Nfc
BleInfo.Type.THERMOMETER -> Icons.Rounded.Thermostat
},
contentDescription = null
)
}
Column { Column {
Text(text = ble.name) Text(text = ble.name)
Text(text = ble.serial)
Text(text = ble.uuid) Text(
Text(text = ble.batteryLevel.toString()) style = MaterialTheme.typography.bodyMedium,
text = ble.serial
)
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.alpha(0.7f)
) {
Icon(
modifier = Modifier.size(16.dp),
imageVector = Icons.Rounded.NetworkCell,
contentDescription = null
)
Text(
style = MaterialTheme.typography.bodyMedium,
text = ble.rssi.toString() + " dBm"
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.alpha(0.7f)
) {
Icon(
modifier = Modifier.size(16.dp),
imageVector = Icons.Rounded.BatteryFull,
contentDescription = null
)
Text(
style = MaterialTheme.typography.bodyMedium,
text = ble.batteryLevel.toString() + " %"
)
}
}
} }
} }

View File

@ -1,5 +1,6 @@
package llc.arma.ble.app.ui.screen.ble package llc.arma.ble.app.ui.screen.ble
import android.util.Log
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn

View File

@ -3,7 +3,9 @@ package llc.arma.ble.app.ui.screen.connection
import llc.arma.ble.app.ui.common.ViewEvent import llc.arma.ble.app.ui.common.ViewEvent
import llc.arma.ble.app.ui.common.ViewSideEffect import llc.arma.ble.app.ui.common.ViewSideEffect
import llc.arma.ble.app.ui.common.ViewState import llc.arma.ble.app.ui.common.ViewState
import llc.arma.ble.app.ui.model.BleView
import llc.arma.ble.app.ui.screen.beacon.BeaconContract import llc.arma.ble.app.ui.screen.beacon.BeaconContract
import llc.arma.ble.app.ui.screen.thermometer.ThermometerContract
import llc.arma.ble.domain.model.Ble import llc.arma.ble.domain.model.Ble
import llc.arma.ble.domain.usecase.GetBleBySerial import llc.arma.ble.domain.usecase.GetBleBySerial
@ -11,9 +13,15 @@ class ConnectionContract {
sealed class Event : ViewEvent { sealed class Event : ViewEvent {
object OnNavigateUp : Event()
data class OnBeaconNavigationEvent( data class OnBeaconNavigationEvent(
val event: BeaconContract.Effect.Navigation val event: BeaconContract.Effect.Navigation
) ) : Event()
data class OnThermometerNavigationEvent(
val event: ThermometerContract.Effect.Navigation
) : Event()
} }
@ -25,18 +33,14 @@ class ConnectionContract {
val exception: GetBleBySerial.GetBleException val exception: GetBleBySerial.GetBleException
) : State() ) : State()
data class Display(
val ble: Ble
) : State()
} }
sealed class Effect : ViewSideEffect { sealed class Effect : ViewSideEffect {
sealed class ChildNavigation : Effect() {
data class NavigateToBeacon(
val ble: Ble
) : ChildNavigation()
}
sealed class Navigation : Effect() { sealed class Navigation : Effect() {
object NavigateUp : Navigation() object NavigateUp : Navigation()

View File

@ -1,15 +1,30 @@
package llc.arma.ble.app.ui.screen.connection package llc.arma.ble.app.ui.screen.connection
import androidx.compose.foundation.layout.Box import androidx.compose.animation.*
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.ArrowBack
import androidx.compose.material3.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import llc.arma.ble.app.ui.model.BleView
import llc.arma.ble.app.ui.screen.BleInfoView
import llc.arma.ble.app.ui.screen.beacon.BeaconScreen
import llc.arma.ble.app.ui.screen.thermometer.ThermometerContract
import llc.arma.ble.app.ui.screen.thermometer.ThermometerScreen
import llc.arma.ble.domain.model.Ble
import llc.arma.ble.domain.usecase.GetBleBySerial import llc.arma.ble.domain.usecase.GetBleBySerial
@OptIn(ExperimentalMaterial3Api::class, ExperimentalAnimationApi::class)
@Composable @Composable
fun ConnectionScreen( fun ConnectionScreen(
onNavigationEvent: (ConnectionContract.Effect.Navigation) -> Unit onNavigationEvent: (ConnectionContract.Effect.Navigation) -> Unit
@ -18,25 +33,108 @@ fun ConnectionScreen(
val viewModel = hiltViewModel<ConnectionViewModel>() val viewModel = hiltViewModel<ConnectionViewModel>()
val state = viewModel.viewState.value val state = viewModel.viewState.value
Column() { LaunchedEffect("effect"){
viewModel.effect.onEach {
when(it){
is ConnectionContract.Effect.Navigation -> onNavigationEvent(it)
}
}.launchIn(this)
}
Column {
CenterAlignedTopAppBar(
navigationIcon = {
IconButton(
onClick = {
viewModel.setEvent(ConnectionContract.Event.OnNavigateUp)
},
content = {
Icon(
imageVector = Icons.Rounded.ArrowBack,
contentDescription = null
)
}
)
},
title = {
AnimatedContent(
targetState = when(state){
is ConnectionContract.State.Display -> state.ble.info.name
is ConnectionContract.State.DisplayException -> "Исключение"
is ConnectionContract.State.Loading -> "Соединение.."
},
transitionSpec = {
(slideInVertically { height -> height } + fadeIn() with
slideOutVertically { height -> -height } + fadeOut()).using(
SizeTransform(clip = false)
)
}
) { targetText ->
Text(
text = targetText
)
}
}
)
when (state) { when (state) {
is ConnectionContract.State.DisplayException -> DisplayException(state.exception) is ConnectionContract.State.DisplayException -> DisplayException(state.exception)
is ConnectionContract.State.Loading -> LoadingState() is ConnectionContract.State.Loading -> LoadingState()
is ConnectionContract.State.Display -> {
when(state.ble){
is Ble.Beacon -> {}/*BeaconScreen(
ble = state.ble,
onNavigationEvent = {
viewModel.setEvent(ConnectionContract.Event.OnBeaconNavigationEvent(it))
}
)*/
is Ble.Thermometer -> {
Column(modifier = Modifier.weight(1f)) {
ThermometerScreen(
ble = state.ble,
onNavigationEvent = {
viewModel.setEvent(
ConnectionContract.Event.OnThermometerNavigationEvent(
it
)
)
}
)
}
}
}
}
} }
} }
} }
@Composable @Composable
private fun LoadingState(){ private fun LoadingState(){
Box(modifier = Modifier.fillMaxSize()){ Column {
Box(modifier = Modifier.fillMaxSize()) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
} }
}
} }
@Composable @Composable
private fun DisplayException(exception: GetBleBySerial.GetBleException){ private fun DisplayException(
exception: GetBleBySerial.GetBleException
){
Column {
}
} }

View File

@ -5,14 +5,23 @@ import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import llc.arma.ble.app.ui.common.BaseViewModel import llc.arma.ble.app.ui.common.BaseViewModel
import llc.arma.ble.app.ui.mapper.BleMapper
import llc.arma.ble.app.ui.mapper.BleViewMapper
import llc.arma.ble.app.ui.model.BleView
import llc.arma.ble.app.ui.screen.beacon.BeaconContract
import llc.arma.ble.app.ui.screen.thermometer.ThermometerContract
import llc.arma.ble.domain.model.Ble import llc.arma.ble.domain.model.Ble
import llc.arma.ble.domain.usecase.GetBleBySerial import llc.arma.ble.domain.usecase.GetBleBySerial
import llc.arma.ble.domain.usecase.WriteBle
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class ConnectionViewModel @Inject constructor( class ConnectionViewModel @Inject constructor(
savedStateHandle: SavedStateHandle, savedStateHandle: SavedStateHandle,
getBleBySerial: GetBleBySerial getBleBySerial: GetBleBySerial,
private val writeBle: WriteBle,
private val bleMapper: BleMapper,
private val bleViewMapper: BleViewMapper
) : BaseViewModel<ConnectionContract.State, ConnectionContract.Event, ConnectionContract.Effect>() { ) : BaseViewModel<ConnectionContract.State, ConnectionContract.Event, ConnectionContract.Effect>() {
init { init {
@ -23,12 +32,13 @@ class ConnectionViewModel @Inject constructor(
viewModelScope.launch { viewModelScope.launch {
getBleBySerial(serial).fold( getBleBySerial(serial).fold(
onSuccess = { onSuccess = {
setEffect {
when(it){ setState {
is Ble.Beacon -> ConnectionContract.Effect.ChildNavigation.NavigateToBeacon(it) ConnectionContract.State.Display(
is Ble.Thermometer -> TODO() ble = it
} )
} }
}, },
onFailure = { onFailure = {
setState { setState {
@ -46,8 +56,48 @@ class ConnectionViewModel @Inject constructor(
override fun setInitialState() = ConnectionContract.State.Loading override fun setInitialState() = ConnectionContract.State.Loading
override fun handleEvents(event: ConnectionContract.Event) { override fun handleEvents(event: ConnectionContract.Event) {
TODO("Not yet implemented") when(event){
is ConnectionContract.Event.OnBeaconNavigationEvent -> reduce(viewState.value, event)
is ConnectionContract.Event.OnNavigateUp -> reduce(viewState.value, event)
is ConnectionContract.Event.OnThermometerNavigationEvent -> reduce(viewState.value, event)
}
} }
private fun reduce(
state: ConnectionContract.State,
event: ConnectionContract.Event.OnBeaconNavigationEvent
) {
when(event.event){
BeaconContract.Effect.Navigation.NavigateUp -> {
setEffect {
ConnectionContract.Effect.Navigation.NavigateUp
}
}
}
}
private fun reduce(
state: ConnectionContract.State,
event: ConnectionContract.Event.OnThermometerNavigationEvent
) {
when(event.event){
ThermometerContract.Effect.Navigation.NavigateUp -> {
setEffect {
ConnectionContract.Effect.Navigation.NavigateUp
}
}
}
}
private fun reduce(
state: ConnectionContract.State,
event: ConnectionContract.Event.OnNavigateUp
) {
setEffect {
ConnectionContract.Effect.Navigation.NavigateUp
}
}
} }

View File

@ -29,7 +29,7 @@ fun MainScreen() {
BleListScreen( BleListScreen(
onNavigationEvent = { onNavigationEvent = {
when(it){ when(it){
is BleListContract.Effect.Navigation.NavigateToBle -> controller.navigate("beacon/${it.serial}") is BleListContract.Effect.Navigation.NavigateToBle -> controller.navigate("connection/${it.serial}")
} }
} }
) )
@ -52,16 +52,8 @@ fun MainScreen() {
} }
) )
composable(
route = "thermometer",
content = {
ThermometerScreen()
} }
)
}
) )
} }

View File

@ -0,0 +1,99 @@
package llc.arma.ble.app.ui.screen.thermometer
import llc.arma.ble.app.ui.common.ViewEvent
import llc.arma.ble.app.ui.common.ViewSideEffect
import llc.arma.ble.app.ui.common.ViewState
import llc.arma.ble.app.ui.model.BleView
import llc.arma.ble.domain.model.Ble
class ThermometerContract {
sealed class Event : ViewEvent {
object OnWriteBle : Event()
object OnHideWriteBlePreview : Event()
object OnShowWriteBlePreview : Event()
object OnShowTemperatureHistory : Event()
object OnHideTemperatureHistory : Event()
data class OnSaveHistoryChanged(
val saveHistory: Boolean
) : Event()
object OnPowerEdit : Event()
data class OnPowerChanged(
val tx: BleView.BleState.TX
) : Event()
object OnSaveIntervalEdit : Event()
data class OnSaveIntervalChanged(
val interval: Long
) : Event()
data class OnBleChanged(
val ble: Ble.Thermometer
) : Event()
data class OnTxChanged(val tx: Int) : Event()
object OnNavigateUpClicked : Event()
}
sealed class State : ViewState {
object Loading : State()
data class Display(
val origin: Ble.Thermometer,
val thermometer: BleView.Thermometer,
val writeState: WriteState?
) : State() {
sealed class WriteState {
data class DisplayPreview(
val writeRequest: Ble.Thermometer.WriteRequest
) : WriteState()
data class Writing(
val writeRequest: Ble.Thermometer.WriteRequest
) : WriteState()
object Success : WriteState()
}
}
}
sealed class Effect : ViewSideEffect {
object ShowTemperatureHistory : Effect()
object HideTemperatureHistory : Effect()
object ShowIntervalPicker : Effect()
object HideIntervalPicker : Effect()
object ShowPowerPicker : Effect()
object HidePowerPicker : Effect()
sealed class Navigation : Effect() {
object NavigateUp : Navigation()
}
}
}

View File

@ -1,7 +1,499 @@
package llc.arma.ble.app.ui.screen.thermometer package llc.arma.ble.app.ui.screen.thermometer
import androidx.compose.runtime.Composable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.KeyboardArrowDown
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import llc.arma.ble.app.ui.screen.thermometer.view.*
import llc.arma.ble.domain.model.Ble
enum class SheetPage {
INTERVAL, POWER, TEMPERATURE_HISTORY
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ThermometerScreen(
ble: Ble.Thermometer,
onNavigationEvent: (ThermometerContract.Effect.Navigation) -> Unit
) {
var sheetPage by remember {
mutableStateOf<SheetPage?>(null)
}
val viewModel = hiltViewModel<ThermometerViewModel>()
val state = viewModel.viewState.value
val bottomSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val writeSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
LaunchedEffect("effect"){
viewModel.effect.onEach {
when(it){
is ThermometerContract.Effect.Navigation -> onNavigationEvent(it)
is ThermometerContract.Effect.HideIntervalPicker -> launch {
bottomSheetState.hide()
sheetPage = null
}
is ThermometerContract.Effect.ShowIntervalPicker -> launch {
sheetPage = SheetPage.INTERVAL
}
is ThermometerContract.Effect.HidePowerPicker -> launch {
bottomSheetState.hide()
sheetPage = null
}
is ThermometerContract.Effect.ShowPowerPicker -> launch {
sheetPage = SheetPage.POWER
}
is ThermometerContract.Effect.HideTemperatureHistory -> launch {
bottomSheetState.hide()
sheetPage = null
}
is ThermometerContract.Effect.ShowTemperatureHistory -> launch {
sheetPage = SheetPage.TEMPERATURE_HISTORY
}
}
}.launchIn(this)
}
LaunchedEffect(ble){
viewModel.setEvent(ThermometerContract.Event.OnBleChanged(ble))
}
Column {
when(state){
is ThermometerContract.State.Display -> {
DisplayState(
ble = state.thermometer,
onEvent = {
viewModel.setEvent(it)
}
)
}
is ThermometerContract.State.Loading -> LoadingState()
}
}
sheetPage?.let {
Column() {
ModalBottomSheet(
modifier = Modifier,
sheetState = bottomSheetState,
onDismissRequest = {
sheetPage = null
},
content = {
Column() {
if (state is ThermometerContract.State.Display) {
when (sheetPage) {
SheetPage.INTERVAL -> {
IntervalEdit(
state = state.thermometer,
onEvent = {
viewModel.setEvent(it)
}
)
}
SheetPage.POWER -> {
PowerEdit(
state = state.thermometer,
onEvent = {
viewModel.setEvent(it)
}
)
}
SheetPage.TEMPERATURE_HISTORY -> TemperatureHistory(state.thermometer.info)
null -> {}
}
}
Spacer(modifier = Modifier.height(48.dp))
}
}
)
}
}
if(state is ThermometerContract.State.Display){
state.writeState?.let {
val scope = rememberCoroutineScope()
ModalBottomSheet(
modifier = Modifier,
containerColor = MaterialTheme.colorScheme.surface,
sheetState = writeSheetState,
onDismissRequest = {
viewModel.setEvent(ThermometerContract.Event.OnHideWriteBlePreview)
},
content = {
Column() {
when (it) {
is ThermometerContract.State.Display.WriteState.DisplayPreview -> {
Text(
modifier = Modifier.padding(horizontal = 12.dp),
text = "Записать изменения?",
style = MaterialTheme.typography.titleLarge
)
it.writeRequest.tx?.let {
Box(
modifier = Modifier.padding(
vertical = 8.dp,
horizontal = 8.dp
)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
.padding(8.dp)
) {
Column(
modifier = Modifier.weight(1f)
) {
Text(
text = "Мощность"
)
Text(
color = MaterialTheme.colorScheme.secondary,
style = MaterialTheme.typography.bodyMedium,
text = "${it} db"
)
}
}
}
}
it.writeRequest.saveHistory?.let {
}
it.writeRequest.historyInterval?.let {
Box(
modifier = Modifier.padding(
vertical = 8.dp,
horizontal = 8.dp
)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
.padding(8.dp)
) {
Column(
modifier = Modifier.weight(1f)
) {
Text(
text = "Интервал измерний"
)
Text(
color = MaterialTheme.colorScheme.secondary,
style = MaterialTheme.typography.bodyMedium,
text = "${ state.origin.thermometerState.historyInterval / 1000 / 60 / 60 } ч. -> ${it / 1000 / 60 / 60} ч."
)
}
}
}
}
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
.height(50.dp),
shape = CircleShape,
color = MaterialTheme.colorScheme.primaryContainer,
onClick = {
viewModel.setEvent(ThermometerContract.Event.OnWriteBle)
}
) {
Box(modifier = Modifier.fillMaxSize()) {
Text(
modifier = Modifier.align(Alignment.Center),
color = MaterialTheme.colorScheme.background,
style = MaterialTheme.typography.labelLarge,
text = "Записать"
)
}
}
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
.height(50.dp),
shape = CircleShape,
color = MaterialTheme.colorScheme.surfaceVariant,
onClick = {
scope.launch {
writeSheetState.hide()
viewModel.setEvent(ThermometerContract.Event.OnHideWriteBlePreview)
}
}
) {
Box(modifier = Modifier.fillMaxSize()) {
Text(
modifier = Modifier.align(Alignment.Center),
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.labelLarge,
text = "Отменить"
)
}
}
}
is ThermometerContract.State.Display.WriteState.Writing -> {
Box {
Column() {
Text(
modifier = Modifier.padding(horizontal = 12.dp),
text = "Запись",
style = MaterialTheme.typography.titleLarge
)
Column(
modifier = Modifier.alpha(0.6f)
) {
it.writeRequest.tx?.let {
Box(
modifier = Modifier.padding(
vertical = 8.dp,
horizontal = 8.dp
)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
.padding(8.dp)
) {
Column(
modifier = Modifier.weight(1f)
) {
Text(
text = "Мощность"
)
Text(
color = MaterialTheme.colorScheme.secondary,
style = MaterialTheme.typography.bodyMedium,
text = "${it} db"
)
}
}
}
}
it.writeRequest.saveHistory?.let {
}
it.writeRequest.historyInterval?.let {
Box(
modifier = Modifier.padding(
vertical = 8.dp,
horizontal = 8.dp
)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
.padding(8.dp)
) {
Column(
modifier = Modifier.weight(1f)
) {
Text(
text = "Интервал измерний"
)
Text(
color = MaterialTheme.colorScheme.secondary,
style = MaterialTheme.typography.bodyMedium,
text = "${state.origin.thermometerState.historyInterval / 1000 / 60 / 60} ч. -> ${it / 1000 / 60 / 60} ч."
)
}
}
}
}
}
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
.height(50.dp),
shape = CircleShape,
color = MaterialTheme.colorScheme.surfaceVariant,
onClick = {
scope.launch {
writeSheetState.hide()
viewModel.setEvent(ThermometerContract.Event.OnHideWriteBlePreview)
}
}
) {
Box(modifier = Modifier.fillMaxSize()) {
Text(
modifier = Modifier.align(Alignment.Center),
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.labelLarge,
text = "Отменить"
)
}
}
}
CircularProgressIndicator(
modifier = Modifier
.align(Alignment.Center)
.padding(bottom = 48.dp)
)
}
}
ThermometerContract.State.Display.WriteState.Success -> {
Box {
Column {
Text(
modifier = Modifier.padding(horizontal = 12.dp),
text = "Запись завершена",
style = MaterialTheme.typography.titleLarge
)
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
.height(50.dp),
shape = CircleShape,
color = MaterialTheme.colorScheme.primary,
onClick = {
scope.launch {
writeSheetState.hide()
viewModel.setEvent(ThermometerContract.Event.OnHideWriteBlePreview)
}
}
) {
Box(modifier = Modifier.fillMaxSize()) {
Text(
modifier = Modifier.align(Alignment.Center),
color = MaterialTheme.colorScheme.onPrimary,
style = MaterialTheme.typography.labelLarge,
text = "Ок"
)
}
}
}
}
}
}
Spacer(modifier = Modifier.height(48.dp))
}
}
)
}
}
@Composable
fun ThermometerScreen() {
} }

View File

@ -0,0 +1,233 @@
package llc.arma.ble.app.ui.screen.thermometer
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
import llc.arma.ble.app.ui.common.BaseViewModel
import llc.arma.ble.app.ui.mapper.BleMapper
import llc.arma.ble.app.ui.mapper.BleViewMapper
import llc.arma.ble.app.ui.model.BleView
import llc.arma.ble.domain.model.Ble
import llc.arma.ble.domain.usecase.WriteBle
import javax.inject.Inject
@HiltViewModel
class ThermometerViewModel @Inject constructor(
private val bleMapper: BleMapper,
private val bleViewMapper: BleViewMapper,
private val writeBle: WriteBle
) : BaseViewModel<ThermometerContract.State, ThermometerContract.Event, ThermometerContract.Effect>() {
override fun setInitialState() = ThermometerContract.State.Loading
override fun handleEvents(event: ThermometerContract.Event) {
when(event){
is ThermometerContract.Event.OnNavigateUpClicked -> reduce(viewState.value, event)
is ThermometerContract.Event.OnTxChanged -> reduce(viewState.value, event)
is ThermometerContract.Event.OnBleChanged -> reduce(viewState.value, event)
is ThermometerContract.Event.OnSaveIntervalChanged -> reduce(viewState.value, event)
is ThermometerContract.Event.OnSaveIntervalEdit -> reduce(viewState.value, event)
is ThermometerContract.Event.OnPowerChanged -> reduce(viewState.value, event)
is ThermometerContract.Event.OnPowerEdit -> reduce(viewState.value, event)
is ThermometerContract.Event.OnSaveHistoryChanged -> reduce(viewState.value, event)
is ThermometerContract.Event.OnHideTemperatureHistory -> reduce(viewState.value, event)
is ThermometerContract.Event.OnShowTemperatureHistory -> reduce(viewState.value, event)
is ThermometerContract.Event.OnShowWriteBlePreview -> reduce(viewState.value, event)
is ThermometerContract.Event.OnHideWriteBlePreview -> reduce(viewState.value, event)
is ThermometerContract.Event.OnWriteBle -> reduce(viewState.value, event)
}
}
private fun reduce(
state: ThermometerContract.State,
event: ThermometerContract.Event.OnNavigateUpClicked
) {
setEffect { ThermometerContract.Effect.Navigation.NavigateUp }
}
private fun reduce(
state: ThermometerContract.State,
event: ThermometerContract.Event.OnTxChanged
) {
}
private fun reduce(
state: ThermometerContract.State,
event: ThermometerContract.Event.OnBleChanged
) {
setState {
ThermometerContract.State.Display(
origin = event.ble,
thermometer = bleMapper.map(event.ble) as BleView.Thermometer,
writeState = null
)
}
}
private fun reduce(
state: ThermometerContract.State,
event: ThermometerContract.Event.OnSaveIntervalEdit
) {
setEffect {
ThermometerContract.Effect.ShowIntervalPicker
}
}
private fun reduce(
state: ThermometerContract.State,
event: ThermometerContract.Event.OnSaveIntervalChanged
) {
if(state is ThermometerContract.State.Display) {
state.thermometer.thermometerState.historyInterval = event.interval
}
setEffect {
ThermometerContract.Effect.HideIntervalPicker
}
}
private fun reduce(
state: ThermometerContract.State,
event: ThermometerContract.Event.OnPowerEdit
) {
setEffect {
ThermometerContract.Effect.ShowPowerPicker
}
}
private fun reduce(
state: ThermometerContract.State,
event: ThermometerContract.Event.OnPowerChanged
) {
if(state is ThermometerContract.State.Display) {
state.thermometer.state.tx = event.tx
}
setEffect {
ThermometerContract.Effect.HidePowerPicker
}
}
private fun reduce(
state: ThermometerContract.State,
event: ThermometerContract.Event.OnSaveHistoryChanged
) {
if(state is ThermometerContract.State.Display) {
state.thermometer.thermometerState.saveHistory = event.saveHistory
}
}
private fun reduce(
state: ThermometerContract.State,
event: ThermometerContract.Event.OnShowTemperatureHistory
) {
setEffect {
ThermometerContract.Effect.ShowTemperatureHistory
}
}
private fun reduce(
state: ThermometerContract.State,
event: ThermometerContract.Event.OnHideTemperatureHistory
) {
setEffect {
ThermometerContract.Effect.HideTemperatureHistory
}
}
private fun reduce(
state: ThermometerContract.State,
event: ThermometerContract.Event.OnShowWriteBlePreview
) {
if(state is ThermometerContract.State.Display){
val newBle = bleViewMapper.map(state.thermometer) as Ble.Thermometer
val writeRequest = Ble.Thermometer.WriteRequest(
tx = if(newBle.state.tx == state.origin.state.tx) null else newBle.state.tx,
saveHistory = if(newBle.thermometerState.saveHistory == state.origin.thermometerState.saveHistory) null else newBle.thermometerState.saveHistory,
historyInterval = if(newBle.thermometerState.historyInterval == state.origin.thermometerState.historyInterval) null else newBle.thermometerState.historyInterval,
)
setState {
state.copy(
writeState = ThermometerContract.State.Display.WriteState.DisplayPreview(
writeRequest
)
)
}
}
}
private fun reduce(
state: ThermometerContract.State,
event: ThermometerContract.Event.OnWriteBle
) {
if(state is ThermometerContract.State.Display){
state.writeState?.let {
if(it is ThermometerContract.State.Display.WriteState.DisplayPreview) {
viewModelScope.launch {
setState {
state.copy(
writeState = ThermometerContract.State.Display.WriteState.Writing(it.writeRequest)
)
}
writeBle(state.thermometer.info.serial, it.writeRequest)
setState {
state.copy(
writeState = ThermometerContract.State.Display.WriteState.Success
)
}
}
}
}
}
}
private fun reduce(
state: ThermometerContract.State,
event: ThermometerContract.Event.OnHideWriteBlePreview
) {
if(state is ThermometerContract.State.Display){
setState {
state.copy(
writeState = null
)
}
}
}
}

View File

@ -0,0 +1,284 @@
package llc.arma.ble.app.ui.screen.thermometer.view
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.KeyboardArrowDown
import androidx.compose.material.icons.rounded.KeyboardArrowRight
import androidx.compose.material.icons.rounded.Refresh
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import llc.arma.ble.app.ui.model.BleView
import llc.arma.ble.app.ui.screen.BleInfoView
import llc.arma.ble.app.ui.screen.thermometer.ThermometerContract
@Composable
fun DisplayState(
onEvent: (ThermometerContract.Event) -> Unit,
ble: BleView.Thermometer
) {
Column() {
Column(
modifier = Modifier
.verticalScroll(rememberScrollState())
.weight(1f)
) {
Box(
modifier = Modifier.padding(
vertical = 8.dp,
horizontal = 8.dp
)
) {
BleInfoView(bleInfo = ble.info)
}
Column(
modifier = Modifier,
content = {
Box(
modifier = Modifier.padding(
vertical = 8.dp,
horizontal = 8.dp
)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
.clickable {
onEvent(ThermometerContract.Event.OnPowerEdit)
}
.padding(8.dp)
) {
Column(
modifier = Modifier.weight(1f)
) {
Text(
text = "Мощность"
)
Text(
color = MaterialTheme.colorScheme.secondary,
style = MaterialTheme.typography.bodyMedium,
text = "${ble.state.tx.value} db"
)
}
Icon(
imageVector = Icons.Rounded.KeyboardArrowDown,
contentDescription = null
)
}
}
Box(
modifier = Modifier.padding(
vertical = 8.dp,
horizontal = 8.dp
)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
.clickable { }
.padding(8.dp)
) {
Column(
modifier = Modifier.weight(1f)
) {
Text(
text = "Температура"
)
Text(
color = MaterialTheme.colorScheme.secondary,
style = MaterialTheme.typography.bodyMedium,
text = "${ble.thermometerState.temperature.value} °C"
)
}
if (ble.thermometerState.temperature.loading) {
CircularProgressIndicator()
} else {
Icon(
imageVector = Icons.Rounded.Refresh,
contentDescription = null
)
}
}
}
Box(
modifier = Modifier.padding(
vertical = 8.dp,
horizontal = 8.dp
)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
.clickable { }
.padding(8.dp)
) {
Column(
modifier = Modifier.weight(1f)
) {
Text(
text = "Сохранять историю измерений"
)
}
Switch(
checked = ble.thermometerState.saveHistory,
onCheckedChange = {
onEvent(ThermometerContract.Event.OnSaveHistoryChanged(it))
}
)
}
}
Box(
modifier = Modifier.padding(
vertical = 8.dp,
horizontal = 8.dp
)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
.clickable {
onEvent(ThermometerContract.Event.OnSaveIntervalEdit)
}
.padding(8.dp)
) {
Column(
modifier = Modifier.weight(1f)
) {
Text(
text = "Интервал измерний"
)
Text(
color = MaterialTheme.colorScheme.secondary,
style = MaterialTheme.typography.bodyMedium,
text = "${ble.thermometerState.historyInterval / 1000 / 60 / 60} ч."
)
}
Icon(
imageVector = Icons.Rounded.KeyboardArrowDown,
contentDescription = null
)
}
}
Box(
modifier = Modifier.padding(
vertical = 8.dp,
horizontal = 8.dp
)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
.clickable {
onEvent(ThermometerContract.Event.OnShowTemperatureHistory)
}
.padding(8.dp)
) {
Column(
modifier = Modifier.weight(1f)
) {
Text(
text = "График измерений"
)
}
Icon(
imageVector = Icons.Rounded.KeyboardArrowRight,
contentDescription = null
)
}
}
}
)
}
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
.height(50.dp),
shape = CircleShape,
color = MaterialTheme.colorScheme.primaryContainer,
onClick = {
onEvent(ThermometerContract.Event.OnShowWriteBlePreview)
}
) {
Box(modifier = Modifier.fillMaxSize()) {
Text(
modifier = Modifier.align(Alignment.Center),
color = MaterialTheme.colorScheme.background,
style = MaterialTheme.typography.labelLarge,
text = "Сохранить"
)
}
}
}
}

View File

@ -0,0 +1,95 @@
package llc.arma.ble.app.ui.screen.thermometer.view
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.chargemap.compose.numberpicker.NumberPicker
import llc.arma.ble.app.ui.model.BleView
import llc.arma.ble.app.ui.screen.thermometer.ThermometerContract
@Composable
fun IntervalEdit(
state: BleView.Thermometer,
onEvent: (ThermometerContract.Event) -> Unit,
){
var value by remember(state.thermometerState.historyInterval) {
mutableStateOf((state.thermometerState.historyInterval / 1000 / 60 / 60).toInt())
}
Column(
modifier = Modifier
) {
Text(
modifier = Modifier.padding(horizontal = 12.dp),
text = "Интервал измерений",
style = MaterialTheme.typography.titleLarge
)
Spacer(modifier = Modifier.height(16.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.align(Alignment.CenterHorizontally)
) {
NumberPicker(
dividersColor = MaterialTheme.colorScheme.primary,
value = value,
onValueChange = {
value = it
},
textStyle = MaterialTheme.typography.titleMedium,
range = 1..100
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "ч.",
style = MaterialTheme.typography.titleMedium
)
}
Spacer(modifier = Modifier.height(16.dp))
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
.height(50.dp),
shape = CircleShape,
color = MaterialTheme.colorScheme.primaryContainer,
onClick = {
onEvent(
ThermometerContract.Event.OnSaveIntervalChanged(
value.toLong() * 1000 * 60 * 60
)
)
}
) {
Box(modifier = Modifier.fillMaxSize()) {
Text(
modifier = Modifier.align(Alignment.Center),
color = MaterialTheme.colorScheme.background,
style = MaterialTheme.typography.labelLarge,
text = "Применить"
)
}
}
}
}

View File

@ -0,0 +1,19 @@
package llc.arma.ble.app.ui.screen.thermometer.view
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@Composable
fun LoadingState(){
Box(modifier = Modifier.fillMaxSize()) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
}
}

View File

@ -0,0 +1,96 @@
package llc.arma.ble.app.ui.screen.thermometer.view
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import llc.arma.ble.app.ui.model.BleView
import llc.arma.ble.app.ui.screen.thermometer.ThermometerContract
@Composable
fun PowerEdit(
state: BleView.Thermometer,
onEvent: (ThermometerContract.Event) -> Unit,
){
var value by remember(state.state.tx) {
mutableStateOf(state.state.tx)
}
Column(
modifier = Modifier
) {
Text(
modifier = Modifier.padding(horizontal = 12.dp),
text = "Мощность",
style = MaterialTheme.typography.titleLarge
)
Spacer(modifier = Modifier.height(16.dp))
BleView.BleState.TX.values().forEach {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.clickable { value = it }
.padding(4.dp)
) {
RadioButton(
selected = it == value,
onClick = { value = it }
)
Text(text = it.value.toString() + " db")
}
}
Spacer(modifier = Modifier.height(16.dp))
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
.height(50.dp),
shape = CircleShape,
color = MaterialTheme.colorScheme.primaryContainer,
onClick = {
onEvent(
ThermometerContract.Event.OnPowerChanged(
value
)
)
}
) {
Box(modifier = Modifier.fillMaxSize()) {
Text(
modifier = Modifier.align(Alignment.Center),
color = MaterialTheme.colorScheme.background,
style = MaterialTheme.typography.labelLarge,
text = "Применить"
)
}
}
}
}

View File

@ -0,0 +1,216 @@
package llc.arma.ble.app.ui.screen.thermometer.view
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.patrykandpatrick.vico.compose.axis.horizontal.bottomAxis
import com.patrykandpatrick.vico.compose.axis.vertical.startAxis
import com.patrykandpatrick.vico.compose.chart.Chart
import com.patrykandpatrick.vico.compose.chart.column.columnChart
import com.patrykandpatrick.vico.compose.chart.line.lineChart
import com.patrykandpatrick.vico.core.chart.composed.plus
import com.patrykandpatrick.vico.core.entry.ChartEntryModelProducer
import com.patrykandpatrick.vico.core.entry.FloatEntry
import com.patrykandpatrick.vico.core.entry.composed.plus
import com.patrykandpatrick.vico.core.entry.entriesOf
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
import llc.arma.ble.app.ui.common.BaseViewModel
import llc.arma.ble.app.ui.common.ViewEvent
import llc.arma.ble.app.ui.common.ViewSideEffect
import llc.arma.ble.app.ui.common.ViewState
import llc.arma.ble.domain.model.BleInfo
import llc.arma.ble.domain.usecase.GetTemperatureHistoryBySerial
import javax.inject.Inject
import kotlin.random.Random
import kotlin.random.nextInt
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Refresh
import com.patrykandpatrick.vico.core.axis.AxisPosition
import com.patrykandpatrick.vico.core.axis.formatter.AxisValueFormatter
import com.patrykandpatrick.vico.core.entry.ChartEntry
import llc.arma.ble.domain.model.Ble
import java.text.SimpleDateFormat
import java.util.*
class TemperatureEntry(
val localDate: Long,
override val x: Float,
override val y: Float,
) : ChartEntry {
override fun withY(y: Float) = TemperatureEntry(localDate, x, y)
}
val formatter = SimpleDateFormat("dd.MM.yy HH:mm", Locale.getDefault())
@Composable
fun TemperatureHistory(
ble: BleInfo
) {
val viewModel = hiltViewModel<TemperatureHistoryViewModel>()
val state = viewModel.viewState.value
LaunchedEffect(ble.serial) {
viewModel.setEvent(TemperatureHistoryContract.Event.LoadHistory(ble.serial))
}
Column() {
Row(
modifier = Modifier.padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
modifier = Modifier.weight(1f),
text = "История измерений",
style = MaterialTheme.typography.titleLarge
)
IconButton(
onClick = {
viewModel.setEvent(TemperatureHistoryContract.Event.LoadHistory(ble.serial))
},
enabled = state is TemperatureHistoryContract.State.Display
) {
Icon(
imageVector = Icons.Rounded.Refresh,
contentDescription = null
)
}
}
Spacer(modifier = Modifier.height(16.dp))
when (state) {
is TemperatureHistoryContract.State.Display -> {
val producer = state.history.mapIndexed { index, measurePoint ->
TemperatureEntry(measurePoint.date, index.toFloat(), measurePoint.value) }.let {
ChartEntryModelProducer(it)
}
val axisValueFormatter = AxisValueFormatter<AxisPosition.Horizontal.Bottom> { value, chartValues ->
(chartValues.chartEntryModel.entries.first().getOrNull(value.toInt()) as? TemperatureEntry)
?.localDate
?.let { formatter.format(Date(it)) }
.orEmpty()
}
val lineChart = lineChart()
Box(modifier = Modifier.padding(8.dp)) {
Chart(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1.5f),
chart = lineChart,
chartModelProducer = producer,
startAxis = startAxis(),
bottomAxis = bottomAxis(
valueFormatter = axisValueFormatter,
labelRotationDegrees = 0f,
),
)
}
}
is TemperatureHistoryContract.State.Loading -> {
Box(modifier = Modifier.padding(8.dp)) {
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(2f),
){
CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center)
)
}
}
}
}
}
}
class TemperatureHistoryContract {
sealed class Event : ViewEvent {
data class LoadHistory(
val serial: String
) : Event()
}
sealed class State : ViewState {
object Loading : State()
data class Display(
var history : List<Ble.Thermometer.MeasurePoint>
) : State()
}
sealed class Effect : ViewSideEffect {
}
}
@HiltViewModel
class TemperatureHistoryViewModel @Inject constructor(
private val getTemperatureHistoryBySerial: GetTemperatureHistoryBySerial
) : BaseViewModel<TemperatureHistoryContract.State, TemperatureHistoryContract.Event, TemperatureHistoryContract.Effect>() {
override fun setInitialState() = TemperatureHistoryContract.State.Loading
override fun handleEvents(event: TemperatureHistoryContract.Event) {
when(event){
is TemperatureHistoryContract.Event.LoadHistory -> reduce(viewState.value, event)
}
}
private fun reduce(
state: TemperatureHistoryContract.State,
event: TemperatureHistoryContract.Event.LoadHistory
) {
viewModelScope.launch {
setState {
TemperatureHistoryContract.State.Loading
}
val history = getTemperatureHistoryBySerial(event.serial)
setState {
TemperatureHistoryContract.State.Display(history)
}
}
}
}

View File

@ -2,34 +2,74 @@ package llc.arma.ble.data
import android.Manifest import android.Manifest
import android.app.Application import android.app.Application
import android.bluetooth.BluetoothAdapter import android.bluetooth.*
import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothProfile
import android.bluetooth.le.ScanCallback import android.bluetooth.le.ScanCallback
import android.bluetooth.le.ScanFilter
import android.bluetooth.le.ScanResult import android.bluetooth.le.ScanResult
import android.bluetooth.le.ScanSettings import android.bluetooth.le.ScanSettings
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.os.Build
import android.os.ParcelUuid
import android.util.Log import android.util.Log
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.flow
import llc.arma.ble.domain.Result import llc.arma.ble.domain.Result
import llc.arma.ble.domain.model.Ble import llc.arma.ble.domain.model.Ble
import llc.arma.ble.domain.model.BleInfo import llc.arma.ble.domain.model.BleInfo
import llc.arma.ble.domain.repository.BleRepository import llc.arma.ble.domain.repository.BleRepository
import llc.arma.ble.domain.usecase.GetBleBySerial import llc.arma.ble.domain.usecase.GetBleBySerial
import java.nio.ByteBuffer
import java.util.* import java.util.*
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
@Singleton
class BleRepositoryImpl @Inject constructor( class BleRepositoryImpl @Inject constructor(
private val app: Application private val app: Application
) : BleRepository { ) : BleRepository {
private val ScanResult.info: BleInfo
get() {
return BleInfo(
name = scanRecord?.deviceName ?: "",
serial = device.address,
batteryLevel = batteryLevel ?: 0,
rssi = rssi,
type = type
)
}
private val ScanResult.timerEnabled: Boolean
get() {
return scanRecord?.manufacturerSpecificData?.get(89)?.get(2) == 1.toByte()
}
private val ScanResult.batteryLevel: Int?
get() {
return scanRecord?.manufacturerSpecificData?.get(89)?.get(1)
?.toUByte()?.toInt()
}
private val ScanResult.type: BleInfo.Type
get() {
return when(scanRecord?.manufacturerSpecificData?.get(89)?.get(0)?.toUByte()?.toInt()){
2 -> BleInfo.Type.THERMOMETER
else -> BleInfo.Type.BEACON
}
}
private fun ByteArray.getUIntAt(idx: Int) =
((this[idx + 3].toUInt() and 0xFFu) shl 24) or
((this[idx + 2].toUInt() and 0xFFu) shl 16) or
((this[idx + 1].toUInt() and 0xFFu) shl 8) or
(this[idx].toUInt() and 0xFFu)
private val deviceCache = mutableMapOf<String, ScanResult>()
override fun getBleAroundFlow(): Flow<List<BleInfo>> { override fun getBleAroundFlow(): Flow<List<BleInfo>> {
val resultList = mutableMapOf<String, BleInfo>() val resultList = mutableMapOf<String, BleInfo>()
@ -51,13 +91,13 @@ class BleRepositoryImpl @Inject constructor(
) == PackageManager.PERMISSION_GRANTED ) == PackageManager.PERMISSION_GRANTED
) { ) {
resultList[result.device.address] = BleInfo( if(result.scanRecord?.deviceName?.contains("ArmA") == true) {
name = result.scanRecord?.deviceName ?: "",
serial = result.device.address, resultList[result.device.address] = result.info
uuid = result.device.uuids?.firstOrNull()?.toString() ?: "",
batteryLevel = 1, deviceCache[result.device.address] = result
rssi = result.rssi
) }
} }
@ -66,7 +106,16 @@ class BleRepositoryImpl @Inject constructor(
} }
val bleScanner = app.getSystemService(BluetoothManager::class.java).adapter.bluetoothLeScanner val bleScanner = app.getSystemService(BluetoothManager::class.java).adapter.bluetoothLeScanner
bleScanner.startScan(bleCallback) bleScanner.startScan(
listOf(),
ScanSettings.Builder()
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
.setCallbackType(ScanSettings.CALLBACK_TYPE_ALL_MATCHES)
.setMatchMode(ScanSettings.MATCH_MODE_AGGRESSIVE)
.setNumOfMatches(ScanSettings.MATCH_NUM_ONE_ADVERTISEMENT)
.setReportDelay(0L)
.build(),
bleCallback)
val timer = Timer().apply { val timer = Timer().apply {
schedule(object : TimerTask() { schedule(object : TimerTask() {
@ -75,7 +124,7 @@ class BleRepositoryImpl @Inject constructor(
send(resultList.values.toList()) send(resultList.values.toList())
} }
} }
}, 0, 1000) }, 100, 500)
} }
awaitClose { awaitClose {
@ -88,52 +137,11 @@ class BleRepositoryImpl @Inject constructor(
} }
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
override suspend fun getBleBySerial(serial: String): Result<Ble, GetBleBySerial.GetBleException> = suspendCancellableCoroutine { override suspend fun getBleBySerial(
serial: String
): Result<Ble, GetBleBySerial.GetBleException> = suspendCancellableCoroutine {
val bluetoothManager = app.getSystemService(BluetoothManager::class.java) deviceCache[serial]?.let { result ->
val bleScanner = bluetoothManager.adapter.bluetoothLeScanner
if (ActivityCompat.checkSelfPermission(
app,
Manifest.permission.BLUETOOTH_CONNECT
) != PackageManager.PERMISSION_GRANTED
) {
it.resume(Result.failure(GetBleBySerial.GetBleException.BlePermissionDenied)){
}
}
val connected = bluetoothManager.getConnectedDevices(BluetoothProfile.GATT)
.firstOrNull { device -> device.address == serial }
if(connected != null){
it.resume(
Result.success(
Ble.Beacon(
info = BleInfo(
name = connected.name,
serial = connected.address,
uuid = connected.uuids?.firstOrNull()?.toString() ?: "",
rssi = 0,
batteryLevel = 1
)
)
)
){}
} else {
val bleCallback = object : ScanCallback() {
override fun onScanResult(
callbackType: Int,
result: ScanResult
) {
super.onScanResult(callbackType, result)
if (ActivityCompat.checkSelfPermission( if (ActivityCompat.checkSelfPermission(
app, app,
@ -141,19 +149,378 @@ class BleRepositoryImpl @Inject constructor(
) == PackageManager.PERMISSION_GRANTED ) == PackageManager.PERMISSION_GRANTED
) { ) {
it.resume( if (it.isActive) {
Result.success(
Ble.Beacon( val info = result.info
info = BleInfo(
name = result.device.name, val state = Ble.BleState(
serial = result.device.address, tx = when (result.scanRecord?.txPowerLevel) {
uuid = result.device.uuids?.firstOrNull()?.toString() ?: "", -40 -> Ble.BleState.TX.MINUS_40
rssi = result.rssi, -20 -> Ble.BleState.TX.MINUS_20
batteryLevel = 1 -16 -> Ble.BleState.TX.MINUS_16
-12 -> Ble.BleState.TX.MINUS_12
-8 -> Ble.BleState.TX.MINUS_8
-4 -> Ble.BleState.TX.MINUS_4
3 -> Ble.BleState.TX.PLUS_3
4 -> Ble.BleState.TX.PLUS_4
else -> Ble.BleState.TX.ZERO
}
)
CoroutineScope(Dispatchers.IO).launch {
val resultValue = when (info.type) {
BleInfo.Type.BEACON -> Ble.Beacon(
info = info,
state = state
)
BleInfo.Type.THERMOMETER -> {
Ble.Thermometer(
info = info,
state = state,
thermometerState = readThermometerState(result)
)
}
}
it.resume(Result.success(resultValue)) {}
}
}
} else {
it.resume(Result.failure(GetBleBySerial.GetBleException.BlePermissionDenied)) {}
}
}
}
private suspend fun readThermometerState(
record: ScanResult
): Ble.Thermometer.ThermometerState {
return Ble.Thermometer.ThermometerState(
temperature = readTemperature(record),
saveHistory = record.timerEnabled,
historyInterval = readHistoryInterval(record)
)
}
private suspend fun readTemperature(
record: ScanResult
): Float {
val dataResult = readCharacteristic(
device = record.device,
serviceId = UUID.fromString("a77db03a-9bc4-11ed-a8fc-0242ac120002"),
characteristicId = UUID.fromString("00002a6e-0000-1000-8000-00805f9b34fb")
)
return (dataResult[0] + dataResult[1] * 256).toFloat() / 100f
}
private suspend fun readHistoryInterval(
record: ScanResult
): Long {
writeCharacteristic(
device = record.device,
serviceId = UUID.fromString("a77db03a-9bc4-11ed-a8fc-0242ac120002"),
characteristicId = UUID.fromString("0000b2d8-0000-1000-8000-00805f9b34fb"),
writeData = byteArrayOf(3, 0, 0, 0, 0)
)
val dataResult = readCharacteristic(
device = record.device,
serviceId = UUID.fromString("a77db03a-9bc4-11ed-a8fc-0242ac120002"),
characteristicId = UUID.fromString("0000b2d8-0000-1000-8000-00805f9b34fb")
)
return if(dataResult.size == 4){
dataResult.getUIntAt(0).toLong()
}else{
0
}
}
override suspend fun getTemperatureHistoryBySerial(
serial: String
): List<Ble.Thermometer.MeasurePoint> {
fun ByteArray.getUIntAt(idx: Int) =
((this[idx + 3].toUInt() and 0xFFu) shl 24) or
((this[idx + 2].toUInt() and 0xFFu) shl 16) or
((this[idx + 1].toUInt() and 0xFFu) shl 8) or
(this[idx].toUInt() and 0xFFu)
deviceCache[serial]?.device?.let { device ->
writeCharacteristic(
device = device,
serviceId = UUID.fromString("a77db03a-9bc4-11ed-a8fc-0242ac120002"),
characteristicId = UUID.fromString("0000b2d8-0000-1000-8000-00805f9b34fb"),
writeData = byteArrayOf(2)
)
val countDataArray = readCharacteristic(
device = device,
serviceId = UUID.fromString("a77db03a-9bc4-11ed-a8fc-0242ac120002"),
characteristicId = UUID.fromString("0000b2d8-0000-1000-8000-00805f9b34fb"),
)
writeCharacteristic(
device = device,
serviceId = UUID.fromString("a77db03a-9bc4-11ed-a8fc-0242ac120002"),
characteristicId = UUID.fromString("0000b2d8-0000-1000-8000-00805f9b34fb"),
writeData = mutableListOf(
1.toByte(),
0.toByte(),
0.toByte()
).apply {
addAll(countDataArray.toList())
}.toByteArray()
)
val firstPackageResponse = readCharacteristic(
device = device,
serviceId = UUID.fromString("a77db03a-9bc4-11ed-a8fc-0242ac120002"),
characteristicId = UUID.fromString("0000b2d8-0000-1000-8000-00805f9b34fb"),
)
if(firstPackageResponse[0] == 250.toByte()){
val interval = firstPackageResponse.getUIntAt(2).toLong()
val lastMeasureTime = firstPackageResponse.getUIntAt(6).toLong()
val realTime = firstPackageResponse.getUIntAt(10).toLong()
val lastMeasureSystemTime = System.currentTimeMillis() - ((realTime - lastMeasureTime) / 10_000)
var temperatureDataArray = firstPackageResponse.asList().subList(14, firstPackageResponse.size)
val temperaturePackage = temperatureDataArray.chunked(2).map {
(it[0] + it[1] * 256).toFloat() / 100f
}.toMutableList()
Log.d("read", temperaturePackage.size.toString())
var dataCount = firstPackageResponse[1]
while(dataCount != 0.toByte()){
writeCharacteristic(
device = device,
serviceId = UUID.fromString("a77db03a-9bc4-11ed-a8fc-0242ac120002"),
characteristicId = UUID.fromString("0000b2d8-0000-1000-8000-00805f9b34fb"),
writeData = byteArrayOf(5)
)
val readResponse = readCharacteristic(
device = device,
serviceId = UUID.fromString("a77db03a-9bc4-11ed-a8fc-0242ac120002"),
characteristicId = UUID.fromString("0000b2d8-0000-1000-8000-00805f9b34fb"),
)
dataCount = readResponse.get(1)
temperatureDataArray = readResponse.toList().subList(2, readResponse.size)
temperaturePackage.addAll(
temperatureDataArray.chunked(2).map {
(it[0] + it[1] * 256).toFloat() / 100f
}
)
Log.d("read",(temperatureDataArray.size / 2).toString())
}
Log.d("metadata", interval.toString() + " " + lastMeasureSystemTime.toString())
return temperaturePackage.withIndex().map {
Ble.Thermometer.MeasurePoint(
date = lastMeasureSystemTime - (((temperaturePackage.size - 1) - it.index) * interval),
value = it.value
)
}
}
}
return emptyList()
}
override suspend fun writeBle(ble: Ble) {
when(ble){
is Ble.Beacon -> writeBeacon(ble)
is Ble.Thermometer -> writeThermometer(ble)
}
}
override suspend fun writeBle(
serial: String,
request: Ble.Thermometer.WriteRequest
) {
deviceCache[serial]?.let { result ->
request.tx?.let { writeTx(result.device, it) }
request.historyInterval?.let { writeSaveInterval(result.device, it) }
request.saveHistory?.let { writeSaveEnabled(result.device, it) }
}
}
private suspend fun writeBeacon(ble: Ble.Beacon){
deviceCache[ble.info.serial]?.device?.let {
writeTx(it, ble.state.tx)
}
}
private suspend fun writeThermometer(ble: Ble.Thermometer){
deviceCache[ble.info.serial]?.device?.let {
writeTx(it, ble.state.tx)
writeSaveInterval(it, ble.thermometerState.historyInterval)
}
}
private suspend fun writeTx(
device: BluetoothDevice,
tx: Ble.BleState.TX
) {
writeCharacteristic(
device = device,
serviceId = UUID.fromString("a77db03a-9bc4-11ed-a8fc-0242ac120002"),
characteristicId = UUID.fromString("00002a07-0000-1000-8000-00805f9b34fb"),
writeData = byteArrayOf(
when(tx) {
Ble.BleState.TX.MINUS_40 -> -40
Ble.BleState.TX.MINUS_20 -> -20
Ble.BleState.TX.MINUS_16 -> -16
Ble.BleState.TX.MINUS_12 -> -12
Ble.BleState.TX.MINUS_8 -> -8
Ble.BleState.TX.MINUS_4 -> -4
Ble.BleState.TX.ZERO -> 0
Ble.BleState.TX.PLUS_3 -> 3
Ble.BleState.TX.PLUS_4 -> 4
}
) )
) )
}
private suspend fun writeSaveInterval(
device: BluetoothDevice,
interval: Long
) {
fun UInt.to4ByteArrayInBigEndian(): ByteArray =
(3 downTo 0).map {
(this shr (it * Byte.SIZE_BITS)).toByte()
}.reversed().toByteArray()
writeCharacteristic(
device = device,
serviceId = UUID.fromString("a77db03a-9bc4-11ed-a8fc-0242ac120002"),
characteristicId = UUID.fromString("0000b6f2-0000-1000-8000-00805f9b34fb"),
writeData = mutableListOf<Byte>(3).apply {
addAll(interval.toUInt().to4ByteArrayInBigEndian().toList())
}.toByteArray()
) )
){}
}
private suspend fun writeSaveEnabled(
device: BluetoothDevice,
enabled: Boolean
) {
writeCharacteristic(
device = device,
serviceId = UUID.fromString("a77db03a-9bc4-11ed-a8fc-0242ac120002"),
characteristicId = UUID.fromString("0000b6f2-0000-1000-8000-00805f9b34fb"),
writeData = mutableListOf<Byte>(3).apply {
add(if(enabled) 1 else 0)
}.toByteArray()
)
}
private suspend fun readCharacteristic(
device: BluetoothDevice,
serviceId: UUID,
characteristicId: UUID
): ByteArray = suspendCoroutine {
val callback = object : BluetoothGattCallback() {
override fun onConnectionStateChange(
gatt: BluetoothGatt?,
status: Int,
newState: Int
) {
if (newState == BluetoothProfile.STATE_CONNECTED) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S || ActivityCompat.checkSelfPermission(
app,
Manifest.permission.BLUETOOTH_CONNECT
) == PackageManager.PERMISSION_GRANTED
) {
gatt?.discoverServices()
}
} else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
}
}
override fun onServicesDiscovered(
gatt: BluetoothGatt?,
status: Int
) {
super.onServicesDiscovered(gatt, status)
if (status == BluetoothGatt.GATT_SUCCESS) {
gatt?.services?.firstOrNull { service ->
service.uuid == serviceId
}?.characteristics?.firstOrNull { characteristic ->
characteristic.uuid == characteristicId
}?.let {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S || ActivityCompat.checkSelfPermission(
app,
Manifest.permission.BLUETOOTH_CONNECT
) == PackageManager.PERMISSION_GRANTED
) {
gatt.readCharacteristic(it)
} }
@ -161,19 +528,105 @@ class BleRepositoryImpl @Inject constructor(
} }
bleScanner.startScan(
listOf(ScanFilter.Builder().setDeviceAddress(serial).build()),
ScanSettings.Builder()
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
.setCallbackType(ScanSettings.CALLBACK_TYPE_FIRST_MATCH)
.setMatchMode(ScanSettings.MATCH_MODE_AGGRESSIVE)
.setNumOfMatches(ScanSettings.MATCH_NUM_ONE_ADVERTISEMENT)
.setReportDelay(0L)
.build(),
bleCallback
)
} }
override fun onCharacteristicRead(
gatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic,
value: ByteArray,
status: Int
) {
super.onCharacteristicRead(gatt, characteristic, value, status)
it.resume(value)
}
}
device.connectGatt(app, true, callback)
}
private suspend fun writeCharacteristic(
device: BluetoothDevice,
serviceId: UUID,
characteristicId: UUID,
writeData: ByteArray
) = suspendCoroutine {
val callback = object : BluetoothGattCallback() {
override fun onConnectionStateChange(
gatt: BluetoothGatt?,
status: Int,
newState: Int
) {
if (newState == BluetoothProfile.STATE_CONNECTED) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S || ActivityCompat.checkSelfPermission(
app,
Manifest.permission.BLUETOOTH_CONNECT
) == PackageManager.PERMISSION_GRANTED
) {
gatt?.discoverServices()
}
} else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
}
}
override fun onServicesDiscovered(
gatt: BluetoothGatt?,
status: Int
) {
super.onServicesDiscovered(gatt, status)
if (status == BluetoothGatt.GATT_SUCCESS) {
gatt?.services?.firstOrNull { service ->
service.uuid == serviceId
}?.characteristics?.firstOrNull { characteristic ->
characteristic.uuid == characteristicId
}?.let {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S || ActivityCompat.checkSelfPermission(
app,
Manifest.permission.BLUETOOTH_CONNECT
) == PackageManager.PERMISSION_GRANTED
) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
gatt.writeCharacteristic(it, writeData, BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT)
}else{
it.value = writeData
gatt.writeCharacteristic(it)
}
}
}
}
}
override fun onCharacteristicWrite(
gatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic?,
status: Int
) {
super.onCharacteristicWrite(gatt, characteristic, status)
it.resume(Unit)
}
}
device.connectGatt(app, true, callback)
} }
} }

View File

@ -1,29 +1,53 @@
package llc.arma.ble.domain.model package llc.arma.ble.domain.model
import android.service.controls.templates.TemperatureControlTemplate
sealed class Ble( sealed class Ble(
val info: BleInfo val info: BleInfo
) { ) {
class Beacon( class Beacon(
info: BleInfo info: BleInfo,
val state: BleState
) : Ble(info){ ) : Ble(info){
class WriteRequest(
val tx: BleState.TX?
)
} }
class Thermometer( class Thermometer(
info: BleInfo, info: BleInfo,
val currentTemperature: Float val state: BleState,
val thermometerState: ThermometerState
) : Ble(info) { ) : Ble(info) {
class TemperatureRecord( class MeasurePoint(
val date: Long,
val value: Float
)
class ThermometerState(
val temperature: Float, val temperature: Float,
val date: Long val saveHistory: Boolean,
val historyInterval: Long
)
class WriteRequest(
val tx: BleState.TX?,
val saveHistory: Boolean?,
val historyInterval: Long?
) )
} }
class BleState(
val tx: TX
){
enum class TX {
MINUS_40, MINUS_20, MINUS_16, MINUS_12, MINUS_8, MINUS_4, ZERO, PLUS_3, PLUS_4
}
}
} }

View File

@ -1,9 +1,17 @@
package llc.arma.ble.domain.model package llc.arma.ble.domain.model
import java.util.UUID
class BleInfo( class BleInfo(
val name: String, val name: String,
val serial: String, val serial: String,
val uuid: String,
val batteryLevel: Int, val batteryLevel: Int,
val rssi: Int val rssi: Int,
) val type: Type
){
enum class Type(val serviceUUID: String?) {
BEACON(null), THERMOMETER("a77db03a-9bc4-11ed-a8fc-0242ac120002")
}
}

View File

@ -12,4 +12,10 @@ interface BleRepository {
suspend fun getBleBySerial(serial: String): Result<Ble, GetBleBySerial.GetBleException> suspend fun getBleBySerial(serial: String): Result<Ble, GetBleBySerial.GetBleException>
suspend fun getTemperatureHistoryBySerial(serial: String): List<Ble.Thermometer.MeasurePoint>
suspend fun writeBle(ble: Ble)
suspend fun writeBle(serial: String, request: Ble.Thermometer.WriteRequest)
} }

View File

@ -0,0 +1,17 @@
package llc.arma.ble.domain.usecase
import llc.arma.ble.domain.model.Ble
import llc.arma.ble.domain.repository.BleRepository
import javax.inject.Inject
class GetTemperatureHistoryBySerial @Inject constructor(
private val bleRepository: BleRepository
) {
suspend operator fun invoke(serial: String): List<Ble.Thermometer.MeasurePoint> {
return bleRepository.getTemperatureHistoryBySerial(serial)
}
}

View File

@ -0,0 +1,20 @@
package llc.arma.ble.domain.usecase
import android.app.appsearch.SetSchemaRequest
import llc.arma.ble.domain.model.Ble
import llc.arma.ble.domain.repository.BleRepository
import javax.inject.Inject
class WriteBle @Inject constructor(
private val bleRepository: BleRepository
) {
suspend operator fun invoke(ble: Ble){
bleRepository.writeBle(ble)
}
suspend operator fun invoke(serial: String, request: Ble.Thermometer.WriteRequest){
bleRepository.writeBle(serial, request)
}
}

View File

@ -1,5 +1,9 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources xmlns:tools="http://schemas.android.com/tools">
<style name="Theme.Ble" parent="android:Theme.Material.Light.NoActionBar" /> <style name="Theme.Ble" parent="android:Theme.Material.Light.NoActionBar" >
<item name="android:statusBarColor">#00000000</item>
<item name="android:navigationBarColor">#00ffffff</item>
<item name="android:windowLightStatusBar" >true</item>
</style>
</resources> </resources>