First commit

This commit is contained in:
2023-08-19 22:04:05 +07:00
parent 833f8a99c1
commit a956bc3564
61 changed files with 2163 additions and 0 deletions

View File

@@ -0,0 +1,70 @@
package com.helible.pilot
import android.annotation.SuppressLint
import android.bluetooth.BluetoothSocket
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.onCompletion
import java.io.IOException
import java.util.UUID
sealed interface ConnectionResult {
object ConnectionEstablished: ConnectionResult
data class Error(val message: String) : ConnectionResult
}
interface BluetoothController {
val isConnected: StateFlow<Boolean>
val errors: SharedFlow<String>
fun connectToDevice(device: Device?): Flow<ConnectionResult>
fun closeConnection()
}
class AndroidBluetoothController : BluetoothController {
private val _isConnected: MutableStateFlow<Boolean> = MutableStateFlow(false)
override val isConnected: StateFlow<Boolean>
get() = _isConnected.asStateFlow()
private val _errors = MutableSharedFlow<String>()
override val errors: SharedFlow<String>
get() = _errors.asSharedFlow()
private var currentClientSocket: BluetoothSocket? = null
companion object {
const val SERVICE_UUID = "af7cc14b-cffa-4a3d-b677-01b0ff0a93d7"
}
@SuppressLint("MissingPermission")
override fun connectToDevice(device: Device?): Flow<ConnectionResult> {
return flow {
currentClientSocket = device?.bluetoothDevice?.createRfcommSocketToServiceRecord(
UUID.fromString(SERVICE_UUID)
)
currentClientSocket?.let { socket ->
try {
socket.connect()
emit(ConnectionResult.ConnectionEstablished)
} catch (e: IOException) {
closeConnection()
emit(ConnectionResult.Error("Connection was interrupted"))
}
}
}.onCompletion { closeConnection() }.flowOn(Dispatchers.IO)
}
override fun closeConnection() {
currentClientSocket?.close()
currentClientSocket = null
}
}

View File

@@ -0,0 +1,249 @@
package com.helible.pilot
import android.Manifest
import android.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothManager
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.location.LocationManager
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.ManagedActivityResultLauncher
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.helible.pilot.components.BluetoothScannerScreen
import com.helible.pilot.components.PreferencesCacheImpl
import com.helible.pilot.components.SavedPreferences
import com.helible.pilot.ui.theme.TestblueTheme
import java.util.concurrent.Executors
@SuppressLint("MissingPermission")
class MainActivity : ComponentActivity() {
// TODO: delegate part of Intent filters logic to BluetoothController
// TODO: move bluetooth states and stateFlow to new BluetoothViewModel
// TODO: replace field bluetoothDevice in Device to deviceAddress field
// TODO: replace some mutableStates to stateFlows
// TODO: share selected device via PersistentViewModel
// TODO: check permissions inside other classes (and throw an exception, if one of this isn't granted)
// TODO: add stub instead of the DevicesList, if there aren't nearby devices
// TODO: add Bluetooth data transfer...
// TODO: add text strings to resource
val mainViewModel: MainViewModel = MainViewModel(AndroidBluetoothController())
private val bluetoothManager: BluetoothManager by lazy {
getSystemService(BluetoothManager::class.java)
}
private val bluetoothAdapter: BluetoothAdapter? by lazy {
bluetoothManager.adapter
}
private var permissionsViewModel = PermissionDialogViewModel()
private lateinit var permissionLauncher: ManagedActivityResultLauncher<Array<String>, Map<String, Boolean>>
private val permissionsToRequest: Array<String> by lazy {
if (Build.VERSION.SDK_INT <= 30) {
arrayOf(
Manifest.permission.ACCESS_FINE_LOCATION
)
} else {
arrayOf(
Manifest.permission.BLUETOOTH_SCAN,
Manifest.permission.BLUETOOTH_CONNECT,
Manifest.permission.BLUETOOTH_ADMIN
)
}
}
private val locationManager: LocationManager by lazy {
getSystemService(LOCATION_SERVICE) as LocationManager
}
private val preferencesCache by lazy {
PreferencesCacheImpl(getSharedPreferences(packageName, Context.MODE_PRIVATE))
}
private val preferencesViewModel by lazy {
PersistentViewModel(preferencesCache)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
mainViewModel.bluetoothTurnOnState.value = bluetoothAdapter?.isEnabled
mainViewModel.locationTurnOnState.value =
locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER)
setContent {
this.permissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestMultiplePermissions(),
onResult = { perms ->
permissionsToRequest.forEach { permission ->
permissionsViewModel.onPermissionResult(
permission = permission,
isGranted = perms[permission] == true
)
}
}
)
val state by mainViewModel.state.collectAsState()
LaunchedEffect(key1 = state.errorMessage) {
state.errorMessage?.let { message ->
Toast.makeText(applicationContext, message, Toast.LENGTH_LONG).show()
}
}
LaunchedEffect(key1 = state) {
if (state.isConnected) {
Toast.makeText(applicationContext, "Подключение завершено", Toast.LENGTH_LONG)
.show()
}
}
val navController = rememberNavController()
TestblueTheme {
Surface(color = MaterialTheme.colorScheme.background) {
PermissionsRequest(
dismissCurrentDialog = { permissionsViewModel.dismissDialog() },
visiblePermissionDialogQueue = permissionsViewModel.visiblePermissionDialogQueue,
activity = this,
permissionLauncher = permissionLauncher
)
RequestHardwareFeatures(
activity = this,
turnOnLocation = Manifest.permission.ACCESS_FINE_LOCATION in permissionsToRequest,
bluetoothTurnOnState = mainViewModel.bluetoothTurnOnState,
locationTurnOnState = mainViewModel.locationTurnOnState
)
NavHost(navController = navController, startDestination = "scanner") {
composable("scanner") {
BluetoothScannerScreen(
devices = mainViewModel.devices,
selectedDevice = mainViewModel.selectedDevice,
bluetoothIsDiscoveringState = mainViewModel.isBluetoothDiscoveryRunning,
bluetoothAdapter = bluetoothAdapter,
onScreenChanged = {
bluetoothAdapter?.cancelDiscovery()
preferencesViewModel.savePreferences(
SavedPreferences(
mainViewModel.selectedDevice.value?.bluetoothDevice?.address
)
)
navController.navigate("flight")
Log.i(
"ScanActivity",
"Preferences: ${preferencesViewModel.preferences}"
)
}
)
}
composable("flight") {
LaunchedEffect(Unit) {
// TODO: refactor
val device: Device = mainViewModel.selectedDevice.value!!
mainViewModel.connectToDevice(device)
}
when {
state.isConnecting -> {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
CircularProgressIndicator()
Text(text = "Подключение...", textAlign = TextAlign.Center)
}
}
else -> {
Text(
text = "Device name: ${mainViewModel.selectedDevice.value?.bluetoothDevice?.name}",
modifier = Modifier.fillMaxSize(),
textAlign = TextAlign.Center
)
}
}
}
}
}
}
}
registerIntentFilters(this, receiver)
requestPermissions()
}
private val receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent) {
receiveIntentChanges(
intent,
mainViewModel,
bluetoothAdapter,
locationManager
)
}
}
private fun requestPermissions() {
Executors.newSingleThreadExecutor().execute {
Handler(Looper.getMainLooper()).post {
permissionLauncher.launch(permissionsToRequest)
}
}
}
override fun onDestroy() {
unregisterReceiver(receiver)
super.onDestroy()
Log.i("ScanActivity", "ACTIVITY DESTROYED")
bluetoothAdapter?.cancelDiscovery()
try {
unregisterReceiver(receiver)
} catch (e: IllegalArgumentException) {
Log.e(
"ScanActivity",
"Receiver wasn't registered ${e.localizedMessage}\nStackTrace: ${e.stackTrace}"
)
}
}
override fun onStart() {
super.onStart()
if (bluetoothAdapter?.isDiscovering != true)
bluetoothAdapter?.startDiscovery()
Log.i("ScanActivity", "ACTIVITY STARTED")
}
override fun onStop() {
super.onStop()
bluetoothAdapter?.cancelDiscovery()
mainViewModel.devices.clear()
mainViewModel.selectedDevice.value = null
Log.i("ScanActivity", "ACTIVITY STOPPED")
}
}

View File

@@ -0,0 +1,133 @@
package com.helible.pilot
import android.bluetooth.BluetoothDevice
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.helible.pilot.components.SavedPreferences
import com.helible.pilot.components.SavedPreferencesCache
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
data class Device(
val bluetoothDevice: BluetoothDevice,
val rssi: Short,
)
data class BluetoothUiState(
val isConnected: Boolean = false,
val isConnecting: Boolean = false,
val errorMessage: String? = null
)
class MainViewModel(
private val bluetoothController: BluetoothController
) : ViewModel() {
val devices: MutableList<Device> = mutableStateListOf()
val selectedDevice: MutableState<Device?> = mutableStateOf(null)
val bluetoothTurnOnState: MutableState<Boolean?> = mutableStateOf(false)
val locationTurnOnState: MutableState<Boolean?> = mutableStateOf(null)
val isBluetoothDiscoveryRunning: MutableState<Boolean> = mutableStateOf(false)
private val _bluetoothState = MutableStateFlow(BluetoothUiState())
val state: StateFlow<BluetoothUiState>
get() = _bluetoothState.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), _bluetoothState.value)
init {
bluetoothController.isConnected.onEach {
isConnected -> _bluetoothState.update { it.copy(isConnected = isConnected) }
}.launchIn(viewModelScope)
bluetoothController.errors.onEach { error ->
_bluetoothState.update {
it.copy(errorMessage = error)
}
}.launchIn(viewModelScope)
}
private fun Flow<ConnectionResult>.listen(): Job {
return onEach { result ->
when(result) {
ConnectionResult.ConnectionEstablished -> {
_bluetoothState.update {
it.copy(
isConnected = true,
isConnecting = false,
errorMessage = null
)
}
}
is ConnectionResult.Error -> {
_bluetoothState.update { it.copy(
isConnected = false,
isConnecting = false,
errorMessage = result.message
) }
}
}
}
.catch { throwable ->
bluetoothController.closeConnection()
_bluetoothState.update {
it.copy(
isConnected = false,
isConnecting = false
)
}
}
.launchIn(viewModelScope)
}
private var deviceConnectionJob: Job? = null
fun connectToDevice(device: Device) {
_bluetoothState.update {it.copy(isConnecting = true)}
deviceConnectionJob = bluetoothController
.connectToDevice(device)
.listen()
}
fun disconnectFromDevice() {
deviceConnectionJob?.cancel()
bluetoothController.closeConnection()
_bluetoothState.update {
it.copy(
isConnecting = false,
isConnected = false
)
}
}
}
class PermissionDialogViewModel: ViewModel() {
val visiblePermissionDialogQueue = mutableStateListOf<String>()
fun dismissDialog() {
visiblePermissionDialogQueue.removeFirst()
}
fun onPermissionResult(permission: String, isGranted: Boolean) {
if(!isGranted && !visiblePermissionDialogQueue.contains(permission)){
visiblePermissionDialogQueue.add(permission)
}
}
}
class PersistentViewModel(
private val preferencesCache: SavedPreferencesCache,
) : ViewModel() {
val preferences get() = preferencesCache.getPreferences()
fun savePreferences(savedPreferences: SavedPreferences) {
preferencesCache.savePreferences(
preferences = savedPreferences
)
}
}

View File

@@ -0,0 +1,70 @@
package com.helible.pilot
import android.Manifest
import android.app.Activity
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.provider.Settings
import androidx.activity.compose.ManagedActivityResultLauncher
import androidx.compose.runtime.Composable
import androidx.compose.runtime.snapshots.SnapshotStateList
import com.helible.pilot.components.BluetoothAdminPermissionTextProvider
import com.helible.pilot.components.BluetoothConnectPermissionTextProvider
import com.helible.pilot.components.BluetoothScanPermissionTextProvider
import com.helible.pilot.components.LocationPermissionTextProvider
import com.helible.pilot.components.PermissionDialog
@Composable
fun PermissionsRequest(
visiblePermissionDialogQueue: SnapshotStateList<String>,
dismissCurrentDialog: () -> Unit,
activity: Activity,
permissionLauncher: ManagedActivityResultLauncher<Array<String>, Map<String, Boolean>>
) {
/* Create Dialog windows, which requests all permissions */
visiblePermissionDialogQueue.reversed()
.forEach { permission ->
PermissionDialog(
permissionTextProvider = when (permission) {
Manifest.permission.ACCESS_FINE_LOCATION -> {
LocationPermissionTextProvider()
}
Manifest.permission.BLUETOOTH_SCAN -> {
BluetoothScanPermissionTextProvider()
}
Manifest.permission.BLUETOOTH_CONNECT -> {
BluetoothConnectPermissionTextProvider()
}
Manifest.permission.BLUETOOTH_ADMIN -> {
BluetoothAdminPermissionTextProvider()
}
else -> return@forEach
},
isPermanentDeclined = !activity.shouldShowRequestPermissionRationale(permission),
onDismiss = {
if (activity.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED)
dismissCurrentDialog()
},
onOkClick = {
dismissCurrentDialog()
permissionLauncher.launch(arrayOf(permission))
},
onContinueClick = {
if (activity.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED)
dismissCurrentDialog()
},
onGoToAppSettingsClick = {
val intent = Intent(
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.fromParts("package", activity.packageName, null)
)
activity.startActivity(intent)
}
)
}
}

View File

@@ -0,0 +1,86 @@
package com.helible.pilot
import android.annotation.SuppressLint
import android.app.Activity
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice
import android.content.BroadcastReceiver
import android.content.Intent
import android.content.IntentFilter
import android.location.LocationManager
import android.os.Build
import android.util.Log
fun registerIntentFilters(activity: Activity, receiver: BroadcastReceiver) {
activity.registerReceiver(receiver, IntentFilter(BluetoothDevice.ACTION_FOUND))
activity.registerReceiver(receiver, IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED))
activity.registerReceiver(receiver, IntentFilter(BluetoothAdapter.ACTION_DISCOVERY_STARTED))
activity.registerReceiver(receiver, IntentFilter(BluetoothAdapter.ACTION_DISCOVERY_FINISHED))
if (Build.VERSION.SDK_INT <= 30)
activity.registerReceiver(receiver, IntentFilter(LocationManager.PROVIDERS_CHANGED_ACTION))
}
@SuppressLint("MissingPermission")
fun receiveIntentChanges(
intent: Intent,
mainViewModel: MainViewModel,
bluetoothAdapter: BluetoothAdapter?,
locationManager: LocationManager,
) {
when (intent.action) {
BluetoothDevice.ACTION_FOUND -> {
val device = if (Build.VERSION.SDK_INT >= 33) {
intent.getParcelableExtra(
BluetoothDevice.EXTRA_DEVICE,
BluetoothDevice::class.java
)
} else {
@Suppress("DEPRECATION") intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE)
}
val rssi = intent.getShortExtra(BluetoothDevice.EXTRA_RSSI, Short.MIN_VALUE)
if (device?.name != null)
mainViewModel.devices.add(Device(device, rssi))
Log.i(
"ScanActivity",
"Found new device: ${device?.name} ${device?.address} $rssi"
)
}
BluetoothAdapter.ACTION_STATE_CHANGED -> {
val state: Int = intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, -1)
Log.i("ScanActvity", "Bluetooth state: $state")
when (state) {
BluetoothAdapter.STATE_ON -> {
Log.i("ScanActvity", "Bluetooth turned on")
mainViewModel.bluetoothTurnOnState.value = true
mainViewModel.devices.clear()
bluetoothAdapter?.startDiscovery()
}
BluetoothAdapter.STATE_OFF -> {
Log.i("ScanActvity", "Bluetooth turned off")
mainViewModel.bluetoothTurnOnState.value = false
}
}
}
LocationManager.PROVIDERS_CHANGED_ACTION -> {
mainViewModel.locationTurnOnState.value = locationManager.isLocationEnabled
if (mainViewModel.locationTurnOnState.value == true) {
Log.i("ScanActivity", "LOCATION IS ON")
} else if (mainViewModel.locationTurnOnState.value == false)
Log.i("ScanActivity", "LOCATION IS OFF")
}
BluetoothAdapter.ACTION_DISCOVERY_FINISHED -> {
mainViewModel.isBluetoothDiscoveryRunning.value = false
Log.i("ScanActivity", "DISCOVERY FINISHED")
}
BluetoothAdapter.ACTION_DISCOVERY_STARTED -> {
mainViewModel.isBluetoothDiscoveryRunning.value = true
Log.i("ScanActivity", "DISCOVERY STARTED")
}
}
}

View File

@@ -0,0 +1,46 @@
package com.helible.pilot
import android.annotation.SuppressLint
import android.app.Activity
import android.bluetooth.BluetoothAdapter
import android.content.Intent
import android.provider.Settings
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import com.helible.pilot.components.RequiredHardwareFeatures
@SuppressLint("MissingPermission")
@Composable
fun RequestHardwareFeatures(
activity: Activity,
turnOnLocation: Boolean,
bluetoothTurnOnState: MutableState<Boolean?>,
locationTurnOnState: MutableState<Boolean?>
)
{
RequiredHardwareFeatures(
title = "Включите Bluetooth",
description = "Для работы приложения требуется Bluetooth",
confirmButtonText = "Включить Bluetooth",
featureState = bluetoothTurnOnState,
requestFeature = {
val intent = Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE)
activity.startActivity(intent)
},
onDismissRequest = {}
)
if (turnOnLocation) {
RequiredHardwareFeatures(
title = "Пожалуйста, включите геолокацию",
description = "Для работы с Bluetooth на устройствах с Android 11 и более ранних версиях, " +
"требуется геолокация.",
confirmButtonText = "Включить геолокацию",
featureState = locationTurnOnState,
requestFeature = {
val intent = Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS)
activity.startActivity(intent)
}
) {}
}
}

View File

@@ -0,0 +1,118 @@
package com.helible.pilot.components
import android.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilledIconToggleButton
import androidx.compose.material3.Icon
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.constraintlayout.compose.ConstraintLayout
import androidx.constraintlayout.compose.Dimension
import com.helible.pilot.Device
@SuppressLint("MissingPermission")
@Composable
fun BluetoothScannerScreen(
devices: MutableList<Device>,
selectedDevice: MutableState<Device?>,
bluetoothIsDiscoveringState: MutableState<Boolean>,
bluetoothAdapter: BluetoothAdapter?,
onScreenChanged: () -> Unit,
modifier: Modifier = Modifier
) {
Surface(
modifier = modifier,
) {
ConstraintLayout(modifier = Modifier.fillMaxSize()) {
val (title, devicesList, controls) = createRefs()
Title(
text = "Поиск устройств",
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 10.dp)
.constrainAs(title) {}
)
DiscoveredDevicesList(
devices = devices,
selectedDevice = selectedDevice,
modifier = Modifier
.constrainAs(devicesList) {
top.linkTo(title.bottom)
bottom.linkTo(controls.top)
height = Dimension.fillToConstraints
}
)
if (devices.isEmpty() && bluetoothIsDiscoveringState.value) {
Box(
modifier = Modifier.fillMaxSize()
) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
}
}
Row(
modifier = Modifier
.padding(5.dp)
.constrainAs(controls) {
bottom.linkTo(parent.bottom)
width = Dimension.matchParent
height = Dimension.fillToConstraints
},
horizontalArrangement = Arrangement.Center
) {
FilledIconToggleButton(
checked = bluetoothIsDiscoveringState.value,
onCheckedChange = {
selectedDevice.value = null
if (bluetoothIsDiscoveringState.value)
bluetoothAdapter?.cancelDiscovery()
else {
devices.clear()
bluetoothAdapter?.startDiscovery()
}
}, modifier = Modifier
.align(Alignment.Bottom)
.padding(5.dp)
) {
Icon(
if (bluetoothIsDiscoveringState.value) Icons.Filled.Close
else Icons.Filled.Refresh,
contentDescription = null
)
}
Button(
onClick = {
onScreenChanged()
},
modifier = Modifier
.align(Alignment.Bottom)
.padding(5.dp),
enabled = selectedDevice.value != null,
) {
Text(text = "Далее")
}
}
}
}
}

View File

@@ -0,0 +1,71 @@
package com.helible.pilot.components
import android.annotation.SuppressLint
import android.widget.TextView
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.text.HtmlCompat
import com.helible.pilot.Device
import com.helible.pilot.R
@SuppressLint("MissingPermission")
@Composable
fun DeviceItem(deviceInfo: Device, selectedDevice: MutableState<Device?>, modifier: Modifier) {
ElevatedCard(
modifier=modifier.clickable {
selectedDevice.value = deviceInfo
},
colors = CardDefaults.elevatedCardColors(containerColor = if (deviceInfo.bluetoothDevice == selectedDevice.value?.bluetoothDevice)
MaterialTheme.colorScheme.secondaryContainer
else MaterialTheme.colorScheme.surface
)
) {
Row(modifier=Modifier.padding(8.dp)) {
Column(verticalArrangement = Arrangement.Center) {
Text(text=deviceInfo.bluetoothDevice.name, fontWeight = FontWeight.Bold, softWrap = true)
AndroidView(factory = { context ->
TextView(context).apply {
// Using old TextView for text formatting
text = HtmlCompat.fromHtml("<b>MAC</b>: ${deviceInfo.bluetoothDevice.address}", HtmlCompat.FROM_HTML_MODE_LEGACY)
}
})
}
Box(contentAlignment = Alignment.CenterEnd, modifier = Modifier.fillMaxSize()) {
Icon(
painterResource(id = getSignalIconForRssiValue(deviceInfo.rssi)),
contentDescription = null,
modifier = Modifier
.fillMaxHeight()
.padding(10.dp),
tint = MaterialTheme.colorScheme.primary
)
}
}
}
}
fun getSignalIconForRssiValue(rssi: Short): Int{
if (rssi >= -80) return R.drawable.signal_icon4
else if (rssi >= -90) return R.drawable.signal_icon3
else if (rssi >= -100) return R.drawable.signal_icon2
return R.drawable.signal_icon1
}

View File

@@ -0,0 +1,28 @@
package com.helible.pilot.components
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.helible.pilot.Device
@Composable
fun DiscoveredDevicesList(devices: MutableList<Device>, selectedDevice: MutableState<Device?>, modifier: Modifier = Modifier) {
LazyColumn(modifier = modifier) {
items(devices) { device ->
DeviceItem(
deviceInfo = device,
selectedDevice = selectedDevice,
modifier = Modifier
.fillMaxWidth()
.padding(
5.dp
)
)
}
}
}

View File

@@ -0,0 +1,38 @@
package com.helible.pilot.components
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Divider
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.ui.Modifier
@Composable
fun RequiredHardwareFeatures(
title: String,
description: String,
confirmButtonText: String,
featureState: MutableState<Boolean?>,
requestFeature: () -> Unit,
onDismissRequest: () -> Unit
) {
if (featureState.value == false || featureState.value == null) {
AlertDialog(
confirmButton = {
Divider()
TextButton(onClick = requestFeature, modifier = Modifier.fillMaxWidth()) {
Text(text=confirmButtonText)
}
},
onDismissRequest = onDismissRequest,
text = {
Text(
text = description
)
},
title = { Text(text = title)}
)
}
}

View File

@@ -0,0 +1,130 @@
package com.helible.pilot.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Divider
import androidx.compose.material3.Text
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
@Composable
fun PermissionDialog(
permissionTextProvider: PermissionTextProvider,
isPermanentDeclined: Boolean,
onDismiss: () -> Unit,
onOkClick: () -> Unit,
onContinueClick: () -> Unit,
onGoToAppSettingsClick: () -> Unit,
modifier: Modifier = Modifier
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(text = "Требуется разрешение") },
confirmButton = {
Column(
modifier = Modifier.fillMaxWidth()
) {
Divider()
Text(
text = if (isPermanentDeclined) {
"Выдать разрешение"
} else {
"OK"
},
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.clickable {
if (isPermanentDeclined) {
onGoToAppSettingsClick()
} else {
onOkClick()
}
}
.padding(16.dp)
)
}
},
dismissButton = {if(isPermanentDeclined)
Box(modifier=Modifier.fillMaxWidth()){
Divider()
Text(
text = "Снова проверить наличие разрешения",
modifier = Modifier
.fillMaxWidth()
.padding(paddingValues = PaddingValues(top=10.dp))
.clickable {onContinueClick()},
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center
)}
else
Unit},
text = {
Text(text = permissionTextProvider.getDescription(
isPermanentDeclined = isPermanentDeclined
))
},
modifier = modifier
)
}
interface PermissionTextProvider {
fun getDescription(isPermanentDeclined: Boolean): String
}
class LocationPermissionTextProvider : PermissionTextProvider {
override fun getDescription(isPermanentDeclined: Boolean): String {
return if (isPermanentDeclined){
"Похоже вы навсегда запретили приложению доступ к геолокации. " +
"Вы можете зайти в настройки, чтобы выдать это разрешение."
} else {
"Приложению необходимо разрешение для определения местоположения " +
"для работы с Bluetooth на устройствах с Android 11 и ниже."
}
}
}
class BluetoothScanPermissionTextProvider : PermissionTextProvider {
override fun getDescription(isPermanentDeclined: Boolean): String {
return if (isPermanentDeclined){
"Похоже вы навсегда запретили приложению доступ к сканированию по Bluetooth. " +
"Вы можете зайти в настройки, чтобы выдать это разрешение."
} else {
"Приложению необходимо разрешение для к сканированию по Bluetooth " +
"для работы с Bluetooth на устройствах с Android 11 и ниже"
}
}
}
class BluetoothConnectPermissionTextProvider : PermissionTextProvider {
override fun getDescription(isPermanentDeclined: Boolean): String {
return if (isPermanentDeclined){
"Похоже вы навсегда запретили приложению доступ к подключению по Bluetooth." +
"Вы можете зайти в настройки, чтобы выдать это разрешение."
} else {
"Приложению необходимо разрешение для к подключению по Bluetooth." +
"для работы с Bluetooth на устройствах с Android 11 и ниже"
}
}
}
class BluetoothAdminPermissionTextProvider : PermissionTextProvider {
override fun getDescription(isPermanentDeclined: Boolean): String {
return if (isPermanentDeclined){
"Похоже вы навсегда запретили приложению доступ к управлению настройками Bluetooth. " +
"Вы можете зайти в настройки, чтобы выдать это разрешение."
} else {
"Приложению необходимо разрешение для к управлению настройками Bluetooth " +
"для работы с Bluetooth на устройствах с Android 11 и ниже"
}
}
}

View File

@@ -0,0 +1,37 @@
package com.helible.pilot.components
import android.content.SharedPreferences
import android.os.Parcelable
import com.google.gson.Gson
import kotlinx.parcelize.Parcelize
@Parcelize
data class SavedPreferences(
val deviceAddress: String?
): Parcelable
interface SavedPreferencesCache {
fun getPreferences(): SavedPreferences?
fun savePreferences(preferences: SavedPreferences)
fun clearPreferences()
}
class PreferencesCacheImpl(private val sharedPreferences: SharedPreferences) : SavedPreferencesCache {
override fun getPreferences(): SavedPreferences? {
val json = sharedPreferences.getString("preferences", null) ?: return null
return Gson().fromJson(json, SavedPreferences::class.java)
}
override fun savePreferences(preferences: SavedPreferences)
{
sharedPreferences.edit()
.putString("preferences", Gson().toJson(preferences))
.apply()
}
override fun clearPreferences()
{
sharedPreferences.edit().remove("preferences").apply()
}
}

View File

@@ -0,0 +1,19 @@
package com.helible.pilot.components
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.sp
@Composable
fun Title(text: String, modifier: Modifier = Modifier) {
Text(
text = text,
textAlign = TextAlign.Center,
modifier = modifier,
fontSize = 23.sp,
fontWeight = FontWeight.Bold
)
}

View File

@@ -0,0 +1,11 @@
package com.helible.pilot.ui.theme
import androidx.compose.ui.graphics.Color
val Purple80 = Color(0xFFD0BCFF)
val PurpleGrey80 = Color(0xFFCCC2DC)
val Pink80 = Color(0xFFEFB8C8)
val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260)

View File

@@ -0,0 +1,70 @@
package com.helible.pilot.ui.theme
import android.app.Activity
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
private val DarkColorScheme = darkColorScheme(
primary = Purple80,
secondary = PurpleGrey80,
tertiary = Pink80
)
private val LightColorScheme = lightColorScheme(
primary = Purple40,
secondary = PurpleGrey40,
tertiary = Pink40
/* Other default colors to override
background = Color(0xFFFFFBFE),
surface = Color(0xFFFFFBFE),
onPrimary = Color.White,
onSecondary = Color.White,
onTertiary = Color.White,
onBackground = Color(0xFF1C1B1F),
onSurface = Color(0xFF1C1B1F),
*/
)
@Composable
fun TestblueTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
// Dynamic color is available on Android 12+
dynamicColor: Boolean = true,
content: @Composable () -> Unit
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
val view = LocalView.current
if (!view.isInEditMode) {
SideEffect {
val window = (view.context as Activity).window
window.statusBarColor = colorScheme.primary.toArgb()
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme
}
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content
)
}

View File

@@ -0,0 +1,34 @@
package com.helible.pilot.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
// Set of Material typography styles to start with
val Typography = Typography(
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
)
/* Other default text styles to override
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 22.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp
),
labelSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
)
*/
)