Migration to new BLEK library (#143)

* Changed view.

* Clear messages.

* Clear messages.

* Add or delete configuration.

* Fixed configuration picker.

* Edit configuration.

* Create new macro.

* removed unnecessary resource files.

* Fixed running macro command.

* Delete macro

* Edit macro

* Changed to peripheral name.

* Show peripheral name.

* Fixed Eol tab design.

* Removed icon resource

* String changes

* Removed any permission from home view.

* Clear device after disconnection.

* 1 line app bar

* Changed missing services text.

* Throughput service view changes.

* Throughput service fixes.

* Removed unused resources.

* Fixed Health temperature profile.

* Show heart rate.

* Fixed hrs view.

* Show heart rate data from left to right in the chart.

* Changed chart color, solid, and scroll to see history.

* Horizontal grid hidden, in case needed.

* HTS view update

* Changed padding.

* Removed circular icon background.

* Updated Battery level view.

* Updated hrs body sensor location.

* Moved ui mappers into view.

* Updated gls view.

* Changed focus color.

* Fixed issue with job.

* Fixed bps.

* Added Blood pressure feature uuid.

* Added blood pressure feature data.

* Added rscs feature data.

* Fixed cscs view.

* Show supported features.

* Fixed ui

* Suspend the service discovery for GLS and CGMS until bonding is completed.

* Added suspend on the function level.

* Bonding state check only to cgms service

* Removed stacktrace print.

* Make cgms record available within a scrollable box

* Changed to gray color.

* removed padding

* Fix height for output section.

* onExpand click event.

* Added todo for 9th item.

* Removed unused code block.

* When in focus, reduce the hint text alpha value.

* Show empty text error.

* Clear focus on tap outside.

* Add border when focused.

* Propagate focus changes.

* CGM graph

* Added sample of one to many uart configuration database.

* Added device and configuration entities.

* Fixed issue with only showing last item from the list.

* Changed configuration database irrespective of device address.

* File rename.

* Added last configuration datastore.

* Check if configuration name is unique

* Removed Macro text.

* Included x and y axis data points.

* Added channel sounding service uuid.

* Upgraded agp version to 2.7.

* Added channel sounding manager.

* Downgraded datastore preference to 1.1.4.

* Changed to nordic colors.

* Added ranging permission.

* channel sounding repository

* channel sounding service data

* channel sounding profile

* channel sounding profile in viewmodel

* channel sounding manager class

* channel sounding testing

* CS service characteristics

* Create bonding before channel sounding connection.

* Clean up.

* Added LBS profile

* Read/write data to LBS

* LBS ui events

* LBS service

* LBS profile

* LBS ui

* Agp upgrade

* Fixed LBS profile

* Removed focus

* Changed macro size to 9

* Changed macro color

* Show macro in bottom sheet

* View refactoring

* Added Blek dependency

* Added utils dependency

* rename

* Removed unused event

* reorganization

* uart macro view update

* background color update

* different color for input and output message type

* Changed to uart event

* removed duplicate

* rename

* auto scroll to new record

* removed unused dependency

* Fixed crash with ChannelSoundingManager injection.

* Require bonding only if it has bonding information

* Changed disconnection

* CGMS graph

* changes in the home view

* Home view fixes

* changed color

* Show MacroEol character in the input message.

* Home view icon fixes.

* Cadence data parser fixes

* Fixed CSC settings view.

* Fixed rscs view

* hiding graphs until its finished

* Removed duplicate

* Fixed RSCS view

* Fixed notification icon

* fixed csc module name

* Fixed icon cutoff

* Fixed CSCDataParser

* Fixed CGMS profile

* Fixed GLS view

* Fixed GLS strings

* Fixed HTS view

* Fixed HTS view

* title change

* Added hts timestamp

* Deleted verbose text

* UART: changed macro/configuration to preset

* UART: fixed input text field

* UART: removed expandable/collapsable preset

* UART: added extra warning to delete action

* UART: don't trim message end.

* UART: message section

* UART: configuration fixed

* UART: configuration fixed

* Fix crash when disconnecting before MTU change completes

* Disabled incomplete PRX profile

* Moved non-composable lambdas to parameters

* refactoring display text

* Fixed channel sounding screen

* Disconnect on missing services before navigation

* Fixed label name

* Tailored disconnection message.

* Tailored disconnection message.

* Moved profile file to utils

* App analytics events and modes

* Integrated analytics with the profile actions.

* Show only first non-battery service if multiple services are present.

* Fixed window insets for camera notch.

* Fixed glucose measurement context.

* Fixed glucose concentration unit.

* Fixed duplicate analytics update.

* rename

* refactoring text

* Handled disconnecting event.

* Replaced with LazyColumn

* Fixed window insets

* Replaced TitleAppBar with NordicAppBar

* Show device address

* Show multiple service names if available.

* Fixed padding

* BPS: Fixed waiting for measurement view.

* BPS: view

* GLS: Fixed padding

* Ui: Fixed dialog

* RSCS: fixed distance formatting error

* CGMS: ui consistency

* DFS: ui fixes

* Replaced local scanner with common library scanner.

* Fixed padding

* reorganization

* Removed previous uart module

* Text with animated three dots

* HTS: text fixes

* formatting texts

* changed text style

* fixed string

* Fixed HRS, not completed

* DFS: fixed ui

* HRS: graph fixes

* UART: scroll up when keyboard is visible

* Uart input: Add focus

* Uart fix: input text field

* UART: created rememberImeState

* HRS: heart rate ui fixes

* profile view scrollable fix

* DFS: ui fixes

* Fixed logger

* Check if the battery characteristics supports NOTIFY or INDICATE property

* Dependency update

* Changed background color

* cleanup

* Fixed distance measurement data update.

* Filtered devices with testing address

* Added preview data

* Fixed section view

* Fixed elevation view

* Removed duplicate views

* Fixes control points

* String fixes

* Elevation view fixes

* Range slider view update

* Fixed DFS views

* Fixed DFS ui

* Fixed DFS views

* Separated views

* Separated profile viewmodel into individual profile view models.

* AGP upgrade

* Job canceled and make jobs null on clear

* Profile name update

* Request maximum MTU size only if it is not already set.

* Fixed null pointer exception

* Battery characteristics read property check

* Fixed early mtu request

* Removed garbage states

* Removed logs

* Removed multiple vertical scroll

* Fixed padding

* Ui fixes

* File reorganization

* Fixed previous configuration not loading on reconnection

* Removed unused files

* Dependency update

* Renamed module name

* Removed unused dependencies

* Added param

* Removed unused code block

* Code optimization

* Removed unused file

* Readme update

* Hide Channel sounding until implementation is complete

* Handled initial state closed

* revert changes

* Added library as module placeholder

* Fixed multiple flows for the same peripheral

* Request mtu size only when needed

* Readme update
This commit is contained in:
Himali Aryal
2025-07-30 14:51:02 +02:00
committed by GitHub
parent 9a71e66c10
commit b67abd60e6
513 changed files with 19164 additions and 14446 deletions

View File

@@ -11,10 +11,12 @@ It contains applications demonstrating standard Bluetooth LE profiles:
* **Health Thermometer Monitor**,
* **Glucose Monitor**,
* **Continuous Glucose Monitor**,
* **Proximity Monitor**
* **Universal Asynchronous Receiver/Transmitter (UART)**,
* **Throughput**,
* **Direction Finder**,
* **Blinky (LBS) Service**
Since version 1.10.0 the *nRF Toolbox* also supports the **Nordic UART Service** which may be used
for bidirectional text communication between devices.
**_NOTE:_** The Proximity profile is not included in this version of the app. If you need it, please download the previous version.
### How to import to Android Studio

View File

@@ -31,9 +31,10 @@
plugins {
alias(libs.plugins.nordic.application.compose)
alias(libs.plugins.nordic.hilt)
alias(libs.plugins.kotlin.parcelize)
}
if (getGradle().getStartParameter().getTaskRequests().toString().contains("Release")) {
if (getGradle().startParameter.taskRequests.toString().contains("Release")) {
apply(plugin = "com.google.gms.google-services")
apply(plugin = "com.google.firebase.crashlytics")
}
@@ -43,44 +44,31 @@ android {
}
dependencies {
//Hilt requires to implement every module in the main app module
//https://github.com/google/dagger/issues/2123
implementation(project(":profile_bps"))
implementation(project(":profile_csc"))
implementation(project(":profile_cgms"))
implementation(project(":profile_gls"))
implementation(project(":profile_hrs"))
implementation(project(":profile_hts"))
implementation(project(":profile_prx"))
implementation(project(":profile_rscs"))
implementation(project(":profile_uart"))
implementation(project(":lib_analytics"))
implementation(project(":profile-parsers"))
implementation(project(":profile_manager"))
implementation(project(":profile"))
implementation(project(":profile_data"))
implementation(project(":lib_ui"))
implementation(project(":lib_utils"))
implementation(project(":lib_service"))
implementation(project(":lib_scanner"))
implementation(libs.nordic.core)
implementation(libs.nordic.theme)
implementation(libs.nordic.navigation)
implementation(libs.nordic.blek.uiscanner)
implementation(libs.nordic.theme)
implementation(libs.nordic.logger)
implementation(libs.nordic.permissions.ble)
implementation(libs.nordic.analytics)
implementation(libs.nordic.blek.client)
implementation(libs.nordic.ui)
implementation(libs.nordic.core)
implementation(libs.nordic.scanner.ble)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.material.iconsExtended)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.compose.material.iconsExtended)
implementation(libs.androidx.compose.runtime)
implementation(libs.androidx.hilt.navigation.compose)
// Timber & SLF4J
implementation (libs.slf4j.timber)
implementation(libs.nordic.log.timber)
implementation(libs.nordic.blek.client.android)
}

View File

@@ -35,8 +35,6 @@ import android.app.Application
import dagger.hilt.android.HiltAndroidApp
import no.nordicsemi.android.analytics.AppAnalytics
import no.nordicsemi.android.analytics.AppOpenEvent
import no.nordicsemi.android.gls.GLSServer
import no.nordicsemi.android.uart.UartServer
import timber.log.Timber
import javax.inject.Inject
@@ -46,20 +44,11 @@ class NrfToolboxApplication : Application() {
@Inject
lateinit var analytics: AppAnalytics
@Inject
lateinit var uartServer: UartServer
@Inject
lateinit var glsServer: GLSServer
override fun onCreate() {
super.onCreate()
analytics.logEvent(AppOpenEvent)
uartServer.start(this)
glsServer.start(this)
Timber.plant(Timber.DebugTree())
}
}

View File

@@ -33,6 +33,13 @@
<manifest xmlns:tools="http://schemas.android.com/tools"
xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
<queries>
<package android:name="no.nordicsemi.android.dfu" />

View File

@@ -31,45 +31,10 @@
package no.nordicsemi.android.nrftoolbox
import no.nordicsemi.android.bps.view.BPSScreen
import no.nordicsemi.android.cgms.view.CGMScreen
import no.nordicsemi.android.common.navigation.createSimpleDestination
import no.nordicsemi.android.common.navigation.defineDestination
import no.nordicsemi.android.csc.view.CSCScreen
import no.nordicsemi.android.gls.main.view.GLSScreen
import no.nordicsemi.android.hrs.view.HRSScreen
import no.nordicsemi.android.hts.view.HTSScreen
import no.nordicsemi.android.nrftoolbox.view.HomeScreen
import no.nordicsemi.android.prx.view.PRXScreen
import no.nordicsemi.android.rscs.view.RSCSScreen
import no.nordicsemi.android.toolbox.scanner.ScannerDestination
import no.nordicsemi.android.uart.view.UARTScreen
import no.nordicsemi.android.nrftoolbox.view.HomeView
val HomeDestinationId = createSimpleDestination("home-destination")
val HomeDestinations = listOf(
defineDestination(HomeDestinationId) { HomeScreen() },
ScannerDestination
)
val CSCDestinationId = createSimpleDestination("csc-destination")
val HRSDestinationId = createSimpleDestination("hrs-destination")
val HTSDestinationId = createSimpleDestination("hts-destination")
val GLSDestinationId = createSimpleDestination("gls-destination")
val BPSDestinationId = createSimpleDestination("bps-destination")
val PRXDestinationId = createSimpleDestination("prx-destination")
val RSCSDestinationId = createSimpleDestination("rscs-destination")
val CGMSDestinationId = createSimpleDestination("cgms-destination")
val UARTDestinationId = createSimpleDestination("uart-destination")
val ProfileDestinations = listOf(
defineDestination(CSCDestinationId) { CSCScreen() },
defineDestination(HRSDestinationId) { HRSScreen() },
defineDestination(HTSDestinationId) { HTSScreen() },
defineDestination(GLSDestinationId) { GLSScreen() },
defineDestination(BPSDestinationId) { BPSScreen() },
defineDestination(PRXDestinationId) { PRXScreen() },
defineDestination(RSCSDestinationId) { RSCSScreen() },
defineDestination(CGMSDestinationId) { CGMScreen() },
defineDestination(UARTDestinationId) { UARTScreen() },
)
val HomeDestinations = defineDestination(HomeDestinationId) { HomeView() }

View File

@@ -42,17 +42,11 @@ import no.nordicsemi.android.common.analytics.view.AnalyticsPermissionRequestDia
import no.nordicsemi.android.common.navigation.NavigationView
import no.nordicsemi.android.common.theme.NordicActivity
import no.nordicsemi.android.common.theme.NordicTheme
import no.nordicsemi.android.gls.GLSDestination
import no.nordicsemi.android.nrftoolbox.repository.ActivitySignals
import no.nordicsemi.android.toolbox.scanner.ScannerDestination
import javax.inject.Inject
import no.nordicsemi.android.toolbox.profile.ProfileDestination
@AndroidEntryPoint
class MainActivity : NordicActivity() {
@Inject
lateinit var activitySignals: ActivitySignals
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -62,7 +56,7 @@ class MainActivity : NordicActivity() {
color = MaterialTheme.colorScheme.surface,
modifier = Modifier.fillMaxSize()
) {
NavigationView(HomeDestinations + ProfileDestinations + ScannerDestination + GLSDestination)
NavigationView(HomeDestinations + ScannerDestination + ProfileDestination)
}
AnalyticsPermissionRequestDialog()
@@ -70,8 +64,4 @@ class MainActivity : NordicActivity() {
}
}
override fun onResume() {
super.onResume()
activitySignals.onResume()
}
}

View File

@@ -0,0 +1,46 @@
package no.nordicsemi.android.nrftoolbox
import androidx.hilt.navigation.compose.hiltViewModel
import no.nordicsemi.android.common.navigation.createDestination
import no.nordicsemi.android.common.navigation.defineDestination
import no.nordicsemi.android.common.navigation.viewmodel.SimpleNavigationViewModel
import no.nordicsemi.android.common.scanner.DeviceSelected
import no.nordicsemi.android.common.scanner.ScannerScreen
import no.nordicsemi.android.common.scanner.ScanningCancelled
import no.nordicsemi.android.common.scanner.data.OnlyNearby
import no.nordicsemi.android.common.scanner.data.OnlyWithNames
import no.nordicsemi.android.common.scanner.rememberFilterState
import no.nordicsemi.android.toolbox.profile.ProfileDestinationId
import no.nordicsemi.kotlin.ble.client.android.ScanResult
val ScannerDestinationId = createDestination<Unit, ScanResult>("ble-scanner")
val ScannerDestination = defineDestination(ScannerDestinationId) {
val navigationVM = hiltViewModel<SimpleNavigationViewModel>()
ScannerScreen(
cancellable = true,
state = rememberFilterState(
dynamicFilters = listOf(
OnlyNearby(),
OnlyWithNames(),
)
),
onResultSelected = {
when (it) {
is DeviceSelected -> {
navigationVM.navigateTo(ProfileDestinationId, it.scanResult.peripheral.address)
{
popUpTo(ScannerDestinationId.toString()) {
inclusive = true
}
}
}
ScanningCancelled -> {
navigationVM.navigateUp()
}
}
}
)
}

View File

@@ -1,10 +1,11 @@
package no.nordicsemi.android.nrftoolbox
package no.nordicsemi.android.nrftoolbox.di
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@Module
@@ -12,5 +13,5 @@ import kotlinx.coroutines.SupervisorJob
class ApplicationScopeModule {
@Provides
fun applicationScope() = CoroutineScope(SupervisorJob())
fun applicationScope() = CoroutineScope(SupervisorJob() + Dispatchers.IO)
}

View File

@@ -0,0 +1,24 @@
package no.nordicsemi.android.nrftoolbox.di
import android.content.Context
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineScope
import no.nordicsemi.kotlin.ble.client.android.CentralManager
import no.nordicsemi.kotlin.ble.client.android.native
@Module
@InstallIn(SingletonComponent::class)
object CentralManagerModule {
@Provides
fun provideCentralManager(
@ApplicationContext context: Context,
scope: CoroutineScope
): CentralManager {
return CentralManager.Factory.native(context, scope)
}
}

View File

@@ -1,48 +0,0 @@
/*
* Copyright (c) 2022, 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.nrftoolbox.repository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class ActivitySignals @Inject constructor() {
private val _onResumeTrigger = MutableStateFlow(false)
val state = _onResumeTrigger.asStateFlow()
fun onResume() {
_onResumeTrigger.value = !_onResumeTrigger.value
}
}

View File

@@ -1,120 +1,76 @@
/*
* Copyright (c) 2022, 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.nrftoolbox.view
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.Column
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.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.painterResource
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 no.nordicsemi.android.nrftoolbox.R
@Composable
fun FeatureButton(
internal fun FeatureButton(
@DrawableRes iconId: Int,
@StringRes nameCode: Int,
@StringRes name: Int,
isRunning: Boolean? = null,
@StringRes description: Int? = null,
@StringRes description: Int,
profileNames: List<String> = listOf(stringResource(description)),
deviceName: String?,
deviceAddress: String,
onClick: () -> Unit
) {
OutlinedCard(onClick = onClick) {
OutlinedCard(onClick = onClick, modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier.padding(16.dp).fillMaxWidth(),
modifier = Modifier
.padding(16.dp)
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
val color = if (isRunning == true) {
colorResource(id = R.color.nordicGrass)
} else {
MaterialTheme.colorScheme.secondary
}
Image(
painter = painterResource(iconId),
contentDescription = stringResource(id = name),
contentScale = ContentScale.Crop,
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSecondary),
contentDescription = stringResource(id = description),
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary),
modifier = Modifier
.size(64.dp)
.clip(CircleShape)
.background(color)
.padding(16.dp)
.size(40.dp)
)
Spacer(modifier = Modifier.size(16.dp))
Column(modifier = Modifier.weight(1f)) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
modifier = Modifier.fillMaxWidth(),
text = stringResource(id = name),
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center
text = deviceName ?: stringResource(R.string.unknown_device),
style = MaterialTheme.typography.titleMedium,
)
Text(
modifier = Modifier.fillMaxWidth(),
text = profileNames.joinToString(", "),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
description?.let {
Spacer(modifier = Modifier.size(4.dp))
Text(
modifier = Modifier.fillMaxWidth(),
text = stringResource(id = it),
style = MaterialTheme.typography.labelMedium,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Text(
modifier = Modifier.fillMaxWidth(),
text = deviceAddress,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
@@ -123,5 +79,64 @@ fun FeatureButton(
@Preview
@Composable
private fun FeatureButtonPreview() {
FeatureButton(R.drawable.ic_csc, R.string.csc_module, R.string.csc_module_full) { }
FeatureButton(
R.drawable.ic_csc,
R.string.csc_module_full,
listOf("Cycling Speed and Cadence", "Cycling Speed Sensor"),
"Testing peripheral",
deviceAddress = "AA:BB:CC:DD:EE:FF",
) { }
}
@Composable
internal fun FeatureButton(
iconId: ImageVector,
@StringRes description: Int,
profileNames: List<String> = listOf(stringResource(description)),
deviceName: String?,
deviceAddress: String,
onClick: () -> Unit
) {
OutlinedCard(onClick = onClick, modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier
.padding(16.dp)
.fillMaxWidth(),
verticalAlignment = Alignment.Top,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Image(
imageVector = iconId,
contentDescription = deviceAddress,
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary),
modifier = Modifier
.size(40.dp)
)
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
modifier = Modifier.fillMaxWidth(),
text = deviceName ?: stringResource(R.string.unknown_device),
style = MaterialTheme.typography.titleMedium,
)
Text(
modifier = Modifier.fillMaxWidth(),
text = profileNames.joinToString(", "),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
modifier = Modifier.fillMaxWidth(),
text = deviceAddress,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}

View File

@@ -1,263 +1,344 @@
/*
* Copyright (c) 2022, 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.nrftoolbox.view
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.displayCutout
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.union
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Lightbulb
import androidx.compose.material.icons.filled.SocialDistance
import androidx.compose.material.icons.filled.SyncAlt
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import no.nordicsemi.android.analytics.Link
import no.nordicsemi.android.analytics.Profile
import no.nordicsemi.android.analytics.ProfileOpenEvent
import no.nordicsemi.android.nrftoolbox.BPSDestinationId
import no.nordicsemi.android.nrftoolbox.BuildConfig
import no.nordicsemi.android.nrftoolbox.CGMSDestinationId
import no.nordicsemi.android.nrftoolbox.CSCDestinationId
import no.nordicsemi.android.nrftoolbox.GLSDestinationId
import no.nordicsemi.android.nrftoolbox.HRSDestinationId
import no.nordicsemi.android.nrftoolbox.HTSDestinationId
import no.nordicsemi.android.nrftoolbox.PRXDestinationId
import no.nordicsemi.android.common.analytics.view.AnalyticsPermissionButton
import no.nordicsemi.android.common.ui.view.NordicAppBar
import no.nordicsemi.android.nrftoolbox.R
import no.nordicsemi.android.nrftoolbox.RSCSDestinationId
import no.nordicsemi.android.nrftoolbox.UARTDestinationId
import no.nordicsemi.android.nrftoolbox.viewmodel.HomeViewModel
import no.nordicsemi.android.nrftoolbox.viewmodel.UiEvent
import no.nordicsemi.android.toolbox.lib.utils.Profile
private const val DFU_PACKAGE_NAME = "no.nordicsemi.android.dfu"
private const val DFU_LINK = "https://play.google.com/store/apps/details?id=no.nordicsemi.android.dfu"
private const val LOGGER_PACKAGE_NAME = "no.nordicsemi.android.log"
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HomeScreen() {
val viewModel: HomeViewModel = hiltViewModel()
internal fun HomeView() {
val viewModel = hiltViewModel<HomeViewModel>()
val state by viewModel.state.collectAsStateWithLifecycle()
val onEvent: (UiEvent) -> Unit = { viewModel.onClickEvent(it) }
Scaffold(
contentWindowInsets = WindowInsets(0, 0, 0, 0),
topBar = {
TitleAppBar(stringResource(id = R.string.app_name))
}
) {
Column(
modifier = Modifier
.padding(it)
.fillMaxSize()
.verticalScroll(rememberScrollState())
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 16.dp)
NordicAppBar(
title = { Text(stringResource(id = R.string.app_name)) },
showBackButton = false,
) {
Spacer(modifier = Modifier.height(16.dp))
AnalyticsPermissionButton()
}
},
floatingActionButton = {
ExtendedFloatingActionButton(
onClick = { onEvent(UiEvent.OnConnectDeviceClick) },
modifier = Modifier.padding(top = 8.dp, bottom = 16.dp, end = 8.dp, start = 8.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = "Add device from scanner"
)
Text(text = stringResource(R.string.connect_device))
}
}
}
) { paddingValues ->
// Get notch padding for devices with a display cutout (notch)
val notchPadding = WindowInsets.displayCutout
.union(WindowInsets(left = 8.dp, right = 8.dp, top = 8.dp, bottom = 8.dp))
.only(WindowInsetsSides.Horizontal)
.asPaddingValues()
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
.padding(paddingValues),
contentPadding = notchPadding,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
item {
// Show the title at the top
Text(
text = stringResource(id = R.string.viewmodel_profiles),
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.fillMaxWidth(),
text = stringResource(R.string.connected_devices),
modifier = Modifier
.alpha(0.5f)
.padding(start = 16.dp),
)
if (state.connectedDevices.isNotEmpty()) {
Column(
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.fillMaxWidth()
) {
state.connectedDevices.values.forEach { (peripheral, services) ->
// Skip if no services
if (services.isEmpty()) return@forEach
// Case 1: If only one service, show it directly like battery service
if (services.size == 1 && services.first().profile == Profile.BATTERY) {
FeatureButton(
iconId = R.drawable.ic_battery,
description = R.string.battery_module_full,
deviceName = peripheral.name,
deviceAddress = peripheral.address,
onClick = {
onEvent(
UiEvent.OnDeviceClick(
peripheral.address,
services.first().profile
)
)
},
)
}
// Case 2: Show the first *non-Battery* profile.
// This ensures only one service is shown per peripheral when multiple services are available.
services.firstOrNull { it.profile != Profile.BATTERY }
?.let { serviceManager ->
when (serviceManager.profile) {
Profile.HRS -> FeatureButton(
iconId = R.drawable.ic_hrs,
description = R.string.hrs_module_full,
deviceName = peripheral.name,
profileNames = services.map { it.profile.toString() },
deviceAddress = peripheral.address,
onClick = {
onEvent(
UiEvent.OnDeviceClick(
peripheral.address,
serviceManager.profile
)
)
},
)
Spacer(modifier = Modifier.height(16.dp))
Profile.HTS -> FeatureButton(
iconId = R.drawable.ic_hts,
description = R.string.hts_module_full,
deviceName = peripheral.name,
deviceAddress = peripheral.address,
profileNames = services.map { it.profile.toString() },
onClick = {
onEvent(
UiEvent.OnDeviceClick(
peripheral.address,
serviceManager.profile
)
)
},
)
FeatureButton(R.drawable.ic_gls, R.string.gls_module, R.string.gls_module_full) {
viewModel.openProfile(GLSDestinationId)
viewModel.logEvent(ProfileOpenEvent(Profile.GLS))
}
Profile.BPS -> FeatureButton(
iconId = R.drawable.ic_bps,
description = R.string.bps_module_full,
deviceName = peripheral.name,
deviceAddress = peripheral.address,
profileNames = services.map { it.profile.toString() },
onClick = {
onEvent(
UiEvent.OnDeviceClick(
peripheral.address,
serviceManager.profile
)
)
},
)
Spacer(modifier = Modifier.height(16.dp))
Profile.GLS -> FeatureButton(
iconId = R.drawable.ic_gls,
description = R.string.gls_module_full,
deviceName = peripheral.name,
deviceAddress = peripheral.address,
profileNames = services.map { it.profile.toString() },
onClick = {
onEvent(
UiEvent.OnDeviceClick(
peripheral.address,
serviceManager.profile
)
)
},
)
FeatureButton(R.drawable.ic_bps, R.string.bps_module, R.string.bps_module_full) {
viewModel.openProfile(BPSDestinationId)
viewModel.logEvent(ProfileOpenEvent(Profile.BPS))
}
Profile.CGM -> FeatureButton(
iconId = R.drawable.ic_cgm,
description = R.string.cgm_module_full,
deviceName = peripheral.name,
deviceAddress = peripheral.address,
profileNames = services.map { it.profile.toString() },
onClick = {
onEvent(
UiEvent.OnDeviceClick(
peripheral.address,
serviceManager.profile
)
)
},
)
Spacer(modifier = Modifier.height(16.dp))
Profile.RSCS -> FeatureButton(
iconId = R.drawable.ic_rscs,
description = R.string.rscs_module_full,
deviceName = peripheral.name,
deviceAddress = peripheral.address,
profileNames = services.map { it.profile.toString() },
onClick = {
onEvent(
UiEvent.OnDeviceClick(
peripheral.address,
serviceManager.profile
)
)
},
)
Text(
text = stringResource(id = R.string.service_profiles),
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.fillMaxWidth(),
)
Profile.DFS -> FeatureButton(
iconId = R.drawable.ic_distance,
description = R.string.direction_module_full,
deviceName = peripheral.name,
deviceAddress = peripheral.address,
profileNames = services.map { it.profile.toString() },
onClick = {
onEvent(
UiEvent.OnDeviceClick(
peripheral.address,
serviceManager.profile
)
)
},
)
Spacer(modifier = Modifier.height(16.dp))
Profile.CSC -> FeatureButton(
iconId = R.drawable.ic_csc,
description = R.string.csc_module_full,
deviceName = peripheral.name,
deviceAddress = peripheral.address,
profileNames = services.map { it.profile.toString() },
onClick = {
onEvent(
UiEvent.OnDeviceClick(
peripheral.address,
serviceManager.profile
)
)
},
)
FeatureButton(
R.drawable.ic_csc,
R.string.csc_module,
R.string.csc_module_full,
state.isCSCModuleRunning
) {
viewModel.openProfile(CSCDestinationId)
viewModel.logEvent(ProfileOpenEvent(Profile.CSC))
}
Profile.THROUGHPUT -> {
FeatureButton(
iconId = Icons.Default.SyncAlt,
description = R.string.throughput_module,
deviceName = peripheral.name,
deviceAddress = peripheral.address,
profileNames = services.map { it.profile.toString() },
onClick = {
onEvent(
UiEvent.OnDeviceClick(
peripheral.address,
serviceManager.profile
)
)
},
)
}
Spacer(modifier = Modifier.height(16.dp))
Profile.UART -> {
FeatureButton(
iconId = R.drawable.ic_uart,
description = R.string.uart_module_full,
deviceName = peripheral.name,
deviceAddress = peripheral.address,
profileNames = services.map { it.profile.toString() },
onClick = {
onEvent(
UiEvent.OnDeviceClick(
peripheral.address,
serviceManager.profile
)
)
},
)
}
FeatureButton(
R.drawable.ic_hrs,
R.string.hrs_module,
R.string.hrs_module_full,
state.isHRSModuleRunning
) {
viewModel.openProfile(HRSDestinationId)
viewModel.logEvent(ProfileOpenEvent(Profile.HRS))
}
Profile.CHANNEL_SOUNDING -> {
FeatureButton(
iconId = Icons.Default.SocialDistance,
description = R.string.channel_sounding_module,
deviceName = peripheral.name,
deviceAddress = peripheral.address,
profileNames = services.map { it.profile.toString() },
onClick = {
onEvent(
UiEvent.OnDeviceClick(
peripheral.address,
serviceManager.profile
)
)
},
)
}
Spacer(modifier = Modifier.height(16.dp))
Profile.LBS -> {
FeatureButton(
iconId = Icons.Default.Lightbulb,
description = R.string.lbs_blinky_module,
deviceName = peripheral.name,
deviceAddress = peripheral.address,
profileNames = services.map { it.profile.toString() },
onClick = {
onEvent(
UiEvent.OnDeviceClick(
peripheral.address,
serviceManager.profile
)
)
},
)
}
FeatureButton(
R.drawable.ic_hts,
R.string.hts_module,
R.string.hts_module_full,
state.isHTSModuleRunning
) {
viewModel.openProfile(HTSDestinationId)
viewModel.logEvent(ProfileOpenEvent(Profile.HTS))
}
Spacer(modifier = Modifier.height(16.dp))
FeatureButton(
R.drawable.ic_rscs,
R.string.rscs_module,
R.string.rscs_module_full,
state.isRSCSModuleRunning
) {
viewModel.openProfile(RSCSDestinationId)
viewModel.logEvent(ProfileOpenEvent(Profile.RSCS))
}
Spacer(modifier = Modifier.height(16.dp))
FeatureButton(
R.drawable.ic_cgm,
R.string.cgm_module,
R.string.cgm_module_full,
state.isCGMModuleRunning
) {
viewModel.openProfile(CGMSDestinationId)
viewModel.logEvent(ProfileOpenEvent(Profile.CGMS))
}
Spacer(modifier = Modifier.height(16.dp))
FeatureButton(
R.drawable.ic_prx,
R.string.prx_module,
R.string.prx_module_full,
state.isPRXModuleRunning
) {
viewModel.openProfile(PRXDestinationId)
viewModel.logEvent(ProfileOpenEvent(Profile.PRX))
}
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(id = R.string.utils_services),
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(16.dp))
FeatureButton(
R.drawable.ic_uart,
R.string.uart_module,
R.string.uart_module_full,
state.isUARTModuleRunning
) {
viewModel.openProfile(UARTDestinationId)
viewModel.logEvent(ProfileOpenEvent(Profile.UART))
}
Spacer(modifier = Modifier.height(16.dp))
val uriHandler = LocalUriHandler.current
val context = LocalContext.current
val packageManger = context.packageManager
val description = packageManger.getLaunchIntentForPackage(DFU_PACKAGE_NAME)?.let {
R.string.dfu_module_info
} ?: R.string.dfu_module_install
FeatureButton(R.drawable.ic_dfu, R.string.dfu_module, R.string.dfu_module_full, null, description) {
val intent = packageManger.getLaunchIntentForPackage(DFU_PACKAGE_NAME)
if (intent != null) {
context.startActivity(intent)
} else {
uriHandler.openUri(DFU_LINK)
else -> {
// TODO: Add more profiles
}
}
}
}
}
viewModel.logEvent(ProfileOpenEvent(Link.DFU))
} else {
NoConnectedDeviceView()
}
Spacer(modifier = Modifier.height(16.dp))
val loggerDescription = packageManger.getLaunchIntentForPackage(LOGGER_PACKAGE_NAME)?.let {
R.string.logger_module_info
} ?: R.string.dfu_module_install
FeatureButton(
R.drawable.ic_logger,
R.string.logger_module,
R.string.logger_module_full,
null,
loggerDescription
) {
viewModel.openLogger()
viewModel.logEvent(ProfileOpenEvent(Link.LOGGER))
}
Spacer(modifier = Modifier.height(16.dp))
Text(
text = BuildConfig.VERSION_NAME,
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(16.dp))
}
item {
Links { onEvent(it) }
}
}
}

View File

@@ -1,48 +0,0 @@
/*
* Copyright (c) 2022, 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.nrftoolbox.view
data class HomeViewState(
val isCSCModuleRunning: Boolean = false,
val isHRSModuleRunning: Boolean = false,
val isHTSModuleRunning: Boolean = false,
val isRSCSModuleRunning: Boolean = false,
val isPRXModuleRunning: Boolean = false,
val isCGMModuleRunning: Boolean = false,
val isUARTModuleRunning: Boolean = false,
val refreshToggle: Boolean = false
) {
fun copyWithRefresh(): HomeViewState {
return copy(refreshToggle = !refreshToggle)
}
}

View File

@@ -0,0 +1,103 @@
package no.nordicsemi.android.nrftoolbox.view
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Code
import androidx.compose.material.icons.filled.Language
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import no.nordicsemi.android.nrftoolbox.R
import no.nordicsemi.android.nrftoolbox.viewmodel.UiEvent
@Composable
internal fun Links(onEvent: (UiEvent) -> Unit) {
Column {
Text(
text = stringResource(R.string.links),
modifier = Modifier
.alpha(0.5f)
.padding(start = 16.dp),
)
OutlinedCard(
modifier = Modifier.fillMaxWidth()
) {
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(topStart = 8.dp, topEnd = 8.dp))
.clickable { onEvent(UiEvent.OnGitHubClick) }
.background(MaterialTheme.colorScheme.surface)
.padding(16.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
imageVector = Icons.Default.Code,
contentDescription = stringResource(R.string.github_repo),
modifier = Modifier.size(40.dp),
tint = MaterialTheme.colorScheme.primary
)
Text(
text = stringResource(R.string.github_repo),
style = MaterialTheme.typography.bodyLarge,
)
}
}
HorizontalDivider()
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(bottomStart = 8.dp, bottomEnd = 8.dp))
.clickable { onEvent(UiEvent.OnNordicDevZoneClick) }
.background(MaterialTheme.colorScheme.surface)
.padding(16.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
imageVector = Icons.Default.Language,
contentDescription = stringResource(R.string.nordic_dev_zone),
modifier = Modifier.size(40.dp),
tint = MaterialTheme.colorScheme.primary
)
Text(
text = stringResource(R.string.nordic_dev_zone),
style = MaterialTheme.typography.bodyLarge,
)
}
}
}
}
}
@Preview
@Composable
private fun LinksPreview() {
Links { }
}

View File

@@ -0,0 +1,83 @@
package no.nordicsemi.android.nrftoolbox.view
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.res.painterResource
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 no.nordicsemi.android.common.theme.NordicTheme
import no.nordicsemi.android.nrftoolbox.R
@Composable
internal fun NoConnectedDeviceView() {
val infiniteTransition = rememberInfiniteTransition()
val scale by infiniteTransition.animateFloat(
initialValue = 1f,
targetValue = 1.1f,
animationSpec = infiniteRepeatable(
animation = tween(1000, easing = LinearEasing),
repeatMode = RepeatMode.Reverse
)
)
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Image(
painter = painterResource(id = R.drawable.ic_notification_icon),
contentDescription = null,
modifier = Modifier
.size(80.dp)
.graphicsLayer {
scaleX = scale
scaleY = scale
},
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary),
)
Text(
text = stringResource(R.string.device_not_connected_title),
style = MaterialTheme.typography.titleMedium,
textAlign = TextAlign.Center
)
Text(
text = stringResource(R.string.device_not_connected_message),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
@Preview(showBackground = true)
@Composable
private fun NoConnectedDeviceViewPreview() {
NordicTheme {
NoConnectedDeviceView()
}
}

View File

@@ -1,60 +0,0 @@
/*
* Copyright (c) 2022, 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.nrftoolbox.view
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.colorResource
import no.nordicsemi.android.common.analytics.view.AnalyticsPermissionButton
import no.nordicsemi.android.nrftoolbox.R
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TitleAppBar(text: String) {
TopAppBar(
title = { Text(text, maxLines = 2) },
colors = TopAppBarDefaults.topAppBarColors(
scrolledContainerColor = MaterialTheme.colorScheme.primary,
containerColor = colorResource(id = R.color.appBarColor),
titleContentColor = MaterialTheme.colorScheme.onPrimary,
actionIconContentColor = MaterialTheme.colorScheme.onPrimary,
navigationIconContentColor = MaterialTheme.colorScheme.onPrimary,
),
actions = {
AnalyticsPermissionButton()
}
)
}

View File

@@ -1,123 +1,74 @@
/*
* Copyright (c) 2022, 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.nrftoolbox.viewmodel
import android.content.Context
import androidx.core.net.toUri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import no.nordicsemi.android.analytics.AppAnalytics
import no.nordicsemi.android.analytics.Link
import no.nordicsemi.android.analytics.ProfileOpenEvent
import no.nordicsemi.android.cgms.repository.CGMRepository
import no.nordicsemi.android.common.logger.LoggerLauncher
import no.nordicsemi.android.common.navigation.DestinationId
import no.nordicsemi.android.common.navigation.Navigator
import no.nordicsemi.android.csc.repository.CSCRepository
import no.nordicsemi.android.hrs.service.HRSRepository
import no.nordicsemi.android.hts.repository.HTSRepository
import no.nordicsemi.android.nrftoolbox.repository.ActivitySignals
import no.nordicsemi.android.nrftoolbox.view.HomeViewState
import no.nordicsemi.android.prx.repository.PRXRepository
import no.nordicsemi.android.rscs.repository.RSCSRepository
import no.nordicsemi.android.uart.repository.UARTRepository
import no.nordicsemi.android.nrftoolbox.ScannerDestinationId
import no.nordicsemi.android.toolbox.profile.manager.ServiceManager
import no.nordicsemi.android.toolbox.profile.ProfileDestinationId
import no.nordicsemi.android.toolbox.profile.repository.DeviceRepository
import no.nordicsemi.kotlin.ble.client.android.Peripheral
import javax.inject.Inject
@HiltViewModel
class HomeViewModel @Inject constructor(
@ApplicationContext
private val context: Context,
private val navigationManager: Navigator,
private val activitySignals: ActivitySignals,
cgmRepository: CGMRepository,
cscRepository: CSCRepository,
hrsRepository: HRSRepository,
htsRepository: HTSRepository,
prxRepository: PRXRepository,
rscsRepository: RSCSRepository,
uartRepository: UARTRepository,
private val analytics: AppAnalytics
) : ViewModel() {
internal data class HomeViewState(
val connectedDevices: Map<String, Pair<Peripheral, List<ServiceManager>>> = emptyMap(),
)
private const val GITHUB_REPO_URL = "https://github.com/NordicSemiconductor/Android-nRF-Toolbox.git"
private const val NORDIC_DEV_ZONE_URL = "https://devzone.nordicsemi.com/"
@HiltViewModel
internal class HomeViewModel @Inject constructor(
private val navigator: Navigator,
deviceRepository: DeviceRepository,
private val analytics: AppAnalytics,
) : ViewModel() {
private val _state = MutableStateFlow(HomeViewState())
val state = _state.asStateFlow()
init {
cgmRepository.isRunning.onEach {
_state.value = _state.value.copy(isCGMModuleRunning = it)
}.launchIn(viewModelScope)
cscRepository.isRunning.onEach {
_state.value = _state.value.copy(isCSCModuleRunning = it)
}.launchIn(viewModelScope)
hrsRepository.isRunning.onEach {
_state.value = _state.value.copy(isHRSModuleRunning = it)
}.launchIn(viewModelScope)
htsRepository.isRunning.onEach {
_state.value = _state.value.copy(isHTSModuleRunning = it)
}.launchIn(viewModelScope)
prxRepository.isRunning.onEach {
_state.value = _state.value.copy(isPRXModuleRunning = it)
}.launchIn(viewModelScope)
rscsRepository.isRunning.onEach {
_state.value = _state.value.copy(isRSCSModuleRunning = it)
}.launchIn(viewModelScope)
uartRepository.isRunning.onEach {
_state.value = _state.value.copy(isUARTModuleRunning = it)
}.launchIn(viewModelScope)
activitySignals.state.onEach {
_state.value = _state.value.copyWithRefresh()
// Observe connected devices from the repository
deviceRepository.connectedDevices.onEach { devices ->
_state.update { currentState ->
currentState.copy(connectedDevices = devices)
}
}.launchIn(viewModelScope)
}
fun openProfile(destination: DestinationId<Unit, Unit>) {
navigationManager.navigateTo(destination)
fun onClickEvent(event: UiEvent) {
when (event) {
UiEvent.OnConnectDeviceClick -> navigator.navigateTo(ScannerDestinationId)
is UiEvent.OnDeviceClick -> {
// Log the event for analytics.
analytics.logEvent(ProfileOpenEvent(event.profile))
navigator.navigateTo(
ProfileDestinationId, event.deviceAddress
)
}
UiEvent.OnGitHubClick -> {
// Log the event for analytics.
analytics.logEvent(ProfileOpenEvent(Link.GITHUB))
navigator.open(GITHUB_REPO_URL.toUri())
}
UiEvent.OnNordicDevZoneClick -> {
// Log the event for analytics.
analytics.logEvent(ProfileOpenEvent(Link.DEV_ACADEMY))
navigator.open(NORDIC_DEV_ZONE_URL.toUri())
}
}
}
fun openLogger() {
LoggerLauncher.launch(context, null)
}
fun logEvent(event: ProfileOpenEvent) {
analytics.logEvent(event)
}
}

View File

@@ -0,0 +1,23 @@
package no.nordicsemi.android.nrftoolbox.viewmodel
import no.nordicsemi.android.toolbox.lib.utils.Profile
/**
* HomeViewEvent is a sealed interface that represents the events that can be emitted by the Home view.
*/
sealed interface UiEvent {
/** OnConnectDeviceClick event that is emitted when the user clicks on the Connect Device button. */
data object OnConnectDeviceClick : UiEvent
/** OnDeviceClick event is emitted when the user clicks on a connected device. */
data class OnDeviceClick(val deviceAddress: String, val profile: Profile) : UiEvent
/**
* OnGitHubClick event is emitted when the user clicks on the GitHub repository option.
*/
data object OnGitHubClick : UiEvent
/** OnNordicDevZoneClick event is emitted when the user clicks on the Nordic DevZone option. */
data object OnNordicDevZoneClick : UiEvent
}

View File

@@ -1,43 +0,0 @@
<!--
~ Copyright (c) 2022, 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.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="80dp"
android:height="80dp"
android:viewportWidth="1024"
android:viewportHeight="1024">
<path
android:fillColor="#00B3DC"
android:pathData="M386.1,310.1c0,-21.9 -5.5,-43.7 -15.9,-63c-0.2,-0.4 -0.5,-0.9 -0.7,-1.3c-2.4,-4.3 -5,-8.4 -7.8,-12.4C311,152.8 276,96.7 271.7,89.2c-4.3,-8.4 -12.5,-14.3 -22.1,-15.4c-11,-1.3 -21.7,3.9 -27.5,13.3L133,230.6c-4.8,6.4 -8.9,13.2 -12.5,20.3c-0.1,0.2 -0.2,0.4 -0.3,0.7c-9.1,18.4 -13.6,38.1 -13.6,58.6c0,74.9 62.7,135.9 139.8,135.9S386.1,385 386.1,310.1zM163.5,310.1c0,-11.8 2.7,-23.1 7.9,-33.6c0,0 0,0 0,-0.1c2.2,-4.3 4.8,-8.6 7.8,-12.5c0.5,-0.7 1,-1.4 1.5,-2.2l65.4,-105.3c4,6.3 8.5,13.6 13.8,22.1c19.9,31.8 42.3,67.5 53.9,85.9c0.3,0.5 0.6,1 0.9,1.4c1.8,2.5 3.5,5.2 5,7.9c0,0 0,0 0,0c0,0 0,0.1 0,0.1c6.2,11.3 9.3,23.5 9.3,36.3c0,43.5 -37.1,78.9 -82.8,78.9S163.5,353.6 163.5,310.1z" />
<path
android:fillColor="#00B3DC"
android:pathData="M975.7,103c-50.2,-48.2 -130.2,-46.5 -178.4,3.7L228.8,699.8c-10.9,11.4 -10.5,29.4 0.8,40.3l49.9,47.9c-1.7,1.2 -3.4,2.7 -4.8,4.3l-91.4,102.9c-10.5,11.8 -9.4,29.8 2.4,40.2c5.4,4.8 12.2,7.2 18.9,7.2c7.9,0 15.7,-3.2 21.3,-9.6l91.4,-102.9c1,-1.1 1.8,-2.2 2.6,-3.4l50.7,48.7c5.3,5.1 12.4,7.9 19.7,7.9c0.2,0 0.4,0 0.6,0c7.6,-0.2 14.8,-3.3 20,-8.8l568.4,-593c0,0 0.1,-0.1 0.1,-0.1C1027.6,231.2 1025.9,151.2 975.7,103zM938.3,241.9C938.3,241.9 938.3,241.9 938.3,241.9L389.5,814.5l-99.8,-95.8l548.8,-572.5c26.4,-27.5 70.3,-28.4 97.8,-2C963.8,170.5 964.7,214.3 938.3,241.9z" />
</vector>

View File

@@ -32,33 +32,68 @@
<resources>
<string name="csc_module">CSC</string>
<string name="csc_module_full">Cyclic Speed and Cadence</string>
<string name="hrs_module">HRS</string>
<string name="hrs_module_full">Heart Rate</string>
<string name="gls_module">GLS</string>
<string name="gls_module_full">Glucose</string>
<string name="hts_module">HTS</string>
<string name="hts_module_full">Health Thermometer</string>
<string name="bps_module">BPS</string>
<string name="bps_module_full">Blood Pressure</string>
<string name="rscs_module">RSCS</string>
<string name="rscs_module_full">Running Speed and Cadence</string>
<string name="prx_module">PRX</string>
<string name="prx_module_full">Proximity</string>
<string name="cgm_module">CGMS</string>
<string name="cgm_module_full">Continuous Glucose</string>
<string name="uart_module">UART</string>
<string name="uart_module_full">Universal Asynchronous Receiver/Transmitter (UART)</string>
<string name="dfu_module">DFU</string>
<string name="dfu_module_full">Device Firmware Update</string>
<string name="dfu_module_info">Open DFU application.</string>
<string name="dfu_module_install">Download from Google Play.</string>
<string name="logger_module">nRF Logger</string>
<string name="logger_module_full">nRF Logger</string>
<string name="logger_module_info">Open nRF Logger application.</string>
<string name="direction_module">DF</string>
<string name="direction_module_full">Direction Finder</string>
<string name="viewmodel_profiles">ViewModel profiles</string>
<string name="service_profiles">Service profiles</string>
<string name="utils_services">Utils services</string>
<string name="running_profile_icon">Icon indicating if the profile is running</string>
<string name="battery_module">BATTERY</string>
<string name="battery_module_full">Battery</string>
<string name="channel_sounding_module"> CHANNEL SOUNDING</string>
<string name="throughput_module">Throughput</string>
<string name="unknown_device">Unknown Device</string>
<string name="lbs_blinky_module">LBS/Blinky</string>
<string name="device_not_connected_title">NO DEVICES CONNECTED</string>
<string name="device_not_connected_message">Tap Connect device button to begin.</string>
<string name="connect_device">Connect Device</string>
<string name="connected_devices">Connected devices</string>
<string name="github_repo">Source code (GitHub)</string>
<string name="nordic_dev_zone">Help (Nordic DevZone)</string>
<string name="links">Links</string>
</resources>

View File

@@ -28,10 +28,9 @@
# NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE,
# EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
#
#Mon Feb 14 14:46:55 CET 2022
#Thu Jul 03 10:42:00 CEST 2025
distributionBase=GRADLE_USER_HOME
distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip
distributionPath=wrapper/dists
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

View File

@@ -38,6 +38,7 @@ android {
}
dependencies {
implementation(project(":lib_utils"))
implementation(platform(libs.firebase.bom))
implementation(libs.firebase.analytics)
implementation(libs.firebase.crashlytics)

View File

@@ -31,24 +31,20 @@
package no.nordicsemi.android.analytics
enum class Profile(val displayName: String) {
BPS("BPS"),
CGMS("CGMS"),
CSC("CSC"),
GLS("GLS"),
HRS("HRS"),
HTS("HTS"),
PRX("PRX"),
RSCS("RSCS"),
UART("UART");
}
/**
* Represents the different links that can be used in the application.
*/
enum class Link(val displayName: String) {
DFU("DFU"),
LOGGER("LOGGER");
LOGGER("LOGGER"),
GITHUB("GITHUB"),
DEV_ACADEMY("DEV_ACADEMY"),
}
/**
* Represents the mode of the UART service.
* Used to determine how the UART service should behave.
*/
enum class UARTMode(val displayName: String) {
MACRO("MACRO"),
PRESET("PRESET"),
TEXT("TEXT")
}

View File

@@ -32,15 +32,39 @@
package no.nordicsemi.android.analytics
import android.os.Bundle
import no.nordicsemi.android.toolbox.lib.utils.Profile
/**
* Base class for Firebase Analytics events.
*/
sealed class FirebaseEvent(val eventName: String, val params: Bundle?)
/**
* Represents an event that is logged when the app is opened.
* This event does not carry any additional parameters.
*/
data object AppOpenEvent : FirebaseEvent("APP_OPEN", null)
/**
* Represents an event that is logged when profile is opened.
* This event can be created with a [Profile] or a [Link].
*/
class ProfileOpenEvent : FirebaseEvent {
constructor(profile: Profile) : super(EVENT_NAME, createBundle(profile.displayName))
/**
* Creates a new instance of [ProfileOpenEvent] with the given profile.
* The profile's string representation is used as a parameter.
*
* @param profile The profile that was opened.
*/
constructor(profile: Profile) : super(EVENT_NAME, createBundle(profile.toString()))
/**
* Creates a new instance of [ProfileOpenEvent] with the given link.
* The link's display name is used as a parameter.
*
* @param link The link that was opened.
*/
constructor(link: Link) : super(EVENT_NAME, createBundle(link.displayName))
companion object {
@@ -48,11 +72,12 @@ class ProfileOpenEvent : FirebaseEvent {
}
}
class ProfileConnectedEvent : FirebaseEvent {
constructor(profile: Profile) : super(EVENT_NAME, createBundle(profile.displayName))
constructor(link: Link) : super(EVENT_NAME, createBundle(link.displayName))
/**
* Represents an event that is logged when a profile is connected.
* This event can be created with a [Profile] or a [Link].
*/
class ProfileConnectedEvent(profile: Profile) :
FirebaseEvent(EVENT_NAME, createBundle(profile.toString())) {
companion object {
private const val EVENT_NAME = "PROFILE_CONNECTED"
@@ -61,13 +86,28 @@ class ProfileConnectedEvent : FirebaseEvent {
const val PROFILE_PARAM_KEY = "PROFILE_NAME"
/**
* Creates a [Bundle] with the given profile name.
*
* @param name The name of the profile to be included in the bundle.
* @return A [Bundle] containing the profile name.
*/
private fun createBundle(name: String): Bundle {
return Bundle().apply { putString(PROFILE_PARAM_KEY, name) }
}
sealed class UARTAnalyticsEvent(eventName: String, params: Bundle?) : FirebaseEvent(eventName, params)
/**
* Represents an event related to UART (Universal Asynchronous Receiver-Transmitter) analytics.
*/
sealed class UARTAnalyticsEvent(eventName: String, params: Bundle?) :
FirebaseEvent(eventName, params)
class UARTSendAnalyticsEvent(mode: UARTMode) : UARTAnalyticsEvent("UART_SEND_EVENT", createParams(mode)) {
/**
* Represents an event that is logged when a UART message is send or received.
* This event can be created with a [UARTMode].
*/
class UARTSendAnalyticsEvent(mode: UARTMode) :
UARTAnalyticsEvent("UART_SEND_EVENT", createParams(mode)) {
companion object {
fun createParams(mode: UARTMode) = Bundle().apply {
@@ -76,6 +116,14 @@ class UARTSendAnalyticsEvent(mode: UARTMode) : UARTAnalyticsEvent("UART_SEND_EVE
}
}
/**
* Represents an event that is logged when a UART preset configuration is created.
* This event can be created with a [UARTMode].
*/
class UARTCreateConfiguration : UARTAnalyticsEvent("UART_CREATE_CONF", null)
/**
* Represents an event that is logged when a UART preset configuration is changed.
* This event does not carry any additional parameters.
*/
class UARTChangeConfiguration : UARTAnalyticsEvent("UART_CHANGE_CONF", null)

View File

@@ -1,50 +0,0 @@
/*
* Copyright (c) 2022, 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.
*/
plugins {
alias(libs.plugins.nordic.feature)
}
android {
namespace = "no.nordicsemi.android.toolbox.scanner"
}
dependencies {
implementation(libs.nordic.navigation)
implementation(libs.nordic.blek.uiscanner)
implementation(libs.nordic.blek.scanner)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.material.iconsExtended)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.activity.compose)
}

View File

@@ -1,17 +0,0 @@
# Add project specific ProGuard rules here.
# By default, the flags in this file are appended to flags specified
# in C:/Users/alno/AppData/Local/Android/sdk/tools/proguard/proguard-android.txt
# You can edit the include path and order by changing the proguardFiles
# directive in build.gradle.kts.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# Add any project specific keep options here:
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}

View File

@@ -1,29 +0,0 @@
package no.nordicsemi.android.toolbox.scanner
import android.os.ParcelUuid
import androidx.hilt.navigation.compose.hiltViewModel
import no.nordicsemi.android.common.navigation.createDestination
import no.nordicsemi.android.common.navigation.defineDestination
import no.nordicsemi.android.common.navigation.viewmodel.SimpleNavigationViewModel
import no.nordicsemi.android.kotlin.ble.core.ServerDevice
import no.nordicsemi.android.kotlin.ble.ui.scanner.DeviceSelected
import no.nordicsemi.android.kotlin.ble.ui.scanner.ScannerScreen
import no.nordicsemi.android.kotlin.ble.ui.scanner.ScanningCancelled
val ScannerDestinationId = createDestination<ParcelUuid, ServerDevice>("uiscanner-destination")
val ScannerDestination = defineDestination(ScannerDestinationId) {
val navigationViewModel = hiltViewModel<SimpleNavigationViewModel>()
val arg = navigationViewModel.parameterOf(ScannerDestinationId)
ScannerScreen(
uuid = arg,
onResult = {
when (it) {
is DeviceSelected -> navigationViewModel.navigateUpWithResult(ScannerDestinationId, it.scanResults.device)
ScanningCancelled -> navigationViewModel.navigateUp()
}
}
)
}

View File

@@ -31,7 +31,6 @@
plugins {
alias(libs.plugins.nordic.feature)
alias(libs.plugins.kotlin.serialization)
}
android {
@@ -40,11 +39,16 @@ android {
dependencies {
implementation(project(":lib_ui"))
implementation(project(":lib_utils"))
implementation(project(":profile_manager"))
implementation(libs.nordic.blek.uiscanner)
implementation(libs.nordic.blek.core)
implementation(libs.nordic.logger)
implementation(libs.nordic.log.timber)
implementation(libs.nordic.blek.client.android)
implementation(libs.androidx.lifecycle.service)
implementation(libs.androidx.localbroadcastmanager)
implementation(libs.androidx.core)
implementation(libs.slf4j.timber)
}

View File

@@ -30,13 +30,4 @@
~ EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
</manifest>
<manifest/>

View File

@@ -1,3 +0,0 @@
package no.nordicsemi.android.service
class DisconnectAndStopEvent

View File

@@ -54,11 +54,19 @@ abstract class NotificationService : LifecycleService() {
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()
stopSelf()
super.onDestroy()
}
override fun onTaskRemoved(rootIntent: Intent?) {
super.onTaskRemoved(rootIntent)
// This method is called when user removed the app from recent app list.
// By default, the service will be killed and recreated immediately after that.
// However, all managed devices will be lost and devices will be disconnected.
stopSelf()
}
/**
* Sets the service as a foreground service
*/
@@ -77,7 +85,7 @@ abstract class NotificationService : LifecycleService() {
/**
* Stops the service as a foreground service
*/
private fun stopForegroundService() {
fun stopForegroundService() {
// when the activity rebinds to the service, remove the notification and stop the foreground service
// on devices running Android 8.0 (Oreo) or above
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
@@ -85,6 +93,7 @@ abstract class NotificationService : LifecycleService() {
} else {
cancelNotification()
}
stopSelf() // Ensure the service stops when it's no longer needed
}
/**
@@ -102,9 +111,9 @@ abstract class NotificationService : LifecycleService() {
val pendingIntent = PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_IMMUTABLE)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_launcher_foreground)
.setContentTitle(getString(R.string.app_name))
.setContentText(getString(messageResId, "Device"))
.setSmallIcon(R.drawable.ic_launcher_foreground)
.setColor(ContextCompat.getColor(this, R.color.md_theme_primary))
.setContentIntent(pendingIntent)
.build()

View File

@@ -1,3 +0,0 @@
package no.nordicsemi.android.service
class OpenLoggerEvent

View File

@@ -1,41 +0,0 @@
/*
* Copyright (c) 2022, 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.service
import no.nordicsemi.android.kotlin.ble.core.ServerDevice
const val DEVICE_DATA = "device-data"
interface ServiceManager {
fun <T> startService(service: Class<T>, device: ServerDevice)
}

View File

@@ -1,21 +0,0 @@
package no.nordicsemi.android.service
import android.content.Context
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
@Module
@InstallIn(SingletonComponent::class)
class ServiceManagerHiltModule {
@Provides
fun createServiceManager(
@ApplicationContext
context: Context,
): ServiceManager {
return ServiceManagerImpl(context)
}
}

View File

@@ -1,20 +0,0 @@
package no.nordicsemi.android.service
import android.content.Context
import android.content.Intent
import dagger.hilt.android.qualifiers.ApplicationContext
import no.nordicsemi.android.kotlin.ble.core.ServerDevice
import javax.inject.Inject
class ServiceManagerImpl @Inject constructor(
@ApplicationContext
private val context: Context
): ServiceManager {
override fun <T> startService(service: Class<T>, device: ServerDevice) {
val intent = Intent(context, service).apply {
putExtra(DEVICE_DATA, device)
}
context.startService(intent)
}
}

View File

@@ -0,0 +1,21 @@
package no.nordicsemi.android.service.di
import android.content.Context
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import no.nordicsemi.android.service.profile.ProfileServiceManager
import no.nordicsemi.android.service.profile.ProfileServiceManagerImp
@Module
@InstallIn(SingletonComponent::class)
object ProfileServiceManagerImpModule {
@Provides
fun provideServiceManager(
@ApplicationContext context: Context
): ProfileServiceManager = ProfileServiceManagerImp(context)
}

View File

@@ -0,0 +1,321 @@
package no.nordicsemi.android.service.profile
import android.content.Intent
import android.os.Binder
import android.os.IBinder
import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import no.nordicsemi.android.log.timber.nRFLoggerTree
import no.nordicsemi.android.service.NotificationService
import no.nordicsemi.android.service.R
import no.nordicsemi.android.toolbox.lib.utils.spec.CGMS_SERVICE_UUID
import no.nordicsemi.android.toolbox.profile.manager.ServiceManager
import no.nordicsemi.android.toolbox.profile.manager.ServiceManagerFactory
import no.nordicsemi.android.ui.view.internal.DisconnectReason
import no.nordicsemi.kotlin.ble.client.android.CentralManager
import no.nordicsemi.kotlin.ble.client.android.CentralManager.ConnectionOptions
import no.nordicsemi.kotlin.ble.client.android.ConnectionPriority
import no.nordicsemi.kotlin.ble.client.android.Peripheral
import no.nordicsemi.kotlin.ble.core.BondState
import no.nordicsemi.kotlin.ble.core.ConnectionState
import no.nordicsemi.kotlin.ble.core.Manager
import no.nordicsemi.kotlin.ble.core.Phy
import no.nordicsemi.kotlin.ble.core.PhyOption
import no.nordicsemi.kotlin.ble.core.WriteType
import timber.log.Timber
import javax.inject.Inject
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.toKotlinUuid
@AndroidEntryPoint
internal class ProfileService : NotificationService() {
@Inject
lateinit var centralManager: CentralManager
private var logger: nRFLoggerTree? = null
private val binder = LocalBinder()
private val _connectedDevices =
MutableStateFlow<Map<String, Pair<Peripheral, List<ServiceManager>>>>(emptyMap())
private val _isMissingServices = MutableStateFlow(false)
private val _disconnectionReason = MutableStateFlow<DeviceDisconnectionReason?>(null)
private val connectionJobs = mutableMapOf<String, Job>()
private val serviceHandlingJob = mutableMapOf<String, Job>()
override fun onBind(intent: Intent): IBinder {
super.onBind(intent)
return binder
}
override fun onCreate() {
super.onCreate()
// Observe the Bluetooth state
centralManager.state.onEach { state ->
if (state == Manager.State.POWERED_OFF) {
_disconnectionReason.tryEmit(CustomReason(DisconnectReason.BLUETOOTH_OFF))
}
}.launchIn(lifecycleScope)
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
super.onStartCommand(intent, flags, startId)
intent?.getStringExtra(DEVICE_ADDRESS)?.let { deviceAddress ->
initLogger(deviceAddress)
initiateConnection(deviceAddress)
}
return START_REDELIVER_INTENT
}
inner class LocalBinder : Binder(), ServiceApi {
override val connectedDevices: Flow<Map<String, Pair<Peripheral, List<ServiceManager>>>>
get() = _connectedDevices.asSharedFlow()
override val isMissingServices: Flow<Boolean>
get() = _isMissingServices.asStateFlow()
override val disconnectionReason: Flow<DeviceDisconnectionReason?>
get() = _disconnectionReason.asStateFlow()
override suspend fun getMaxWriteValue(address: String, writeType: WriteType): Int? {
val peripheral = getPeripheralById(address) ?: return null
if (!peripheral.isConnected) return null
return try {
peripheral.requestHighestValueLength()
peripheral.requestConnectionPriority(ConnectionPriority.HIGH)
peripheral.setPreferredPhy(Phy.PHY_LE_2M, Phy.PHY_LE_2M, PhyOption.S2)
peripheral.maximumWriteValueLength(writeType)
} catch (e: Exception) {
Timber.e("Failed to configure $address for MTU change with reason: ${e.message}")
null
}
}
override suspend fun createBonding(address: String) {
val peripheral = getPeripheralById(address)
peripheral?.bondState
?.onEach { state ->
if (state == BondState.NONE) {
peripheral.createBond()
}
}
?.filter { it == BondState.BONDED }
?.first() // suspend until bonded
}
override fun getPeripheralById(address: String?): Peripheral? =
address?.let { centralManager.getPeripheralById(it) }
override fun disconnect(deviceAddress: String) {
lifecycleScope.launch {
try {
getPeripheralById(deviceAddress)
?.let { peripheral ->
if (peripheral.isConnected) peripheral.disconnect()
handleDisconnection(deviceAddress)
}
} catch (e: Exception) {
Timber.e(e, "Couldn't disconnect from the $deviceAddress")
}
}
}
override fun getConnectionState(address: String): Flow<ConnectionState>? {
val peripheral = getPeripheralById(address) ?: return null
return peripheral.state.also { stateFlow ->
connectionJobs[address]?.cancel()
val job = stateFlow.onEach { state ->
when (state) {
ConnectionState.Connected -> {
_isMissingServices.tryEmit(false)
// Discover services if not already discovered
if (_connectedDevices.value[address] == null) {
discoverServices(peripheral)
}
}
ConnectionState.Connecting -> _disconnectionReason.tryEmit(null)
is ConnectionState.Disconnected -> {
_disconnectionReason.tryEmit(StateReason(state.reason))
}
ConnectionState.Closed -> return@onEach
ConnectionState.Disconnecting -> {
connectionJobs[address]?.cancel()
handleDisconnection(address)
}
}
}.onCompletion {
connectionJobs[address]?.cancel()
connectionJobs.remove(address)
}.launchIn(lifecycleScope)
connectionJobs[address] = job
}
}
}
/**
* Connect to the peripheral and observe its state.
*/
private fun initiateConnection(deviceAddress: String) {
centralManager.getPeripheralById(deviceAddress)?.let { peripheral ->
lifecycleScope.launch { connectPeripheral(peripheral) }
}
}
private suspend fun connectPeripheral(peripheral: Peripheral) {
runCatching {
centralManager.connect(peripheral, options = ConnectionOptions.Direct())
}.onFailure { exception ->
Timber.e(exception, "Could not connect to the ${peripheral.address}")
stopForegroundService() // Stop service if connection fails
}
}
/**
* Discover services and characteristics for the connected [peripheral].
*/
@OptIn(ExperimentalUuidApi::class)
private fun discoverServices(peripheral: Peripheral) {
val discoveredServices = mutableListOf<ServiceManager>()
serviceHandlingJob[peripheral.address]?.cancel()
val job = peripheral.services().onEach { remoteServices ->
remoteServices?.forEach { remoteService ->
val serviceManager = ServiceManagerFactory.createServiceManager(remoteService.uuid)
serviceManager?.let { manager ->
Timber.tag("DiscoverServices").i("${manager.profile}")
discoveredServices.add(manager)
lifecycleScope.launch {
try {
val requiresBonding =
remoteService.uuid == CGMS_SERVICE_UUID.toKotlinUuid() && peripheral.hasBondInformation
if (requiresBonding) {
peripheral.bondState
.onEach { if (it == BondState.NONE) peripheral.createBond() }
.filter { it == BondState.BONDED }
.first()
}
manager.observeServiceInteractions(
peripheral.address,
remoteService,
this
)
} catch (e: Exception) {
Timber.tag("ObserveServices").e(e)
handleDisconnection(peripheral.address)
}
}
}
}
when {
discoveredServices.isEmpty() -> {
if (remoteServices?.isNotEmpty() == true) {
_isMissingServices.tryEmit(true)
serviceHandlingJob[peripheral.address]?.cancel()
serviceHandlingJob.remove(peripheral.address)
}
}
peripheral.isConnected -> {
_isMissingServices.tryEmit(false)
updateConnectedDevices(peripheral, discoveredServices)
}
}
}
.onCompletion {
serviceHandlingJob[peripheral.address]?.cancel()
serviceHandlingJob.remove(peripheral.address)
}
.launchIn(lifecycleScope)
serviceHandlingJob[peripheral.address] = job
}
/**
* Update the connected devices with the latest state.
*/
private fun updateConnectedDevices(peripheral: Peripheral, handlers: List<ServiceManager>) {
_connectedDevices.update {
it.toMutableMap().apply { this[peripheral.address] = peripheral to handlers }
}
}
/**
* Handle disconnection and cleanup for the given peripheral.
*/
private fun handleDisconnection(peripheral: String) {
val currentDevices = _connectedDevices.value.toMutableMap()
currentDevices[peripheral]?.let {
currentDevices.remove(peripheral)
_connectedDevices.tryEmit(currentDevices)
}
clearJobs(peripheral)
clearFlags()
stopServiceIfNoDevices()
}
/**
* Clear any active jobs for connection and service handling.
*/
private fun clearJobs(peripheral: String) {
connectionJobs[peripheral]?.cancel()
connectionJobs.remove(peripheral)
serviceHandlingJob[peripheral]?.cancel()
serviceHandlingJob.remove(peripheral)
}
/**
* Stop the service if no devices are connected.
*/
private fun stopServiceIfNoDevices() {
if (_connectedDevices.value.isEmpty()) {
stopForegroundService()
stopSelf()
}
}
/**
* Initialize the logger for the specified device.
*/
private fun initLogger(device: String) {
logger?.let { Timber.uproot(it) }
logger = nRFLoggerTree(this, this.getString(R.string.app_name), device)
.also { Timber.plant(it) }
}
/**
* Uproot the logger and clear the logger instance.
*/
private fun uprootLogger() {
logger?.let { Timber.uproot(it) }
logger = null
}
/**
* Clear the missing services and battery level flags.
*/
private fun clearFlags() {
_isMissingServices.tryEmit(false)
uprootLogger()
}
}

View File

@@ -0,0 +1,56 @@
package no.nordicsemi.android.service.profile
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.os.IBinder
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.suspendCancellableCoroutine
import javax.inject.Inject
import kotlin.coroutines.resumeWithException
const val DEVICE_ADDRESS = "deviceAddress"
sealed interface ProfileServiceManager {
suspend fun bindService(): ServiceApi
fun unbindService()
fun connectToPeripheral(deviceAddress: String)
}
internal class ProfileServiceManagerImp @Inject constructor(
@ApplicationContext private val context: Context,
) : ProfileServiceManager {
private var serviceConnection: ServiceConnection? = null
private var api: ServiceApi? = null
override suspend fun bindService(): ServiceApi = suspendCancellableCoroutine { continuation ->
val intent = Intent(context, ProfileService::class.java)
serviceConnection = object : ServiceConnection {
override fun onServiceConnected(className: ComponentName, service: IBinder) {
api = service as ServiceApi
continuation.resume(api!!) { _, _, _ -> }
}
override fun onServiceDisconnected(p0: ComponentName?) {
continuation.resumeWithException(Exception("Service disconnected"))
}
override fun onBindingDied(p0: ComponentName?) {
continuation.resumeWithException(Exception("Service binding died"))
}
}.apply {
context.bindService(intent, this, Context.BIND_AUTO_CREATE)
}
}
override fun unbindService() {
serviceConnection?.let { context.unbindService(it) }
}
override fun connectToPeripheral(deviceAddress: String) {
val intent = Intent(context, ProfileService::class.java)
intent.putExtra(DEVICE_ADDRESS, deviceAddress)
context.startService(intent)
}
}

View File

@@ -0,0 +1,69 @@
package no.nordicsemi.android.service.profile
import kotlinx.coroutines.flow.Flow
import no.nordicsemi.android.toolbox.profile.manager.ServiceManager
import no.nordicsemi.android.ui.view.internal.DisconnectReason
import no.nordicsemi.kotlin.ble.client.android.Peripheral
import no.nordicsemi.kotlin.ble.core.ConnectionState
import no.nordicsemi.kotlin.ble.core.WriteType
/** Device disconnection reason. */
sealed interface DeviceDisconnectionReason
/** Includes the [ConnectionState.Disconnected.Reason]. */
data class StateReason(val reason: ConnectionState.Disconnected.Reason) : DeviceDisconnectionReason
/** Includes the custom made [DisconnectReason] to include other disconnection reasons which are not included in the [ConnectionState.Disconnected.Reason]. */
data class CustomReason(val reason: DisconnectReason) :
DeviceDisconnectionReason
interface ServiceApi {
/** Flow of connected devices. */
val connectedDevices: Flow<Map<String, Pair<Peripheral, List<ServiceManager>>>>
/** Missing services flag. */
val isMissingServices: Flow<Boolean>
/**
* Get the peripheral by its [address].
*
* @return the peripheral instance.
*/
fun getPeripheralById(address: String?): Peripheral?
/**
* Disconnect the device with the given [deviceAddress].
*
* @param deviceAddress the device address.
*/
fun disconnect(deviceAddress: String)
/**
* Get the connection state of the device with the given [address].
*
* @return the connection state flow.
*/
fun getConnectionState(address: String): Flow<ConnectionState>?
/**
* Get the disconnection reason of the device with the given address.
*
* @return the disconnection reason flow.
*/
val disconnectionReason: Flow<DeviceDisconnectionReason?>
/**
* Request maximum write value length.
* For [WriteType.WITHOUT_RESPONSE] it is equal to *ATT MTU - 3 bytes*.
*/
suspend fun getMaxWriteValue(
address: String,
writeType: WriteType = WriteType.WITHOUT_RESPONSE
): Int?
suspend fun createBonding(
address: String
)
}

View File

@@ -0,0 +1,14 @@
plugins {
alias(libs.plugins.nordic.feature)
alias(libs.plugins.ksp)
}
android {
namespace = "no.nordicsemi.android.toolbox.lib.storage"
}
dependencies {
implementation(libs.room.ktx)
ksp(libs.room.compiler)
}

View File

@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@@ -1,4 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest>
</manifest>
<manifest/>

View File

@@ -0,0 +1,14 @@
package no.nordicsemi.android.toolbox.lib.storage
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "configurations")
data class ConfigurationEntity(
@PrimaryKey(autoGenerate = true)
@ColumnInfo(name = "_id") val _id: Int?,
@ColumnInfo(name = "name") val name: String,
@ColumnInfo(name = "xml") val xml: String,
@ColumnInfo(name = "deleted", defaultValue = "0") val deleted: Int
)

View File

@@ -0,0 +1,20 @@
package no.nordicsemi.android.toolbox.lib.storage
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import kotlinx.coroutines.flow.Flow
@Dao
interface ConfigurationsDao {
@Query("SELECT * FROM configurations")
fun getAllConfigurations(): Flow<List<ConfigurationEntity>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertConfiguration(configuration: ConfigurationEntity): Long
@Query("DELETE FROM configurations WHERE name = :configurationName")
suspend fun deleteConfiguration(configurationName: String)
}

View File

@@ -0,0 +1,13 @@
package no.nordicsemi.android.toolbox.lib.storage
import androidx.room.Database
import androidx.room.RoomDatabase
@Database(
entities = [ConfigurationEntity::class],
version = 1,
exportSchema = false
)
internal abstract class ConfigurationDatabase : RoomDatabase() {
abstract fun configurationDao(): ConfigurationsDao
}

View File

@@ -0,0 +1,10 @@
package no.nordicsemi.android.toolbox.lib.storage
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
val MIGRATION_2_3 = object : Migration(2, 3) {
override fun migrate(db: SupportSQLiteDatabase) {
// Empty implementation, because the schema isn't changing.
}
}

View File

@@ -0,0 +1,20 @@
package no.nordicsemi.android.toolbox.lib.storage.di
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import no.nordicsemi.android.toolbox.lib.storage.ConfigurationDatabase
import no.nordicsemi.android.toolbox.lib.storage.ConfigurationsDao
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
class DaoHiltModule {
@Provides
@Singleton
internal fun provideDeviceDao(db: ConfigurationDatabase): ConfigurationsDao {
return db.configurationDao()
}
}

View File

@@ -1,4 +1,4 @@
package no.nordicsemi.android.uart
package no.nordicsemi.android.toolbox.lib.storage.di
import android.content.Context
import androidx.room.Room
@@ -7,8 +7,7 @@ import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import no.nordicsemi.android.uart.db.ConfigurationsDatabase
import no.nordicsemi.android.uart.db.MIGRATION_1_2
import no.nordicsemi.android.toolbox.lib.storage.ConfigurationDatabase
import javax.inject.Singleton
@Module
@@ -17,10 +16,11 @@ class DbHiltModule {
@Provides
@Singleton
internal fun provideDB(@ApplicationContext context: Context): ConfigurationsDatabase {
internal fun provideDeviceDB(@ApplicationContext context: Context): ConfigurationDatabase {
return Room.databaseBuilder(
context,
ConfigurationsDatabase::class.java, "toolbox_uart.db"
).addMigrations(MIGRATION_1_2).build()
ConfigurationDatabase::class.java,
"toolbox_uart.db"
).build()
}
}

View File

@@ -31,22 +31,18 @@
plugins {
alias(libs.plugins.nordic.feature)
alias(libs.plugins.kotlin.parcelize)
}
android {
namespace = "no.nordicsemi.android.ui"
testOptions {
unitTests.isIncludeAndroidResources = true
}
}
dependencies {
implementation(libs.nordic.theme)
implementation(libs.nordic.ui)
implementation(libs.nordic.logger)
implementation(libs.nordic.blek.client)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.material.iconsExtended)
implementation(libs.androidx.core.ktx)

View File

@@ -0,0 +1,91 @@
package no.nordicsemi.android.ui.view
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@Composable
fun AnimatedThreeDots(
modifier: Modifier = Modifier,
dotSize: Dp = 8.dp
) {
val dotCount = 3
val infiniteTransition = rememberInfiniteTransition()
val dotAlphas = List(dotCount) { index ->
infiniteTransition.animateFloat(
initialValue = 0.3f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(
durationMillis = 500,
delayMillis = index * 200,
easing = LinearEasing
),
repeatMode = RepeatMode.Reverse
)
)
}
Row(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
dotAlphas.forEach { alpha ->
Box(
modifier = Modifier
.size(dotSize)
.clip(CircleShape)
.background(Color.Gray.copy(alpha = alpha.value))
)
}
}
}
@Composable
fun TextWithAnimatedDots(
text: String,
modifier: Modifier = Modifier,
dotSize: Dp = 2.dp,
textStyle: TextStyle = MaterialTheme.typography.bodyLarge,
textAlign: TextAlign = TextAlign.Center
) {
Row(
verticalAlignment = Alignment.Bottom
) {
Text(
text = text,
textAlign = textAlign,
style = textStyle,
)
Spacer(modifier = Modifier.width(2.dp))
AnimatedThreeDots(
modifier = modifier.padding(bottom = 4.dp),
dotSize = dotSize
)
}
}

View File

@@ -0,0 +1,130 @@
package no.nordicsemi.android.ui.view
import androidx.compose.animation.animateColor
import androidx.compose.animation.core.LinearOutSlowInEasing
import androidx.compose.animation.core.TweenSpec
import androidx.compose.animation.core.animateDp
import androidx.compose.animation.core.tween
import androidx.compose.animation.core.updateTransition
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@Composable
fun createCircleTransition(
isInAccessibilityMode: Boolean,
duration: Int
): CircleTransitionState {
val transition = updateTransition(targetState = isInAccessibilityMode, label = "Transition")
return CircleTransitionState(
dotRadius = transition.animateDp(
label = "Dot Radius",
transitionSpec = { tween(duration, easing = LinearOutSlowInEasing) }
) { if (it) 10.dp else 5.dp },
circleWidth = transition.animateDp(
label = "Circle Width",
transitionSpec = { tween(duration, easing = LinearOutSlowInEasing) }
) { if (it) 8.dp else 5.dp },
circleColor = transition.animateColor(
label = "Circle Color",
transitionSpec = { tween(duration, easing = LinearOutSlowInEasing) }
) { if (it) MaterialTheme.colorScheme.tertiaryContainer else MaterialTheme.colorScheme.primaryContainer },
dotColor = transition.animateColor(
label = "Dot Color",
transitionSpec = { tween(duration, easing = LinearOutSlowInEasing) }
) { if (it) MaterialTheme.colorScheme.tertiary else MaterialTheme.colorScheme.primary }
)
}
data class CircleTransitionState(
val dotRadius: State<Dp>,
val circleWidth: State<Dp>,
val circleColor: State<Color>,
val dotColor: State<Color>,
) {
fun toggleAccessibilityMode() {
dotRadius.value
}
}
data class LinearTransitionState(
val border: State<Dp>,
val height: State<Dp>,
val radius: State<Dp>,
val color: State<Color>,
val inactiveColor: State<Color>,
)
@Composable
fun createLinearTransition(
isInAccessibilityMode: Boolean,
duration: Int,
): LinearTransitionState {
val transition = updateTransition(targetState = isInAccessibilityMode, label = "Transition")
return LinearTransitionState(
border = transition.animateDp(
label = "Border",
transitionSpec = { TweenSpec(duration, 0, LinearOutSlowInEasing) }
) { if (it) 3.dp else 0.dp },
height = transition.animateDp(
label = "Height",
transitionSpec = { TweenSpec(duration, 0, LinearOutSlowInEasing) }
) { if (it) 30.dp else 25.dp },
radius = transition.animateDp(
label = "Radius",
transitionSpec = { TweenSpec(duration / 2, 0, LinearOutSlowInEasing) }
) { if (it) 4.dp else 8.dp },
color = transition.animateColor(
label = "Color",
transitionSpec = { TweenSpec(duration, 0, LinearOutSlowInEasing) }
) {
if (it) MaterialTheme.colorScheme.tertiary else MaterialTheme.colorScheme.primary
},
inactiveColor = transition.animateColor(
label = "In-active color",
transitionSpec = { TweenSpec(duration, 0, LinearOutSlowInEasing) }
) {
if (it) MaterialTheme.colorScheme.tertiaryContainer else MaterialTheme.colorScheme.onPrimary
},
)
}
@Composable
fun createAngularTransition(
isInAccessibilityMode: Boolean,
duration: Int,
): ChartTransition {
val transition = updateTransition(
targetState = isInAccessibilityMode,
label = "Accessibility transition"
)
return ChartTransition(
height = transition.animateDp(
label = "Height",
transitionSpec = { TweenSpec(duration, 0, LinearOutSlowInEasing) }
) { if (it) 100.dp else 50.dp },
avgLineWidth = transition.animateDp(
label = "Average Line Width",
transitionSpec = { TweenSpec(duration, 0, LinearOutSlowInEasing) }
) { if (it) 8.dp else 2.dp },
chartColor = transition.animateColor(
label = "Chart Color",
transitionSpec = { TweenSpec(duration, 0, LinearOutSlowInEasing) }
) { if (it) MaterialTheme.colorScheme.secondary else MaterialTheme.colorScheme.primary },
avgLineColor = transition.animateColor(
label = "Average Line Color",
transitionSpec = { TweenSpec(duration, 0, LinearOutSlowInEasing) }
) { if (it) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.tertiary }
)
}
data class ChartTransition(
val height: State<Dp>,
val avgLineWidth: State<Dp>,
val chartColor: State<Color>,
val avgLineColor: State<Color>,
)

View File

@@ -1,46 +0,0 @@
/*
* Copyright (c) 2022, 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.ui.view
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import no.nordicsemi.android.ui.R
@Composable
fun BatteryLevelView(batteryLevel: Int) {
ScreenSection {
KeyValueField(
stringResource(id = R.string.field_battery),
"$batteryLevel%"
)
}
}

View File

@@ -0,0 +1,113 @@
package no.nordicsemi.android.ui.view
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Error
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MenuAnchorType
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@OptIn(ExperimentalMaterial3Api::class)
@Composable
inline fun <reified T> DropdownView(
items: List<T>,
label: String,
placeholder: String,
defaultSelectedItem: T? = null,
isError: Boolean = false,
errorMessage: String = "",
crossinline onItemSelected: (T) -> Unit,
) {
var expanded by remember { mutableStateOf(false) }
var selectedText by rememberSaveable { mutableStateOf(defaultSelectedItem) }
Box {
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = !expanded }) {
OutlinedTextField(
value = selectedText?.toString() ?: placeholder,
onValueChange = { }, // No need to handle since it's readOnly
readOnly = true,
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
modifier = Modifier
.fillMaxWidth()
.menuAnchor(MenuAnchorType.PrimaryNotEditable),
placeholder = { Text(text = placeholder) },
label = { Text(text = label) },
isError = isError,
supportingText = {
if (isError) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
imageVector = Icons.Default.Error,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
)
Text(
text = errorMessage,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
}
}
},
)
// Animated dropdown menu
AnimatedVisibility(visible = expanded) {
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
modifier = Modifier.exposedDropdownSize(),
) {
items.forEach {
DropdownMenuItem(
text = { Text(it.toString()) },
onClick = {
selectedText = it
expanded = false
onItemSelected(it)
},
)
}
}
}
}
}
}
@Preview
@Composable
private fun DropdownViewPreview() {
val items = listOf("Item 1", "Item 2", "Item 3")
DropdownView(
items = items,
label = "Label",
placeholder = "Placeholder",
defaultSelectedItem = items[0],
onItemSelected = {}
)
}

View File

@@ -0,0 +1,53 @@
package no.nordicsemi.android.ui.view
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@Composable
fun FeatureSupported(
text: String,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Box(
modifier = Modifier.background(
color = MaterialTheme.colorScheme.primary,
shape = RectangleShape
)
) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(20.dp)
)
}
Text(
text = text,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
@Preview(showBackground = true)
@Composable
private fun FeatureSupportedPreview() {
FeatureSupported("Instantaneous stride length measurement supported")
}

View File

@@ -0,0 +1,152 @@
package no.nordicsemi.android.ui.view
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@Composable
fun KeyValueColumn(
value: String,
key: String,
modifier: Modifier = Modifier,
verticalSpacing: Dp = 8.dp,
keyStyle: TextStyle?= null
) {
Box(
modifier = Modifier
.padding(end = 8.dp)
) {
Column(
verticalArrangement = Arrangement.spacedBy(verticalSpacing),
horizontalAlignment = Alignment.Start,
modifier = modifier
) {
Text(
text = value,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onBackground,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
Text(
text = key,
color = MaterialTheme.colorScheme.onBackground,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
style = keyStyle ?: MaterialTheme.typography.bodyLarge
)
}
}
}
@Preview(showBackground = true)
@Composable
private fun KeyValueColumnPreview() {
KeyValueColumn(
value = "Sample Value",
key = "Sample Key",
// keyStyle = MaterialTheme.typography.labelLarge
)
}
@Composable
fun KeyValueColumn(
value: String,
modifier: Modifier = Modifier,
key: @Composable (() -> Unit)
) {
Box(
modifier = Modifier
.padding(end = 8.dp)
) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.Start,
modifier = modifier
) {
Text(
text = value,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onBackground,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
key()
}
}
}
@Composable
fun KeyValueColumnReverse(
value: String,
key: String,
modifier: Modifier = Modifier,
verticalSpacing: Dp = 8.dp,
keyStyle: TextStyle? = null,
) {
Box(
modifier = Modifier
.padding(start = 8.dp),
contentAlignment = Alignment.TopEnd,
) {
Column(
verticalArrangement = Arrangement.spacedBy(verticalSpacing),
horizontalAlignment = Alignment.End,
modifier = modifier
) {
Text(
text = value,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onBackground,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
Text(
text = key,
color = MaterialTheme.colorScheme.onBackground,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
style = keyStyle ?: MaterialTheme.typography.bodyLarge
)
}
}
}
@Composable
fun KeyValueColumnReverse(
value: String,
modifier: Modifier = Modifier,
key: @Composable (() -> Unit)
) {
Box(
modifier = Modifier
.padding(start = 8.dp),
contentAlignment = Alignment.TopEnd,
) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.End,
modifier = modifier
) {
Text(
text = value,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onBackground,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
key()
}
}
}

View File

@@ -1,5 +1,6 @@
package no.nordicsemi.android.ui.view
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.OutlinedCard
@@ -8,9 +9,15 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun ScreenSection(content: @Composable () -> Unit) {
fun ScreenSection(
modifier: Modifier = Modifier.padding(16.dp),
content: @Composable () -> Unit
) {
OutlinedCard {
Column(modifier = Modifier.padding(16.dp)) {
Column(
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = modifier
) {
content()
}
}

View File

@@ -0,0 +1,22 @@
package no.nordicsemi.android.ui.view
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@Composable
fun SectionRow(
content: @Composable RowScope.() -> Unit
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
content()
}
}

View File

@@ -31,36 +31,42 @@
package no.nordicsemi.android.ui.view
import android.annotation.SuppressLint
import androidx.annotation.DrawableRes
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
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.compose.ui.unit.sp
import no.nordicsemi.android.ui.R
@Composable
fun SectionTitle(
@DrawableRes resId: Int,
title: String,
menu: @Composable (() -> Unit)? = null,
modifier: Modifier = Modifier.fillMaxWidth()
modifier: Modifier = Modifier.fillMaxWidth(),
menu: @Composable (() -> Unit)? = null
) {
Row(
modifier = modifier,
@@ -70,26 +76,106 @@ fun SectionTitle(
Image(
painter = painterResource(id = resId),
contentDescription = null,
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSecondary),
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.secondary),
modifier = Modifier
.background(
color = MaterialTheme.colorScheme.secondary,
shape = CircleShape
)
.padding(8.dp)
.size(28.dp)
)
Spacer(modifier = Modifier.size(8.dp))
Text(
text = title,
textAlign = TextAlign.Start,
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f)
)
menu?.invoke()
}
}
@Composable
fun SectionTitle(
icon: ImageVector,
title: String,
modifier: Modifier = Modifier.fillMaxWidth(),
menu: @Composable (() -> Unit)? = null
) {
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start
) {
Image(
imageVector = icon,
contentDescription = null,
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.secondary),
modifier = Modifier
.size(28.dp)
)
Spacer(modifier = Modifier.size(8.dp))
Text(
text = title,
textAlign = TextAlign.Start,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f)
)
menu?.invoke()
}
}
@Composable
@SuppressLint("ModifierParameter")
fun SectionTitle(
@DrawableRes resId: Int,
title: String,
modifier: Modifier = Modifier.fillMaxWidth(),
rotateArrow: Float? = null,
iconBackground: Color = MaterialTheme.colorScheme.secondary
) {
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start
) {
Image(
painter = painterResource(id = resId),
contentDescription = null,
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.secondary),
modifier = Modifier
.size(28.dp)
)
Spacer(modifier = Modifier.padding(8.dp))
Text(
text = title,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.titleMedium,
)
rotateArrow?.let {
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterEnd) {
Image(
Icons.Default.ArrowDropDown,
contentDescription = null,
modifier = Modifier
.padding(8.dp)
.rotate(it)
)
}
}
}
}
@Preview(showBackground = true)
@Composable
private fun SectionTitle_Preview() {
SectionTitle(
resId = R.drawable.ic_records,
title = stringResource(id = R.string.back_screen),
modifier = Modifier
.fillMaxWidth()
.clickable { },
rotateArrow = 0f
)
}
@Composable
fun SectionTitle(
icon: ImageVector,
@@ -104,20 +190,15 @@ fun SectionTitle(
Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSecondary,
tint = MaterialTheme.colorScheme.secondary,
modifier = Modifier
.background(
color = MaterialTheme.colorScheme.secondary,
shape = CircleShape
)
.padding(8.dp)
.size(28.dp)
)
Spacer(modifier = Modifier.size(8.dp))
Text(
text = title,
textAlign = TextAlign.Center,
fontSize = 24.sp,
fontWeight = FontWeight.Bold
style = MaterialTheme.typography.titleMedium,
)
}
}

View File

@@ -0,0 +1,104 @@
package no.nordicsemi.android.ui.view
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Error
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.input.OffsetMapping
import androidx.compose.ui.text.input.TransformedText
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
private class PlaceholderTransformation(private val placeholder: String) : VisualTransformation {
override fun filter(text: AnnotatedString): TransformedText {
return placeholderFilter(placeholder)
}
}
fun placeholderFilter(placeholder: String): TransformedText {
val numberOffsetTranslator = object : OffsetMapping {
override fun originalToTransformed(offset: Int): Int {
return 0
}
override fun transformedToOriginal(offset: Int): Int {
return 0
}
}
return TransformedText(AnnotatedString(placeholder), numberOffsetTranslator)
}
/**
* Compose view to input text in OutlinedTextField.
*/
@Composable
fun TextInputField(
modifier: Modifier = Modifier,
input: String,
label: String,
hint: String? = null,
placeholder: String = "",
errorMessage: String = "",
errorState: Boolean = false,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
onUpdate: (String) -> Unit
) {
val textColor = MaterialTheme.colorScheme.onSurface.copy(
alpha = if (input.isEmpty()) 0.5f else LocalContentColor.current.alpha
)
OutlinedTextField(
value = input,
onValueChange = { onUpdate(it) },
visualTransformation = if (input.isEmpty())
PlaceholderTransformation(placeholder) else VisualTransformation.None,
modifier = modifier.fillMaxWidth(),
label = { Text(text = label) },
keyboardOptions = keyboardOptions,
placeholder = {
Text(
text = placeholder,
)
},
supportingText = {
if (errorState) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
imageVector = Icons.Default.Error,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
)
Text(
text = errorMessage,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.alpha(1f)
)
}
} else if (hint != null) {
Text(
text = hint,
modifier = Modifier.alpha(0.38f)
)
}
},
colors = OutlinedTextFieldDefaults.colors(textColor),
isError = errorState,
)
}

View File

@@ -31,10 +31,9 @@
package no.nordicsemi.android.ui.view
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -48,21 +47,24 @@ import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import no.nordicsemi.android.kotlin.ble.core.data.GattConnectionState
import no.nordicsemi.android.kotlin.ble.core.data.GattConnectionStateWithStatus
import no.nordicsemi.android.common.theme.NordicTheme
import no.nordicsemi.android.ui.R
private const val TOP_APP_BAR_TITLE = "Nordic_Appbar"
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CloseIconAppBar(text: String, onClick: () -> Unit) {
TopAppBar(
title = { Text(text, maxLines = 2) },
title = { Text(text, maxLines = 1) },
colors = TopAppBarDefaults.topAppBarColors(
scrolledContainerColor = MaterialTheme.colorScheme.primary,
containerColor = MaterialTheme.colorScheme.primaryContainer,
containerColor = colorResource(id = R.color.appBarColor),
titleContentColor = MaterialTheme.colorScheme.onPrimary,
actionIconContentColor = MaterialTheme.colorScheme.onPrimary,
navigationIconContentColor = MaterialTheme.colorScheme.onPrimary,
@@ -78,29 +80,41 @@ fun CloseIconAppBar(text: String, onClick: () -> Unit) {
)
}
@Preview
@Composable
private fun CloseIconAppBarPreview() {
NordicTheme {
CloseIconAppBar(TOP_APP_BAR_TITLE) {}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LoggerBackIconAppBar(text: String, onClick: () -> Unit) {
fun LoggerBackIconAppBar(
text: String,
onBackClick: () -> Unit,
onLoggerClick: () -> Unit
) {
TopAppBar(
title = { Text(text, maxLines = 2) },
title = { Text(text, maxLines = 1) },
colors = TopAppBarDefaults.topAppBarColors(
scrolledContainerColor = MaterialTheme.colorScheme.primary,
containerColor = MaterialTheme.colorScheme.primaryContainer,
containerColor = colorResource(id = R.color.appBarColor),
titleContentColor = MaterialTheme.colorScheme.onPrimary,
actionIconContentColor = MaterialTheme.colorScheme.onPrimary,
navigationIconContentColor = MaterialTheme.colorScheme.onPrimary,
),
navigationIcon = {
IconButton(onClick = { onClick() }) {
IconButton(onClick = { onBackClick() }) {
Icon(
Icons.Default.ArrowBack,
Icons.AutoMirrored.Filled.ArrowBack,
tint = MaterialTheme.colorScheme.onPrimary,
contentDescription = stringResource(id = R.string.back_screen),
)
}
},
actions = {
IconButton(onClick = { onClick() }) {
IconButton(onClick = { onLoggerClick() }) {
Icon(
painterResource(id = R.drawable.ic_logger),
contentDescription = stringResource(id = R.string.open_logger),
@@ -112,14 +126,22 @@ fun LoggerBackIconAppBar(text: String, onClick: () -> Unit) {
)
}
@Preview
@Composable
private fun LoggerBackIconAppBarPreview() {
NordicTheme {
LoggerBackIconAppBar(TOP_APP_BAR_TITLE, {}) {}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BackIconAppBar(text: String, onClick: () -> Unit) {
TopAppBar(
title = { Text(text, maxLines = 2) },
title = { Text(text, maxLines = 1) },
colors = TopAppBarDefaults.topAppBarColors(
scrolledContainerColor = MaterialTheme.colorScheme.primary,
containerColor = MaterialTheme.colorScheme.primaryContainer,
containerColor = colorResource(id = R.color.appBarColor),
titleContentColor = MaterialTheme.colorScheme.onPrimary,
actionIconContentColor = MaterialTheme.colorScheme.onPrimary,
navigationIconContentColor = MaterialTheme.colorScheme.onPrimary,
@@ -127,7 +149,7 @@ fun BackIconAppBar(text: String, onClick: () -> Unit) {
navigationIcon = {
IconButton(onClick = { onClick() }) {
Icon(
Icons.Default.ArrowBack,
Icons.AutoMirrored.Filled.ArrowBack,
tint = MaterialTheme.colorScheme.onPrimary,
contentDescription = stringResource(id = R.string.back_screen),
)
@@ -136,6 +158,14 @@ fun BackIconAppBar(text: String, onClick: () -> Unit) {
)
}
@Preview
@Composable
private fun BackIconAppBarPreview() {
NordicTheme {
BackIconAppBar(TOP_APP_BAR_TITLE) {}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LoggerIconAppBar(
@@ -145,10 +175,10 @@ fun LoggerIconAppBar(
onLoggerClick: () -> Unit
) {
TopAppBar(
title = { Text(text, maxLines = 2) },
title = { Text(text, maxLines = 1) },
colors = TopAppBarDefaults.topAppBarColors(
scrolledContainerColor = MaterialTheme.colorScheme.primary,
containerColor = MaterialTheme.colorScheme.primaryContainer,
containerColor = colorResource(id = R.color.appBarColor),
titleContentColor = MaterialTheme.colorScheme.onPrimary,
actionIconContentColor = MaterialTheme.colorScheme.onPrimary,
navigationIconContentColor = MaterialTheme.colorScheme.onPrimary,
@@ -156,7 +186,7 @@ fun LoggerIconAppBar(
navigationIcon = {
IconButton(onClick = { onClick() }) {
Icon(
Icons.Default.ArrowBack,
Icons.AutoMirrored.Filled.ArrowBack,
tint = MaterialTheme.colorScheme.onPrimary,
contentDescription = stringResource(id = R.string.back_screen),
)
@@ -184,23 +214,10 @@ fun LoggerIconAppBar(
)
}
@Preview
@Composable
fun ProfileAppBar(
deviceName: String?,
connectionState: GattConnectionStateWithStatus?,
@StringRes
title: Int,
navigateUp: () -> Unit,
disconnect: () -> Unit,
openLogger: () -> Unit
) {
if (deviceName?.isNotBlank() == true) {
if (connectionState?.state == GattConnectionState.STATE_DISCONNECTING || connectionState?.state == GattConnectionState.STATE_DISCONNECTED) {
LoggerBackIconAppBar(deviceName, openLogger)
} else {
LoggerIconAppBar(deviceName, navigateUp, disconnect, openLogger)
}
} else {
BackIconAppBar(stringResource(id = title), navigateUp)
private fun LoggerIconAppBarPreview() {
NordicTheme {
LoggerIconAppBar(TOP_APP_BAR_TITLE, {}, {}) {}
}
}

View File

@@ -0,0 +1,49 @@
package no.nordicsemi.android.ui.view.animate
import androidx.compose.animation.core.EaseInOutCubic
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@Preview(showBackground = true)
@Composable
fun AnimatedHeart(
modifier: Modifier = Modifier
) {
// Infinite transition for pulsing animation
val infiniteTransition = rememberInfiniteTransition()
val scale by infiniteTransition.animateFloat(
initialValue = 1f,
targetValue = 1.15f,
animationSpec = infiniteRepeatable(
animation = tween(600, easing = EaseInOutCubic),
repeatMode = RepeatMode.Reverse
)
)
Icon(
imageVector = Icons.Filled.Favorite,
contentDescription = "heart icon",
modifier = modifier
.size(28.dp)
.graphicsLayer(
scaleX = scale,
scaleY = scale
),
tint = Color.Red
)
}

View File

@@ -32,9 +32,6 @@
package no.nordicsemi.android.ui.view.dialog
import androidx.compose.runtime.Composable
import androidx.compose.ui.text.buildAnnotatedString
@Composable
fun String.toAnnotatedString() = buildAnnotatedString {
append(this@toAnnotatedString)
}
fun Boolean.toBooleanText(): String = if (this) "YES" else "NO"

View File

@@ -1,101 +0,0 @@
/*
* Copyright (c) 2022, 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.ui.view.dialog
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
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.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import no.nordicsemi.android.ui.R
@Composable
fun StringListDialog(config: StringListDialogConfig) {
AlertDialog(
onDismissRequest = { config.onResult(FlowCanceled) },
title = { Text(text = config.title ?: stringResource(id = R.string.dialog).toAnnotatedString()) },
text = {
Column(
modifier = Modifier
.verticalScroll(rememberScrollState())
) {
config.items.forEachIndexed { i, entry ->
Row(
modifier = Modifier
.clip(RoundedCornerShape(10.dp))
.clickable { config.onResult(ItemSelectedResult(i)) }
.padding(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
config.leftIcon?.let {
Image(
modifier = Modifier.padding(horizontal = 4.dp),
painter = painterResource(it),
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurfaceVariant),
contentDescription = "Content image",
)
}
Text(
text = entry,
modifier = Modifier.fillMaxWidth(),
style = MaterialTheme.typography.titleLarge
)
}
}
}
},
confirmButton = {
TextButton(onClick = { config.onResult(FlowCanceled) }) {
Text(
text = stringResource(id = R.string.cancel),
)
}
}
)
}

View File

@@ -1,43 +0,0 @@
/*
* Copyright (c) 2022, 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.ui.view.dialog
import androidx.annotation.DrawableRes
import androidx.compose.ui.text.AnnotatedString
data class StringListDialogConfig(
val title: AnnotatedString? = null,
@DrawableRes
val leftIcon: Int? = null,
val items: List<String> = emptyList(),
val onResult: (StringListDialogResult) -> Unit
)

View File

@@ -1,38 +0,0 @@
/*
* Copyright (c) 2022, 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.ui.view.dialog
sealed class StringListDialogResult
data class ItemSelectedResult(val index: Int): StringListDialogResult()
object FlowCanceled : StringListDialogResult()

View File

@@ -0,0 +1,80 @@
package no.nordicsemi.android.ui.view.internal
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.HourglassTop
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.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.common.ui.view.CircularIcon
import no.nordicsemi.android.ui.R
import no.nordicsemi.android.ui.view.TextWithAnimatedDots
@Composable
fun DeviceConnectingView(
modifier: Modifier = Modifier,
content: @Composable ColumnScope.(PaddingValues) -> Unit = {}
) {
Column(
modifier = Modifier
.fillMaxSize()
.then(modifier),
horizontalAlignment = Alignment.CenterHorizontally
) {
OutlinedCard(
modifier = Modifier
.widthIn(max = 460.dp),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
CircularIcon(imageVector = Icons.Default.HourglassTop)
TextWithAnimatedDots(
text = stringResource(id = R.string.device_connecting),
textStyle = MaterialTheme.typography.titleMedium,
)
TextWithAnimatedDots(
text = stringResource(id = R.string.device_connecting_des),
textStyle = MaterialTheme.typography.bodyMedium,
)
}
}
content(PaddingValues(top = 16.dp))
}
}
@Preview(showBackground = true)
@Composable
private fun DeviceConnectingView_Preview() {
MaterialTheme {
DeviceConnectingView { padding ->
Button(
onClick = {},
modifier = Modifier.padding(padding)
) {
Text(text = "Cancel")
}
}
}
}

View File

@@ -0,0 +1,116 @@
package no.nordicsemi.android.ui.view.internal
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.DeveloperBoardOff
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
enum class DisconnectReason {
USER, UNKNOWN, LINK_LOSS, MISSING_SERVICE, BLUETOOTH_OFF
}
@Composable
fun DeviceDisconnectedView(
reason: DisconnectReason,
modifier: Modifier = Modifier,
content: @Composable ColumnScope.(PaddingValues) -> Unit = {},
) {
val disconnectedReason = when (reason) {
DisconnectReason.USER -> "Device disconnected successfully."
DisconnectReason.UNKNOWN -> "Oops...! Connection went on a coffee break."
DisconnectReason.LINK_LOSS -> "Device signal has been lost."
DisconnectReason.MISSING_SERVICE -> "The peripheral has services that aren't supported in the nRF Toolbox."
DisconnectReason.BLUETOOTH_OFF -> "Bluetooth adapter is turned off."
}
DeviceDisconnectedView(
disconnectedReason = disconnectedReason,
modifier = modifier,
content = content,
isMissingService = reason == DisconnectReason.MISSING_SERVICE
)
}
@Composable
fun DeviceDisconnectedView(
disconnectedReason: String,
modifier: Modifier = Modifier,
isMissingService: Boolean = false,
content: @Composable ColumnScope.(PaddingValues) -> Unit = {},
) {
Column(
modifier = Modifier
.fillMaxSize()
.then(modifier),
horizontalAlignment = Alignment.CenterHorizontally
) {
OutlinedCard(
modifier = Modifier
.widthIn(max = 460.dp),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(
imageVector = Icons.Default.DeveloperBoardOff,
contentDescription = null,
tint = MaterialTheme.colorScheme.secondary,
modifier = Modifier.size(48.dp)
)
Text(
text = if (isMissingService) "No supported services" else "Device disconnected",
style = MaterialTheme.typography.titleMedium
)
Text(
text = disconnectedReason,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyMedium
)
}
}
content(PaddingValues(top = 16.dp))
}
}
@Preview(showBackground = true)
@Composable
private fun DeviceDisconnectedViewPreview() {
MaterialTheme {
DeviceDisconnectedView(
reason = DisconnectReason.UNKNOWN,
content = { padding ->
Button(
onClick = {},
modifier = Modifier.padding(padding)
) {
Text(text = "Retry")
}
}
)
}
}

View File

@@ -0,0 +1,40 @@
package no.nordicsemi.android.ui.view.internal
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.BluetoothSearching
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
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 no.nordicsemi.android.common.ui.view.WarningView
import no.nordicsemi.android.ui.R
@Composable
fun EmptyView(
@StringRes title: Int,
@StringRes hint: Int,
) {
WarningView(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
imageVector = Icons.AutoMirrored.Filled.BluetoothSearching,
title = stringResource(title).uppercase(),
hint = stringResource(hint).uppercase(),
hintTextAlign = TextAlign.Justify,
)
}
@Preview(showBackground = true)
@Composable
private fun EmptyViewPreview() {
EmptyView(
R.string.app_name,
R.string.app_name,
)
}

View File

@@ -0,0 +1,32 @@
package no.nordicsemi.android.ui.view.internal
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@Composable
fun LoadingView() {
Box(
modifier = Modifier
.padding(8.dp)
.fillMaxSize()
.fillMaxHeight(),
) {
CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center)
)
}
}
@Preview(showBackground = true)
@Composable
private fun LoadingViewPreview() {
LoadingView()
}

View File

@@ -0,0 +1,81 @@
package no.nordicsemi.android.ui.view.internal
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.HourglassTop
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.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.common.ui.view.CircularIcon
import no.nordicsemi.android.ui.R
import no.nordicsemi.android.ui.view.TextWithAnimatedDots
@Composable
fun ServiceDiscoveryView(
modifier: Modifier = Modifier,
content: @Composable ColumnScope.(PaddingValues) -> Unit = {}
) {
Column(
modifier = Modifier
.fillMaxSize()
.then(modifier),
horizontalAlignment = Alignment.CenterHorizontally
) {
OutlinedCard(
modifier = Modifier
.widthIn(max = 460.dp),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
CircularIcon(imageVector = Icons.Default.HourglassTop)
TextWithAnimatedDots(
text = stringResource(id = R.string.discovering_services),
textStyle = MaterialTheme.typography.titleMedium
)
TextWithAnimatedDots(
text = stringResource(id = R.string.discovering_services_des),
textStyle = MaterialTheme.typography.bodyMedium
)
}
}
content(PaddingValues(top = 16.dp))
}
}
@Preview(showBackground = true)
@Composable
private fun ServiceDiscoveryViewPreview() {
MaterialTheme {
ServiceDiscoveryView(
modifier = Modifier.padding(16.dp)
) { padding ->
Button(
onClick = {},
modifier = Modifier.padding(padding)
) {
Text(text = "Cancel")
}
}
}
}

View File

@@ -0,0 +1,22 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="330dp"
android:height="205dp"
android:viewportWidth="330"
android:viewportHeight="205">
<path
android:pathData="M126.36,33.18h77.27v152.87h-77.27z"
android:strokeWidth="8"
android:fillColor="#00000000"
android:strokeColor="#00a1c5"/>
<path
android:pathData="M148.06,19.04h33.88v14.13h-33.88z"
android:strokeWidth="8"
android:fillColor="#00000000"
android:strokeColor="#00a1c5"/>
<path
android:pathData="M177.88,72.65l-25.76,36.96l25.76,0l-25.76,39.2"
android:strokeWidth="8"
android:fillColor="#00000000"
android:strokeColor="#00a1c5"
android:strokeLineCap="round"/>
</vector>

View File

@@ -30,8 +30,8 @@
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="80dp"
android:height="80dp"
android:width="24dp"
android:height="24dp"
android:viewportWidth="1024"
android:viewportHeight="1024">
<path

View File

@@ -34,7 +34,11 @@
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M18.66,2C18.4,2 18.16,2.09 17.97,2.28L16.13,4.13L19.88,7.88L21.72,6.03C22.11,5.64 22.11,5 21.72,4.63L19.38,2.28C19.18,2.09 18.91,2 18.66,2M3.28,4L2,5.28L8.5,11.75L4,16.25V20H7.75L12.25,15.5L18.72,22L20,20.72L13.5,14.25L9.75,10.5L3.28,4M15.06,5.19L11.03,9.22L14.78,12.97L18.81,8.94L15.06,5.19Z"/>
<path
android:fillColor="#00B3DC"
android:pathData="M9,7.27c0,-0.51 -0.13,-1.02 -0.37,-1.48c-0.01,-0.01 -0.01,-0.02 -0.02,-0.03c-0.06,-0.1 -0.12,-0.2 -0.18,-0.29C7.3,3.5 6.48,2.27 6.38,2.09c-0.1,-0.2 -0.29,-0.34 -0.52,-0.36c-0.26,-0.03 -0.51,0.09 -0.65,0.31L3.13,5.43c-0.11,0.15 -0.21,0.32 -0.3,0.49c-0.01,0.01 -0.01,0.01 -0.01,0.02c-0.21,0.44 -0.32,0.9 -0.32,1.38c0,1.77 1.48,3.21 3.29,3.21s3.29,-1.45 3.29,-3.21zM3.83,7.27c0,-0.28 0.06,-0.54 0.19,-0.79c0,0 0,0 0,-0.01c0.05,-0.1 0.11,-0.2 0.19,-0.29c0.01,-0.02 0.02,-0.03 0.03,-0.05l1.55,-2.5c0.1,0.15 0.2,0.33 0.33,0.53c0.47,0.76 1.01,1.61 1.28,2.05c0.01,0.01 0.01,0.02 0.02,0.04c0.04,0.06 0.08,0.13 0.12,0.2c0,0 0,0 0,0c0,0 0,0.01 0,0.01c0.14,0.26 0.21,0.54 0.21,0.84c0,1 -0.86,1.82 -1.91,1.82s-1.91,-0.82 -1.91,-1.82z"/>
<path
android:fillColor="#00B3DC"
android:pathData="M22.875,2.344c-1.144,-1.1 -2.966,-1.062 -4.067,0.085L5.356,16.406c-0.248,0.259 -0.24,0.668 0.018,0.914l1.2,1.153c-0.041,0.027 -0.08,0.063 -0.112,0.101l-2.195,2.472c-0.252,0.282 -0.225,0.712 0.054,0.961c0.123,0.11 0.277,0.165 0.429,0.165c0.179,0 0.356,-0.073 0.483,-0.22l2.195,-2.472c0.024,-0.027 0.041,-0.053 0.06,-0.082l1.217,1.169c0.127,0.123 0.297,0.191 0.47,0.191c0.006,0 0.014,0 0.019,0c0.182,-0.004 0.355,-0.08 0.48,-0.2l13.645,-14.234c0,0 0.003,-0.003 0.003,-0.003C23.84,4.579 23.799,3.445 22.875,2.344zM21.993,5.654C21.993,5.654 21.993,5.654 21.993,5.654L9.127,19.539l-2.363,-2.268l12.866,-13.417c0.619,-0.645 1.647,-0.665 2.294,-0.047C22.563,4.019 22.583,5.048 21.993,5.654z" />
</vector>

View File

@@ -32,5 +32,6 @@
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/md_theme_primary"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

View File

@@ -33,7 +33,6 @@
<resources>
<string name="app_name">nRF Toolbox</string>
<string name="dialog">Dialog</string>
<string name="cancel">Cancel</string>
<string name="go_up">Back</string>
@@ -44,4 +43,10 @@
<string name="disconnect">Disconnect</string>
<string name="field_battery">Battery</string>
<string name="discovering_services">Discovering services</string>
<string name="discovering_services_des">Please wait</string>
<string name="device_connecting">Connecting</string>
<string name="device_connecting_des">Please wait</string>
</resources>

View File

@@ -1,47 +1,11 @@
/*
* Copyright (c) 2022, 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.
*/
plugins {
alias(libs.plugins.nordic.feature)
alias(libs.plugins.kotlin.serialization)
}
android {
namespace = "no.nordicsemi.android.utils"
namespace = "no.nordicsemi.android.toolbox.lib.utils"
}
dependencies {
implementation(libs.nordic.navigation)
implementation(libs.nordic.blek.uiscanner)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.nordic.log.timber)
}

View File

@@ -1,17 +1,21 @@
# Add project specific ProGuard rules here.
# By default, the flags in this file are appended to flags specified
# in C:/Users/alno/AppData/Local/Android/sdk/tools/proguard/proguard-android.txt
# You can edit the include path and order by changing the proguardFiles
# directive in build.gradle.kts.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# Add any project specific keep options here:
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@@ -1,35 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright (c) 2022, 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.
-->
<manifest>
</manifest>
<manifest/>

View File

@@ -0,0 +1,7 @@
package no.nordicsemi.android.toolbox.lib.utils
import timber.log.Timber
fun Throwable.logAndReport() {
Timber.e(this)
}

View File

@@ -0,0 +1,37 @@
package no.nordicsemi.android.toolbox.lib.utils
enum class Profile {
BPS,
CGM,
CHANNEL_SOUNDING,
CSC,
DFS,
GLS,
HRS,
HTS,
LBS,
RSCS,
// PRX, TODO: PRX is not implemented yet, it will be added in the future.
BATTERY,
THROUGHPUT,
UART;
override fun toString(): String =
when (this) {
BPS -> "Blood Pressure"
CGM -> "Continuous Glucose Monitoring"
CHANNEL_SOUNDING -> "Channel Sounding"
CSC -> "Cycling Speed and Cadence"
DFS -> "Direction Finder Service"
GLS -> "Glucose"
HRS -> "Heart Rate Sensor"
HTS -> "Health Thermometer"
LBS -> "Blinky/LED Button Service"
RSCS -> "Running Speed and Cadence Sensor"
BATTERY -> "Battery Service"
THROUGHPUT -> "Throughput Service"
UART -> "UART Service"
}
}

View File

@@ -0,0 +1,18 @@
package no.nordicsemi.android.toolbox.lib.utils.spec
import java.util.UUID
val HTS_SERVICE_UUID: UUID = UUID.fromString("00001809-0000-1000-8000-00805f9b34fb")
val BPS_SERVICE_UUID: UUID = UUID.fromString("00001810-0000-1000-8000-00805f9b34fb")
val CSC_SERVICE_UUID: UUID = UUID.fromString("00001816-0000-1000-8000-00805f9b34fb")
val CGMS_SERVICE_UUID: UUID = UUID.fromString("0000181F-0000-1000-8000-00805f9b34fb")
val DF_SERVICE_UUID: UUID = UUID.fromString("21490000-494a-4573-98af-f126af76f490")
val GLS_SERVICE_UUID: UUID = UUID.fromString("00001808-0000-1000-8000-00805f9b34fb")
val HRS_SERVICE_UUID: UUID = UUID.fromString("0000180D-0000-1000-8000-00805f9b34fb")
val PRX_SERVICE_UUID: UUID = UUID.fromString("00001802-0000-1000-8000-00805f9b34fb")
val RSCS_SERVICE_UUID: UUID = UUID.fromString("00001814-0000-1000-8000-00805F9B34FB")
val UART_SERVICE_UUID: UUID = UUID.fromString("6E400001-B5A3-F393-E0A9-E50E24DCCA9E")
val BATTERY_SERVICE_UUID: UUID = UUID.fromString("0000180F-0000-1000-8000-00805f9b34fb")
val THROUGHPUT_SERVICE_UUID: UUID = UUID.fromString("0483DADD-6C9D-6CA9-5D41-03AD4FFF4ABB")
val CHANNEL_SOUND_SERVICE_UUID: UUID = UUID.fromString("0000185B-0000-1000-8000-00805F9B34FB")
val LBS_SERVICE_UUID: UUID = UUID.fromString("00001523-1212-EFDE-1523-785FEABCD123")

View File

@@ -1,4 +1,4 @@
package no.nordicsemi.android.utils
package no.nordicsemi.android.toolbox.lib.utils
suspend fun tryOrLog(block: suspend () -> Unit) {
try {

View File

@@ -1,58 +0,0 @@
/*
* Copyright (c) 2022, 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.utils
import android.app.ActivityManager
import android.content.Context
import android.util.Log
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
val String.Companion.EMPTY
get() = ""
fun Context.isServiceRunning(serviceClassName: String): Boolean {
val activityManager = getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
val services = activityManager.getRunningServices(Integer.MAX_VALUE)
return services.find { it.service.className == serviceClassName } != null
}
private val exceptionHandler = CoroutineExceptionHandler { _, t ->
Log.e("COROUTINE-EXCEPTION", "Uncaught exception", t)
}
fun CoroutineScope.launchWithCatch(block: suspend CoroutineScope.() -> Unit) =
launch(SupervisorJob() + exceptionHandler) {
block()
}

View File

@@ -0,0 +1,11 @@
plugins {
alias(libs.plugins.nordic.feature)
}
android {
namespace = "no.nordicsemi.android.permissions_ranging"
}
dependencies {
implementation(libs.accompanist.permissions)
}

View File

@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.RANGING" />
</manifest>

View File

@@ -0,0 +1,42 @@
package no.nordicsemi.android.permissions_ranging
import android.app.Activity
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.LocalContext
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import no.nordicsemi.android.permissions_ranging.utils.RangingNotAvailableReason
import no.nordicsemi.android.permissions_ranging.utils.RangingPermissionState
import no.nordicsemi.android.permissions_ranging.view.RangingPermissionRequestView
import no.nordicsemi.android.permissions_ranging.viewmodel.RangingPermissionViewModel
@Composable
fun RequestRangingPermission(
onChanged: (Boolean) -> Unit = {},
content: @Composable (Boolean) -> Unit,
) {
val permissionViewModel = hiltViewModel<RangingPermissionViewModel>()
val context = LocalContext.current
val activity = context as? Activity
val state by activity?.let { permissionViewModel.requestRangingPermission(it) }!!
.collectAsStateWithLifecycle()
LaunchedEffect(state) {
onChanged(state is RangingPermissionState.Available)
}
when (val s = state) {
is RangingPermissionState.Available -> content(true)
is RangingPermissionState.NotAvailable -> {
when (s.reason) {
RangingNotAvailableReason.NOT_AVAILABLE -> RangingPermissionRequestView(content)
RangingNotAvailableReason.PERMISSION_DENIED -> content(false)
}
}
}
}

View File

@@ -0,0 +1,85 @@
package no.nordicsemi.android.permissions_ranging.repository
import android.app.Activity
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.callbackFlow
import no.nordicsemi.android.permissions_ranging.utils.LocalDataProvider
import no.nordicsemi.android.permissions_ranging.utils.RangingNotAvailableReason
import no.nordicsemi.android.permissions_ranging.utils.RangingPermissionState
import no.nordicsemi.android.permissions_ranging.utils.RangingPermissionUtils
import javax.inject.Inject
import javax.inject.Singleton
private const val REFRESH_PERMISSIONS =
"no.nordicsemi.android.permissions_ranging.repository.REFRESH_RANGING_PERMISSIONS"
private const val RANGING_PERMISSION_REQUEST_CODE = 1001
@Singleton
internal class RangingStateManager @Inject constructor(
@ApplicationContext private val context: Context,
) {
private val dataProvider = LocalDataProvider(context)
private val utils = RangingPermissionUtils(context, dataProvider)
fun rangingPermissionState(activity: Activity) = callbackFlow {
trySend(getRangingPermissionState())
val rangingStateChangeHandler = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
trySend(getRangingPermissionState())
}
}
ContextCompat.registerReceiver(
context,
rangingStateChangeHandler,
IntentFilter(),
ContextCompat.RECEIVER_EXPORTED
)
ActivityCompat.requestPermissions(
activity,
arrayOf("android.permission.RANGING"),
RANGING_PERMISSION_REQUEST_CODE
)
awaitClose {
context.unregisterReceiver(rangingStateChangeHandler)
}
}
fun refreshRangingPermissionState() {
val intent = Intent(REFRESH_PERMISSIONS)
context.sendBroadcast(intent)
}
fun markRangingPermissionAsRequested() {
dataProvider.isRangingPermissionRequested = true
}
fun isRangingPermissionDenied(): Boolean {
return utils.isRangingPermissionDenied()
}
private fun getRangingPermissionState(): RangingPermissionState {
return when {
!utils.isRangingPermissionAvailable -> RangingPermissionState.NotAvailable(
RangingNotAvailableReason.NOT_AVAILABLE
)
utils.isRangingPermissionAvailable && !utils.isRangingPermissionGranted -> RangingPermissionState.NotAvailable(
RangingNotAvailableReason.PERMISSION_DENIED
)
else -> RangingPermissionState.Available
}
}
}

View File

@@ -0,0 +1,35 @@
package no.nordicsemi.android.permissions_ranging.utils
import android.content.Context
import android.content.SharedPreferences
import android.os.Build
import androidx.annotation.ChecksSdkIntAtLeast
import androidx.core.app.ActivityCompat
import androidx.core.content.edit
import javax.inject.Inject
import javax.inject.Singleton
private const val SHARED_PREFS_NAME = "SHARED_PREFS_RANGING"
private const val PREFS_PERMISSION_REQUESTED = "ranging_permission_requested"
@Singleton
internal class LocalDataProvider @Inject constructor(
private val context: Context,
) {
private val sharedPrefs: SharedPreferences
get() = context.getSharedPreferences(SHARED_PREFS_NAME, Context.MODE_PRIVATE)
val isBaklavaOrAbove: Boolean
@ChecksSdkIntAtLeast(api = Build.VERSION_CODES.BAKLAVA)
get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA
/**
* The first time an app requests a permission there is no 'Don't Allow' checkbox and
* [ActivityCompat.shouldShowRequestPermissionRationale] returns false.
*/
var isRangingPermissionRequested: Boolean
get() = sharedPrefs.getBoolean(PREFS_PERMISSION_REQUESTED, false)
set(value) {
sharedPrefs.edit { putBoolean(PREFS_PERMISSION_REQUESTED, value) }
}
}

Some files were not shown because too many files have changed in this diff Show More