Implement preconditions for app usage (BLE support, permissions, bluetooth and network condition)

This commit is contained in:
2026-06-25 21:58:17 +07:00
parent cc17e03ec1
commit 6c6a5bfff9
27 changed files with 336 additions and 166 deletions
@@ -72,4 +72,6 @@
<string name="location_disabled_title">LOCATION DISABLED</string>
<string name="location_disabled_info">Location is turned off. Most Android phones require it in order to scan for Bluetooth LE devices.\n
Click the button below to enable Location.</string>
<string name="network_not_available_title">NO NETWORK CONNECTION</string>
<string name="network_not_available_info">Please check Wi-Fi or mobile data settings to continue.</string>
</resources>
@@ -4,22 +4,26 @@ import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import com.digitoolsolutions.app.torquevaultkmp.screens.MainScreen
import com.digitoolsolutions.app.torquevaultkmp.screens.bluetooth.BluetoothRequirementWrapper
import com.digitoolsolutions.app.torquevaultkmp.screens.requirement.RequirementWrapper
import com.digitoolsolutions.app.torquevaultkmp.screens.scanner.ScannerViewModel
import com.digitoolsolutions.app.torquevaultkmp.theme.TorqueVaultTheme
import io.github.aakira.napier.Napier
import org.koin.compose.viewmodel.koinViewModel
@Composable
@Preview
fun App(
viewModel: AppViewModel = koinViewModel()
viewModel: AppViewModel = koinViewModel(),
scannerViewModel: ScannerViewModel = koinViewModel(),
) {
val savedTheme = viewModel.isDarkTheme.value
val isDarkTheme = savedTheme ?: isSystemInDarkTheme()
val isLoggedIn = viewModel.isLoggedIn()
TorqueVaultTheme(darkTheme = isDarkTheme) {
BluetoothRequirementWrapper {
RequirementWrapper(
onReady = { scannerViewModel.startScan() },
onPause = { scannerViewModel.stopScan() }
) {
MainScreen(
isDarkTheme = isDarkTheme,
onThemeToggle = {
@@ -1,9 +0,0 @@
package com.digitoolsolutions.app.torquevaultkmp
class Greeting {
private val platform = getPlatform()
fun greet(): String {
return "Hello, ${platform.name}!"
}
}
@@ -41,6 +41,7 @@ import com.digitoolsolutions.app.torquevaultkmp.screens.settings.SettingDestinat
import com.digitoolsolutions.app.torquevaultkmp.screens.settings.SettingScreen
import org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel
import torquevaultkmp.composeapp.generated.resources.Res
import torquevaultkmp.composeapp.generated.resources.btn_login
@@ -52,8 +53,7 @@ fun MainScreen(
isDarkTheme: Boolean,
onThemeToggle: (Boolean) -> Unit,
isLoggedIn: Boolean,
viewModel: AppViewModel = koinViewModel(),
scannerViewModel: ScannerViewModel = koinViewModel(),
viewModel: AppViewModel = koinInject()
) {
val destinations = listOf(
HomeDestination,
@@ -88,10 +88,6 @@ fun MainScreen(
)
}
LaunchedEffect(Unit) {
scannerViewModel.startScan()
}
Scaffold(
modifier = Modifier.fillMaxSize(),
bottomBar = {
@@ -23,7 +23,7 @@ class AuthViewModel(
private val _loginState = MutableStateFlow<LoginUiState>(LoginUiState.Idle)
val loginState = _loginState.asStateFlow()
private val _serverUrl = MutableStateFlow(tokenManager.getBaseUrl() ?: "")
private val _serverUrl = MutableStateFlow(tokenManager.getBaseUrl() ?: "http://digitoolsolutions.synology.me:3000")
val serverUrl = _serverUrl.asStateFlow()
fun onChangeServerUrl(url: String) {
@@ -1,104 +0,0 @@
package com.digitoolsolutions.app.torquevaultkmp.screens.bluetooth
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.LifecycleResumeEffect
import com.digitoolsolutions.app.torquevaultkmp.screens.bluetooth.views.BluetoothDisabledView
import com.digitoolsolutions.app.torquevaultkmp.screens.bluetooth.views.LocationDisabledView
import com.digitoolsolutions.app.torquevaultkmp.screens.bluetooth.views.LocationPermissionRequiredView
import com.digitoolsolutions.app.torquevaultkmp.utils.BluetoothManager
import com.digitoolsolutions.app.torquevaultkmp.utils.PermissionHandler
import org.koin.compose.koinInject
@Composable
fun BluetoothRequirementWrapper(
content: @Composable () -> Unit,
) {
val bluetoothManager: BluetoothManager = koinInject()
val permissionHandler: PermissionHandler = koinInject()
val isEnabled by bluetoothManager.isBluetoothEnabled.collectAsState()
val hasPermission by bluetoothManager.hasBluetoothPermission.collectAsState()
var hasPermanentlyDenied by remember { mutableStateOf(false) }
val isLocationEnabled by permissionHandler.isLocationEnabled.collectAsState()
var showPermissionRequest by remember { mutableStateOf(false) }
if (showPermissionRequest) {
permissionHandler.HandlePermissionRequest(
{ allGranted, denied ->
showPermissionRequest = false
bluetoothManager.checkBluetoothPermission()
hasPermanentlyDenied = denied
}
) {
requestPermissions ->
LaunchedEffect(Unit) {
requestPermissions()
}
}
}
LifecycleResumeEffect(Unit) {
bluetoothManager.checkBluetoothState()
bluetoothManager.checkBluetoothPermission()
permissionHandler.checkLocationState()
onPauseOrDispose { }
}
/* MainScreen */
content()
/* Overlay content */
if (!isEnabled || !hasPermission || !isLocationEnabled) {
BluetoothRequestScreen(
isEnabled = isEnabled,
hasPermission = hasPermission,
hasPermanentlyDenied = hasPermanentlyDenied,
isLocationEnabled = isLocationEnabled,
onEnableBluetooth = { bluetoothManager.enableBluetooth() },
onEnableLocation = { permissionHandler.enableLocation() },
onRequestPermission = { showPermissionRequest = true },
onOpenSettings = { bluetoothManager.openSettings() }
)
}
}
@Composable
fun BluetoothRequestScreen(
isEnabled: Boolean,
hasPermission: Boolean,
hasPermanentlyDenied: Boolean,
isLocationEnabled: Boolean,
onEnableBluetooth: () -> Unit,
onEnableLocation: () -> Unit,
onRequestPermission: () -> Unit,
onOpenSettings: () -> Unit
) {
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.padding(12.dp)
){
when {
!isEnabled -> BluetoothDisabledView(onEnable = onEnableBluetooth)
!hasPermission -> LocationPermissionRequiredView(
hasPermission = hasPermission,
hasPermanentlyDenied = hasPermanentlyDenied,
onRequestPermission = onRequestPermission,
onOpenSetting = onOpenSettings
)
!isLocationEnabled -> LocationDisabledView(onEnable = onEnableLocation)
}
}
}
@@ -66,7 +66,7 @@ fun UartCommunicationScreen(
Icon(
Icons.Rounded.DeleteForever,
contentDescription = "Clear history",
tint = Color.Red
tint = Color.White
)
}
}
@@ -0,0 +1,180 @@
package com.digitoolsolutions.app.torquevaultkmp.screens.requirement
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.LifecycleResumeEffect
import com.digitoolsolutions.app.torquevaultkmp.getPlatform
import com.digitoolsolutions.app.torquevaultkmp.screens.requirement.views.BluetoothDisabledView
import com.digitoolsolutions.app.torquevaultkmp.screens.requirement.views.BluetoothNotAvailableView
import com.digitoolsolutions.app.torquevaultkmp.screens.requirement.views.BluetoothPermissionRequiredView
import com.digitoolsolutions.app.torquevaultkmp.screens.requirement.views.LocationDisabledView
import com.digitoolsolutions.app.torquevaultkmp.screens.requirement.views.LocationPermissionRequiredView
import com.digitoolsolutions.app.torquevaultkmp.screens.requirement.views.NetworkNotAvailableView
import com.digitoolsolutions.app.torquevaultkmp.utils.BluetoothManager
import com.digitoolsolutions.app.torquevaultkmp.utils.NetworkMonitor
import com.digitoolsolutions.app.torquevaultkmp.utils.NetworkStatus
import com.digitoolsolutions.app.torquevaultkmp.utils.PermissionHandler
import org.koin.compose.koinInject
data class Requirements(
val hasBleFeature: Boolean,
val isEnabled: Boolean,
val hasPermission: Boolean,
val hasPermanentlyDenied: Boolean,
val isLocationEnabled: Boolean,
val networkAvailable: Boolean,
)
sealed class RequirementState {
object BleUnsupported : RequirementState()
object BluetoothDisabled : RequirementState()
data class BluetoothPermissionRequired(val permanentlyDenied: Boolean) : RequirementState()
data class LocationPermissionRequired(val permanentlyDenied: Boolean) : RequirementState()
object LocationDisabled : RequirementState()
object NetworkUnavailable : RequirementState()
object Ready : RequirementState()
}
fun Requirements.toState(): RequirementState {
return when {
!hasBleFeature -> RequirementState.BleUnsupported
!hasPermission -> {
if (getPlatform().name == "IOS") {
RequirementState.BluetoothPermissionRequired(hasPermanentlyDenied)
} else {
RequirementState.LocationPermissionRequired(hasPermanentlyDenied)
}
}
!isEnabled -> RequirementState.BluetoothDisabled
!isLocationEnabled -> RequirementState.LocationDisabled
!networkAvailable -> RequirementState.NetworkUnavailable
else -> RequirementState.Ready
}
}
@Composable
fun RequirementWrapper(
onReady: () -> Unit,
onPause: () -> Unit,
content: @Composable () -> Unit,
) {
val bluetoothManager: BluetoothManager = koinInject()
val permissionHandler: PermissionHandler = koinInject()
val networkMonitor: NetworkMonitor = koinInject()
val hasBleFeature = permissionHandler.hasBleFeature
val isEnabled by bluetoothManager.isBluetoothEnabled.collectAsState()
val hasPermission by bluetoothManager.hasBluetoothPermission.collectAsState()
var hasPermanentlyDenied by remember { mutableStateOf(false) }
val isLocationEnabled by permissionHandler.isLocationEnabled.collectAsState()
var showPermissionRequest by remember { mutableStateOf(false) }
val networkStatus by networkMonitor.status.collectAsState()
if (showPermissionRequest) {
permissionHandler.HandlePermissionRequest(
{ denied ->
showPermissionRequest = false
bluetoothManager.checkBluetoothPermission()
hasPermanentlyDenied = denied
}
) {
requestPermissions ->
LaunchedEffect(Unit) {
requestPermissions()
}
}
}
LifecycleResumeEffect(Unit) {
bluetoothManager.checkBluetoothState()
bluetoothManager.checkBluetoothPermission()
permissionHandler.checkLocationState()
onPauseOrDispose { }
}
DisposableEffect(networkMonitor) {
networkMonitor.start()
onDispose {
networkMonitor.stop()
}
}
val requirements = Requirements(
isEnabled = isEnabled,
hasPermission = hasPermission,
hasPermanentlyDenied = hasPermanentlyDenied,
isLocationEnabled = isLocationEnabled,
hasBleFeature = hasBleFeature,
networkAvailable = networkStatus == NetworkStatus.Available
)
val state = requirements.toState()
LaunchedEffect(state) {
if (state == RequirementState.Ready) {
onReady()
} else {
onPause()
}
}
/* MainScreen */
content()
/* Overlay content */
if (state != RequirementState.Ready) {
RequirementContent(
state = state,
onEnableBluetooth = { bluetoothManager.enableBluetooth() },
onEnableLocation = { permissionHandler.enableLocation() },
onRequestPermission = { showPermissionRequest = true },
onOpenSettings = { bluetoothManager.openSettings() }
)
}
}
@Composable
fun RequirementContent(
state: RequirementState,
onEnableBluetooth: () -> Unit,
onEnableLocation: () -> Unit,
onRequestPermission: () -> Unit,
onOpenSettings: () -> Unit
) {
val platform = getPlatform().name
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.padding(12.dp)
){
when (state){
RequirementState.BleUnsupported -> BluetoothNotAvailableView()
RequirementState.BluetoothDisabled -> BluetoothDisabledView(onEnable = onEnableBluetooth)
is RequirementState.LocationPermissionRequired -> LocationPermissionRequiredView(
hasPermission = false,
hasPermanentlyDenied = state.permanentlyDenied,
onRequestPermission = onRequestPermission,
onOpenSetting = onOpenSettings
)
is RequirementState.BluetoothPermissionRequired -> BluetoothPermissionRequiredView(
hasPermission = false,
hasPermanentlyDenied = state.permanentlyDenied,
onRequestPermission = onRequestPermission,
onOpenSetting = onOpenSettings
)
RequirementState.LocationDisabled -> LocationDisabledView(onEnable = onEnableLocation)
RequirementState.NetworkUnavailable -> NetworkNotAvailableView()
RequirementState.Ready -> {}
}
}
}
@@ -1,7 +1,6 @@
package com.digitoolsolutions.app.torquevaultkmp.screens.bluetooth.views
package com.digitoolsolutions.app.torquevaultkmp.screens.requirement.views
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
@@ -10,7 +9,6 @@ import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.digitoolsolutions.app.torquevaultkmp.components.WarningView
import org.jetbrains.compose.resources.stringResource
import torquevaultkmp.composeapp.generated.resources.Res
@@ -1,4 +1,4 @@
package com.digitoolsolutions.app.torquevaultkmp.screens.bluetooth.views
package com.digitoolsolutions.app.torquevaultkmp.screens.requirement.views
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.rememberScrollState
@@ -1,30 +1,15 @@
package com.digitoolsolutions.app.torquevaultkmp.screens.bluetooth.views
package com.digitoolsolutions.app.torquevaultkmp.screens.requirement.views
//import android.Manifest
//import android.content.Context
//import android.content.Intent
//import android.net.Uri
//import android.os.Build
//import android.provider.Settings
//import androidx.activity.compose.rememberLauncherForActivityResult
//import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.BluetoothDisabled
import androidx.compose.material3.Button
import androidx.compose.material3.SegmentedButtonDefaults.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
//import androidx.compose.ui.platform.LocalContext
import com.digitoolsolutions.app.torquevaultkmp.components.WarningView
import org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.resources.stringResource
import torquevaultkmp.composeapp.generated.resources.Res
import torquevaultkmp.composeapp.generated.resources.action_grant_permission
@@ -1,4 +1,4 @@
package com.digitoolsolutions.app.torquevaultkmp.screens.bluetooth.views
package com.digitoolsolutions.app.torquevaultkmp.screens.requirement.views
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.rememberScrollState
@@ -1,18 +1,14 @@
package com.digitoolsolutions.app.torquevaultkmp.screens.bluetooth.views
package com.digitoolsolutions.app.torquevaultkmp.screens.requirement.views
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.LocationOff
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.digitoolsolutions.app.torquevaultkmp.components.WarningView
import org.jetbrains.compose.resources.stringResource
import torquevaultkmp.composeapp.generated.resources.Res
@@ -0,0 +1,26 @@
package com.digitoolsolutions.app.torquevaultkmp.screens.requirement.views
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.WifiOff
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.digitoolsolutions.app.torquevaultkmp.components.WarningView
import org.jetbrains.compose.resources.stringResource
import torquevaultkmp.composeapp.generated.resources.Res
import torquevaultkmp.composeapp.generated.resources.network_not_available_info
import torquevaultkmp.composeapp.generated.resources.network_not_available_title
@Composable
internal fun NetworkNotAvailableView() {
WarningView(
imageVector = Icons.Rounded.WifiOff,
title = stringResource(Res.string.network_not_available_title),
hint = stringResource(Res.string.network_not_available_info),
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
)
}
@@ -0,0 +1,13 @@
package com.digitoolsolutions.app.torquevaultkmp.utils
import kotlinx.coroutines.flow.StateFlow
enum class NetworkStatus {
Available, Unavailable
}
interface NetworkMonitor {
val status: StateFlow<NetworkStatus>
fun start()
fun stop()
}
@@ -6,9 +6,10 @@ import kotlinx.coroutines.flow.StateFlow
interface PermissionHandler {
@Composable
fun HandlePermissionRequest(
onPermissionResult: (Boolean, Boolean) -> Unit,
onPermissionResult: (Boolean) -> Unit,
content: @Composable (requestPermissions: () -> Unit) -> Unit
)
val hasBleFeature: Boolean
val isLocationEnabled: StateFlow<Boolean>
fun enableLocation()
fun checkLocationState()