diff --git a/app/build.gradle b/app/build.gradle index ac2b5294..df211256 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -51,8 +51,10 @@ dependencies { //Hilt requires to implement every module in the main app module //https://github.com/google/dagger/issues/2123 implementation project(":feature_csc") - implementation project(":lib_theme") + implementation project(":feature_hrs") implementation project(':feature_scanner') + implementation project(":lib_theme") + implementation project(":lib_utils") implementation libs.nordic.ble.common diff --git a/app/src/main/java/no/nordicsemi/android/nrftoolbox/FeatureButton.kt b/app/src/main/java/no/nordicsemi/android/nrftoolbox/FeatureButton.kt new file mode 100644 index 00000000..6aab9719 --- /dev/null +++ b/app/src/main/java/no/nordicsemi/android/nrftoolbox/FeatureButton.kt @@ -0,0 +1,54 @@ +package no.nordicsemi.android.nrftoolbox + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import no.nordicsemi.android.theme.NordicColors + +@Composable +fun FeatureButton(@DrawableRes iconId: Int, @StringRes nameId: Int, onClick: () -> Unit) { + Button( + modifier = Modifier.fillMaxWidth(), + onClick = { onClick() }, + colors = ButtonDefaults.buttonColors(backgroundColor = NordicColors.NordicGray4.value()), + ) { + Image( + painter = painterResource(iconId), + contentDescription = stringResource(id = nameId), + contentScale = ContentScale.Crop, + modifier = Modifier + .size(64.dp) + .clip(CircleShape) + .background(Color.White) + ) + Row( + modifier = Modifier + .padding(16.dp) + .fillMaxWidth(), + horizontalArrangement = Arrangement.Center + ) { + Text( + text = stringResource(id = nameId), + modifier = Modifier.padding(16.dp), + ) + } + } +} diff --git a/app/src/main/java/no/nordicsemi/android/nrftoolbox/HomeScreen.kt b/app/src/main/java/no/nordicsemi/android/nrftoolbox/HomeScreen.kt index b6d77b5a..9f32178b 100644 --- a/app/src/main/java/no/nordicsemi/android/nrftoolbox/HomeScreen.kt +++ b/app/src/main/java/no/nordicsemi/android/nrftoolbox/HomeScreen.kt @@ -1,86 +1,105 @@ package no.nordicsemi.android.nrftoolbox -import androidx.annotation.DrawableRes -import androidx.annotation.StringRes -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement +import androidx.activity.OnBackPressedCallback +import androidx.activity.compose.LocalOnBackPressedDispatcherOwner import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.Button -import androidx.compose.material.ButtonDefaults import androidx.compose.material.Text import androidx.compose.material.TopAppBar import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.res.painterResource +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.navigation.NavController +import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController -import no.nordicsemi.android.csc.CSCRoute +import no.nordicsemi.android.csc.view.CscScreen +import no.nordicsemi.android.hrs.view.HRSScreen +import no.nordicsemi.android.scanner.view.BluetoothNotAvailableScreen +import no.nordicsemi.android.scanner.view.BluetoothNotEnabledScreen +import no.nordicsemi.android.scanner.view.RequestPermissionScreen +import no.nordicsemi.android.scanner.view.ScanDeviceScreen +import no.nordicsemi.android.scanner.view.ScanDeviceScreenResult +import no.nordicsemi.android.utils.exhaustive @Composable fun HomeScreen() { val navController = rememberNavController() - NavHost(navController = navController, startDestination = "home") { - composable("home") { HomeView(navController) } - composable("csc-route") { CSCRoute() } + val viewModel = hiltViewModel() + val continueAction: () -> Unit = { viewModel.finish() } + val state = viewModel.state.collectAsState().value + + BackHandler { viewModel.navigateUp() } + + NavHost(navController = navController, startDestination = NavDestination.HOME.id) { + composable(NavDestination.HOME.id) { HomeView { viewModel.navigate(it) } } + composable(NavDestination.CSC.id) { CscScreen { viewModel.navigateUp() } } + composable(NavDestination.HRS.id) { HRSScreen { viewModel.navigateUp() } } + composable(NavDestination.REQUEST_PERMISSION.id) { RequestPermissionScreen(continueAction) } + composable(NavDestination.BLUETOOTH_NOT_AVAILABLE.id) { BluetoothNotAvailableScreen() } + composable(NavDestination.BLUETOOTH_NOT_ENABLED.id) { + BluetoothNotEnabledScreen(continueAction) + } + composable(NavDestination.DEVICE_NOT_CONNECTED.id) { + ScanDeviceScreen { + when (it) { + ScanDeviceScreenResult.SUCCESS -> viewModel.finish() + ScanDeviceScreenResult.CANCEL -> viewModel.navigateUp() + }.exhaustive + } + } + } + + LaunchedEffect(state) { + navController.navigate(state.id) } } @Composable -fun HomeView(navHostController: NavController) { +fun HomeView(callback: (NavDestination) -> Unit) { Column { TopAppBar(title = { Text(text = stringResource(id = R.string.app_name)) }) - FeatureButton(R.drawable.ic_csc, R.string.csc_module) { navHostController.navigate("csc-route") } + FeatureButton(R.drawable.ic_csc, R.string.csc_module) { callback(NavDestination.CSC) } + FeatureButton(R.drawable.ic_hrs, R.string.hrs_module) { callback(NavDestination.HRS) } } } @Composable -fun FeatureButton(@DrawableRes iconId: Int, @StringRes nameId: Int, onClick: () -> Unit) { - Button( - modifier = Modifier.fillMaxWidth(), - onClick = { onClick() }, - colors = ButtonDefaults.buttonColors(backgroundColor = Color.Transparent) - ) { - Image( - painter = painterResource(iconId), - contentDescription = stringResource(id = nameId), - contentScale = ContentScale.Crop, - modifier = Modifier - .size(64.dp) - .clip(CircleShape) - .background(Color.White) - ) - Row( - modifier = Modifier - .padding(16.dp) - .fillMaxWidth(), - horizontalArrangement = Arrangement.Center - ) { - Text( - text = stringResource(id = nameId), - modifier = Modifier.padding(16.dp), - ) +private fun BackHandler(enabled: Boolean = true, onBack: () -> Unit) { + val currentOnBack = rememberUpdatedState(onBack) + val backCallback = remember { + object : OnBackPressedCallback(enabled) { + override fun handleOnBackPressed() { + currentOnBack.value() + } + } + } + SideEffect { + backCallback.isEnabled = enabled + } + val backDispatcher = checkNotNull(LocalOnBackPressedDispatcherOwner.current) { + "No OnBackPressedDispatcherOwner was provided via LocalOnBackPressedDispatcherOwner" + }.onBackPressedDispatcher + val lifecycleOwner = LocalLifecycleOwner.current + DisposableEffect(lifecycleOwner, backDispatcher) { + backDispatcher.addCallback(lifecycleOwner, backCallback) + onDispose { + backCallback.remove() } } } + @Preview(showBackground = true) @Composable fun DefaultPreview() { - HomeView(rememberNavController()) + HomeView { } } diff --git a/app/src/main/java/no/nordicsemi/android/nrftoolbox/NavDestination.kt b/app/src/main/java/no/nordicsemi/android/nrftoolbox/NavDestination.kt new file mode 100644 index 00000000..19b87a19 --- /dev/null +++ b/app/src/main/java/no/nordicsemi/android/nrftoolbox/NavDestination.kt @@ -0,0 +1,11 @@ +package no.nordicsemi.android.nrftoolbox + +enum class NavDestination(val id: String) { + HOME("home-screen"), + CSC("csc-screen"), + HRS("hrs-screen"), + REQUEST_PERMISSION("request-permission"), + BLUETOOTH_NOT_AVAILABLE("bluetooth-not-available"), + BLUETOOTH_NOT_ENABLED("bluetooth-not-enabled"), + DEVICE_NOT_CONNECTED("device-not-connected"), +} diff --git a/app/src/main/java/no/nordicsemi/android/nrftoolbox/NavigationViewModel.kt b/app/src/main/java/no/nordicsemi/android/nrftoolbox/NavigationViewModel.kt new file mode 100644 index 00000000..cc9ce9ff --- /dev/null +++ b/app/src/main/java/no/nordicsemi/android/nrftoolbox/NavigationViewModel.kt @@ -0,0 +1,58 @@ +package no.nordicsemi.android.nrftoolbox + +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import no.nordicsemi.android.scanner.tools.NordicBleScanner +import no.nordicsemi.android.scanner.tools.PermissionHelper +import no.nordicsemi.android.scanner.tools.ScannerStatus +import no.nordicsemi.android.service.SelectedBluetoothDeviceHolder +import no.nordicsemi.android.scanner.viewmodel.BluetoothPermissionState +import javax.inject.Inject + +@HiltViewModel +class NavigationViewModel @Inject constructor( + private val bleScanner: NordicBleScanner, + private val permissionHelper: PermissionHelper, + private val selectedDevice: no.nordicsemi.android.service.SelectedBluetoothDeviceHolder +): ViewModel() { + + val state= MutableStateFlow(NavDestination.HOME) + private var targetDestination = NavDestination.HOME + + fun navigate(destination: NavDestination) { + targetDestination = destination + navigateToNextScreen() + } + + fun navigateUp() { + targetDestination = NavDestination.HOME + state.value = NavDestination.HOME + } + + fun finish() { + if (state.value != targetDestination) { + navigateToNextScreen() + } + } + + private fun getBluetoothState(): BluetoothPermissionState { + return if (!permissionHelper.isRequiredPermissionGranted()) { + BluetoothPermissionState.PERMISSION_REQUIRED + } else when (bleScanner.getBluetoothStatus()) { + ScannerStatus.NOT_AVAILABLE -> BluetoothPermissionState.BLUETOOTH_NOT_AVAILABLE + ScannerStatus.DISABLED -> BluetoothPermissionState.BLUETOOTH_NOT_ENABLED + ScannerStatus.ENABLED -> selectedDevice.device?.let { BluetoothPermissionState.READY } ?: BluetoothPermissionState.DEVICE_NOT_CONNECTED + } + } + + private fun navigateToNextScreen() { + state.value = when (getBluetoothState()) { + BluetoothPermissionState.PERMISSION_REQUIRED -> NavDestination.REQUEST_PERMISSION + BluetoothPermissionState.BLUETOOTH_NOT_AVAILABLE -> NavDestination.BLUETOOTH_NOT_AVAILABLE + BluetoothPermissionState.BLUETOOTH_NOT_ENABLED -> NavDestination.BLUETOOTH_NOT_ENABLED + BluetoothPermissionState.DEVICE_NOT_CONNECTED -> NavDestination.DEVICE_NOT_CONNECTED + BluetoothPermissionState.READY -> targetDestination + } + } +} diff --git a/app/src/main/res/drawable/ic_hrs.xml b/app/src/main/res/drawable/ic_hrs.xml new file mode 100644 index 00000000..e4324138 --- /dev/null +++ b/app/src/main/res/drawable/ic_hrs.xml @@ -0,0 +1,4 @@ + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml deleted file mode 100644 index eca70cfe..00000000 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml deleted file mode 100644 index eca70cfe..00000000 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp deleted file mode 100644 index c209e78e..00000000 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher.webp and /dev/null differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp deleted file mode 100644 index b2dfe3d1..00000000 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp and /dev/null differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp deleted file mode 100644 index 4f0f1d64..00000000 Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher.webp and /dev/null differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp deleted file mode 100644 index 62b611da..00000000 Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp and /dev/null differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp deleted file mode 100644 index 948a3070..00000000 Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp and /dev/null differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp deleted file mode 100644 index 1b9a6956..00000000 Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp and /dev/null differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp deleted file mode 100644 index 28d4b77f..00000000 Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp and /dev/null differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp deleted file mode 100644 index 9287f508..00000000 Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp and /dev/null differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp deleted file mode 100644 index aa7d6427..00000000 Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp and /dev/null differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp deleted file mode 100644 index 9126ae37..00000000 Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp and /dev/null differ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fadc34db..7abbce26 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,5 +1,4 @@ - nRF Toolbox - CSC + HRS \ No newline at end of file diff --git a/feature_csc/build.gradle b/feature_csc/build.gradle index 0b49fa39..d397c91b 100644 --- a/feature_csc/build.gradle +++ b/feature_csc/build.gradle @@ -4,7 +4,6 @@ apply plugin: 'kotlin-parcelize' dependencies { implementation project(":lib_service") implementation project(":lib_theme") - implementation project(':feature_scanner') implementation project(":lib_utils") implementation libs.nordic.ble.common diff --git a/feature_csc/src/main/AndroidManifest.xml b/feature_csc/src/main/AndroidManifest.xml index aaee1182..bca3c340 100644 --- a/feature_csc/src/main/AndroidManifest.xml +++ b/feature_csc/src/main/AndroidManifest.xml @@ -1,12 +1,9 @@ - diff --git a/feature_csc/src/main/java/no/nordicsemi/android/csc/CscNavigation.kt b/feature_csc/src/main/java/no/nordicsemi/android/csc/CscNavigation.kt deleted file mode 100644 index be8462b2..00000000 --- a/feature_csc/src/main/java/no/nordicsemi/android/csc/CscNavigation.kt +++ /dev/null @@ -1,19 +0,0 @@ -package no.nordicsemi.android.csc - -import androidx.compose.runtime.Composable -import androidx.navigation.compose.NavHost -import androidx.navigation.compose.composable -import androidx.navigation.compose.rememberNavController -import no.nordicsemi.android.csc.view.CscScreen -import no.nordicsemi.android.scanner.ScannerRoute - -@Composable -fun CSCRoute() { - - val navController = rememberNavController() - - NavHost(navController = navController, startDestination = "csc_screen") { - composable("csc_screen") { CscScreen(navController) } - composable("scanner-destination") { ScannerRoute(navController) } - } -} diff --git a/feature_csc/src/main/java/no/nordicsemi/android/csc/events/CSCServiceEvent.kt b/feature_csc/src/main/java/no/nordicsemi/android/csc/events/CSCServiceEvent.kt index 64f7de80..41674a26 100644 --- a/feature_csc/src/main/java/no/nordicsemi/android/csc/events/CSCServiceEvent.kt +++ b/feature_csc/src/main/java/no/nordicsemi/android/csc/events/CSCServiceEvent.kt @@ -4,10 +4,10 @@ import android.bluetooth.BluetoothDevice import android.os.Parcelable import kotlinx.parcelize.Parcelize -sealed class CSCServiceEvent : Parcelable +internal sealed class CSCServiceEvent : Parcelable @Parcelize -data class OnDistanceChangedEvent( +internal data class OnDistanceChangedEvent( val bluetoothDevice: BluetoothDevice, val speed: Float, val distance: Float, @@ -15,14 +15,14 @@ data class OnDistanceChangedEvent( ) : CSCServiceEvent() @Parcelize -data class CrankDataChanged( +internal data class CrankDataChanged( val bluetoothDevice: BluetoothDevice, val crankCadence: Int, val gearRatio: Float ) : CSCServiceEvent() @Parcelize -data class OnBatteryLevelChanged( +internal data class OnBatteryLevelChanged( val device: BluetoothDevice, val batteryLevel: Int ) : CSCServiceEvent() diff --git a/feature_csc/src/main/java/no/nordicsemi/android/csc/service/CSCDataReadBroadcast.kt b/feature_csc/src/main/java/no/nordicsemi/android/csc/service/CSCDataReadBroadcast.kt index 930d1d8c..9a854e5e 100644 --- a/feature_csc/src/main/java/no/nordicsemi/android/csc/service/CSCDataReadBroadcast.kt +++ b/feature_csc/src/main/java/no/nordicsemi/android/csc/service/CSCDataReadBroadcast.kt @@ -9,7 +9,7 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class CSCDataReadBroadcast @Inject constructor() : BluetoothDataReadBroadcast() { +internal class CSCDataReadBroadcast @Inject constructor() : BluetoothDataReadBroadcast() { private val _wheelSize = MutableSharedFlow( replay = 1, diff --git a/feature_csc/src/main/java/no/nordicsemi/android/csc/service/CSCManager.kt b/feature_csc/src/main/java/no/nordicsemi/android/csc/service/CSCManager.kt index 22a11caa..2e90c1cf 100644 --- a/feature_csc/src/main/java/no/nordicsemi/android/csc/service/CSCManager.kt +++ b/feature_csc/src/main/java/no/nordicsemi/android/csc/service/CSCManager.kt @@ -35,6 +35,12 @@ import no.nordicsemi.android.log.LogContract import no.nordicsemi.android.service.BatteryManager import java.util.* +/** Cycling Speed and Cadence service UUID. */ +private val CYCLING_SPEED_AND_CADENCE_SERVICE_UUID = UUID.fromString("00001816-0000-1000-8000-00805f9b34fb") + +/** Cycling Speed and Cadence Measurement characteristic UUID. */ +private val CSC_MEASUREMENT_CHARACTERISTIC_UUID = UUID.fromString("00002A5B-0000-1000-8000-00805f9b34fb") + internal class CSCManager(context: Context) : BatteryManager(context) { private var cscMeasurementCharacteristic: BluetoothGattCharacteristic? = null @@ -114,14 +120,4 @@ internal class CSCManager(context: Context) : BatteryManager Unit) { + if (state.showDialog) { + SelectWheelSizeDialog { onEvent(it) } + } + + Column( + modifier = Modifier.padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + SettingsSection(state, onEvent) + + Spacer(modifier = Modifier.height(16.dp)) + + SensorsReadingView(state = state) + + Spacer(modifier = Modifier.height(16.dp)) + + Button( + colors = ButtonDefaults.buttonColors(backgroundColor = MaterialTheme.colors.secondary), + onClick = { onEvent(OnDisconnectButtonClick) } + ) { + Text(text = stringResource(id = R.string.disconnect)) + } + } +} + +@Composable +private fun SettingsSection(state: CSCViewState, onEvent: (CSCViewEvent) -> Unit) { + Card( + backgroundColor = NordicColors.NordicGray4.value(), + shape = RoundedCornerShape(10.dp), + elevation = 0.dp + ) { + Column( + modifier = Modifier.padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + WheelSizeView(state, onEvent) + + Spacer(modifier = Modifier.height(16.dp)) + + SpeedUnitRadioGroup(state.selectedSpeedUnit) { onEvent(it) } + } + } +} + +@Preview +@Composable +private fun ConnectedPreview() { + ContentView(CSCViewState()) { } +} diff --git a/feature_csc/src/main/java/no/nordicsemi/android/csc/view/CscScreen.kt b/feature_csc/src/main/java/no/nordicsemi/android/csc/view/CscScreen.kt index 3e5311b7..12dae03e 100644 --- a/feature_csc/src/main/java/no/nordicsemi/android/csc/view/CscScreen.kt +++ b/feature_csc/src/main/java/no/nordicsemi/android/csc/view/CscScreen.kt @@ -1,143 +1,52 @@ package no.nordicsemi.android.csc.view -import android.bluetooth.BluetoothDevice import android.content.Intent -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.material.Button import androidx.compose.material.Text import androidx.compose.material.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel -import androidx.navigation.NavController import no.nordicsemi.android.csc.R import no.nordicsemi.android.csc.service.CSCService +import no.nordicsemi.android.csc.viewmodel.CSCViewState import no.nordicsemi.android.csc.viewmodel.CscViewModel -import no.nordicsemi.android.utils.exhaustive import no.nordicsemi.android.utils.isServiceRunning @Composable -internal fun CscScreen(navController: NavController, viewModel: CscViewModel = hiltViewModel()) { - - val secondScreenResult = navController.currentBackStackEntry - ?.savedStateHandle - ?.getLiveData("result")?.observeAsState() - - secondScreenResult?.value?.let { - viewModel.onEvent(OnBluetoothDeviceSelected(it)) - - navController.currentBackStackEntry - ?.savedStateHandle - ?.set("result", null) - } - +fun CscScreen(finishAction: () -> Unit) { + val viewModel: CscViewModel = hiltViewModel() val state = viewModel.state.collectAsState().value - CSCView(navController, state) { viewModel.onEvent(it) } + val context = LocalContext.current + LaunchedEffect(state.isScreenActive) { + if (!state.isScreenActive) { + finishAction() + } + if (context.isServiceRunning(CSCService::class.java.name)) { + val intent = Intent(context, CSCService::class.java) + context.stopService(intent) + } + } + + LaunchedEffect("start-service") { + if (!context.isServiceRunning(CSCService::class.java.name)) { + val intent = Intent(context, CSCService::class.java) + context.startService(intent) + } + } + + CSCView(state) { viewModel.onEvent(it) } } @Composable -private fun CSCView(navController: NavController, state: CSCViewState, onEvent: (CSCViewEvent) -> Unit) { +private fun CSCView(state: CSCViewState, onEvent: (CSCViewEvent) -> Unit) { Column { TopAppBar(title = { Text(text = stringResource(id = R.string.csc_title)) }) - when (state) { - is CSCViewConnectedState -> ConnectedView(state) { onEvent(it) } - is CSCViewNotConnectedState -> NotConnectedScreen(navController, state) { - onEvent(it) - } - }.exhaustive + ContentView(state) { onEvent(it) } } } - -@Composable -private fun NotConnectedScreen( - navController: NavController, - state: CSCViewNotConnectedState, - onEvent: (CSCViewEvent) -> Unit -) { - if (state.showScannerDialog) { - navController.navigate("scanner-destination") - onEvent(OnMovedToScannerScreen) - } - - if (LocalContext.current.isServiceRunning(CSCService::class.java.name)) { - val intent = Intent(LocalContext.current, CSCService::class.java) - LocalContext.current.stopService(intent) - } - - NotConnectedView(onEvent) - - LocalContext.current.stopService(Intent(LocalContext.current, CSCService::class.java)) -} - -@Composable -private fun NotConnectedView( - onEvent: (CSCViewEvent) -> Unit -) { - Column( - modifier = Modifier - .fillMaxWidth() - .fillMaxHeight(), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text(text = stringResource(id = R.string.csc_no_connection)) - Spacer(modifier = Modifier.height(16.dp)) - Button(onClick = { onEvent(OnConnectButtonClick) }) { - Text(text = stringResource(id = R.string.csc_connect)) - } - } -} - -@Composable -private fun ConnectedView(state: CSCViewConnectedState, onEvent: (CSCViewEvent) -> Unit) { - if (state.showDialog) { - SelectWheelSizeDialog { onEvent(it) } - } - - if (!LocalContext.current.isServiceRunning(CSCService::class.java.name)) { - val intent = Intent(LocalContext.current, CSCService::class.java) - LocalContext.current.startService(intent) - } - - Column( - modifier = Modifier.padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - WheelSizeView(state, onEvent) - - SpeedUnitRadioGroup(state.selectedSpeedUnit) { onEvent(it) } - - SensorsReadingView(state = state) - - Button(onClick = { onEvent(OnDisconnectButtonClick) }) { - Text(text = stringResource(id = R.string.csc_disconnect)) - } - } -} - -@Preview -@Composable -private fun NotConnectedPreview() { - NotConnectedView { } -} - -@Preview -@Composable -private fun ConnectedPreview() { - ConnectedView(CSCViewConnectedState()) { } -} diff --git a/feature_csc/src/main/java/no/nordicsemi/android/csc/view/SelectWheelSizeDialog.kt b/feature_csc/src/main/java/no/nordicsemi/android/csc/view/SelectWheelSizeDialog.kt index 5280fc6d..a4cc852b 100644 --- a/feature_csc/src/main/java/no/nordicsemi/android/csc/view/SelectWheelSizeDialog.kt +++ b/feature_csc/src/main/java/no/nordicsemi/android/csc/view/SelectWheelSizeDialog.kt @@ -1,18 +1,29 @@ package no.nordicsemi.android.csc.view import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Card +import androidx.compose.material.TabRowDefaults.Divider import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringArrayResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog import no.nordicsemi.android.csc.R -import no.nordicsemi.android.theme.Background +import no.nordicsemi.android.theme.NordicColors +import no.nordicsemi.android.theme.NordicColors.NordicLightGray import no.nordicsemi.android.theme.TestTheme @Composable @@ -27,13 +38,47 @@ private fun SelectWheelSizeView(onEvent: (OnWheelSizeSelected) -> Unit) { val wheelEntries = stringArrayResource(R.array.wheel_entries) val wheelValues = stringArrayResource(R.array.wheel_values) - Box(Modifier.padding(16.dp)) { - Column(modifier = Background.whiteRoundedCorners()) { - Text(text = "Wheel size") - wheelEntries.forEachIndexed { i, entry -> - Text(text = entry, modifier = Modifier.clickable { - onEvent(OnWheelSizeSelected(wheelValues[i].toInt(), entry)) - }) + Card( + modifier = Modifier.height(300.dp), + backgroundColor = NordicColors.NordicGray4.value(), + shape = RoundedCornerShape(10.dp), + elevation = 0.dp + ) { + Column { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "Wheel size", + fontSize = 28.sp, + fontWeight = FontWeight.Bold + ) + } + + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .padding(16.dp) + ) { + + wheelEntries.forEachIndexed { i, entry -> + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = entry, + fontSize = 16.sp, + modifier = Modifier + .fillMaxWidth() + .clickable { + onEvent(OnWheelSizeSelected(wheelValues[i].toInt(), entry)) + } + ) + + if (i != wheelEntries.size - 1) { + Spacer(modifier = Modifier.height(4.dp)) + Divider(color = NordicLightGray.value(), thickness = 1.dp/2) + } + } } } } diff --git a/feature_csc/src/main/java/no/nordicsemi/android/csc/view/SensorsReadingView.kt b/feature_csc/src/main/java/no/nordicsemi/android/csc/view/SensorsReadingView.kt index 893cd4af..59e5c568 100644 --- a/feature_csc/src/main/java/no/nordicsemi/android/csc/view/SensorsReadingView.kt +++ b/feature_csc/src/main/java/no/nordicsemi/android/csc/view/SensorsReadingView.kt @@ -1,55 +1,52 @@ package no.nordicsemi.android.csc.view -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.material.Text +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Card import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import no.nordicsemi.android.csc.R -import no.nordicsemi.android.theme.Background +import no.nordicsemi.android.csc.viewmodel.CSCViewState +import no.nordicsemi.android.theme.NordicColors +import no.nordicsemi.android.theme.view.BatteryLevelView +import no.nordicsemi.android.theme.view.KeyValueField @Composable -internal fun SensorsReadingView(state: CSCViewConnectedState) { - Column { - Column(modifier = Background.whiteRoundedCorners()) { +internal fun SensorsReadingView(state: CSCViewState) { + Card( + backgroundColor = NordicColors.NordicGray4.value(), + shape = RoundedCornerShape(10.dp), + elevation = 0.dp + ) { + Column(modifier = Modifier.padding(16.dp)) { KeyValueField(stringResource(id = R.string.scs_field_speed), state.displaySpeed()) + Spacer(modifier = Modifier.height(4.dp)) KeyValueField(stringResource(id = R.string.scs_field_cadence), state.displayCadence()) + Spacer(modifier = Modifier.height(4.dp)) KeyValueField(stringResource(id = R.string.scs_field_distance), state.displayDistance()) + Spacer(modifier = Modifier.height(4.dp)) KeyValueField( stringResource(id = R.string.scs_field_total_distance), state.displayTotalDistance() ) - KeyValueField(stringResource(id = R.string.scs_field_gear_ratio), state.displaySpeed()) - } - - Spacer(modifier = Modifier.height(16.dp)) - - Column(modifier = Background.whiteRoundedCorners()) { - KeyValueField(stringResource(id = R.string.scs_field_battery), state.displayBatteryLever()) + Spacer(modifier = Modifier.height(4.dp)) + KeyValueField(stringResource(id = R.string.scs_field_gear_ratio), state.displayGearRatio()) } } -} -@Composable -private fun KeyValueField(key: String, value: String) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text(text = key) - Text(text = value) - } + Spacer(modifier = Modifier.height(16.dp)) + + BatteryLevelView(state.batteryLevel) } @Preview @Composable private fun Preview() { - SensorsReadingView(CSCViewConnectedState()) + SensorsReadingView(CSCViewState()) } diff --git a/feature_csc/src/main/java/no/nordicsemi/android/csc/view/SpeedUnitRadioGroup.kt b/feature_csc/src/main/java/no/nordicsemi/android/csc/view/SpeedUnitRadioGroup.kt index bb10069c..36b51e83 100644 --- a/feature_csc/src/main/java/no/nordicsemi/android/csc/view/SpeedUnitRadioGroup.kt +++ b/feature_csc/src/main/java/no/nordicsemi/android/csc/view/SpeedUnitRadioGroup.kt @@ -2,8 +2,10 @@ package no.nordicsemi.android.csc.view import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.material.RadioButton import androidx.compose.material.Text import androidx.compose.runtime.Composable @@ -16,7 +18,7 @@ internal fun SpeedUnitRadioGroup( onEvent: (OnSelectedSpeedUnitSelected) -> Unit ) { Row( - modifier = Modifier.fillMaxWidth().padding(16.dp), + modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly ) { SpeedUnitRadioButton(currentUnit, SpeedUnit.KM_H, onEvent) @@ -36,6 +38,7 @@ internal fun SpeedUnitRadioButton( selected = (selectedUnit == displayedUnit), onClick = { onEvent(OnSelectedSpeedUnitSelected(displayedUnit)) } ) + Spacer(modifier = Modifier.width(4.dp)) Text(text = createSpeedUnitLabel(displayedUnit)) } } diff --git a/feature_csc/src/main/java/no/nordicsemi/android/csc/view/WheelSizeView.kt b/feature_csc/src/main/java/no/nordicsemi/android/csc/view/WheelSizeView.kt index 909018ef..76f69dd1 100644 --- a/feature_csc/src/main/java/no/nordicsemi/android/csc/view/WheelSizeView.kt +++ b/feature_csc/src/main/java/no/nordicsemi/android/csc/view/WheelSizeView.kt @@ -12,9 +12,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import no.nordicsemi.android.csc.R +import no.nordicsemi.android.csc.viewmodel.CSCViewState @Composable -internal fun WheelSizeView(state: CSCViewConnectedState, onEvent: (CSCViewEvent) -> Unit) { +internal fun WheelSizeView(state: CSCViewState, onEvent: (CSCViewEvent) -> Unit) { OutlinedTextField( modifier = Modifier.fillMaxWidth(), value = state.wheelSize, @@ -35,5 +36,5 @@ private fun EditIcon(onEvent: (CSCViewEvent) -> Unit) { @Preview @Composable private fun WheelSizeViewPreview() { - WheelSizeView(CSCViewConnectedState()) { } + WheelSizeView(CSCViewState()) { } } diff --git a/feature_csc/src/main/java/no/nordicsemi/android/csc/viewmodel/CSCViewConnectedState.kt b/feature_csc/src/main/java/no/nordicsemi/android/csc/viewmodel/CSCViewConnectedState.kt new file mode 100644 index 00000000..b63c5970 --- /dev/null +++ b/feature_csc/src/main/java/no/nordicsemi/android/csc/viewmodel/CSCViewConnectedState.kt @@ -0,0 +1,58 @@ +package no.nordicsemi.android.csc.viewmodel + +import no.nordicsemi.android.csc.view.CSCSettings +import no.nordicsemi.android.csc.view.SpeedUnit +import java.util.* + +internal data class CSCViewState( + val showDialog: Boolean = false, + val scanDevices: Boolean = false, + val selectedSpeedUnit: SpeedUnit = SpeedUnit.M_S, + val speed: Float = 0f, + val cadence: Int = 0, + val distance: Float = 0f, + val totalDistance: Float = 0f, + val gearRatio: Float = 0f, + val batteryLevel: Int = 0, + val wheelSize: String = CSCSettings.DefaultWheelSize.NAME, + val isScreenActive: Boolean = true +) { + + private val speedWithUnit = when (selectedSpeedUnit) { + SpeedUnit.M_S -> speed + SpeedUnit.KM_H -> speed * 3.6f + SpeedUnit.MPH -> speed * 2.2369f + } + + fun displaySpeed(): String { + return when (selectedSpeedUnit) { + SpeedUnit.M_S -> String.format("%.1f m/s", speedWithUnit) + SpeedUnit.KM_H -> String.format("%.1f km/h", speedWithUnit) + SpeedUnit.MPH -> String.format("%.1f mph", speedWithUnit) + } + } + + fun displayCadence(): String { + return String.format("%d RPM", cadence) + } + + fun displayDistance(): String { + return when (selectedSpeedUnit) { + SpeedUnit.M_S -> String.format("%.0f m", distance) + SpeedUnit.KM_H -> String.format("%.0f m", distance) + SpeedUnit.MPH -> String.format("%.0f yd", distance) + } + } + + fun displayTotalDistance(): String { + return when (selectedSpeedUnit) { + SpeedUnit.M_S -> String.format("%.2f km", distance) + SpeedUnit.KM_H -> String.format("%.2f km", distance) + SpeedUnit.MPH -> String.format("%.2f mile", distance) + } + } + + fun displayGearRatio(): String { + return String.format(Locale.US, "%.1f", gearRatio) + } +} diff --git a/feature_csc/src/main/java/no/nordicsemi/android/csc/viewmodel/CscViewModel.kt b/feature_csc/src/main/java/no/nordicsemi/android/csc/viewmodel/CscViewModel.kt index b2cde153..1f5d2910 100644 --- a/feature_csc/src/main/java/no/nordicsemi/android/csc/viewmodel/CscViewModel.kt +++ b/feature_csc/src/main/java/no/nordicsemi/android/csc/viewmodel/CscViewModel.kt @@ -13,28 +13,20 @@ import no.nordicsemi.android.csc.events.CrankDataChanged import no.nordicsemi.android.csc.events.OnBatteryLevelChanged import no.nordicsemi.android.csc.events.OnDistanceChangedEvent import no.nordicsemi.android.csc.service.CSCDataReadBroadcast -import no.nordicsemi.android.csc.view.CSCViewConnectedState import no.nordicsemi.android.csc.view.CSCViewEvent -import no.nordicsemi.android.csc.view.CSCViewNotConnectedState -import no.nordicsemi.android.csc.view.CSCViewState -import no.nordicsemi.android.csc.view.OnBluetoothDeviceSelected -import no.nordicsemi.android.csc.view.OnConnectButtonClick import no.nordicsemi.android.csc.view.OnDisconnectButtonClick -import no.nordicsemi.android.csc.view.OnMovedToScannerScreen import no.nordicsemi.android.csc.view.OnSelectedSpeedUnitSelected import no.nordicsemi.android.csc.view.OnShowEditWheelSizeDialogButtonClick import no.nordicsemi.android.csc.view.OnWheelSizeSelected -import no.nordicsemi.android.scanner.tools.SelectedBluetoothDeviceHolder import no.nordicsemi.android.utils.exhaustive import javax.inject.Inject @HiltViewModel internal class CscViewModel @Inject constructor( - private val localBroadcast: CSCDataReadBroadcast, - private val deviceHolder: SelectedBluetoothDeviceHolder + private val localBroadcast: CSCDataReadBroadcast ) : ViewModel() { - val state = MutableStateFlow(createInitialState()) + val state = MutableStateFlow(CSCViewState()) init { localBroadcast.events.onEach { @@ -42,10 +34,6 @@ internal class CscViewModel @Inject constructor( }.launchIn(viewModelScope) } - private fun createInitialState(): CSCViewState { - return deviceHolder.device?.let { CSCViewConnectedState() } ?: CSCViewNotConnectedState() - } - private fun consumeEvent(event: CSCServiceEvent) { val newValue = when (event) { is CrankDataChanged -> createNewState(event) @@ -55,21 +43,21 @@ internal class CscViewModel @Inject constructor( state.value = newValue } - private fun createNewState(event: CrankDataChanged): CSCViewConnectedState { - return state.value.ensureConnectedState().copy( + private fun createNewState(event: CrankDataChanged): CSCViewState { + return state.value.copy( cadence = event.crankCadence, gearRatio = event.gearRatio ) } - private fun createNewState(event: OnBatteryLevelChanged): CSCViewConnectedState { - return state.value.ensureConnectedState().copy( + private fun createNewState(event: OnBatteryLevelChanged): CSCViewState { + return state.value.copy( batteryLevel = event.batteryLevel ) } - private fun createNewState(event: OnDistanceChangedEvent): CSCViewConnectedState { - return state.value.ensureConnectedState().copy( + private fun createNewState(event: OnDistanceChangedEvent): CSCViewState { + return state.value.copy( speed = event.speed, distance = event.distance, totalDistance = event.totalDistance @@ -82,41 +70,26 @@ internal class CscViewModel @Inject constructor( OnShowEditWheelSizeDialogButtonClick -> onShowDialogEvent() is OnWheelSizeSelected -> onWheelSizeChanged(event) OnDisconnectButtonClick -> onDisconnectButtonClick() - OnConnectButtonClick -> onConnectButtonClick() - OnMovedToScannerScreen -> onOnMovedToScannerScreen() - is OnBluetoothDeviceSelected -> onBluetoothDeviceSelected() }.exhaustive } private fun onSelectedSpeedUnit(event: OnSelectedSpeedUnitSelected) { - state.tryEmit(state.value.ensureConnectedState().copy(selectedSpeedUnit = event.selectedSpeedUnit)) + state.tryEmit(state.value.copy(selectedSpeedUnit = event.selectedSpeedUnit)) } private fun onShowDialogEvent() { - state.tryEmit(state.value.ensureConnectedState().copy(showDialog = true)) + state.tryEmit(state.value.copy(showDialog = true)) } private fun onWheelSizeChanged(event: OnWheelSizeSelected) { localBroadcast.setWheelSize(event.wheelSize) - state.tryEmit(state.value.ensureConnectedState().copy( + state.tryEmit(state.value.copy( showDialog = false, wheelSize = event.wheelSizeDisplayInfo )) } private fun onDisconnectButtonClick() { - state.tryEmit(CSCViewNotConnectedState()) - } - - private fun onConnectButtonClick() { - state.tryEmit(state.value.ensureDisconnectedState().copy(showScannerDialog = true)) - } - - private fun onOnMovedToScannerScreen() { - state.tryEmit(state.value.ensureDisconnectedState().copy(showScannerDialog = false)) - } - - private fun onBluetoothDeviceSelected() { - state.tryEmit(CSCViewConnectedState()) + state.tryEmit(state.value.copy(isScreenActive = false)) } } diff --git a/feature_csc/src/main/res/values/strings.xml b/feature_csc/src/main/res/values/strings.xml index 9a0b11a9..c38b30c3 100644 --- a/feature_csc/src/main/res/values/strings.xml +++ b/feature_csc/src/main/res/values/strings.xml @@ -2,16 +2,11 @@ Cyclic and speed cadence - Disconnect - No device connected - Connect - Speed Cadence Distance Total Distance Gear Ratio - Battery Wheel size diff --git a/feature_csc/src/test/java/no/nordicsemi/android/csc/ExampleUnitTest.kt b/feature_csc/src/test/java/no/nordicsemi/android/csc/ExampleUnitTest.kt index ef4fed15..f140885a 100644 --- a/feature_csc/src/test/java/no/nordicsemi/android/csc/ExampleUnitTest.kt +++ b/feature_csc/src/test/java/no/nordicsemi/android/csc/ExampleUnitTest.kt @@ -1,9 +1,9 @@ package no.nordicsemi.android.csc +import androidx.annotation.FloatRange +import org.junit.Assert.assertEquals import org.junit.Test -import org.junit.Assert.* - /** * Example local unit test, which will execute on the development machine (host). * @@ -12,6 +12,12 @@ import org.junit.Assert.* class ExampleUnitTest { @Test fun addition_isCorrect() { + + println("red: ${colorToHex(0f)}") + println("green: ${colorToHex(169f)}") + println("blue: ${colorToHex(206f)}") assertEquals(4, 2 + 2) } + + private fun colorToHex(@FloatRange(from = 0.0, to = 1.0) value: Float) = Integer.toHexString((0xFF * value).toInt()) } \ No newline at end of file diff --git a/feature_hrs/build.gradle b/feature_hrs/build.gradle new file mode 100644 index 00000000..662e3f13 --- /dev/null +++ b/feature_hrs/build.gradle @@ -0,0 +1,28 @@ +apply from: rootProject.file("library.gradle") +apply plugin: 'kotlin-parcelize' + +dependencies { + implementation project(":lib_service") + implementation project(":lib_theme") + implementation project(":lib_utils") + + implementation libs.chart + + implementation libs.nordic.ble.common + + implementation libs.nordic.log + + implementation libs.bundles.compose + implementation libs.androidx.core + implementation libs.material + implementation libs.lifecycle.activity + implementation libs.lifecycle.service + implementation libs.compose.lifecycle + implementation libs.compose.activity + + testImplementation libs.test.junit + androidTestImplementation libs.android.test.junit + androidTestImplementation libs.android.test.espresso + androidTestImplementation libs.android.test.compose.ui + debugImplementation libs.android.test.compose.tooling +} diff --git a/feature_hrs/src/androidTest/java/no/nordicsemi/android/hrs/ExampleInstrumentedTest.kt b/feature_hrs/src/androidTest/java/no/nordicsemi/android/hrs/ExampleInstrumentedTest.kt new file mode 100644 index 00000000..8e759ebc --- /dev/null +++ b/feature_hrs/src/androidTest/java/no/nordicsemi/android/hrs/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package no.nordicsemi.android.hrs + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("no.nordicsemi.android.hrs.test", appContext.packageName) + } +} \ No newline at end of file diff --git a/feature_hrs/src/main/AndroidManifest.xml b/feature_hrs/src/main/AndroidManifest.xml new file mode 100644 index 00000000..f623e468 --- /dev/null +++ b/feature_hrs/src/main/AndroidManifest.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/feature_hrs/src/main/java/no/nordicsemi/android/hrs/events/HRSAggregatedData.kt b/feature_hrs/src/main/java/no/nordicsemi/android/hrs/events/HRSAggregatedData.kt new file mode 100644 index 00000000..700be485 --- /dev/null +++ b/feature_hrs/src/main/java/no/nordicsemi/android/hrs/events/HRSAggregatedData.kt @@ -0,0 +1,7 @@ +package no.nordicsemi.android.hrs.events + +internal data class HRSAggregatedData( + val heartRates: List = emptyList(), + val batteryLevel: Int = 0, + val sensorLocation: Int = 0 +) diff --git a/feature_hrs/src/main/java/no/nordicsemi/android/hrs/service/BodySensorLocationParser.kt b/feature_hrs/src/main/java/no/nordicsemi/android/hrs/service/BodySensorLocationParser.kt new file mode 100644 index 00000000..6b1d706a --- /dev/null +++ b/feature_hrs/src/main/java/no/nordicsemi/android/hrs/service/BodySensorLocationParser.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2015, Nordic Semiconductor + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this + * software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE + * USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package no.nordicsemi.android.hrs.service + +import no.nordicsemi.android.ble.data.Data + +object BodySensorLocationParser { + fun parse(data: Data): String { + val value = data.getIntValue(Data.FORMAT_UINT8, 0)!! + return when (value) { + 6 -> "Foot" + 5 -> "Ear Lobe" + 4 -> "Hand" + 3 -> "Finger" + 2 -> "Wrist" + 1 -> "Chest" + 0 -> "Other" + else -> "Other" + } + } +} \ No newline at end of file diff --git a/feature_hrs/src/main/java/no/nordicsemi/android/hrs/service/HRSDataBroadcast.kt b/feature_hrs/src/main/java/no/nordicsemi/android/hrs/service/HRSDataBroadcast.kt new file mode 100644 index 00000000..29eddcf5 --- /dev/null +++ b/feature_hrs/src/main/java/no/nordicsemi/android/hrs/service/HRSDataBroadcast.kt @@ -0,0 +1,9 @@ +package no.nordicsemi.android.hrs.service + +import no.nordicsemi.android.hrs.events.HRSAggregatedData +import no.nordicsemi.android.service.BluetoothDataReadBroadcast +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +internal class HRSDataBroadcast @Inject constructor() : BluetoothDataReadBroadcast() diff --git a/feature_hrs/src/main/java/no/nordicsemi/android/hrs/service/HRSManager.kt b/feature_hrs/src/main/java/no/nordicsemi/android/hrs/service/HRSManager.kt new file mode 100644 index 00000000..76e74287 --- /dev/null +++ b/feature_hrs/src/main/java/no/nordicsemi/android/hrs/service/HRSManager.kt @@ -0,0 +1,162 @@ +/* + * Copyright (c) 2015, Nordic Semiconductor + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this + * software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE + * USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package no.nordicsemi.android.hrs.service + +import android.bluetooth.BluetoothDevice +import android.bluetooth.BluetoothGatt +import android.bluetooth.BluetoothGattCharacteristic +import android.content.Context +import android.util.Log +import androidx.annotation.IntRange +import no.nordicsemi.android.ble.common.callback.hr.BodySensorLocationDataCallback +import no.nordicsemi.android.ble.common.callback.hr.HeartRateMeasurementDataCallback +import no.nordicsemi.android.ble.common.profile.hr.BodySensorLocation +import no.nordicsemi.android.ble.data.Data +import no.nordicsemi.android.log.LogContract +import no.nordicsemi.android.service.BatteryManager +import java.util.* + +/** + * HRSManager class performs BluetoothGatt operations for connection, service discovery, + * enabling notification and reading characteristics. + * All operations required to connect to device with BLE Heart Rate Service and reading + * heart rate values are performed here. + */ +class HRSManager(context: Context) : BatteryManager(context) { + + private var heartRateCharacteristic: BluetoothGattCharacteristic? = null + private var bodySensorLocationCharacteristic: BluetoothGattCharacteristic? = null + + override fun getGattCallback(): BatteryManagerGattCallback { + return HeartRateManagerCallback() + } + + /** + * BluetoothGatt callbacks for connection/disconnection, service discovery, + * receiving notification, etc. + */ + private inner class HeartRateManagerCallback : BatteryManagerGattCallback() { + override fun initialize() { + super.initialize() + readCharacteristic(bodySensorLocationCharacteristic) + .with(object : BodySensorLocationDataCallback() { + + override fun onDataReceived(device: BluetoothDevice, data: Data) { + log( + LogContract.Log.Level.APPLICATION, + "\"" + BodySensorLocationParser.parse(data) + "\" received" + ) + super.onDataReceived(device, data) + } + + override fun onBodySensorLocationReceived( + device: BluetoothDevice, + @BodySensorLocation sensorLocation: Int + ) { + mCallbacks?.onBodySensorLocationReceived(device, sensorLocation) + } + + }) + .fail { device: BluetoothDevice?, status: Int -> + log(Log.WARN, "Body Sensor Location characteristic not found") + } + .enqueue() + + setNotificationCallback(heartRateCharacteristic) + .with(object : HeartRateMeasurementDataCallback() { + + override fun onDataReceived(device: BluetoothDevice, data: Data) { + log( + LogContract.Log.Level.APPLICATION, + "\"" + HeartRateMeasurementParser.parse(data) + "\" received" + ) + super.onDataReceived(device, data) + } + + override fun onHeartRateMeasurementReceived( + device: BluetoothDevice, + @IntRange(from = 0) heartRate: Int, + contactDetected: Boolean?, + @IntRange(from = 0) energyExpanded: Int?, + rrIntervals: List? + ) { + mCallbacks?.onHeartRateMeasurementReceived( + device, + heartRate, + contactDetected, + energyExpanded, + rrIntervals + ) + } + }) + enableNotifications(heartRateCharacteristic).enqueue() + } + + override fun isRequiredServiceSupported(gatt: BluetoothGatt): Boolean { + val service = gatt.getService(HR_SERVICE_UUID) + if (service != null) { + heartRateCharacteristic = service.getCharacteristic( + HEART_RATE_MEASUREMENT_CHARACTERISTIC_UUID + ) + } + return heartRateCharacteristic != null + } + + override fun isOptionalServiceSupported(gatt: BluetoothGatt): Boolean { + super.isOptionalServiceSupported(gatt) + val service = gatt.getService(HR_SERVICE_UUID) + if (service != null) { + bodySensorLocationCharacteristic = service.getCharacteristic( + BODY_SENSOR_LOCATION_CHARACTERISTIC_UUID + ) + } + return bodySensorLocationCharacteristic != null + } + + override fun onDeviceDisconnected() { + super.onDeviceDisconnected() + bodySensorLocationCharacteristic = null + heartRateCharacteristic = null + } + + override fun onServicesInvalidated() {} + } + + companion object { + + val HR_SERVICE_UUID = UUID.fromString("0000180D-0000-1000-8000-00805f9b34fb") + private val BODY_SENSOR_LOCATION_CHARACTERISTIC_UUID = UUID.fromString("00002A38-0000-1000-8000-00805f9b34fb") + private val HEART_RATE_MEASUREMENT_CHARACTERISTIC_UUID = UUID.fromString("00002A37-0000-1000-8000-00805f9b34fb") + private var managerInstance: HRSManager? = null + + /** + * Singleton implementation of HRSManager class. + */ + @Synchronized + fun getInstance(context: Context): HRSManager? { + if (managerInstance == null) { + managerInstance = HRSManager(context) + } + return managerInstance + } + } +} \ No newline at end of file diff --git a/feature_hrs/src/main/java/no/nordicsemi/android/hrs/service/HRSManagerCallbacks.kt b/feature_hrs/src/main/java/no/nordicsemi/android/hrs/service/HRSManagerCallbacks.kt new file mode 100644 index 00000000..dbb1cb6a --- /dev/null +++ b/feature_hrs/src/main/java/no/nordicsemi/android/hrs/service/HRSManagerCallbacks.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2015, Nordic Semiconductor + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this + * software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE + * USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package no.nordicsemi.android.hrs.service + +import no.nordicsemi.android.ble.common.profile.hr.BodySensorLocationCallback +import no.nordicsemi.android.ble.common.profile.hr.HeartRateMeasurementCallback +import no.nordicsemi.android.service.BatteryManagerCallbacks + +interface HRSManagerCallbacks + : BatteryManagerCallbacks, BodySensorLocationCallback, HeartRateMeasurementCallback \ No newline at end of file diff --git a/feature_hrs/src/main/java/no/nordicsemi/android/hrs/service/HRSService.kt b/feature_hrs/src/main/java/no/nordicsemi/android/hrs/service/HRSService.kt new file mode 100644 index 00000000..a8d94192 --- /dev/null +++ b/feature_hrs/src/main/java/no/nordicsemi/android/hrs/service/HRSService.kt @@ -0,0 +1,53 @@ +package no.nordicsemi.android.hrs.service + +import android.bluetooth.BluetoothDevice +import dagger.hilt.android.AndroidEntryPoint +import no.nordicsemi.android.ble.BleManagerCallbacks +import no.nordicsemi.android.hrs.events.HRSAggregatedData +import no.nordicsemi.android.service.ForegroundBleService +import no.nordicsemi.android.service.LoggableBleManager +import javax.inject.Inject + +@AndroidEntryPoint +internal class HRSService : ForegroundBleService(), HRSManagerCallbacks { + + private var data = HRSAggregatedData() + private val points = mutableListOf() + + @Inject + lateinit var localBroadcast: HRSDataBroadcast + + override val manager: HRSManager by lazy { + HRSManager(this).apply { + setGattCallbacks(this@HRSService) + } + } + + override fun initializeManager(): LoggableBleManager { + return manager + } + + override fun onBatteryLevelChanged(device: BluetoothDevice, batteryLevel: Int) { + sendNewData(data.copy(batteryLevel = batteryLevel)) + } + + override fun onBodySensorLocationReceived(device: BluetoothDevice, sensorLocation: Int) { + sendNewData(data.copy(sensorLocation = sensorLocation)) + } + + override fun onHeartRateMeasurementReceived( + device: BluetoothDevice, + heartRate: Int, + contactDetected: Boolean?, + energyExpanded: Int?, + rrIntervals: MutableList? + ) { + points.add(heartRate) + sendNewData(data.copy(heartRates = points)) + } + + private fun sendNewData(newData: HRSAggregatedData) { + data = newData + localBroadcast.offer(newData) + } +} diff --git a/feature_hrs/src/main/java/no/nordicsemi/android/hrs/service/HeartRateMeasurementParser.kt b/feature_hrs/src/main/java/no/nordicsemi/android/hrs/service/HeartRateMeasurementParser.kt new file mode 100644 index 00000000..c69ad1c5 --- /dev/null +++ b/feature_hrs/src/main/java/no/nordicsemi/android/hrs/service/HeartRateMeasurementParser.kt @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2015, Nordic Semiconductor + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this + * software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE + * USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package no.nordicsemi.android.hrs.service + +import no.nordicsemi.android.ble.data.Data +import java.util.* + +object HeartRateMeasurementParser { + + private const val HEART_RATE_VALUE_FORMAT: Byte = 0x01 // 1 bit + private const val SENSOR_CONTACT_STATUS: Byte = 0x06 // 2 bits + private const val ENERGY_EXPANDED_STATUS: Byte = 0x08 // 1 bit + private const val RR_INTERVAL: Byte = 0x10 // 1 bit + + fun parse(data: Data): String { + var offset = 0 + val flags = data.getIntValue(Data.FORMAT_UINT8, offset++)!! + + /* + * false Heart Rate Value Format is set to UINT8. Units: beats per minute (bpm) + * true Heart Rate Value Format is set to UINT16. Units: beats per minute (bpm) + */ + val value16bit = flags and HEART_RATE_VALUE_FORMAT.toInt() > 0 + + /* + * 0 Sensor Contact feature is not supported in the current connection + * 1 Sensor Contact feature is not supported in the current connection + * 2 Sensor Contact feature is supported, but contact is not detected + * 3 Sensor Contact feature is supported and contact is detected + */ + val sensorContactStatus = flags and SENSOR_CONTACT_STATUS.toInt() shr 1 + + /* + * false Energy Expended field is not present + * true Energy Expended field is present. Units: kilo Joules + */ + val energyExpandedStatus = flags and ENERGY_EXPANDED_STATUS.toInt() > 0 + + /* + * false RR-Interval values are not present. + * true One or more RR-Interval values are present. Units: 1/1024 seconds + */ + val rrIntervalStatus = flags and RR_INTERVAL.toInt() > 0 + + // heart rate value is 8 or 16 bit long + val heartRateValue = data.getIntValue( + if (value16bit) { + Data.FORMAT_UINT16 + } else { + Data.FORMAT_UINT8 + }, + offset++ + ) // bits per minute + if (value16bit) offset++ + + // energy expanded value is present if a flag was set + var energyExpanded = -1 + if (energyExpandedStatus) energyExpanded = data.getIntValue(Data.FORMAT_UINT16, offset)!! + offset += 2 + + // RR-interval is set when a flag is set + val rrIntervals: MutableList = ArrayList() + if (rrIntervalStatus) { + var o = offset + while (o < data.value!!.size) { + val units = data.getIntValue(Data.FORMAT_UINT16, o)!! + rrIntervals.add(units * 1000.0f / 1024.0f) // RR interval is in [1/1024s] + o += 2 + } + } + val builder = StringBuilder() + builder.append("Heart Rate Measurement: ").append(heartRateValue).append(" bpm") + when (sensorContactStatus) { + 0, 1 -> builder.append(",\nSensor Contact Not Supported") + 2 -> builder.append(",\nContact is NOT Detected") + 3 -> builder.append(",\nContact is Detected") + } + if (energyExpandedStatus) { + builder.append(",\nEnergy Expanded: ") + .append(energyExpanded) + .append(" kJ") + } + if (rrIntervalStatus) { + builder.append(",\nRR Interval: ") + for (interval in rrIntervals) builder.append( + String.format( + Locale.US, + "%.02f ms, ", + interval + ) + ) + builder.setLength(builder.length - 2) // remove the ", " at the end + } + return builder.toString() + } +} \ No newline at end of file diff --git a/feature_hrs/src/main/java/no/nordicsemi/android/hrs/view/ContentView.kt b/feature_hrs/src/main/java/no/nordicsemi/android/hrs/view/ContentView.kt new file mode 100644 index 00000000..9ee0c88c --- /dev/null +++ b/feature_hrs/src/main/java/no/nordicsemi/android/hrs/view/ContentView.kt @@ -0,0 +1,224 @@ +package no.nordicsemi.android.hrs.view + +import android.content.Context +import android.graphics.Color +import android.graphics.DashPathEffect +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.Card +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.content.ContextCompat +import com.github.mikephil.charting.charts.LineChart +import com.github.mikephil.charting.data.Entry +import com.github.mikephil.charting.data.LineData +import com.github.mikephil.charting.data.LineDataSet +import com.github.mikephil.charting.formatter.IFillFormatter +import com.github.mikephil.charting.interfaces.datasets.ILineDataSet +import com.github.mikephil.charting.utils.Utils +import no.nordicsemi.android.hrs.R +import no.nordicsemi.android.hrs.viewmodel.HRSViewState +import no.nordicsemi.android.theme.NordicColors +import no.nordicsemi.android.theme.view.BatteryLevelView +import java.util.* + +@Composable +internal fun ContentView(state: HRSViewState, onEvent: (HRSScreenViewEvent) -> Unit) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Card( + backgroundColor = NordicColors.NordicGray4.value(), + shape = RoundedCornerShape(10.dp), + elevation = 0.dp + ) { + Box(modifier = Modifier.padding(16.dp)) { + LineChartView(state) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + BatteryLevelView(state.batteryLevel) + + Spacer(modifier = Modifier.height(16.dp)) + + Button( + colors = ButtonDefaults.buttonColors(backgroundColor = MaterialTheme.colors.secondary), + onClick = { onEvent(DisconnectEvent) } + ) { + Text(text = stringResource(id = R.string.disconnect)) + } + } +} + +@Composable +fun LineChartView(state: HRSViewState) { + AndroidView( + modifier = Modifier + .fillMaxWidth() + .height(300.dp), + factory = { createLineChartView(it, state) }, + update = { updateData(state.points, it) } + ) +} + +fun createLineChartView(context: Context, state: HRSViewState): LineChart { + return LineChart(context).apply { + setBackgroundColor(Color.WHITE) + + description.isEnabled = false + + setTouchEnabled(true) + +// setOnChartValueSelectedListener(this) + setDrawGridBackground(false) + + isDragEnabled = true + setScaleEnabled(true) + setPinchZoom(true) + + xAxis.apply { + xAxis.enableGridDashedLine(10f, 10f, 0f) + } + axisLeft.apply { + enableGridDashedLine(10f, 10f, 0f) + + axisMaximum = 300f + axisMinimum = 100f + } + axisRight.isEnabled = false + + //--- + + val entries = state.points.mapIndexed { i, v -> + Entry(i.toFloat(), v.toFloat()) + } + // create a dataset and give it a type + + if (data != null && data.dataSetCount > 0) { + val set1 = data!!.getDataSetByIndex(0) as LineDataSet + set1.values = entries + set1.notifyDataSetChanged() + data!!.notifyDataChanged() + notifyDataSetChanged() + } else { + val set1 = LineDataSet(entries, "DataSet 1") + + set1.setDrawIcons(false) + + // draw dashed line + + // draw dashed line + set1.enableDashedLine(10f, 5f, 0f) + + // black lines and points + + // black lines and points + set1.color = Color.BLACK + set1.setCircleColor(Color.BLACK) + + // line thickness and point size + + // line thickness and point size + set1.lineWidth = 1f + set1.circleRadius = 3f + + // draw points as solid circles + + // draw points as solid circles + set1.setDrawCircleHole(false) + + // customize legend entry + + // customize legend entry + set1.formLineWidth = 1f + set1.formLineDashEffect = DashPathEffect(floatArrayOf(10f, 5f), 0f) + set1.formSize = 15f + + // text size of values + + // text size of values + set1.valueTextSize = 9f + + // draw selection line as dashed + + // draw selection line as dashed + set1.enableDashedHighlightLine(10f, 5f, 0f) + + // set the filled area + + // set the filled area + set1.setDrawFilled(true) + set1.fillFormatter = IFillFormatter { _, _ -> + axisLeft.axisMinimum + } + + // set color of filled area + + // set color of filled area + if (Utils.getSDKInt() >= 18) { + // drawables only supported on api level 18 and above + val drawable = ContextCompat.getDrawable(context, R.drawable.fade_red) + set1.fillDrawable = drawable + } else { + set1.fillColor = Color.BLACK + } + + val dataSets = ArrayList() + dataSets.add(set1) // add the data sets + + + // create a data object with the data sets + + // create a data object with the data sets + val data = LineData(dataSets) + + // set data + + // set data + setData(data) + } + } +} + +private fun updateData(points: List, chart: LineChart) { + val entries = points.mapIndexed { i, v -> + Entry(i.toFloat(), v.toFloat()) + } + + with(chart) { + if (data != null && data.dataSetCount > 0) { + val set1 = data!!.getDataSetByIndex(0) as LineDataSet + set1.values = entries + set1.notifyDataSetChanged() + data!!.notifyDataChanged() + notifyDataSetChanged() + invalidate() + } + } +} + +@Preview +@Composable +private fun Preview() { + ContentView(state = HRSViewState()) { } +} diff --git a/feature_hrs/src/main/java/no/nordicsemi/android/hrs/view/HRSScreen.kt b/feature_hrs/src/main/java/no/nordicsemi/android/hrs/view/HRSScreen.kt new file mode 100644 index 00000000..a6dc4d2a --- /dev/null +++ b/feature_hrs/src/main/java/no/nordicsemi/android/hrs/view/HRSScreen.kt @@ -0,0 +1,52 @@ +package no.nordicsemi.android.hrs.view + +import android.content.Intent +import androidx.compose.foundation.layout.Column +import androidx.compose.material.Text +import androidx.compose.material.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.hilt.navigation.compose.hiltViewModel +import no.nordicsemi.android.hrs.R +import no.nordicsemi.android.hrs.service.HRSService +import no.nordicsemi.android.hrs.viewmodel.HRSViewModel +import no.nordicsemi.android.hrs.viewmodel.HRSViewState +import no.nordicsemi.android.utils.isServiceRunning + +@Composable +fun HRSScreen(finishAction: () -> Unit) { + val viewModel: HRSViewModel = hiltViewModel() + val state = viewModel.state.collectAsState().value + + val context = LocalContext.current + LaunchedEffect(state.isScreenActive) { + if (!state.isScreenActive) { + finishAction() + } + if (context.isServiceRunning(HRSService::class.java.name)) { + val intent = Intent(context, HRSService::class.java) + context.stopService(intent) + } + } + + LaunchedEffect("start-service") { + if (!context.isServiceRunning(HRSService::class.java.name)) { + val intent = Intent(context, HRSService::class.java) + context.startService(intent) + } + } + + HRSView(state) { viewModel.onEvent(it) } +} + +@Composable +private fun HRSView(state: HRSViewState, onEvent: (HRSScreenViewEvent) -> Unit) { + Column { + TopAppBar(title = { Text(text = stringResource(id = R.string.hrs_title)) }) + + ContentView(state) { onEvent(it) } + } +} diff --git a/feature_hrs/src/main/java/no/nordicsemi/android/hrs/view/HRSScreenViewEvent.kt b/feature_hrs/src/main/java/no/nordicsemi/android/hrs/view/HRSScreenViewEvent.kt new file mode 100644 index 00000000..07ca835b --- /dev/null +++ b/feature_hrs/src/main/java/no/nordicsemi/android/hrs/view/HRSScreenViewEvent.kt @@ -0,0 +1,5 @@ +package no.nordicsemi.android.hrs.view + +sealed class HRSScreenViewEvent + +object DisconnectEvent : HRSScreenViewEvent() diff --git a/feature_hrs/src/main/java/no/nordicsemi/android/hrs/viewmodel/HRSViewModel.kt b/feature_hrs/src/main/java/no/nordicsemi/android/hrs/viewmodel/HRSViewModel.kt new file mode 100644 index 00000000..cd0a4aee --- /dev/null +++ b/feature_hrs/src/main/java/no/nordicsemi/android/hrs/viewmodel/HRSViewModel.kt @@ -0,0 +1,47 @@ +package no.nordicsemi.android.hrs.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.withContext +import no.nordicsemi.android.hrs.events.HRSAggregatedData +import no.nordicsemi.android.hrs.service.HRSDataBroadcast +import no.nordicsemi.android.hrs.view.DisconnectEvent +import no.nordicsemi.android.hrs.view.HRSScreenViewEvent +import javax.inject.Inject + +@HiltViewModel +internal class HRSViewModel @Inject constructor( + private val localBroadcast: HRSDataBroadcast +) : ViewModel() { + + val state = MutableStateFlow(HRSViewState()) + + init { + localBroadcast.events.onEach { + withContext(Dispatchers.Main) { consumeEvent(it) } + }.launchIn(viewModelScope) + } + + private fun consumeEvent(event: HRSAggregatedData) { + state.value = state.value.copy( + points = event.heartRates, + batteryLevel = event.batteryLevel, + sensorLocation = event.sensorLocation + ) + } + + fun onEvent(event: HRSScreenViewEvent) { + (event as? DisconnectEvent)?.let { + onDisconnectButtonClick() + } + } + + private fun onDisconnectButtonClick() { + state.tryEmit(state.value.copy(isScreenActive = false)) + } +} diff --git a/feature_hrs/src/main/java/no/nordicsemi/android/hrs/viewmodel/HRSViewState.kt b/feature_hrs/src/main/java/no/nordicsemi/android/hrs/viewmodel/HRSViewState.kt new file mode 100644 index 00000000..75ef9d94 --- /dev/null +++ b/feature_hrs/src/main/java/no/nordicsemi/android/hrs/viewmodel/HRSViewState.kt @@ -0,0 +1,8 @@ +package no.nordicsemi.android.hrs.viewmodel + +data class HRSViewState( + val points: List = listOf(1, 2, 3), + val batteryLevel: Int = 0, + val sensorLocation: Int = 0, + val isScreenActive: Boolean = true +) diff --git a/feature_hrs/src/main/res/drawable/fade_red.xml b/feature_hrs/src/main/res/drawable/fade_red.xml new file mode 100644 index 00000000..54ac10ba --- /dev/null +++ b/feature_hrs/src/main/res/drawable/fade_red.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/feature_hrs/src/main/res/values/strings.xml b/feature_hrs/src/main/res/values/strings.xml new file mode 100644 index 00000000..6c7eee4d --- /dev/null +++ b/feature_hrs/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + HRS + diff --git a/feature_hrs/src/test/java/no/nordicsemi/android/hrs/ExampleUnitTest.kt b/feature_hrs/src/test/java/no/nordicsemi/android/hrs/ExampleUnitTest.kt new file mode 100644 index 00000000..51644e23 --- /dev/null +++ b/feature_hrs/src/test/java/no/nordicsemi/android/hrs/ExampleUnitTest.kt @@ -0,0 +1,17 @@ +package no.nordicsemi.android.hrs + +import org.junit.Test + +import org.junit.Assert.* + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class ExampleUnitTest { + @Test + fun addition_isCorrect() { + assertEquals(4, 2 + 2) + } +} \ No newline at end of file diff --git a/feature_scanner/build.gradle b/feature_scanner/build.gradle index b29a2804..e1151501 100644 --- a/feature_scanner/build.gradle +++ b/feature_scanner/build.gradle @@ -4,6 +4,7 @@ apply plugin: 'kotlin-parcelize' dependencies { implementation project(":lib_utils") implementation project(":lib_theme") + implementation project(":lib_service") implementation libs.material implementation libs.google.permissions diff --git a/feature_scanner/src/main/java/no/nordicsemi/android/scanner/HiltModule.kt b/feature_scanner/src/main/java/no/nordicsemi/android/scanner/HiltModule.kt index 6b906bd9..02216cab 100644 --- a/feature_scanner/src/main/java/no/nordicsemi/android/scanner/HiltModule.kt +++ b/feature_scanner/src/main/java/no/nordicsemi/android/scanner/HiltModule.kt @@ -7,7 +7,8 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent -import no.nordicsemi.android.scanner.tools.SelectedBluetoothDeviceHolder +import no.nordicsemi.android.scanner.tools.PermissionHelper +import no.nordicsemi.android.service.SelectedBluetoothDeviceHolder import javax.inject.Singleton @Module @@ -24,7 +25,16 @@ internal object HiltModule { fun createSelectedBluetoothDeviceHolder( @ApplicationContext context: Context, bluetoothAdapter: BluetoothAdapter? - ): SelectedBluetoothDeviceHolder { - return SelectedBluetoothDeviceHolder(context, bluetoothAdapter) + ): no.nordicsemi.android.service.SelectedBluetoothDeviceHolder { + return no.nordicsemi.android.service.SelectedBluetoothDeviceHolder( + context, + bluetoothAdapter + ) + } + + @Singleton + @Provides + fun createPermissionHelper(@ApplicationContext context: Context): PermissionHelper { + return PermissionHelper(context) } } diff --git a/feature_scanner/src/main/java/no/nordicsemi/android/scanner/ScannerNavigation.kt b/feature_scanner/src/main/java/no/nordicsemi/android/scanner/ScannerNavigation.kt deleted file mode 100644 index eecf29a4..00000000 --- a/feature_scanner/src/main/java/no/nordicsemi/android/scanner/ScannerNavigation.kt +++ /dev/null @@ -1,41 +0,0 @@ -package no.nordicsemi.android.scanner - -import androidx.compose.foundation.layout.Column -import androidx.compose.material.Text -import androidx.compose.material.TopAppBar -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.ui.res.stringResource -import androidx.hilt.navigation.compose.hiltViewModel -import androidx.navigation.NavController -import no.nordicsemi.android.scanner.tools.ScannerStatus -import no.nordicsemi.android.scanner.view.* -import no.nordicsemi.android.scanner.viewmodel.NordicBleScannerViewModel -import no.nordicsemi.android.scanner.viewmodel.ScannerViewEvent -import no.nordicsemi.android.utils.exhaustive - -@Composable -fun ScannerRoute(navController: NavController) { - val viewModel = hiltViewModel() - - val scannerStatus = viewModel.state.collectAsState().value - - Column { - TopAppBar(title = { Text(text = stringResource(id = R.string.scanner__devices_list)) }) - ScannerScreen(navController, scannerStatus) { viewModel.onEvent(it) } - } -} - -@Composable -private fun ScannerScreen( - navController: NavController, - scannerStatus: ScannerStatus, - onEvent: (ScannerViewEvent) -> Unit -) { - when (scannerStatus) { - ScannerStatus.PERMISSION_REQUIRED -> RequestPermissionScreen { onEvent(ScannerViewEvent.PERMISSION_CHECKED) } - ScannerStatus.NOT_AVAILABLE -> BluetoothNotAvailableScreen() - ScannerStatus.DISABLED -> BluetoothNotEnabledScreen { onEvent(ScannerViewEvent.BLUETOOTH_ENABLED) } - ScannerStatus.ENABLED -> ScanDeviceScreen(navController) - }.exhaustive -} diff --git a/feature_scanner/src/main/java/no/nordicsemi/android/scanner/tools/NordicBleScanner.kt b/feature_scanner/src/main/java/no/nordicsemi/android/scanner/tools/NordicBleScanner.kt index 4ec1ad37..b4bd5183 100644 --- a/feature_scanner/src/main/java/no/nordicsemi/android/scanner/tools/NordicBleScanner.kt +++ b/feature_scanner/src/main/java/no/nordicsemi/android/scanner/tools/NordicBleScanner.kt @@ -2,14 +2,10 @@ package no.nordicsemi.android.scanner.tools import android.annotation.SuppressLint import android.bluetooth.BluetoothAdapter -import android.bluetooth.BluetoothDevice -import kotlinx.coroutines.flow.MutableStateFlow import javax.inject.Inject @SuppressLint("MissingPermission") -internal class NordicBleScanner @Inject constructor(private val bleAdapter: BluetoothAdapter?) { - - val scannerResult = MutableStateFlow(DeviceListResult()) +class NordicBleScanner @Inject constructor(private val bleAdapter: BluetoothAdapter?) { fun getBluetoothStatus(): ScannerStatus { return when { @@ -19,15 +15,3 @@ internal class NordicBleScanner @Inject constructor(private val bleAdapter: Blue } } } - -sealed class ScanningResult - -data class DeviceListResult(val devices: List = emptyList()) : ScanningResult() - -object ScanningErrorResult : ScanningResult() - -private fun MutableList.addIfNotExist(value: T) { - if (!contains(value)) { - add(value) - } -} diff --git a/feature_scanner/src/main/java/no/nordicsemi/android/scanner/tools/PermissionHelper.kt b/feature_scanner/src/main/java/no/nordicsemi/android/scanner/tools/PermissionHelper.kt new file mode 100644 index 00000000..f96447c7 --- /dev/null +++ b/feature_scanner/src/main/java/no/nordicsemi/android/scanner/tools/PermissionHelper.kt @@ -0,0 +1,21 @@ +package no.nordicsemi.android.scanner.tools + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import androidx.core.content.ContextCompat + +class PermissionHelper(private val context: Context) { + + fun isRequiredPermissionGranted(): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + ContextCompat.checkSelfPermission( + context, + Manifest.permission.BLUETOOTH_CONNECT + ) == PackageManager.PERMISSION_GRANTED + } else { + true + } + } +} diff --git a/feature_scanner/src/main/java/no/nordicsemi/android/scanner/tools/ScannerStatus.kt b/feature_scanner/src/main/java/no/nordicsemi/android/scanner/tools/ScannerStatus.kt index e08da090..4c884cab 100644 --- a/feature_scanner/src/main/java/no/nordicsemi/android/scanner/tools/ScannerStatus.kt +++ b/feature_scanner/src/main/java/no/nordicsemi/android/scanner/tools/ScannerStatus.kt @@ -1,5 +1,5 @@ package no.nordicsemi.android.scanner.tools -internal enum class ScannerStatus { - PERMISSION_REQUIRED, ENABLED, DISABLED, NOT_AVAILABLE +enum class ScannerStatus { + ENABLED, DISABLED, NOT_AVAILABLE } diff --git a/feature_scanner/src/main/java/no/nordicsemi/android/scanner/view/BluetoothNotAvailableScreen.kt b/feature_scanner/src/main/java/no/nordicsemi/android/scanner/view/BluetoothNotAvailableScreen.kt index 503bcc97..1756e495 100644 --- a/feature_scanner/src/main/java/no/nordicsemi/android/scanner/view/BluetoothNotAvailableScreen.kt +++ b/feature_scanner/src/main/java/no/nordicsemi/android/scanner/view/BluetoothNotAvailableScreen.kt @@ -5,18 +5,42 @@ import android.bluetooth.BluetoothAdapter import android.content.Intent import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.MaterialTheme import androidx.compose.material.Text +import androidx.compose.material.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import no.nordicsemi.android.scanner.R @Composable -internal fun BluetoothNotAvailableScreen() { - Text("Bluetooth not available.") +fun BluetoothNotAvailableScreen() { + Column { + TopAppBar(title = { Text(text = stringResource(id = R.string.scanner__request_permission)) }) + Column( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text(stringResource(R.string.scanner__bluetooth_not_available)) + } + } } @Composable -internal fun BluetoothNotEnabledScreen(finish: () -> Unit) { +fun BluetoothNotEnabledScreen(finish: () -> Unit) { val contract = ActivityResultContracts.StartActivityForResult() val launcher = rememberLauncherForActivityResult(contract = contract, onResult = { if (it.resultCode == Activity.RESULT_OK) { @@ -25,10 +49,30 @@ internal fun BluetoothNotEnabledScreen(finish: () -> Unit) { }) Column { - Text(text = "Bluetooth not enabled.") - Text(text = "To enable Bluetooth please open settings.") - Button(onClick = { launcher.launch(Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE)) }) { - Text(text = "Bluetooth not available.") + TopAppBar(title = { Text(text = stringResource(id = R.string.scanner__request_permission)) }) + Column( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + textAlign = TextAlign.Center, + text = stringResource(id = R.string.scanner__bluetooth_not_enabled) + ) + Spacer(Modifier.height(16.dp)) + Text( + textAlign = TextAlign.Center, + text = stringResource(id = R.string.scanner__bluetooth_open_settings_info) + ) + Spacer(Modifier.height(32.dp)) + Button( + colors = ButtonDefaults.buttonColors(backgroundColor = MaterialTheme.colors.secondary), + onClick = { launcher.launch(Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE)) } + ) { + Text(text = stringResource(id = R.string.scanner__bluetooth_open_settings)) + } } } } diff --git a/feature_scanner/src/main/java/no/nordicsemi/android/scanner/view/NotConnectedView.kt b/feature_scanner/src/main/java/no/nordicsemi/android/scanner/view/NotConnectedView.kt new file mode 100644 index 00000000..f3790a3b --- /dev/null +++ b/feature_scanner/src/main/java/no/nordicsemi/android/scanner/view/NotConnectedView.kt @@ -0,0 +1,54 @@ +package no.nordicsemi.android.scanner.view + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import no.nordicsemi.android.scanner.R + +@Composable +private fun NotConnectedScreen( + connect: () -> Unit +) { + NotConnectedView(connect) +} + +@Composable +private fun NotConnectedView( + connect: () -> Unit +) { + Column( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text(text = stringResource(id = R.string.csc_no_connection)) + Spacer(modifier = Modifier.height(16.dp)) + Button( + colors = ButtonDefaults.buttonColors(backgroundColor = MaterialTheme.colors.secondary), + onClick = { connect() } + ) { + Text(text = stringResource(id = R.string.csc_connect)) + } + } +} + +@Preview +@Composable +private fun NotConnectedPreview() { + NotConnectedView { } +} diff --git a/feature_scanner/src/main/java/no/nordicsemi/android/scanner/view/RequestPermissionScreen.kt b/feature_scanner/src/main/java/no/nordicsemi/android/scanner/view/RequestPermissionScreen.kt index 75da5753..13552db1 100644 --- a/feature_scanner/src/main/java/no/nordicsemi/android/scanner/view/RequestPermissionScreen.kt +++ b/feature_scanner/src/main/java/no/nordicsemi/android/scanner/view/RequestPermissionScreen.kt @@ -4,9 +4,18 @@ import android.content.Context import android.content.Intent import android.net.Uri import android.provider.Settings -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.material.Button import androidx.compose.material.Text +import androidx.compose.material.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable @@ -14,6 +23,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat.startActivity @@ -24,18 +34,21 @@ import no.nordicsemi.android.scanner.R @OptIn(ExperimentalPermissionsApi::class) @Composable -internal fun RequestPermissionScreen(finish: () -> Unit) { +fun RequestPermissionScreen(finish: () -> Unit) { val permissionsState = rememberMultiplePermissionsState(listOf( - android.Manifest.permission.ACCESS_FINE_LOCATION, -// android.Manifest.permission.BLUETOOTH_SCAN, -// android.Manifest.permission.BLUETOOTH_CONNECT + android.Manifest.permission.BLUETOOTH_CONNECT )) - PermissionsRequired( - multiplePermissionsState = permissionsState, - permissionsNotGrantedContent = { PermissionNotGranted { permissionsState.launchMultiplePermissionRequest() } }, - permissionsNotAvailableContent = { PermissionNotAvailable() } - ) { - finish() + + Column { + TopAppBar(title = { Text(text = stringResource(id = R.string.scanner__request_permission)) }) + + PermissionsRequired( + multiplePermissionsState = permissionsState, + permissionsNotGrantedContent = { PermissionNotGranted { permissionsState.launchMultiplePermissionRequest() } }, + permissionsNotAvailableContent = { PermissionNotAvailable() } + ) { + finish() + } } } @@ -45,7 +58,9 @@ private fun PermissionNotGranted(onClick: () -> Unit) { if (doNotShowRationale.value) { Column( - modifier = Modifier.fillMaxWidth().fillMaxHeight(), + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally ) { @@ -53,18 +68,21 @@ private fun PermissionNotGranted(onClick: () -> Unit) { } } else { Column( - modifier = Modifier.fillMaxWidth().fillMaxHeight(), + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() + .padding(16.dp), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally ) { - Text(stringResource(id = R.string.scanner__permission_rationale)) - Spacer(modifier = Modifier.height(8.dp)) + Text(textAlign = TextAlign.Center, text = stringResource(id = R.string.scanner__permission_rationale)) + Spacer(modifier = Modifier.height(16.dp)) Row { - Button(onClick = { onClick() }) { + Button(modifier = Modifier.width(100.dp), onClick = { onClick() }) { Text(stringResource(id = R.string.scanner__button_ok)) } - Spacer(Modifier.width(8.dp)) - Button(onClick = { doNotShowRationale.value = true }) { + Spacer(Modifier.width(16.dp)) + Button(modifier = Modifier.width(100.dp), onClick = { doNotShowRationale.value = true }) { Text(stringResource(id = R.string.scanner__button_nope)) } } @@ -76,7 +94,9 @@ private fun PermissionNotGranted(onClick: () -> Unit) { private fun PermissionNotAvailable() { val context = LocalContext.current Column( - modifier = Modifier.fillMaxWidth().fillMaxHeight(), + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally ) { diff --git a/feature_scanner/src/main/java/no/nordicsemi/android/scanner/view/ScanDeviceScreen.kt b/feature_scanner/src/main/java/no/nordicsemi/android/scanner/view/ScanDeviceScreen.kt index 4ddc7084..add8df59 100644 --- a/feature_scanner/src/main/java/no/nordicsemi/android/scanner/view/ScanDeviceScreen.kt +++ b/feature_scanner/src/main/java/no/nordicsemi/android/scanner/view/ScanDeviceScreen.kt @@ -1,14 +1,11 @@ package no.nordicsemi.android.scanner.view import android.app.Activity -import android.bluetooth.BluetoothDevice -import android.bluetooth.le.ScanResult import android.companion.AssociationRequest import android.companion.BluetoothLeDeviceFilter import android.companion.CompanionDeviceManager import android.content.Context import android.content.IntentSender -import android.os.Build import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.IntentSenderRequest import androidx.activity.result.contract.ActivityResultContracts @@ -16,54 +13,48 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext -import androidx.navigation.NavController @Composable -fun ScanDeviceScreen(navController: NavController,) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val deviceFilter = BluetoothLeDeviceFilter.Builder() - .build() +fun ScanDeviceScreen(finish: (ScanDeviceScreenResult) -> Unit) { + val deviceManager = + LocalContext.current.getSystemService(Context.COMPANION_DEVICE_SERVICE) as CompanionDeviceManager - val pairingRequest: AssociationRequest = AssociationRequest.Builder() - .addDeviceFilter(deviceFilter) - .build() - - val deviceManager = - LocalContext.current.getSystemService(Context.COMPANION_DEVICE_SERVICE) as CompanionDeviceManager - - val contract = ActivityResultContracts.StartIntentSenderForResult() - val launcher = rememberLauncherForActivityResult(contract = contract, onResult = { - if (it.resultCode == Activity.RESULT_OK) { - //Sometimes result is ScanResult & sometimes BluetoothDevice - val device: BluetoothDevice = try { - it.data?.getParcelableExtra(CompanionDeviceManager.EXTRA_DEVICE)!! - } catch (e: Exception) { - (it.data?.getParcelableExtra(CompanionDeviceManager.EXTRA_DEVICE))!!.device - } - - navController.previousBackStackEntry - ?.savedStateHandle - ?.set("result", device) - } - navController.popBackStack() - }) - - val hasBeenInvoked = remember { mutableStateOf(false) } - if (hasBeenInvoked.value) { - return + val contract = ActivityResultContracts.StartIntentSenderForResult() + val launcher = rememberLauncherForActivityResult(contract = contract) { + val result = if (it.resultCode == Activity.RESULT_OK) { + ScanDeviceScreenResult.SUCCESS + } else { + ScanDeviceScreenResult.CANCEL } - hasBeenInvoked.value = true - deviceManager.associate(pairingRequest, - object : CompanionDeviceManager.Callback() { - override fun onDeviceFound(chooserLauncher: IntentSender) { - val request = IntentSenderRequest.Builder(chooserLauncher).build() - launcher.launch(request) - } - - override fun onFailure(error: CharSequence?) { - } - }, null) - } else { - TODO("VERSION.SDK_INT < O") + finish(result) } -} \ No newline at end of file + + val hasBeenInvoked = remember { mutableStateOf(false) } + if (hasBeenInvoked.value) { + return + } + hasBeenInvoked.value = true + + val deviceFilter = BluetoothLeDeviceFilter.Builder() + .build() + + val pairingRequest: AssociationRequest = AssociationRequest.Builder() + .addDeviceFilter(deviceFilter) + .build() + + deviceManager.associate(pairingRequest, + object : CompanionDeviceManager.Callback() { + override fun onDeviceFound(chooserLauncher: IntentSender) { + val request = IntentSenderRequest.Builder(chooserLauncher).build() + launcher.launch(request) + } + + override fun onFailure(error: CharSequence?) { + } + }, null + ) +} + +enum class ScanDeviceScreenResult { + SUCCESS, CANCEL +} diff --git a/feature_scanner/src/main/java/no/nordicsemi/android/scanner/viewmodel/BluetoothPermissionState.kt b/feature_scanner/src/main/java/no/nordicsemi/android/scanner/viewmodel/BluetoothPermissionState.kt new file mode 100644 index 00000000..6baab097 --- /dev/null +++ b/feature_scanner/src/main/java/no/nordicsemi/android/scanner/viewmodel/BluetoothPermissionState.kt @@ -0,0 +1,9 @@ +package no.nordicsemi.android.scanner.viewmodel + +enum class BluetoothPermissionState { + PERMISSION_REQUIRED, + BLUETOOTH_NOT_AVAILABLE, + BLUETOOTH_NOT_ENABLED, + DEVICE_NOT_CONNECTED, + READY +} diff --git a/feature_scanner/src/main/java/no/nordicsemi/android/scanner/viewmodel/NordicBleScannerViewModel.kt b/feature_scanner/src/main/java/no/nordicsemi/android/scanner/viewmodel/NordicBleScannerViewModel.kt deleted file mode 100644 index 49f9d6fa..00000000 --- a/feature_scanner/src/main/java/no/nordicsemi/android/scanner/viewmodel/NordicBleScannerViewModel.kt +++ /dev/null @@ -1,37 +0,0 @@ -package no.nordicsemi.android.scanner.viewmodel - -import androidx.lifecycle.ViewModel -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.MutableStateFlow -import no.nordicsemi.android.scanner.tools.NordicBleScanner -import no.nordicsemi.android.scanner.tools.ScannerStatus -import no.nordicsemi.android.utils.exhaustive -import javax.inject.Inject - -@HiltViewModel -internal class NordicBleScannerViewModel @Inject constructor( - private val bleScanner: NordicBleScanner -) : ViewModel() { - - val state = - MutableStateFlow(ScannerStatus.PERMISSION_REQUIRED) - - fun onEvent(event: ScannerViewEvent) { - when (event) { - ScannerViewEvent.PERMISSION_CHECKED -> onPermissionChecked() - ScannerViewEvent.BLUETOOTH_ENABLED -> onBluetoothEnabled() - }.exhaustive - } - - private fun onPermissionChecked() { - state.value = bleScanner.getBluetoothStatus() - } - - private fun onBluetoothEnabled() { - state.value = bleScanner.getBluetoothStatus() - } -} - -internal enum class ScannerViewEvent { - PERMISSION_CHECKED, BLUETOOTH_ENABLED -} diff --git a/feature_scanner/src/main/res/values/strings.xml b/feature_scanner/src/main/res/values/strings.xml index 409e94e0..a12e9a62 100644 --- a/feature_scanner/src/main/res/values/strings.xml +++ b/feature_scanner/src/main/res/values/strings.xml @@ -4,12 +4,23 @@ The location permission is required when using Bluetooth LE, because surrounding devices can expose user\'s location. Please grant the permission. Location permission denied. Please, grant us access on the Settings screen. - OK - Nope Open settings Feature not available List of devices Scanning failed due to technical reason. Name: NONE + + No device connected + Connect + + Grant + Deny + + Request permission + + Bluetooth not available. + Bluetooth not enabled. + To enable Bluetooth please open settings. + Open settings diff --git a/lib_service/build.gradle b/lib_service/build.gradle index 51af2af3..0fb97a02 100644 --- a/lib_service/build.gradle +++ b/lib_service/build.gradle @@ -2,7 +2,7 @@ apply from: rootProject.file("library.gradle") apply plugin: 'kotlin-parcelize' dependencies { - implementation project(":feature_scanner") + implementation project(":lib_theme") implementation libs.nordic.ble.common implementation libs.nordic.log diff --git a/lib_service/src/main/AndroidManifest.xml b/lib_service/src/main/AndroidManifest.xml index c2a57b13..ea6c2307 100644 --- a/lib_service/src/main/AndroidManifest.xml +++ b/lib_service/src/main/AndroidManifest.xml @@ -2,4 +2,7 @@ + + + \ No newline at end of file diff --git a/lib_service/src/main/java/no/nordicsemi/android/service/BleProfileService.kt b/lib_service/src/main/java/no/nordicsemi/android/service/BleProfileService.kt index 722b89bb..6f5440d8 100644 --- a/lib_service/src/main/java/no/nordicsemi/android/service/BleProfileService.kt +++ b/lib_service/src/main/java/no/nordicsemi/android/service/BleProfileService.kt @@ -42,7 +42,6 @@ import no.nordicsemi.android.ble.BleManagerCallbacks import no.nordicsemi.android.ble.utils.ILogger import no.nordicsemi.android.log.ILogSession import no.nordicsemi.android.log.Logger -import no.nordicsemi.android.scanner.tools.SelectedBluetoothDeviceHolder import javax.inject.Inject @AndroidEntryPoint @@ -68,7 +67,7 @@ abstract class BleProfileService : LifecycleService(), BleManagerCallbacks { * @return bluetooth device */ protected val bluetoothDevice: BluetoothDevice by lazy { - bluetoothDeviceHolder.device ?: throw UnsupportedOperationException( + bluetoothDeviceHolder.device ?: throw IllegalArgumentException( "No device address at EXTRA_DEVICE_ADDRESS key" ) } diff --git a/lib_service/src/main/java/no/nordicsemi/android/service/ForegroundBleService.kt b/lib_service/src/main/java/no/nordicsemi/android/service/ForegroundBleService.kt index 9e25f020..5422ace3 100644 --- a/lib_service/src/main/java/no/nordicsemi/android/service/ForegroundBleService.kt +++ b/lib_service/src/main/java/no/nordicsemi/android/service/ForegroundBleService.kt @@ -22,16 +22,29 @@ package no.nordicsemi.android.service import android.app.Notification +import android.app.NotificationChannel import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Intent import android.os.Build +import androidx.core.app.NotificationCompat + +private const val CHANNEL_ID = "FOREGROUND_BLE_SERVICE" abstract class ForegroundBleService> : BleProfileService() { protected abstract val manager: T + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + val result = super.onStartCommand(intent, flags, startId) + startForegroundService() + return result + } + override fun onDestroy() { // when user has disconnected from the sensor, we have to cancel the notification that we've created some milliseconds before using unbindService cancelNotification() + stopForegroundService() super.onDestroy() } @@ -87,24 +100,30 @@ abstract class ForegroundBleServiceBonding with the device… The device is now bonded. %s is connected. + Background connections + Shows a notification when a device is connected in background. diff --git a/lib_theme/src/main/java/no/nordicsemi/android/theme/Background.kt b/lib_theme/src/main/java/no/nordicsemi/android/theme/Background.kt deleted file mode 100644 index 91797fe9..00000000 --- a/lib_theme/src/main/java/no/nordicsemi/android/theme/Background.kt +++ /dev/null @@ -1,24 +0,0 @@ -package no.nordicsemi.android.theme - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.dp - -object Background { - - @Composable - fun whiteRoundedCorners(): Modifier { - return Modifier - .background(Color(0xffffffff)) - .padding(16.dp) - .verticalScroll(rememberScrollState()) - .clip(RoundedCornerShape(10.dp)) - } -} \ No newline at end of file diff --git a/lib_theme/src/main/java/no/nordicsemi/android/theme/Color.kt b/lib_theme/src/main/java/no/nordicsemi/android/theme/Color.kt index 2bfd9030..82bbd901 100644 --- a/lib_theme/src/main/java/no/nordicsemi/android/theme/Color.kt +++ b/lib_theme/src/main/java/no/nordicsemi/android/theme/Color.kt @@ -1,26 +1,67 @@ package no.nordicsemi.android.theme +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color object NordicColors { - val Primary = Color(0xFF00A9CE) - val PrimaryLight = Color(0xFF5fdbff) - val PrimaryDark = Color(0xFF007a9d) - val Secondary = Color(0xFF0077c8) - val SecondaryLight = Color(0xFF57c0e2) - val SecondaryDark = Color(0xFF004c97) - val Text = Color(0xFF00A9CE) - + val AlmostWhite = Color(0xFFDADADA) val NordicBlue = Color(0xFF00A9CE) - val NordicBlueDark = Color(0xFF0090B0) - val NordicSky = Color(0xFF6AD1E3) - val NordicBlueLate = Color(0xFF0033A0) - val NordicLake = Color(0xFF0077C8) - val NordicLightGray = Color(0xFFD9E1E2) - val NordicMediumGray = Color(0xFF768692) - val NordicDarkGray = Color(0xFF333F48) - val NordicGrass = Color(0xFFD0DF00) - val NordicSun = Color(0xFFFFCD00) - val NordicRed = Color(0xFFEE2F4E) - val NordicFall = Color(0xFFF58220) + val NordicLake = Color(0xFF008CD2) + + val NordicDarkGray = ThemedColor(Color(0xFF333F48), Color(0xFFCCCBC8)) + +// val NordicGray4 = ThemedColor(Color(0xFFD1D1D6), Color(0xFF3A3A3C)) + val NordicGray4 = ThemedColor(Color.White, Color(0xFF3A3A3C)) + + val NordicGray5 = ThemedColor(Color(0xFFE5E5EA), Color(0xFF2C2C2E)) + val NordicLightGray = NeutralColor(Color(0xFF929CA2)) + val NordicMediumGray = NeutralColor(Color(0xFF929CA2)) + + val NordicFall = ThemedColor(Color(0xFFF99535), Color(0xFFFF9F0A)) + val NordicGreen = ThemedColor(Color(0xFF3ED052), Color(0xFF32D74B)) + + val NordicOrange = ThemedColor(Color(0xFFDF9B16), Color(0xFFFF9F0A)) + val NordicRed = ThemedColor(Color(0xFFD03E51), Color(0xFFFF453A)) + val NordicSky = NeutralColor(Color(0xFF6AD1E3)) + val NordicYellow = ThemedColor(Color(0xFFF9EE35), Color(0xFFFFD60A)) + val TableViewBackground = NeutralColor(Color(0xFFF2F2F6)) + val TableViewSeparator = NeutralColor(Color(0xFFD2D2D6)) + + val Primary = ThemedColor(Color(0xFF00A9CE), Color(0xFF212121)) + val PrimaryVariant = ThemedColor(Color(0xFF008CD2), Color.Black) + val Secondary = ThemedColor(Color(0xFF00A9CE), Color(0xFF008CD2)) + val SecondaryVariant = ThemedColor(Color(0xFF008CD2), Color(0xFF008CD2)) + val OnPrimary = ThemedColor(Color.White, Color.White) + val OnSecondary = ThemedColor(Color.White, Color.White) + val OnBackground = ThemedColor(Color.Black, Color.White) + val OnSurface = ThemedColor(Color.Black, Color.White) + val Background = ThemedColor(Color(0xFFDADADA), Color.Black) + val Surface = ThemedColor(Color(0xFFDADADA), Color.Black) +} + +sealed class NordicColor { + + @Composable + abstract fun value(): Color +} + +data class ThemedColor(val light: Color, val dark: Color): NordicColor() { + + @Composable + override fun value(): Color { + return if (isSystemInDarkTheme()) { + dark + } else { + light + } + } +} + +data class NeutralColor(val color: Color): NordicColor() { + + @Composable + override fun value(): Color { + return color + } } diff --git a/lib_theme/src/main/java/no/nordicsemi/android/theme/Theme.kt b/lib_theme/src/main/java/no/nordicsemi/android/theme/Theme.kt index 0620c362..f8fa424e 100644 --- a/lib_theme/src/main/java/no/nordicsemi/android/theme/Theme.kt +++ b/lib_theme/src/main/java/no/nordicsemi/android/theme/Theme.kt @@ -5,41 +5,40 @@ import androidx.compose.material.MaterialTheme import androidx.compose.material.darkColors import androidx.compose.material.lightColors import androidx.compose.runtime.Composable -import androidx.compose.ui.graphics.Color - -//TODO -private val DarkColorPalette = darkColors( - primary = NordicColors.Primary, - primaryVariant = NordicColors.PrimaryDark, - secondary = NordicColors.Secondary, - secondaryVariant = NordicColors.SecondaryDark, - onSecondary = Color.White, - onPrimary = Color.White, - onBackground = Color.Black, - onSurface = Color.Black, - background = Color.White, - surface = Color.White, -) - -private val LightColorPalette = lightColors( - primary = NordicColors.Primary, - primaryVariant = NordicColors.PrimaryDark, - secondary = NordicColors.Secondary, - secondaryVariant = NordicColors.SecondaryDark, - onSecondary = Color.White, - onPrimary = Color.White, - onBackground = Color.Black, - onSurface = Color.Black, - background = Color.White, - surface = Color.White, -) @Composable fun TestTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable() () -> Unit) { + + val darkColorPalette = darkColors( + primary = NordicColors.Primary.value(), + primaryVariant = NordicColors.PrimaryVariant.value(), + secondary = NordicColors.Secondary.value(), + secondaryVariant = NordicColors.SecondaryVariant.value(), + onSecondary = NordicColors.OnSecondary.value(), + onPrimary = NordicColors.OnPrimary.value(), + onBackground = NordicColors.OnBackground.value(), + onSurface = NordicColors.OnSurface.value(), + background = NordicColors.Background.value(), + surface = NordicColors.Surface.value(), + ) + + val lightColorPalette = lightColors( + primary = NordicColors.Primary.value(), + primaryVariant = NordicColors.PrimaryVariant.value(), + secondary = NordicColors.Secondary.value(), + secondaryVariant = NordicColors.SecondaryVariant.value(), + onSecondary = NordicColors.OnSecondary.value(), + onPrimary = NordicColors.OnPrimary.value(), + onBackground = NordicColors.OnBackground.value(), + onSurface = NordicColors.OnSurface.value(), + background = NordicColors.Background.value(), + surface = NordicColors.Surface.value(), + ) + val colors = if (darkTheme) { - DarkColorPalette + darkColorPalette } else { - LightColorPalette + lightColorPalette } MaterialTheme( diff --git a/lib_theme/src/main/java/no/nordicsemi/android/theme/view/BatteryLevelView.kt b/lib_theme/src/main/java/no/nordicsemi/android/theme/view/BatteryLevelView.kt new file mode 100644 index 00000000..0a1fbd52 --- /dev/null +++ b/lib_theme/src/main/java/no/nordicsemi/android/theme/view/BatteryLevelView.kt @@ -0,0 +1,28 @@ +package no.nordicsemi.android.theme.view + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Card +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import no.nordicsemi.android.theme.NordicColors +import no.nordicsemi.android.theme.R + +@Composable +fun BatteryLevelView(batteryLevel: Int) { + Card( + backgroundColor = NordicColors.NordicGray4.value(), + shape = RoundedCornerShape(10.dp), + elevation = 0.dp + ) { + Box(modifier = Modifier.padding(16.dp)) { + KeyValueField( + stringResource(id = R.string.field_battery), + "$batteryLevel%" + ) + } + } +} \ No newline at end of file diff --git a/lib_theme/src/main/java/no/nordicsemi/android/theme/view/KeyValueField.kt b/lib_theme/src/main/java/no/nordicsemi/android/theme/view/KeyValueField.kt new file mode 100644 index 00000000..08e6a58a --- /dev/null +++ b/lib_theme/src/main/java/no/nordicsemi/android/theme/view/KeyValueField.kt @@ -0,0 +1,23 @@ +package no.nordicsemi.android.theme.view + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import no.nordicsemi.android.theme.NordicColors + +@Composable + fun KeyValueField(key: String, value: String) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text(text = key) + Text( + color = NordicColors.NordicDarkGray.value(), + text = value + ) + } +} diff --git a/lib_theme/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/lib_theme/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 00000000..5adfaf9b --- /dev/null +++ b/lib_theme/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,27 @@ + + + + + + + \ No newline at end of file diff --git a/lib_theme/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/lib_theme/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 00000000..5adfaf9b --- /dev/null +++ b/lib_theme/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,27 @@ + + + + + + + \ No newline at end of file diff --git a/lib_theme/src/main/res/mipmap-hdpi/ic_launcher.png b/lib_theme/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 00000000..a6c183f8 Binary files /dev/null and b/lib_theme/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/lib_theme/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/lib_theme/src/main/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..2aaa362c Binary files /dev/null and b/lib_theme/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/lib_theme/src/main/res/mipmap-hdpi/ic_launcher_round.png b/lib_theme/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 00000000..19f21973 Binary files /dev/null and b/lib_theme/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/lib_theme/src/main/res/mipmap-hdpi/ic_shortcut_dfu.png b/lib_theme/src/main/res/mipmap-hdpi/ic_shortcut_dfu.png new file mode 100644 index 00000000..ee51ccab Binary files /dev/null and b/lib_theme/src/main/res/mipmap-hdpi/ic_shortcut_dfu.png differ diff --git a/lib_theme/src/main/res/mipmap-hdpi/ic_shortcut_uart.png b/lib_theme/src/main/res/mipmap-hdpi/ic_shortcut_uart.png new file mode 100644 index 00000000..e1e7bb3b Binary files /dev/null and b/lib_theme/src/main/res/mipmap-hdpi/ic_shortcut_uart.png differ diff --git a/lib_theme/src/main/res/mipmap-mdpi/ic_launcher.png b/lib_theme/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 00000000..245f8fda Binary files /dev/null and b/lib_theme/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/lib_theme/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/lib_theme/src/main/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..adf48f67 Binary files /dev/null and b/lib_theme/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/lib_theme/src/main/res/mipmap-mdpi/ic_launcher_round.png b/lib_theme/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 00000000..3bda01c2 Binary files /dev/null and b/lib_theme/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/lib_theme/src/main/res/mipmap-mdpi/ic_shortcut_dfu.png b/lib_theme/src/main/res/mipmap-mdpi/ic_shortcut_dfu.png new file mode 100644 index 00000000..7278579b Binary files /dev/null and b/lib_theme/src/main/res/mipmap-mdpi/ic_shortcut_dfu.png differ diff --git a/lib_theme/src/main/res/mipmap-mdpi/ic_shortcut_uart.png b/lib_theme/src/main/res/mipmap-mdpi/ic_shortcut_uart.png new file mode 100644 index 00000000..542a6e83 Binary files /dev/null and b/lib_theme/src/main/res/mipmap-mdpi/ic_shortcut_uart.png differ diff --git a/lib_theme/src/main/res/mipmap-xhdpi/ic_launcher.png b/lib_theme/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 00000000..d7b43ec1 Binary files /dev/null and b/lib_theme/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/lib_theme/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/lib_theme/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..b9f42416 Binary files /dev/null and b/lib_theme/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/lib_theme/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/lib_theme/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 00000000..bb452688 Binary files /dev/null and b/lib_theme/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/lib_theme/src/main/res/mipmap-xhdpi/ic_shortcut_dfu.png b/lib_theme/src/main/res/mipmap-xhdpi/ic_shortcut_dfu.png new file mode 100644 index 00000000..b4e8dd08 Binary files /dev/null and b/lib_theme/src/main/res/mipmap-xhdpi/ic_shortcut_dfu.png differ diff --git a/lib_theme/src/main/res/mipmap-xhdpi/ic_shortcut_uart.png b/lib_theme/src/main/res/mipmap-xhdpi/ic_shortcut_uart.png new file mode 100644 index 00000000..c4799b03 Binary files /dev/null and b/lib_theme/src/main/res/mipmap-xhdpi/ic_shortcut_uart.png differ diff --git a/lib_theme/src/main/res/mipmap-xxhdpi/ic_launcher.png b/lib_theme/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 00000000..3fc95be3 Binary files /dev/null and b/lib_theme/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/lib_theme/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/lib_theme/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..196f812f Binary files /dev/null and b/lib_theme/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/lib_theme/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/lib_theme/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 00000000..fa4849f9 Binary files /dev/null and b/lib_theme/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/lib_theme/src/main/res/mipmap-xxhdpi/ic_shortcut_dfu.png b/lib_theme/src/main/res/mipmap-xxhdpi/ic_shortcut_dfu.png new file mode 100644 index 00000000..71181261 Binary files /dev/null and b/lib_theme/src/main/res/mipmap-xxhdpi/ic_shortcut_dfu.png differ diff --git a/lib_theme/src/main/res/mipmap-xxhdpi/ic_shortcut_uart.png b/lib_theme/src/main/res/mipmap-xxhdpi/ic_shortcut_uart.png new file mode 100644 index 00000000..9fe1e193 Binary files /dev/null and b/lib_theme/src/main/res/mipmap-xxhdpi/ic_shortcut_uart.png differ diff --git a/lib_theme/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/lib_theme/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 00000000..570c6d71 Binary files /dev/null and b/lib_theme/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/lib_theme/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/lib_theme/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..73d15a60 Binary files /dev/null and b/lib_theme/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/lib_theme/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/lib_theme/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 00000000..93fc534d Binary files /dev/null and b/lib_theme/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/lib_theme/src/main/res/mipmap-xxxhdpi/ic_shortcut_dfu.png b/lib_theme/src/main/res/mipmap-xxxhdpi/ic_shortcut_dfu.png new file mode 100644 index 00000000..e6d24cad Binary files /dev/null and b/lib_theme/src/main/res/mipmap-xxxhdpi/ic_shortcut_dfu.png differ diff --git a/lib_theme/src/main/res/mipmap-xxxhdpi/ic_shortcut_uart.png b/lib_theme/src/main/res/mipmap-xxxhdpi/ic_shortcut_uart.png new file mode 100644 index 00000000..4319ac2d Binary files /dev/null and b/lib_theme/src/main/res/mipmap-xxxhdpi/ic_shortcut_uart.png differ diff --git a/lib_theme/src/main/res/values-night/colors.xml b/lib_theme/src/main/res/values-night/colors.xml new file mode 100644 index 00000000..c2f2aef6 --- /dev/null +++ b/lib_theme/src/main/res/values-night/colors.xml @@ -0,0 +1,11 @@ + + + #FF212121 + @android:color/black + #FF008CD2 + #FF008CD2 + #FFFFFFFF + #FFFFFFFF + @android:color/black + #FF0090B0 + \ No newline at end of file diff --git a/lib_theme/src/main/res/values/colors.xml b/lib_theme/src/main/res/values/colors.xml index 009a9678..cdc236f2 100644 --- a/lib_theme/src/main/res/values/colors.xml +++ b/lib_theme/src/main/res/values/colors.xml @@ -6,4 +6,6 @@ #FF0077c8 #FF004c97 #FFFFFFFF + #FFDADADA + #FF0090B0 diff --git a/lib_theme/src/main/res/values/strings.xml b/lib_theme/src/main/res/values/strings.xml new file mode 100644 index 00000000..522d4b3d --- /dev/null +++ b/lib_theme/src/main/res/values/strings.xml @@ -0,0 +1,7 @@ + + + nRF Toolbox + + Disconnect + Battery + \ No newline at end of file diff --git a/lib_theme/src/main/res/values/themes.xml b/lib_theme/src/main/res/values/themes.xml index c9f88cf9..4d31cda5 100644 --- a/lib_theme/src/main/res/values/themes.xml +++ b/lib_theme/src/main/res/values/themes.xml @@ -2,6 +2,7 @@