From b092987709a538d6f3c1ea1c3a269c402ef3c10b Mon Sep 17 00:00:00 2001 From: lamtruong28 Date: Mon, 22 Jun 2026 21:41:58 +0700 Subject: [PATCH] Handle request runtime permission --- .../src/androidMain/AndroidManifest.xml | 15 +- .../app/torquevaultkmp/di/PlatformModules.kt | 3 + .../utils/PermissionHandler.android.kt | 128 ++++++++++++++---- .../composeResources/values/strings.xml | 28 ++++ .../app/torquevaultkmp/App.kt | 1 + .../torquevaultkmp/components/WarningView.kt | 11 +- .../app/torquevaultkmp/screens/MainScreen.kt | 1 + .../bluetooth/BluetoothRequirementWrapper.kt | 113 +++++++--------- .../bluetooth/views/BluetoothDisabledView.kt | 37 +++++ .../views/BluetoothNotAvailableView.kt | 26 ++++ .../views/BluetoothPermissionRequiredView.kt | 64 +++++++++ .../bluetooth/views/LocationDisabledView.kt | 35 +++++ .../views/LocationPermissionRequiredView.kt | 52 +++++++ .../torquevaultkmp/utils/PermissionHandler.kt | 15 +- .../app/torquevaultkmp/di/PlatformModules.kt | 3 + .../utils/BluetoothManager.ios.kt | 26 ++-- .../utils/PermissionHandler.ios.kt | 43 ++++-- 17 files changed, 467 insertions(+), 134 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/bluetooth/views/BluetoothDisabledView.kt create mode 100644 composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/bluetooth/views/BluetoothNotAvailableView.kt create mode 100644 composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/bluetooth/views/BluetoothPermissionRequiredView.kt create mode 100644 composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/bluetooth/views/LocationDisabledView.kt create mode 100644 composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/bluetooth/views/LocationPermissionRequiredView.kt diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml index 70fef6e..8653bd0 100644 --- a/composeApp/src/androidMain/AndroidManifest.xml +++ b/composeApp/src/androidMain/AndroidManifest.xml @@ -1,12 +1,17 @@ - + + - - - + - + + + + + { AppStoragePlatform(androidContext()) } single { AndroidBluetoothManager(androidContext()) } + single { PermissionHandlerPlatform(androidContext()) } single { AndroidSqliteDriver( schema = AppDatabase.Schema, diff --git a/composeApp/src/androidMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/utils/PermissionHandler.android.kt b/composeApp/src/androidMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/utils/PermissionHandler.android.kt index 9f1fd7b..d7f1139 100644 --- a/composeApp/src/androidMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/utils/PermissionHandler.android.kt +++ b/composeApp/src/androidMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/utils/PermissionHandler.android.kt @@ -1,36 +1,114 @@ package com.digitoolsolutions.app.torquevaultkmp.utils import android.Manifest +import android.annotation.SuppressLint +import android.app.Activity +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.location.LocationManager import android.os.Build +import android.provider.Settings import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.platform.LocalContext +import androidx.core.app.ActivityCompat +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow -@Composable -actual fun HandlePermissionRequest( - onPermissionResult: (Boolean) -> Unit -) { - val permissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - arrayOf( - Manifest.permission.BLUETOOTH_SCAN, - Manifest.permission.BLUETOOTH_CONNECT - ) - } else { - arrayOf( - Manifest.permission.BLUETOOTH, - Manifest.permission.ACCESS_FINE_LOCATION - ) - } +class PermissionHandlerPlatform(private val context: Context): PermissionHandler { + @SuppressLint("ContextCastToActivity") + @Composable + override fun HandlePermissionRequest ( + onPermissionResult: (Boolean, Boolean) -> Unit, + content: @Composable (requestPermissions: () -> Unit) -> Unit + ) { + val permissions = if (isPreAndroid12()) { + /** For android 11- */ + arrayOf( + Manifest.permission.ACCESS_FINE_LOCATION + ) + } else { + /** For android 12+ */ + arrayOf( + Manifest.permission.BLUETOOTH_SCAN, + Manifest.permission.BLUETOOTH_CONNECT + ) + } - val launcher = rememberLauncherForActivityResult( - ActivityResultContracts.RequestMultiplePermissions() - ) { result -> - val allGranted = result.values.all { it } - onPermissionResult(allGranted) - } + val activity: Activity? = LocalContext.current as? Activity + val launcher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestMultiplePermissions() + ) { result -> + val allGranted = result.values.all { it } + val permanentlyDenied = if (isPreAndroid12()) { + /** For android 6.0 - 11 */ + val deniedLocation = result[Manifest.permission.ACCESS_FINE_LOCATION] == false + deniedLocation && activity?.let { + !ActivityCompat.shouldShowRequestPermissionRationale(it, + Manifest.permission.ACCESS_FINE_LOCATION) + } ?: false + } else { + /** For android 12+ */ + val deniedScan = result[Manifest.permission.BLUETOOTH_SCAN] == false + val deniedConnect = result[Manifest.permission.BLUETOOTH_CONNECT] == false - LaunchedEffect(Unit) { - launcher.launch(permissions) + val permDeniedScan = deniedScan && activity?.let { + !ActivityCompat.shouldShowRequestPermissionRationale(it, + Manifest.permission.BLUETOOTH_SCAN) + } ?: false + + val permDeniedConnect = deniedConnect && activity?.let { + !ActivityCompat.shouldShowRequestPermissionRationale(it, + Manifest.permission.BLUETOOTH_CONNECT) + } ?: false + permDeniedScan || permDeniedConnect + } + onPermissionResult(allGranted, permanentlyDenied) + } + content { + launcher.launch(permissions) + } } -} + private fun isPreAndroid12(): Boolean { + return Build.VERSION.SDK_INT < Build.VERSION_CODES.S + } + private val locationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager + private val _isLocationEnabled = MutableStateFlow(true) + override val isLocationEnabled: StateFlow = _isLocationEnabled.asStateFlow() + init { + if(isPreAndroid12()) { + val providerChangedReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + if (intent?.action == LocationManager.PROVIDERS_CHANGED_ACTION) { + checkLocationState() + } + } + } + checkLocationState() + context.registerReceiver(providerChangedReceiver, + IntentFilter(LocationManager.PROVIDERS_CHANGED_ACTION) + ) + } + } + override fun checkLocationState() { + if(isPreAndroid12()) { + try { + _isLocationEnabled.value = + locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER) || + locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER) + } catch (e: SecurityException) { + _isLocationEnabled.value = false + } + } + } + override fun enableLocation() { + val intent = Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 5d20ff9..2fc5c9c 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -44,4 +44,32 @@ Retry Connecting… It should just take a moment… + + Grant permission + Settings + Enable + Try again + Location settings + Back + + BLUETOOTH DISABLED + The Bluetooth adapter is turned off.\nClick the button below to enable it. + + LOCATION PERMISSION REQUIRED + From Android 6.0 Marshmallow until Android 11 the application requires Location permission in order to scan for Bluetooth Low Energy devices. This is because Bluetooth LE beacons, for example iBeacons or Eddystone, may be used to + determine the phone's and user's location.\n + The app will not use this information in any way.\n + On Android 12 or newer a new Nearby permission can be requested instead, without the need for location. + + BLUETOOTH PERMISSIONS REQUIRED + The app needs to scan and connect to Bluetooth LE devices. + + BLUETOOTH NOT AVAILABLE + Bluetooth is not available on your phone.\nWe cannot provide you the feature. + Location required + Bluetooth required + + LOCATION DISABLED + 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. diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/App.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/App.kt index 69dddeb..01ddf40 100644 --- a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/App.kt +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/App.kt @@ -6,6 +6,7 @@ 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.theme.TorqueVaultTheme +import io.github.aakira.napier.Napier import org.koin.compose.viewmodel.koinViewModel @Composable diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/components/WarningView.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/components/WarningView.kt index ce8831c..ac99f0f 100644 --- a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/components/WarningView.kt +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/components/WarningView.kt @@ -1,5 +1,6 @@ package com.digitoolsolutions.app.torquevaultkmp.components +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope @@ -101,8 +102,8 @@ fun WarningView( ) { WarningView( painterResource = painterResource, - title = { Text(text = title) }, - hint = { Hint(text = hint, textAlign = hintTextAlign) }, + title = { Text(text = title, color = MaterialTheme.colorScheme.onBackground) }, + hint = { Hint(text = hint, textAlign = hintTextAlign, color = MaterialTheme.colorScheme.onBackground) }, modifier = modifier, content = content, ) @@ -128,8 +129,8 @@ fun WarningView( ) { WarningView( painterResource = painterResource, - title = { Text(text = title) }, - hint = { Hint(text = hint, textAlign = hintTextAlign) }, + title = { Text(text = title, color = MaterialTheme.colorScheme.onBackground) }, + hint = { Hint(text = hint, textAlign = hintTextAlign, color = MaterialTheme.colorScheme.onBackground) }, modifier = modifier, content = content, ) @@ -179,7 +180,7 @@ fun WarningView( content: @Composable ColumnScope.() -> Unit = {} ) { Column( - modifier = modifier, + modifier = modifier.background(MaterialTheme.colorScheme.background), verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically), horizontalAlignment = Alignment.CenterHorizontally, ) { diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/MainScreen.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/MainScreen.kt index 4ceabdb..db12cd6 100644 --- a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/MainScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/MainScreen.kt @@ -36,6 +36,7 @@ import com.digitoolsolutions.app.torquevaultkmp.screens.scanner.ScannerScreen import com.digitoolsolutions.app.torquevaultkmp.screens.scanner.ScannerViewModel import com.digitoolsolutions.app.torquevaultkmp.screens.settings.SettingDestination import com.digitoolsolutions.app.torquevaultkmp.screens.settings.SettingScreen +import io.github.aakira.napier.Napier import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/bluetooth/BluetoothRequirementWrapper.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/bluetooth/BluetoothRequirementWrapper.kt index 654edea..ac5f7ce 100644 --- a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/bluetooth/BluetoothRequirementWrapper.kt +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/bluetooth/BluetoothRequirementWrapper.kt @@ -1,20 +1,10 @@ package com.digitoolsolutions.app.torquevaultkmp.screens.bluetooth -import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.BluetoothDisabled -import androidx.compose.material.icons.filled.Security -import androidx.compose.material3.Button -import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -22,55 +12,62 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.text.style.TextAlign 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.HandlePermissionRequest +import com.digitoolsolutions.app.torquevaultkmp.utils.PermissionHandler import org.koin.compose.koinInject @Composable fun BluetoothRequirementWrapper( - content: @Composable () -> Unit + 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) { - HandlePermissionRequest { _ -> - showPermissionRequest = false - bluetoothManager.checkBluetoothPermission() + permissionHandler.HandlePermissionRequest( + { allGranted, denied -> + showPermissionRequest = false + bluetoothManager.checkBluetoothPermission() + hasPermanentlyDenied = denied + } + ) { + requestPermissions -> + LaunchedEffect(Unit) { + requestPermissions() + } } } LifecycleResumeEffect(Unit) { bluetoothManager.checkBluetoothState() bluetoothManager.checkBluetoothPermission() + permissionHandler.checkLocationState() onPauseOrDispose { } } - LaunchedEffect(isEnabled, hasPermission) { - if (!isEnabled && hasPermission) { - bluetoothManager.enableBluetooth() - } - } - - if (isEnabled && hasPermission) { - content() - } else { + /* MainScreen */ + content() + /* Overlay content */ + if (!isEnabled || !hasPermission || !isLocationEnabled) { BluetoothRequestScreen( isEnabled = isEnabled, hasPermission = hasPermission, + hasPermanentlyDenied = hasPermanentlyDenied, + isLocationEnabled = isLocationEnabled, onEnableBluetooth = { bluetoothManager.enableBluetooth() }, - onRequestPermission = { - bluetoothManager.enableBluetooth() - showPermissionRequest = true - }, + onEnableLocation = { permissionHandler.enableLocation() }, + onRequestPermission = { showPermissionRequest = true }, onOpenSettings = { bluetoothManager.openSettings() } ) } @@ -80,46 +77,28 @@ fun BluetoothRequirementWrapper( fun BluetoothRequestScreen( isEnabled: Boolean, hasPermission: Boolean, + hasPermanentlyDenied: Boolean, + isLocationEnabled: Boolean, onEnableBluetooth: () -> Unit, + onEnableLocation: () -> Unit, onRequestPermission: () -> Unit, onOpenSettings: () -> Unit ) { Column( - modifier = Modifier.fillMaxSize().padding(24.dp), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally - ) { - Icon( - imageVector = if (!hasPermission) Icons.Default.Security else Icons.Default.BluetoothDisabled, - contentDescription = null, - modifier = Modifier.size(80.dp), - tint = MaterialTheme.colorScheme.primary - ) - Spacer(modifier = Modifier.height(24.dp)) - Text( - text = if (!hasPermission) "Bluetooth Permission Required" else "Bluetooth is Disabled", - style = MaterialTheme.typography.headlineSmall, - textAlign = TextAlign.Center - ) - Spacer(modifier = Modifier.height(16.dp)) - Text( - text = if (!hasPermission) - "This app needs Bluetooth permissions to connect to your devices. Please grant permission." - else "Please enable Bluetooth to use this app's features.", - style = MaterialTheme.typography.bodyMedium, - textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Spacer(modifier = Modifier.height(32.dp)) - - if (!hasPermission) { - Button(onClick = onRequestPermission) { - Text("Request Permission") - } - } else if (!isEnabled) { - Button(onClick = onEnableBluetooth) { - Text("Enable Bluetooth") - } + 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) } } } diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/bluetooth/views/BluetoothDisabledView.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/bluetooth/views/BluetoothDisabledView.kt new file mode 100644 index 0000000..5969ad5 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/bluetooth/views/BluetoothDisabledView.kt @@ -0,0 +1,37 @@ +package com.digitoolsolutions.app.torquevaultkmp.screens.bluetooth.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 +import androidx.compose.material.icons.rounded.BluetoothDisabled +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 +import torquevaultkmp.composeapp.generated.resources.action_enable +import torquevaultkmp.composeapp.generated.resources.bluetooth_disabled_info +import torquevaultkmp.composeapp.generated.resources.bluetooth_disabled_title + +@Composable +internal fun BluetoothDisabledView( + onEnable: () -> Unit, +) { + WarningView( + imageVector = Icons.Rounded.BluetoothDisabled, + title = stringResource(Res.string.bluetooth_disabled_title), + hint = stringResource(Res.string.bluetooth_disabled_info), + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + ) { + Button(onClick = onEnable) { + Text(text = stringResource(Res.string.action_enable)) + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/bluetooth/views/BluetoothNotAvailableView.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/bluetooth/views/BluetoothNotAvailableView.kt new file mode 100644 index 0000000..9dbbe19 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/bluetooth/views/BluetoothNotAvailableView.kt @@ -0,0 +1,26 @@ +package com.digitoolsolutions.app.torquevaultkmp.screens.bluetooth.views + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +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.baseline_bluetooth_disabled_24 +import torquevaultkmp.composeapp.generated.resources.bluetooth_not_available_info +import torquevaultkmp.composeapp.generated.resources.bluetooth_not_available_title + +@Composable +internal fun BluetoothNotAvailableView() { + WarningView( + painterResource = painterResource(Res.drawable.baseline_bluetooth_disabled_24), + title = stringResource(Res.string.bluetooth_not_available_title), + hint = stringResource(Res.string.bluetooth_not_available_info), + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + ) +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/bluetooth/views/BluetoothPermissionRequiredView.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/bluetooth/views/BluetoothPermissionRequiredView.kt new file mode 100644 index 0000000..a27984f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/bluetooth/views/BluetoothPermissionRequiredView.kt @@ -0,0 +1,64 @@ +package com.digitoolsolutions.app.torquevaultkmp.screens.bluetooth.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 +import torquevaultkmp.composeapp.generated.resources.action_settings +import torquevaultkmp.composeapp.generated.resources.baseline_bluetooth_disabled_24 +import torquevaultkmp.composeapp.generated.resources.bluetooth_permission_info +import torquevaultkmp.composeapp.generated.resources.bluetooth_permission_title + +@Composable +internal fun BluetoothPermissionRequiredView( + hasPermission: Boolean, + hasPermanentlyDenied: Boolean, + onRequestPermission: () -> Unit, + onOpenSetting: () -> Unit +) { + WarningView( + imageVector = Icons.Rounded.BluetoothDisabled, + title = stringResource(Res.string.bluetooth_permission_title), + hint = stringResource(Res.string.bluetooth_permission_info), + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + ) { + when { + !hasPermission -> Button( + onClick = { onRequestPermission() } + ) { + Text(text = stringResource(Res.string.action_grant_permission)) + } + !hasPermanentlyDenied -> Button( + onClick = { onOpenSetting() } + ) { + Text(text = stringResource(Res.string.action_settings)) + } + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/bluetooth/views/LocationDisabledView.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/bluetooth/views/LocationDisabledView.kt new file mode 100644 index 0000000..b881a3d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/bluetooth/views/LocationDisabledView.kt @@ -0,0 +1,35 @@ +package com.digitoolsolutions.app.torquevaultkmp.screens.bluetooth.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.LocationOff +import androidx.compose.material3.Button +import androidx.compose.material3.Text +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.action_enable +import torquevaultkmp.composeapp.generated.resources.location_disabled_info +import torquevaultkmp.composeapp.generated.resources.location_disabled_title + +@Composable +internal fun LocationDisabledView( + onEnable: () -> Unit, +) { + WarningView( + imageVector = Icons.Rounded.LocationOff, + title = stringResource(Res.string.location_disabled_title), + hint = stringResource(Res.string.location_disabled_info), + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + ) { + Button(onClick = onEnable) { + Text(text = stringResource(Res.string.action_enable)) + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/bluetooth/views/LocationPermissionRequiredView.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/bluetooth/views/LocationPermissionRequiredView.kt new file mode 100644 index 0000000..018f3a9 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/bluetooth/views/LocationPermissionRequiredView.kt @@ -0,0 +1,52 @@ +package com.digitoolsolutions.app.torquevaultkmp.screens.bluetooth.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 +import torquevaultkmp.composeapp.generated.resources.action_grant_permission +import torquevaultkmp.composeapp.generated.resources.action_settings +import torquevaultkmp.composeapp.generated.resources.location_permission_info +import torquevaultkmp.composeapp.generated.resources.location_permission_title + +@Composable +internal fun LocationPermissionRequiredView( + hasPermission: Boolean, + hasPermanentlyDenied: Boolean, + onRequestPermission: () -> Unit, + onOpenSetting: () -> Unit +) { + WarningView( + imageVector = Icons.Rounded.LocationOff, + title = stringResource(Res.string.location_permission_title), + hint = stringResource(Res.string.location_permission_info), + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + ) { + when { + !hasPermission && !hasPermanentlyDenied -> { + Button(onClick = onRequestPermission) { + Text(stringResource(Res.string.action_grant_permission)) + } + } + !hasPermission && hasPermanentlyDenied -> { + Button(onClick = onOpenSetting) { + Text(stringResource(Res.string.action_settings)) + } + } + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/utils/PermissionHandler.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/utils/PermissionHandler.kt index bd90c03..385f995 100644 --- a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/utils/PermissionHandler.kt +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/utils/PermissionHandler.kt @@ -1,8 +1,15 @@ package com.digitoolsolutions.app.torquevaultkmp.utils import androidx.compose.runtime.Composable +import kotlinx.coroutines.flow.StateFlow -@Composable -expect fun HandlePermissionRequest( - onPermissionResult: (Boolean) -> Unit -) +interface PermissionHandler { + @Composable + fun HandlePermissionRequest( + onPermissionResult: (Boolean, Boolean) -> Unit, + content: @Composable (requestPermissions: () -> Unit) -> Unit + ) + val isLocationEnabled: StateFlow + fun enableLocation() + fun checkLocationState() +} diff --git a/composeApp/src/iosMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/di/PlatformModules.kt b/composeApp/src/iosMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/di/PlatformModules.kt index aa7ab7f..ac4c7f6 100644 --- a/composeApp/src/iosMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/di/PlatformModules.kt +++ b/composeApp/src/iosMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/di/PlatformModules.kt @@ -7,11 +7,14 @@ import com.digitoolsolutions.app.torquevaultkmp.data.storage.AppStorage import com.digitoolsolutions.app.torquevaultkmp.data.storage.db.AppDatabase import com.digitoolsolutions.app.torquevaultkmp.utils.BluetoothManager import com.digitoolsolutions.app.torquevaultkmp.utils.IosBluetoothManager +import com.digitoolsolutions.app.torquevaultkmp.utils.PermissionHandler +import com.digitoolsolutions.app.torquevaultkmp.utils.PermissionHandlerPlatform import org.koin.dsl.module actual val platformModule = module { single { AppStoragePlatform() } single { IosBluetoothManager() } + single { PermissionHandlerPlatform() } single { NativeSqliteDriver( schema = AppDatabase.Schema, diff --git a/composeApp/src/iosMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/utils/BluetoothManager.ios.kt b/composeApp/src/iosMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/utils/BluetoothManager.ios.kt index ff54a1b..0e60641 100644 --- a/composeApp/src/iosMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/utils/BluetoothManager.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/utils/BluetoothManager.ios.kt @@ -65,19 +65,8 @@ class IosBluetoothManager : BluetoothManager { val state = centralManager.state if (state == CBCentralManagerStatePoweredOff) { - // Attempt to open Bluetooth settings directly via App-Prefs (best effort) - val bluetoothUrl = NSURL.URLWithString("App-Prefs:root=Bluetooth") - dispatch_async(dispatch_get_main_queue()) { - val app = UIApplication.sharedApplication - if (bluetoothUrl != null && app.canOpenURL(bluetoothUrl)) { - app.openURL(bluetoothUrl, mapOf(), null) - } else { - // Fallback: Trigger the system's "Bluetooth is Off" power alert - // This alert has a "Settings" button that goes to the right place. - centralManager.scanForPeripheralsWithServices(null, null) - centralManager.stopScan() - } - } + // Bluetooth is off + showBluetoothOffAlert() } else if (auth == CBManagerAuthorizationNotDetermined) { // Accessing the state or scanning will trigger the system prompt centralManager.scanForPeripheralsWithServices(null, null) @@ -85,6 +74,17 @@ class IosBluetoothManager : BluetoothManager { checkBluetoothState() } } + + fun showBluetoothOffAlert() { + val alert = UIAlertController.alertControllerWithTitle( + "Bluetooth is Off", + "Please enable Bluetooth in Settings > Bluetooth to use this feature.", + UIAlertControllerStyleAlert + ) + alert.addAction(UIAlertAction.actionWithTitle("Close", UIAlertActionStyleDefault, null)) + + UIApplication.sharedApplication.keyWindow?.rootViewController?.presentViewController(alert, true, null) + } } actual fun createBluetoothManager(): BluetoothManager = IosBluetoothManager() diff --git a/composeApp/src/iosMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/utils/PermissionHandler.ios.kt b/composeApp/src/iosMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/utils/PermissionHandler.ios.kt index f29c971..ba0ecc0 100644 --- a/composeApp/src/iosMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/utils/PermissionHandler.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/utils/PermissionHandler.ios.kt @@ -2,21 +2,34 @@ package com.digitoolsolutions.app.torquevaultkmp.utils import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import org.koin.compose.koinInject -@Composable -actual fun HandlePermissionRequest( - onPermissionResult: (Boolean) -> Unit -) { - val bluetoothManager: BluetoothManager = koinInject() - - LaunchedEffect(Unit) { - // Trigger enableBluetooth which on iOS will touch the CBCentralManager - // and show the permission prompt if it's currently "Not Determined" - bluetoothManager.enableBluetooth() - - // We call the result immediately, but the StateFlows in BluetoothManager - // will update asynchronously via the CBCentralManagerDelegate - onPermissionResult(bluetoothManager.hasBluetoothPermission.value) +class PermissionHandlerPlatform: PermissionHandler { + @Composable + override fun HandlePermissionRequest( + onPermissionResult: (Boolean, Boolean) -> Unit, + content: @Composable (requestPermissions: () -> Unit) -> Unit + ) { + val bluetoothManager: BluetoothManager = koinInject() + + LaunchedEffect(Unit) { + // Trigger enableBluetooth which on iOS will touch the CBCentralManager + // and show the permission prompt if it's currently "Not Determined" + bluetoothManager.enableBluetooth() + + // We call the result immediately, but the StateFlows in BluetoothManager + // will update asynchronously via the CBCentralManagerDelegate + + val granted = bluetoothManager.hasBluetoothPermission.value + val denied = !granted + onPermissionResult(granted, denied) + } } -} + private val _isLocationEnabled = MutableStateFlow(true) + override val isLocationEnabled: StateFlow = _isLocationEnabled.asStateFlow() + override fun enableLocation() { /* no-op */ } + override fun checkLocationState() { /* no-op */ } +} \ No newline at end of file