diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 591321134..9aaf907f6 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -54,7 +54,6 @@ dependencies { implementation(project(":feature:application-keys")) implementation(project(":feature:scenes")) implementation(project(":feature:provisioners")) - implementation(project(":feature:ranges")) implementation(project(":feature:export")) implementation(project(":mesh:core")) implementation(project(":mesh:provisioning")) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 228523c84..49506d618 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -19,4 +19,6 @@ # If you keep the line number information, uncomment this to # hide the original source file name. #-renamesourcefileattribute SourceFile --keep class no.nordicsemi.android.nrfmesh.core.navigation.ClickableSetting { *; } \ No newline at end of file +-keep class no.nordicsemi.android.nrfmesh.core.navigation.ClickableSetting { *; } +-keep class no.nordicsemi.kotlin.mesh.** { *; } +-dontwarn no.nordicsemi.kotlin.mesh.** \ No newline at end of file diff --git a/app/src/main/java/no/nordicsemi/android/nrfmesh/ui/network/NetworkRoute.kt b/app/src/main/java/no/nordicsemi/android/nrfmesh/ui/network/NetworkRoute.kt index e34ced32f..ff235e7f5 100644 --- a/app/src/main/java/no/nordicsemi/android/nrfmesh/ui/network/NetworkRoute.kt +++ b/app/src/main/java/no/nordicsemi/android/nrfmesh/ui/network/NetworkRoute.kt @@ -280,7 +280,7 @@ fun NetworkRoute( keyboardOptions = KeyboardOptions( capitalization = KeyboardCapitalization.Characters ), - regex = Regex("[0-9A-Fa-f]{0,4}"), + regex = Regex("^[0-9A-Fa-f]{0,4}$"), isError = isError, ) Row( diff --git a/core/common/src/main/java/no/nordicsemi/android/nrfmesh/core/common/Utils.kt b/core/common/src/main/java/no/nordicsemi/android/nrfmesh/core/common/Utils.kt index 53e6362cc..52ac1c8e3 100644 --- a/core/common/src/main/java/no/nordicsemi/android/nrfmesh/core/common/Utils.kt +++ b/core/common/src/main/java/no/nordicsemi/android/nrfmesh/core/common/Utils.kt @@ -45,6 +45,9 @@ import no.nordicsemi.kotlin.mesh.core.exception.SceneInUse import no.nordicsemi.kotlin.mesh.core.exception.SecurityException import no.nordicsemi.kotlin.mesh.core.layers.access.AccessError import no.nordicsemi.kotlin.mesh.logger.LogLevel +import kotlin.concurrent.atomics.AtomicLong +import kotlin.concurrent.atomics.ExperimentalAtomicApi +import kotlin.concurrent.atomics.incrementAndFetch /** * Helper object containing utility methods. @@ -127,3 +130,15 @@ fun copyToClipboard( clipboard.setClipEntry(clipEntry = ClipEntry(clipData = clip)) } } + +object KeyIdGenerator { + @OptIn(ExperimentalAtomicApi::class) + private val counter = AtomicLong(0) + + /** + * Generates the next unique ID. This is a helper method used to generate unique IDs for keys in + * a LazyColumn + */ + @OptIn(ExperimentalAtomicApi::class) + fun nextId() = counter.incrementAndFetch() +} \ No newline at end of file diff --git a/core/data/src/main/java/no/nordicsemi/android/nrfmesh/core/data/CoreDataRepository.kt b/core/data/src/main/java/no/nordicsemi/android/nrfmesh/core/data/CoreDataRepository.kt index 79fabe26b..2f028d6d2 100644 --- a/core/data/src/main/java/no/nordicsemi/android/nrfmesh/core/data/CoreDataRepository.kt +++ b/core/data/src/main/java/no/nordicsemi/android/nrfmesh/core/data/CoreDataRepository.kt @@ -229,7 +229,7 @@ class CoreDataRepository @Inject constructor( * @param boundNetworkKey Bound Network Key */ fun addApplicationKey( - name: String = "Application Key ${meshNetwork.applicationKeys.size}", + name: String = "Application Key ${meshNetwork.applicationKeys.size + 1}", boundNetworkKey: NetworkKey, ): ApplicationKey = meshNetwork.add( name = name, diff --git a/core/data/src/main/java/no/nordicsemi/android/nrfmesh/core/data/bearer/AndroidGattBearer.kt b/core/data/src/main/java/no/nordicsemi/android/nrfmesh/core/data/bearer/AndroidGattBearer.kt index 5abac4d54..f4b35c5cc 100644 --- a/core/data/src/main/java/no/nordicsemi/android/nrfmesh/core/data/bearer/AndroidGattBearer.kt +++ b/core/data/src/main/java/no/nordicsemi/android/nrfmesh/core/data/bearer/AndroidGattBearer.kt @@ -22,8 +22,7 @@ class AndroidGattBearer( peripheral = peripheral ) { @OptIn(ExperimentalUuidApi::class) - override suspend fun open() { - super.open() + override suspend fun configurePeripheral(peripheral: Peripheral) { // Request highest connection parameters after connect in the super.open() peripheral.requestHighestValueLength() } diff --git a/core/data/src/main/java/no/nordicsemi/android/nrfmesh/core/data/bearer/AndroidPbGattBearer.kt b/core/data/src/main/java/no/nordicsemi/android/nrfmesh/core/data/bearer/AndroidPbGattBearer.kt index f40252758..b27e9325f 100644 --- a/core/data/src/main/java/no/nordicsemi/android/nrfmesh/core/data/bearer/AndroidPbGattBearer.kt +++ b/core/data/src/main/java/no/nordicsemi/android/nrfmesh/core/data/bearer/AndroidPbGattBearer.kt @@ -22,9 +22,9 @@ class AndroidPbGattBearer( peripheral = peripheral ) { @OptIn(ExperimentalUuidApi::class) - override suspend fun open() { - super.open() + override suspend fun configurePeripheral(peripheral: Peripheral) { // Request highest connection parameters after connect in the super.open() peripheral.requestHighestValueLength() + } } \ No newline at end of file diff --git a/core/data/src/main/java/no/nordicsemi/android/nrfmesh/core/data/models/ApplicationKeyData.kt b/core/data/src/main/java/no/nordicsemi/android/nrfmesh/core/data/models/ApplicationKeyData.kt index 377727763..ec86c5b5b 100644 --- a/core/data/src/main/java/no/nordicsemi/android/nrfmesh/core/data/models/ApplicationKeyData.kt +++ b/core/data/src/main/java/no/nordicsemi/android/nrfmesh/core/data/models/ApplicationKeyData.kt @@ -2,42 +2,44 @@ package no.nordicsemi.android.nrfmesh.core.data.models +import no.nordicsemi.android.nrfmesh.core.common.KeyIdGenerator import no.nordicsemi.kotlin.mesh.core.model.ApplicationKey import no.nordicsemi.kotlin.mesh.core.model.KeyIndex -import no.nordicsemi.kotlin.mesh.core.model.NetworkKey +import kotlin.uuid.ExperimentalUuidApi /** * Application Keys are used to secure communications at the upper transport layer. * The application key (AppKey) shall be generated using a random number generator * compatible with the requirements in Volume 2, Part H, Section 2 of the Core Specification [1]. * - * @property index The index property contains an integer from 0 to 4095 that - * represents the NetKey index for this network key. * @property name Human-readable name for the application functionality associated * with this application key. - * @property boundNetKeyIndex The boundNetKey property contains a corresponding Network Key index - * of the network key in the mesh network. + * @property index The index property contains an integer from 0 to 4095 that + * represents the NetKey index for this network key. * @property key 128-bit application key. * @property oldKey OldKey property contains the previous application key. - * @property boundNetworkKey Network key to which this application key is bound to. - * @param key 128-bit application key. + * @property boundNetKeyIndex The boundNetKey property contains a corresponding Network Key index + * of the network key in the mesh network. + * @property isInUse True if the application key is currently in use in the mesh network. */ +@OptIn(ExperimentalUuidApi::class) data class ApplicationKeyData( val name: String, val index: KeyIndex, val key: ByteArray, val oldKey: ByteArray? = null, val boundNetKeyIndex: KeyIndex, - val boundNetworkKey: NetworkKey?, - val isInUse: Boolean + val boundNetworkKeyName: String, + val isInUse: Boolean, + val id: Long = KeyIdGenerator.nextId() ) { constructor(key: ApplicationKey) : this( name = key.name, index = key.index, key = key.key, oldKey = key.oldKey, - boundNetKeyIndex = key.boundNetKeyIndex, - boundNetworkKey = key.boundNetworkKey, + boundNetKeyIndex = key.boundNetworkKey.index, + boundNetworkKeyName = key.boundNetworkKey.name, isInUse = key.isInUse ) @@ -55,7 +57,6 @@ data class ApplicationKeyData( if (!oldKey.contentEquals(other.oldKey)) return false } else if (other.oldKey != null) return false if (boundNetKeyIndex != other.boundNetKeyIndex) return false - if (boundNetworkKey != other.boundNetworkKey) return false if (isInUse != other.isInUse) return false return true @@ -67,7 +68,6 @@ data class ApplicationKeyData( result = 31 * result + key.contentHashCode() result = 31 * result + (oldKey?.contentHashCode() ?: 0) result = 31 * result + boundNetKeyIndex.hashCode() - result = 31 * result + (boundNetworkKey?.hashCode() ?: 0) result = 31 * result + isInUse.hashCode() return result } diff --git a/core/data/src/main/java/no/nordicsemi/android/nrfmesh/core/data/models/NetworkKeyData.kt b/core/data/src/main/java/no/nordicsemi/android/nrfmesh/core/data/models/NetworkKeyData.kt index 39ba9f043..3e6005abc 100644 --- a/core/data/src/main/java/no/nordicsemi/android/nrfmesh/core/data/models/NetworkKeyData.kt +++ b/core/data/src/main/java/no/nordicsemi/android/nrfmesh/core/data/models/NetworkKeyData.kt @@ -2,6 +2,7 @@ package no.nordicsemi.android.nrfmesh.core.data.models +import no.nordicsemi.android.nrfmesh.core.common.KeyIdGenerator import kotlin.time.Instant import no.nordicsemi.kotlin.mesh.core.model.KeyIndex import no.nordicsemi.kotlin.mesh.core.model.KeyRefreshPhase @@ -56,7 +57,8 @@ data class NetworkKeyData internal constructor( val oldNetworkId: ByteArray?, val timestamp: Instant, val isPrimary: Boolean, - val isInUse: Boolean + val isInUse: Boolean, + val id: Long = KeyIdGenerator.nextId(), ) { /** * Convenience constructor for creating a new network key diff --git a/core/data/src/main/java/no/nordicsemi/android/nrfmesh/core/data/models/NodeData.kt b/core/data/src/main/java/no/nordicsemi/android/nrfmesh/core/data/models/NodeData.kt index 4513830df..e5e8093f9 100644 --- a/core/data/src/main/java/no/nordicsemi/android/nrfmesh/core/data/models/NodeData.kt +++ b/core/data/src/main/java/no/nordicsemi/android/nrfmesh/core/data/models/NodeData.kt @@ -140,7 +140,7 @@ data class NodeData internal constructor( excluded = node.excluded, heartbeatPublication = node.heartbeatPublication, heartbeatSubscription = node.heartbeatSubscription, - primaryElementData = node.primaryElement?.let { ElementData(it) }, + primaryElementData = ElementData(node.primaryElement), elementsCount = node.elementsCount, addresses = node.addresses, unicastRange = node.unicastRange, diff --git a/core/data/src/main/java/no/nordicsemi/android/nrfmesh/core/data/models/ProvisionerData.kt b/core/data/src/main/java/no/nordicsemi/android/nrfmesh/core/data/models/ProvisionerData.kt index 7fb78cbf8..967f8613d 100644 --- a/core/data/src/main/java/no/nordicsemi/android/nrfmesh/core/data/models/ProvisionerData.kt +++ b/core/data/src/main/java/no/nordicsemi/android/nrfmesh/core/data/models/ProvisionerData.kt @@ -1,5 +1,6 @@ package no.nordicsemi.android.nrfmesh.core.data.models +import no.nordicsemi.android.nrfmesh.core.common.KeyIdGenerator import no.nordicsemi.kotlin.mesh.core.model.GroupRange import no.nordicsemi.kotlin.mesh.core.model.Provisioner import no.nordicsemi.kotlin.mesh.core.model.SceneRange @@ -10,6 +11,18 @@ import kotlin.uuid.Uuid /** * ProvisionerData is a data class that represents a Provisioner in the Mesh network. + * + * @param name Name of the provisioner. + * @param uuid UUID of the provisioner. + * @param address Unicast address of the provisioner. + * @param ttl Default TTL value for the provisioner. + * @param deviceKey Device key of the provisioner in hexadecimal string format. + * @param unicastRanges List of unicast address ranges allocated to the provisioner. + * @param groupRanges List of group address ranges allocated to the provisioner. + * @param sceneRanges List of scene ranges allocated to the provisioner. + * @param hasConfigurationCapabilities Indicates if the provisioner has configuration capabilities. + * @param id A unique identifier for the ProvisionerData instance. + * */ @OptIn(ExperimentalUuidApi::class) data class ProvisionerData( @@ -21,7 +34,8 @@ data class ProvisionerData( val unicastRanges: List = emptyList(), val groupRanges: List = emptyList(), val sceneRanges: List = emptyList(), - val hasConfigurationCapabilities: Boolean = false + val hasConfigurationCapabilities: Boolean = false, + val id: Long = KeyIdGenerator.nextId() ) { @OptIn(ExperimentalStdlibApi::class) constructor(provisioner: Provisioner) : this( diff --git a/core/data/src/main/java/no/nordicsemi/android/nrfmesh/core/data/models/SceneData.kt b/core/data/src/main/java/no/nordicsemi/android/nrfmesh/core/data/models/SceneData.kt index a41be6db4..54fd0454c 100644 --- a/core/data/src/main/java/no/nordicsemi/android/nrfmesh/core/data/models/SceneData.kt +++ b/core/data/src/main/java/no/nordicsemi/android/nrfmesh/core/data/models/SceneData.kt @@ -2,6 +2,7 @@ package no.nordicsemi.android.nrfmesh.core.data.models +import no.nordicsemi.android.nrfmesh.core.common.KeyIdGenerator import no.nordicsemi.kotlin.mesh.core.model.Scene import no.nordicsemi.kotlin.mesh.core.model.UnicastAddress @@ -14,12 +15,14 @@ typealias SceneNumber = UShort * @property number Scene number. * @property addresses Addresses containing the scene. * @property isInUse Defines whether the scene is in use by a node. + * @property id Unique ID for the scene data. */ data class SceneData( val name: String, val number: SceneNumber, val addresses: List, - val isInUse: Boolean + val isInUse: Boolean, + val id: Long = KeyIdGenerator.nextId(), ) { constructor(scene: Scene) : this( name = scene.name, diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts index 065147a57..bcce7819e 100644 --- a/core/ui/build.gradle.kts +++ b/core/ui/build.gradle.kts @@ -27,7 +27,7 @@ dependencies { androidTestImplementation(libs.androidx.test.rules) implementation("androidx.compose.material3:material3:1.4.0") - implementation("androidx.compose.material3:material3-adaptive-navigation-suite-android:1.4.0") + implementation("androidx.compose.material3:material3-adaptive-navigation-suite:1.4.0") implementation("androidx.compose.material3.adaptive:adaptive:1.2.0") implementation("androidx.compose.material3.adaptive:adaptive-layout-android:1.2.0") implementation("androidx.compose.material3.adaptive:adaptive-navigation-android:1.2.0") diff --git a/core/ui/src/main/java/no/nordicsemi/android/nrfmesh/core/ui/ElevatedCardItem.kt b/core/ui/src/main/java/no/nordicsemi/android/nrfmesh/core/ui/ElevatedCardItem.kt index d8b4e2bd8..e38a16dcb 100644 --- a/core/ui/src/main/java/no/nordicsemi/android/nrfmesh/core/ui/ElevatedCardItem.kt +++ b/core/ui/src/main/java/no/nordicsemi/android/nrfmesh/core/ui/ElevatedCardItem.kt @@ -110,7 +110,7 @@ private fun NonClickableElevatedCardItem( }, title = title, subtitle = subtitle, - subtitleMaxLines = subtitleMaxLines, + subtitleMaxLines = subtitleMaxLines, trailingComposable = titleAction ) if (supportingText != null) @@ -177,7 +177,7 @@ private fun ClickableElevatedCardItem( }, title = title, subtitle = subtitle, - subtitleMaxLines = subtitleMaxLines, + subtitleMaxLines = subtitleMaxLines, trailingComposable = titleAction ) if (supportingText != null) @@ -306,7 +306,8 @@ fun ElevatedCardItemTextField( content = { Icon( imageVector = Icons.Outlined.DeleteSweep, - contentDescription = null + contentDescription = null, + tint = MaterialTheme.colorScheme.primary ) } ) @@ -333,7 +334,7 @@ fun ElevatedCardItemTextField( Icon( imageVector = Icons.Outlined.Close, contentDescription = null, - tint = LocalContentColor.current.copy(alpha = 0.6f) + tint = MaterialTheme.colorScheme.primary ) } ) @@ -353,7 +354,7 @@ fun ElevatedCardItemTextField( Icon( imageVector = Icons.Outlined.Check, contentDescription = null, - tint = LocalContentColor.current.copy(alpha = 0.6f) + tint = MaterialTheme.colorScheme.primary ) } ) @@ -440,7 +441,8 @@ fun ElevatedCardItemHexTextField( content = { Icon( imageVector = Icons.Outlined.DeleteSweep, - contentDescription = null + contentDescription = null, + tint = MaterialTheme.colorScheme.primary ) } ) @@ -467,7 +469,7 @@ fun ElevatedCardItemHexTextField( Icon( imageVector = Icons.Outlined.Close, contentDescription = null, - tint = LocalContentColor.current.copy(alpha = 0.6f) + tint = MaterialTheme.colorScheme.primary ) } ) @@ -487,7 +489,7 @@ fun ElevatedCardItemHexTextField( Icon( imageVector = Icons.Outlined.Check, contentDescription = null, - tint = LocalContentColor.current.copy(alpha = 0.6f) + tint = MaterialTheme.colorScheme.primary ) } ) diff --git a/core/ui/src/main/java/no/nordicsemi/android/nrfmesh/core/ui/MeshOutlinedButton.kt b/core/ui/src/main/java/no/nordicsemi/android/nrfmesh/core/ui/MeshOutlinedButton.kt index b0201398f..8cc1804fc 100644 --- a/core/ui/src/main/java/no/nordicsemi/android/nrfmesh/core/ui/MeshOutlinedButton.kt +++ b/core/ui/src/main/java/no/nordicsemi/android/nrfmesh/core/ui/MeshOutlinedButton.kt @@ -33,7 +33,7 @@ import androidx.compose.ui.unit.dp fun MeshOutlinedButton( modifier: Modifier = Modifier, isOnClickActionInProgress: Boolean = false, - buttonIcon: ImageVector? = null, + buttonIcon: ImageVector, buttonIconTint: Color? = null, text: String, textColor: Color = Color.Unspecified, @@ -54,7 +54,7 @@ fun MeshOutlinedButton( color = buttonIconTint ?: ProgressIndicatorDefaults.circularColor ) } else { - buttonIcon?.let { + buttonIcon.let { Icon( imageVector = it, contentDescription = null, diff --git a/core/ui/src/main/java/no/nordicsemi/android/nrfmesh/core/ui/MeshOutlinedTextFields.kt b/core/ui/src/main/java/no/nordicsemi/android/nrfmesh/core/ui/MeshOutlinedTextFields.kt index a11349874..24ce08e66 100644 --- a/core/ui/src/main/java/no/nordicsemi/android/nrfmesh/core/ui/MeshOutlinedTextFields.kt +++ b/core/ui/src/main/java/no/nordicsemi/android/nrfmesh/core/ui/MeshOutlinedTextFields.kt @@ -53,7 +53,15 @@ fun MeshOutlinedTextField( enabled = enabled, prefix = prefix, value = value, - onValueChange = onValueChanged, + onValueChange = { value -> + regex?.let { + if(it.matches(value.text)) { + onValueChanged(value) + } + } ?: run { + onValueChanged(value) + } + }, label = label, placeholder = placeholder, trailingIcon = internalTrailingIcon, diff --git a/core/ui/src/main/java/no/nordicsemi/android/nrfmesh/core/ui/MeshSingleLineListItem.kt b/core/ui/src/main/java/no/nordicsemi/android/nrfmesh/core/ui/MeshSingleLineListItem.kt index 97478fb66..e1ace64a2 100644 --- a/core/ui/src/main/java/no/nordicsemi/android/nrfmesh/core/ui/MeshSingleLineListItem.kt +++ b/core/ui/src/main/java/no/nordicsemi/android/nrfmesh/core/ui/MeshSingleLineListItem.kt @@ -5,11 +5,16 @@ package no.nordicsemi.android.nrfmesh.core.ui import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +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.vector.ImageVector import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -37,4 +42,38 @@ fun MeshSingleLineListItem( ) trailingComposable() } +} + + +@Composable +fun MeshSingleLineListItem( + modifier: Modifier = Modifier, + imageVector: ImageVector, + title: String, + titleTextOverflow: TextOverflow = TextOverflow.Ellipsis, + trailingComposable: @Composable () -> Unit = {}, +) { + Row( + modifier = modifier + .fillMaxWidth() + .heightIn(min = 60.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + modifier = Modifier + .padding(start = 28.dp, end = 16.dp) + .size(24.dp), + imageVector = imageVector, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + Text( + modifier = Modifier.weight(weight = 1f), + text = title, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = titleTextOverflow + ) + trailingComposable() + } } \ No newline at end of file diff --git a/core/ui/src/main/java/no/nordicsemi/android/nrfmesh/core/ui/MeshSnackbar.kt b/core/ui/src/main/java/no/nordicsemi/android/nrfmesh/core/ui/MeshSnackbar.kt index 6419ebb57..4983afb03 100644 --- a/core/ui/src/main/java/no/nordicsemi/android/nrfmesh/core/ui/MeshSnackbar.kt +++ b/core/ui/src/main/java/no/nordicsemi/android/nrfmesh/core/ui/MeshSnackbar.kt @@ -103,10 +103,3 @@ fun showSnackbar( } } -fun dismissSnackbar( - snackbarHostState: SnackbarHostState -) { - // Dismiss any snackbar that's been shown already. - snackbarHostState.currentSnackbarData?.dismiss() -} - diff --git a/core/ui/src/main/java/no/nordicsemi/android/nrfmesh/core/ui/MeshSwipeToDismiss.kt b/core/ui/src/main/java/no/nordicsemi/android/nrfmesh/core/ui/MeshSwipeToDismiss.kt deleted file mode 100644 index 2aa015edb..000000000 --- a/core/ui/src/main/java/no/nordicsemi/android/nrfmesh/core/ui/MeshSwipeToDismiss.kt +++ /dev/null @@ -1,68 +0,0 @@ -@file:OptIn(ExperimentalMaterial3Api::class) - -package no.nordicsemi.android.nrfmesh.core.ui - -import androidx.compose.animation.animateColorAsState -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Delete -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.SwipeToDismissBox -import androidx.compose.material3.SwipeToDismissBoxState -import androidx.compose.material3.SwipeToDismissBoxValue -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.Color -import androidx.compose.ui.unit.dp - -/** - * Holds the Swipe to dismiss composable, its animation and the current state - */ -@Composable -@ExperimentalMaterial3Api -fun SwipeDismissItem( - dismissState: SwipeToDismissBoxState, - enableDismissFromStartToEnd: Boolean = true, - enableDismissFromEndToStart: Boolean = true, - content: @Composable () -> Unit -) { - SwipeToDismissBox( - modifier = Modifier.padding(horizontal = 16.dp), - state = dismissState, - enableDismissFromStartToEnd = enableDismissFromStartToEnd, - enableDismissFromEndToStart = enableDismissFromEndToStart, - backgroundContent = { - val color by animateColorAsState(targetValue = Color.Red, label = "dismiss") - Box( - modifier = Modifier - .fillMaxSize() - .background(color = color, shape = CardDefaults.elevatedShape) - .padding(horizontal = 16.dp), - contentAlignment = if (dismissState.dismissDirection == SwipeToDismissBoxValue.StartToEnd) - Alignment.CenterStart - else Alignment.CenterEnd - ) { - Icon(imageVector = Icons.Outlined.Delete, contentDescription = "null") - } - } - ) { - content() - HorizontalDivider() - } -} - -/** - * Returns true if the item is dismissed. - * - * @receiver SwipeToDismissState - * @return Boolean if dismissed or false otherwise. - */ -fun SwipeToDismissBoxState.isDismissed(): Boolean = targetValue != SwipeToDismissBoxValue.Settled \ No newline at end of file diff --git a/core/ui/src/main/java/no/nordicsemi/android/nrfmesh/core/ui/MeshTwoLineListItem.kt b/core/ui/src/main/java/no/nordicsemi/android/nrfmesh/core/ui/MeshTwoLineListItem.kt index 46878a379..83efbdab3 100644 --- a/core/ui/src/main/java/no/nordicsemi/android/nrfmesh/core/ui/MeshTwoLineListItem.kt +++ b/core/ui/src/main/java/no/nordicsemi/android/nrfmesh/core/ui/MeshTwoLineListItem.kt @@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material3.Icon -import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -85,7 +84,7 @@ fun MeshTwoLineListItem( .size(24.dp), imageVector = imageVector, contentDescription = null, - tint = LocalContentColor.current.copy(alpha = 0.6f) + tint = MaterialTheme.colorScheme.primary ) Column( modifier = Modifier diff --git a/feature/application-keys/src/main/java/no/nordicsemi/android/nrfmesh/feature/application/keys/ApplicationKeyRoute.kt b/feature/application-keys/src/main/java/no/nordicsemi/android/nrfmesh/feature/application/keys/ApplicationKeyRoute.kt index 2c881583e..453ec89ee 100644 --- a/feature/application-keys/src/main/java/no/nordicsemi/android/nrfmesh/feature/application/keys/ApplicationKeyRoute.kt +++ b/feature/application-keys/src/main/java/no/nordicsemi/android/nrfmesh/feature/application/keys/ApplicationKeyRoute.kt @@ -60,7 +60,7 @@ internal fun ApplicationKeyRoute( val snackbarHostState = remember { SnackbarHostState() } var isCurrentlyEditable by rememberSaveable { mutableStateOf(true) } val applicationKey by remember(key.index) { derivedStateOf { ApplicationKeyData(key = key) } } - var boundNetKeyIndex by remember(key.index) { mutableIntStateOf(key.boundNetKeyIndex.toInt()) } + var boundNetKeyIndex by remember(key.index) { mutableIntStateOf(key.index.toInt()) } Scaffold( modifier = Modifier.background(color = Color.Red), snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, @@ -103,7 +103,7 @@ internal fun ApplicationKeyRoute( onClick = { if (!applicationKey.isInUse) { boundNetKeyIndex = networkKey.index.toInt() - key.boundNetKeyIndex = networkKey.index + key.bind(networkKey = networkKey) save() } else showSnackbar( scope = coroutineScope, @@ -131,7 +131,7 @@ internal fun ApplicationKeyRoute( } @Composable -fun Name( +private fun Name( name: String, onNameChanged: (String) -> Unit, isCurrentlyEditable: Boolean, @@ -150,7 +150,7 @@ fun Name( @OptIn(ExperimentalStdlibApi::class) @Composable -fun Key( +private fun Key( key: ByteArray, onKeyChanged: (ByteArray) -> Unit, isCurrentlyEditable: Boolean, @@ -182,7 +182,7 @@ fun Key( @OptIn(ExperimentalStdlibApi::class) @Composable -fun OldKey(oldKey: ByteArray?) { +private fun OldKey(oldKey: ByteArray?) { val scope = rememberCoroutineScope() val context = LocalContext.current val clipboard = LocalClipboard.current @@ -205,7 +205,7 @@ fun OldKey(oldKey: ByteArray?) { } @Composable -fun KeyIndex(index: KeyIndex) { +private fun KeyIndex(index: KeyIndex) { ElevatedCardItem( modifier = Modifier.padding(horizontal = 16.dp), imageVector = Icons.Outlined.FormatListNumbered, diff --git a/feature/application-keys/src/main/java/no/nordicsemi/android/nrfmesh/feature/application/keys/ApplicationKeysRoute.kt b/feature/application-keys/src/main/java/no/nordicsemi/android/nrfmesh/feature/application/keys/ApplicationKeysRoute.kt index 482ed12c0..8d045873f 100644 --- a/feature/application-keys/src/main/java/no/nordicsemi/android/nrfmesh/feature/application/keys/ApplicationKeysRoute.kt +++ b/feature/application-keys/src/main/java/no/nordicsemi/android/nrfmesh/feature/application/keys/ApplicationKeysRoute.kt @@ -3,8 +3,12 @@ package no.nordicsemi.android.nrfmesh.feature.application.keys import android.content.Context -import androidx.compose.foundation.layout.Arrangement +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxSize @@ -13,6 +17,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.outlined.Delete import androidx.compose.material.icons.outlined.VpnKey import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api @@ -24,36 +29,36 @@ import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarResult +import androidx.compose.material3.SwipeToDismissBox +import androidx.compose.material3.SwipeToDismissBoxValue import androidx.compose.material3.Text import androidx.compose.material3.rememberSwipeToDismissBoxState import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -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.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.delay import kotlinx.coroutines.launch import no.nordicsemi.android.nrfmesh.core.data.models.ApplicationKeyData import no.nordicsemi.android.nrfmesh.core.ui.ApplicationKeyRow import no.nordicsemi.android.nrfmesh.core.ui.MeshNoItemsAvailable import no.nordicsemi.android.nrfmesh.core.ui.SectionTitle -import no.nordicsemi.android.nrfmesh.core.ui.SwipeDismissItem -import no.nordicsemi.android.nrfmesh.core.ui.isDismissed -import no.nordicsemi.kotlin.data.toHexString import no.nordicsemi.kotlin.mesh.core.model.ApplicationKey import no.nordicsemi.kotlin.mesh.core.model.KeyIndex @Composable internal fun ApplicationKeysRoute( + snackbarHostState: SnackbarHostState, highlightSelectedItem: Boolean, + selectedKeyIndex: KeyIndex?, keys: List, onAddKeyClicked: () -> ApplicationKey, onApplicationKeyClicked: (KeyIndex) -> Unit, @@ -64,8 +69,6 @@ internal fun ApplicationKeysRoute( ) { val context = LocalContext.current val scope = rememberCoroutineScope() - val snackbarHostState = remember { SnackbarHostState() } - var selectedKeyIndex by rememberSaveable { mutableStateOf(null) } Scaffold( snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, floatingActionButton = { @@ -77,7 +80,6 @@ internal fun ApplicationKeysRoute( runCatching { onAddKeyClicked() }.onSuccess { - selectedKeyIndex = it.index.toInt() navigateToKey(it.index) } }, @@ -90,10 +92,6 @@ internal fun ApplicationKeysRoute( .fillMaxSize() .consumeWindowInsets(paddingValues = paddingValues) ) { - SectionTitle( - modifier = Modifier.padding(top = 8.dp), - title = stringResource(id = R.string.label_application_keys) - ) when (keys.isEmpty()) { true -> MeshNoItemsAvailable( modifier = Modifier.fillMaxSize(), @@ -103,31 +101,43 @@ internal fun ApplicationKeysRoute( false -> LazyColumn( modifier = Modifier - .fillMaxSize() - .padding(top = 8.dp), - verticalArrangement = Arrangement.spacedBy(space = 8.dp) + .fillMaxSize(), + contentPadding = PaddingValues(horizontal = 16.dp), + // Removed in favor of padding in SwipeToDismissKey so that hiding an item will not leave any gaps + //verticalArrangement = Arrangement.spacedBy(space = 8.dp) ) { - items(items = keys, key = { it.key.hashCode() }) { key -> - val isSelected = highlightSelectedItem && key.index.toInt() == selectedKeyIndex - SwipeToDismissKey( - scope = scope, - context = context, - snackbarHostState = snackbarHostState, - key = key, - isSelected = isSelected, - onApplicationKeyClicked = { - selectedKeyIndex = it.toInt() - onApplicationKeyClicked(it) - }, - onSwiped = onSwiped, - onUndoClicked = onUndoClicked, - remove = remove + item { + SectionTitle( + modifier = Modifier.padding(vertical = 8.dp), + title = stringResource(id = R.string.label_application_keys) ) } + items(items = keys, key = { it.id }) { key -> + val isSelected = + highlightSelectedItem && key.index == selectedKeyIndex + var visibility by remember { mutableStateOf(true) } + AnimatedVisibility(visibility) { + SwipeToDismissKey( + scope = scope, + context = context, + snackbarHostState = snackbarHostState, + key = key, + isSelected = isSelected, + onApplicationKeyClicked = onApplicationKeyClicked, + onSwiped = { + visibility = false + onSwiped(it) + }, + onUndoClicked = { + visibility = true + onUndoClicked(it) + }, + remove = remove + ) + } + } } } - - } } } @@ -146,19 +156,71 @@ private fun SwipeToDismissKey( remove: (ApplicationKeyData) -> Unit, ) { // Hold the current state from the Swipe to Dismiss composable - val dismissState = rememberSwipeToDismissBoxState( - confirmValueChange = { - handleValueChange( - scope = scope, - context = context, - snackbarHostState = snackbarHostState, - key = key + val dismissState = rememberSwipeToDismissBoxState() + + SwipeToDismissBox( + // Added instead of using Arrangement.spacedBy to avoid leaving gaps when an item is swiped away. + modifier = Modifier.padding(bottom = 8.dp), + state = dismissState, + backgroundContent = { + val color by animateColorAsState( + when (dismissState.targetValue) { + SwipeToDismissBoxValue.Settled, + SwipeToDismissBoxValue.StartToEnd, + SwipeToDismissBoxValue.EndToStart, + -> if (key.isInUse) Color.Gray else Color.Red + } ) + Box( + modifier = Modifier + .fillMaxSize() + .background(color = color, shape = CardDefaults.elevatedShape) + .padding(horizontal = 16.dp), + contentAlignment = if (dismissState.dismissDirection == SwipeToDismissBoxValue.StartToEnd) + Alignment.CenterStart + else Alignment.CenterEnd + ) { + Icon(imageVector = Icons.Outlined.Delete, contentDescription = "null") + } + }, + onDismiss = { + snackbarHostState.currentSnackbarData?.dismiss() + if (key.isInUse) { + scope.launch { + dismissState.reset() + snackbarHostState.showSnackbar( + message = context.getString( + R.string.label_unable_to_delete_key_is_in_use, + key.name + ) + ) + } + } else { + scope.launch { + onSwiped(key) + } + scope.launch { + val result = snackbarHostState.showSnackbar( + message = context.getString( + R.string.label_key_deleted, + key.name + ), + actionLabel = context.getString(R.string.action_undo), + withDismissAction = true, + duration = SnackbarDuration.Short + ) + + when (result) { + SnackbarResult.ActionPerformed -> { + dismissState.reset() + onUndoClicked(key) + } + + SnackbarResult.Dismissed -> remove(key) + } + } + } }, - positionalThreshold = { it * 0.5f } - ) - SwipeDismissItem( - dismissState = dismissState, content = { ApplicationKeyRow( onClick = { onApplicationKeyClicked(key.index) }, @@ -170,52 +232,8 @@ private fun SwipeToDismissKey( else -> CardDefaults.outlinedCardColors() }, title = key.name, - subtitle = "Bound to ${key.boundNetworkKey?.name ?: key.key.toHexString()}" + subtitle = "Bound to ${key.boundNetworkKeyName}" ) } ) - - if (dismissState.isDismissed()) { - LaunchedEffect(snackbarHostState) { - scope.launch { - delay(250) - onSwiped(key) - } - scope.launch { - snackbarHostState.showSnackbar( - message = context.getString(R.string.label_application_key_deleted), - actionLabel = context.getString(R.string.action_undo), - withDismissAction = true, - duration = SnackbarDuration.Short - ).also { - when (it) { - SnackbarResult.Dismissed -> remove(key) - SnackbarResult.ActionPerformed -> { - dismissState.reset() - onUndoClicked(key) - } - } - } - } - } - } -} - -private fun handleValueChange( - scope: CoroutineScope, - context: Context, - snackbarHostState: SnackbarHostState, - key: ApplicationKeyData, -) = when { - // First we check if the key is in use - key.isInUse -> { - scope.launch { - snackbarHostState.showSnackbar( - message = context.getString(R.string.error_cannot_delete_key_in_use) - ) - } - false - } - - else -> true } \ No newline at end of file diff --git a/feature/application-keys/src/main/java/no/nordicsemi/android/nrfmesh/feature/application/keys/ApplicationKeysViewModel.kt b/feature/application-keys/src/main/java/no/nordicsemi/android/nrfmesh/feature/application/keys/ApplicationKeysViewModel.kt index 973095c88..00b915efa 100644 --- a/feature/application-keys/src/main/java/no/nordicsemi/android/nrfmesh/feature/application/keys/ApplicationKeysViewModel.kt +++ b/feature/application-keys/src/main/java/no/nordicsemi/android/nrfmesh/feature/application/keys/ApplicationKeysViewModel.kt @@ -6,6 +6,8 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import no.nordicsemi.android.nrfmesh.core.data.CoreDataRepository @@ -17,28 +19,16 @@ import javax.inject.Inject @HiltViewModel internal class ApplicationKeysViewModel @Inject internal constructor( - private val repository: CoreDataRepository + private val repository: CoreDataRepository, ) : ViewModel() { private lateinit var network: MeshNetwork - private var selectedKeyIndex: KeyIndex? = null private val _uiState = MutableStateFlow(ApplicationKeysScreenUiState(listOf())) val uiState: StateFlow = _uiState.asStateFlow() init { - viewModelScope.launch { - repository.network.collect { network -> - this@ApplicationKeysViewModel.network = network - _uiState.update { state -> - val keys = network.applicationKeys.map{ ApplicationKeyData(it) } - state.copy( - keys = keys, - keysToBeRemoved = keys.filter { it in state.keysToBeRemoved } - ) - } - } - } + observeNetwork() } override fun onCleared() { @@ -46,12 +36,26 @@ internal class ApplicationKeysViewModel @Inject internal constructor( super.onCleared() } + private fun observeNetwork() { + repository.network.onEach { network -> + this.network = network + _uiState.update { state -> + state.copy( + keys = network.applicationKeys + .map { ApplicationKeyData(key = it) } + // Filter out the keys that are marked for deletion. + .filter { it !in state.keysToBeRemoved }, + ) + } + }.launchIn(scope = viewModelScope) + } + /** * Adds an application key to the network. */ internal fun addApplicationKey( - name: String = "nRF Application Key ${network.networkKeys.size}", - boundNetworkKey: NetworkKey = network.networkKeys.first() + name: String = "Application Key ${_uiState.value.keys.size}", + boundNetworkKey: NetworkKey = network.networkKeys.first(), ) = repository.addApplicationKey(name = name, boundNetworkKey = boundNetworkKey) /** @@ -61,12 +65,8 @@ internal class ApplicationKeysViewModel @Inject internal constructor( * @param key Application key to be deleted. */ fun onSwiped(key: ApplicationKeyData) { - viewModelScope.launch { - val state = _uiState.value - _uiState.value = state.copy( - keys = state.keys - key, - keysToBeRemoved = state.keysToBeRemoved + key - ) + _uiState.update { state -> + state.copy(keysToBeRemoved = state.keysToBeRemoved + key) } } @@ -77,13 +77,8 @@ internal class ApplicationKeysViewModel @Inject internal constructor( * @param key Application key to be reverted. */ fun onUndoSwipe(key: ApplicationKeyData) { - viewModelScope.launch { - val state = _uiState.value - _uiState.value = state.copy( - keys = (state.keys + key) - .sortedBy { it.index }, - keysToBeRemoved = state.keysToBeRemoved - key - ) + _uiState.update { state -> + state.copy(keysToBeRemoved = state.keysToBeRemoved - key) } } @@ -93,16 +88,15 @@ internal class ApplicationKeysViewModel @Inject internal constructor( * @param key Key to be removed. */ internal fun remove(key: ApplicationKeyData) { - viewModelScope.launch { - val state = _uiState.value - network.run { - applicationKey(index = key.index)?.let { - remove(key = it) - save() - } - } - _uiState.value = state.copy(keysToBeRemoved = state.keysToBeRemoved - key) + _uiState.update { state -> + state.copy( + keys = state.keys - key, + keysToBeRemoved = state.keysToBeRemoved - key + ) } + network.removeApplicationKeyWithIndex(index = key.index) + // In addition lets remove the keys queued for deletion as well. + removeKeys() } /** @@ -111,11 +105,7 @@ internal class ApplicationKeysViewModel @Inject internal constructor( private fun removeKeys() { runCatching { _uiState.value.keysToBeRemoved.forEach { keyData -> - network.run { - applicationKey(index = keyData.index)?.let { - remove(key = it) - } - } + network.removeApplicationKeyWithIndex(index = keyData.index) } } save() @@ -130,15 +120,15 @@ internal class ApplicationKeysViewModel @Inject internal constructor( internal fun selectKeyIndex(keyIndex: KeyIndex) { - selectedKeyIndex = keyIndex + _uiState.update { state -> + state.copy(selectedKeyIndex = keyIndex) + } } - - internal fun isCurrentlySelectedKey(keyIndex: KeyIndex): Boolean = - keyIndex == selectedKeyIndex } @ConsistentCopyVisibility data class ApplicationKeysScreenUiState internal constructor( val keys: List = listOf(), - val keysToBeRemoved: List = listOf() + val keysToBeRemoved: List = listOf(), + val selectedKeyIndex: KeyIndex? = null, ) diff --git a/feature/application-keys/src/main/java/no/nordicsemi/android/nrfmesh/feature/application/keys/navigation/ApplicationKeysDestination.kt b/feature/application-keys/src/main/java/no/nordicsemi/android/nrfmesh/feature/application/keys/navigation/ApplicationKeysDestination.kt index d03c2271d..c38a9f2e6 100644 --- a/feature/application-keys/src/main/java/no/nordicsemi/android/nrfmesh/feature/application/keys/navigation/ApplicationKeysDestination.kt +++ b/feature/application-keys/src/main/java/no/nordicsemi/android/nrfmesh/feature/application/keys/navigation/ApplicationKeysDestination.kt @@ -1,6 +1,7 @@ package no.nordicsemi.android.nrfmesh.feature.application.keys.navigation import android.os.Parcelable +import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.hilt.navigation.compose.hiltViewModel @@ -17,6 +18,7 @@ data object ApplicationKeysContent : Parcelable @Composable fun ApplicationKeysScreenRoute( + snackbarHostState: SnackbarHostState, highlightSelectedItem: Boolean, onApplicationKeyClicked: (KeyIndex) -> Unit, navigateToKey: (KeyIndex) -> Unit, @@ -25,21 +27,26 @@ fun ApplicationKeysScreenRoute( val viewModel = hiltViewModel() val uiState by viewModel.uiState.collectAsStateWithLifecycle() ApplicationKeysRoute( + snackbarHostState = snackbarHostState, highlightSelectedItem = highlightSelectedItem, + selectedKeyIndex = uiState.selectedKeyIndex, keys = uiState.keys, onAddKeyClicked = viewModel::addApplicationKey, onApplicationKeyClicked = { - viewModel.selectKeyIndex(it) + viewModel.selectKeyIndex(keyIndex = it) onApplicationKeyClicked(it) }, - navigateToKey = navigateToKey, + navigateToKey = { + viewModel.selectKeyIndex(keyIndex = it) + navigateToKey(it) + }, onSwiped = { viewModel.onSwiped(it) - if(viewModel.isCurrentlySelectedKey(it.index)) { + if(uiState.selectedKeyIndex == it.index) { navigateUp() } }, onUndoClicked = viewModel::onUndoSwipe, - remove = { viewModel.remove(it) } + remove = viewModel::remove ) } \ No newline at end of file diff --git a/feature/application-keys/src/main/res/values/strings.xml b/feature/application-keys/src/main/res/values/strings.xml index 6b716adbc..25d88f2e5 100644 --- a/feature/application-keys/src/main/res/values/strings.xml +++ b/feature/application-keys/src/main/res/values/strings.xml @@ -21,10 +21,8 @@ DEADBEEFDEADBEEFDEADBEEFDEADBEEF Unable to edit, key is already in use. Undo - Application Key deleted. Unable to delete, the last network key cannot be deleted. - Unable to delete, key is in use. Switch to a different key to delete the currently selected key. Bound Network Key Cannot change, application key is in @@ -33,4 +31,6 @@ Add Key Application Key No Application Keys currently added + Unable to delete, %1$s is in use. + %1$s deleted \ No newline at end of file diff --git a/feature/config-application-keys/src/main/java/no/nordicsemi/android/nrfmesh/feature/config/applicationkeys/ConfigAppKeysScreen.kt b/feature/config-application-keys/src/main/java/no/nordicsemi/android/nrfmesh/feature/config/applicationkeys/ConfigAppKeysScreen.kt index 5253a6b76..d3eb938d7 100644 --- a/feature/config-application-keys/src/main/java/no/nordicsemi/android/nrfmesh/feature/config/applicationkeys/ConfigAppKeysScreen.kt +++ b/feature/config-application-keys/src/main/java/no/nordicsemi/android/nrfmesh/feature/config/applicationkeys/ConfigAppKeysScreen.kt @@ -2,9 +2,12 @@ package no.nordicsemi.android.nrfmesh.feature.config.applicationkeys import android.content.Context import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxSize @@ -13,7 +16,9 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.outlined.Delete import androidx.compose.material.icons.outlined.VpnKey +import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.Icon @@ -21,6 +26,9 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult +import androidx.compose.material3.SwipeToDismissBox +import androidx.compose.material3.SwipeToDismissBoxValue import androidx.compose.material3.Text import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.material3.rememberModalBottomSheetState @@ -33,7 +41,9 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope 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.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp @@ -47,16 +57,11 @@ import no.nordicsemi.android.nrfmesh.core.ui.MeshMessageStatusDialog import no.nordicsemi.android.nrfmesh.core.ui.MeshNoItemsAvailable import no.nordicsemi.android.nrfmesh.core.ui.Row import no.nordicsemi.android.nrfmesh.core.ui.SectionTitle -import no.nordicsemi.android.nrfmesh.core.ui.SwipeDismissItem -import no.nordicsemi.android.nrfmesh.core.ui.isDismissed -import no.nordicsemi.android.nrfmesh.core.ui.showSnackbar import no.nordicsemi.kotlin.mesh.core.messages.AcknowledgedConfigMessage import no.nordicsemi.kotlin.mesh.core.messages.ConfigStatusMessage import no.nordicsemi.kotlin.mesh.core.messages.foundation.configuration.ConfigAppKeyAdd import no.nordicsemi.kotlin.mesh.core.messages.foundation.configuration.ConfigAppKeyDelete import no.nordicsemi.kotlin.mesh.core.messages.foundation.configuration.ConfigAppKeyGet -import no.nordicsemi.kotlin.mesh.core.messages.foundation.configuration.ConfigNetKeyAdd -import no.nordicsemi.kotlin.mesh.core.messages.foundation.configuration.ConfigNetKeyDelete import no.nordicsemi.kotlin.mesh.core.model.ApplicationKey import no.nordicsemi.kotlin.mesh.core.model.Node @@ -116,7 +121,8 @@ internal fun ConfigAppKeysScreen( when (node.applicationKeys.isNotEmpty()) { true -> LazyColumn( modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.spacedBy(space = 8.dp) + verticalArrangement = Arrangement.spacedBy(space = 8.dp), + contentPadding = PaddingValues(horizontal = 16.dp) ) { items( items = node.applicationKeys, @@ -193,16 +199,28 @@ internal fun ConfigAppKeysScreen( ) false -> LaunchedEffect(snackbarHostState) { - if (messageState.message is ConfigNetKeyDelete) { + if (messageState.message is ConfigAppKeyDelete) { snackbarHostState.showSnackbar( message = context.getString(R.string.label_application_key_deleted), duration = SnackbarDuration.Short, - ) - } else if (messageState.message is ConfigNetKeyAdd) { + ).also { + when (it) { + SnackbarResult.Dismissed, + SnackbarResult.ActionPerformed, + -> resetMessageState() + } + } + } else if (messageState.message is ConfigAppKeyAdd) { snackbarHostState.showSnackbar( message = context.getString(R.string.label_application_key_added), duration = SnackbarDuration.Short, - ) + ).also { + when (it) { + SnackbarResult.Dismissed, + SnackbarResult.ActionPerformed, + -> resetMessageState() + } + } } } } @@ -222,41 +240,57 @@ private fun SwipeToDismissKey( onSwiped: (ApplicationKey) -> Unit, ) { // Hold the current state from the Swipe to Dismiss composable - var shouldNotDismiss by remember { mutableStateOf(false) } - val dismissState = rememberSwipeToDismissBoxState( - confirmValueChange = { + val dismissState = rememberSwipeToDismissBoxState() + + SwipeToDismissBox( + state = dismissState, + backgroundContent = { + val color by animateColorAsState( + when (dismissState.targetValue) { + SwipeToDismissBoxValue.Settled, + SwipeToDismissBoxValue.StartToEnd, + SwipeToDismissBoxValue.EndToStart, + -> if (key.isInUse) Color.Gray else Color.Red + } + ) + Box( + modifier = Modifier + .fillMaxSize() + .background(color = color, shape = CardDefaults.elevatedShape) + .padding(horizontal = 16.dp), + contentAlignment = if (dismissState.dismissDirection == SwipeToDismissBoxValue.StartToEnd) + Alignment.CenterStart + else Alignment.CenterEnd + ) { + Icon(imageVector = Icons.Outlined.Delete, contentDescription = "null") + } + }, + onDismiss = { if (key.isInUse) { - shouldNotDismiss = true - false + scope.launch { + dismissState.reset() + } + snackbarHostState.currentSnackbarData?.dismiss() + scope.launch { + snackbarHostState.showSnackbar( + message = context.getString( + R.string.label_app_key_in_use, + key.name + ), + duration = SnackbarDuration.Short, + ) + } } else { - shouldNotDismiss = false onSwiped(key) - true + snackbarHostState.currentSnackbarData?.dismiss() + scope.launch { + snackbarHostState.showSnackbar( + message = context.getString(R.string.label_application_key_deleted), + duration = SnackbarDuration.Short, + ) + } } }, - positionalThreshold = { it * 0.5f } + content = { key.Row() } ) - SwipeDismissItem(dismissState = dismissState, content = { key.Row() }) - - if (shouldNotDismiss) { - LaunchedEffect(snackbarHostState) { - showSnackbar( - scope = scope, - snackbarHostState = snackbarHostState, - message = context.getString(R.string.error_cannot_delete_key_in_use), - duration = SnackbarDuration.Short, - onDismissed = { shouldNotDismiss = false } - ) - } - } - if (dismissState.isDismissed()) { - LaunchedEffect(snackbarHostState) { - scope.launch { - snackbarHostState.showSnackbar( - message = context.getString(R.string.label_application_key_deleted), - duration = SnackbarDuration.Short, - ) - } - } - } } \ No newline at end of file diff --git a/feature/config-application-keys/src/main/res/values/strings.xml b/feature/config-application-keys/src/main/res/values/strings.xml index b6e8fe8d7..b9bad869c 100644 --- a/feature/config-application-keys/src/main/res/values/strings.xml +++ b/feature/config-application-keys/src/main/res/values/strings.xml @@ -4,7 +4,7 @@ Go to settings and add an application key to the network. Undo - Error, key in use. + Cannot delete, %1$s is in use. Add Key Loading Keys Settings diff --git a/feature/config-network-keys/src/main/java/no/nordicsemi/android/feature/config/networkkeys/ConfigNetKeysScreen.kt b/feature/config-network-keys/src/main/java/no/nordicsemi/android/feature/config/networkkeys/ConfigNetKeysScreen.kt index 7aa7e7712..c07c5ddc4 100644 --- a/feature/config-network-keys/src/main/java/no/nordicsemi/android/feature/config/networkkeys/ConfigNetKeysScreen.kt +++ b/feature/config-network-keys/src/main/java/no/nordicsemi/android/feature/config/networkkeys/ConfigNetKeysScreen.kt @@ -2,9 +2,12 @@ package no.nordicsemi.android.feature.config.networkkeys import android.content.Context import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxSize @@ -13,7 +16,9 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.outlined.Delete import androidx.compose.material.icons.outlined.VpnKey +import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.Icon @@ -21,6 +26,9 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult +import androidx.compose.material3.SwipeToDismissBox +import androidx.compose.material3.SwipeToDismissBoxValue import androidx.compose.material3.Text import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.material3.rememberModalBottomSheetState @@ -33,7 +41,9 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope 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.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp @@ -47,8 +57,6 @@ import no.nordicsemi.android.nrfmesh.core.ui.MeshMessageStatusDialog import no.nordicsemi.android.nrfmesh.core.ui.MeshNoItemsAvailable import no.nordicsemi.android.nrfmesh.core.ui.Row import no.nordicsemi.android.nrfmesh.core.ui.SectionTitle -import no.nordicsemi.android.nrfmesh.core.ui.SwipeDismissItem -import no.nordicsemi.android.nrfmesh.core.ui.showSnackbar import no.nordicsemi.android.nrfmesh.feature.config.networkkeys.R import no.nordicsemi.kotlin.mesh.core.messages.AcknowledgedConfigMessage import no.nordicsemi.kotlin.mesh.core.messages.ConfigStatusMessage @@ -105,7 +113,8 @@ internal fun ConfigNetKeysScreen( when (addedNetworkKeys.isNotEmpty()) { true -> LazyColumn( modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.spacedBy(space = 8.dp) + verticalArrangement = Arrangement.spacedBy(space = 8.dp), + contentPadding = PaddingValues(horizontal = 16.dp) ) { items(items = addedNetworkKeys, key = { it.index.toInt() + 1 }) { key -> SwipeToDismissKey( @@ -182,18 +191,31 @@ internal fun ConfigNetKeysScreen( snackbarHostState.showSnackbar( message = context.getString(R.string.label_network_key_deleted), duration = SnackbarDuration.Short, - ) + ).also { + when (it) { + SnackbarResult.Dismissed, + SnackbarResult.ActionPerformed, + -> resetMessageState() + } + } } else if (messageState.message is ConfigNetKeyAdd) { snackbarHostState.showSnackbar( message = context.getString(R.string.label_network_key_added), duration = SnackbarDuration.Short, - ) + ).also { + when (it) { + SnackbarResult.Dismissed, + SnackbarResult.ActionPerformed, + -> resetMessageState() + } + } } } } } - else -> { /* Do nothing */ } + else -> { /* Do nothing */ + } } } @@ -207,32 +229,47 @@ private fun SwipeToDismissKey( onSwiped: (NetworkKey) -> Unit, ) { // Hold the current state from the Swipe to Dismiss composable - var shouldNotDismiss by remember { mutableStateOf(false) } - val dismissState = rememberSwipeToDismissBoxState( - confirmValueChange = { - if (key.index.toUInt() == 0.toUInt()) { - shouldNotDismiss = true - false + val dismissState = rememberSwipeToDismissBoxState() + SwipeToDismissBox( + state = dismissState, + backgroundContent = { + val color by animateColorAsState( + when (dismissState.targetValue) { + SwipeToDismissBoxValue.Settled, + SwipeToDismissBoxValue.StartToEnd, + SwipeToDismissBoxValue.EndToStart, + -> if (key.isInUse) Color.Gray else Color.Red + } + ) + Box( + modifier = Modifier + .fillMaxSize() + .background(color = color, shape = CardDefaults.elevatedShape) + .padding(horizontal = 16.dp), + contentAlignment = if (dismissState.dismissDirection == SwipeToDismissBoxValue.StartToEnd) + Alignment.CenterStart + else Alignment.CenterEnd + ) { + Icon(imageVector = Icons.Outlined.Delete, contentDescription = "null") + } + }, + onDismiss = { + if (key.isInUse) { + scope.launch { dismissState.reset() } + scope.launch { + snackbarHostState.currentSnackbarData?.dismiss() + snackbarHostState.showSnackbar( + message = context.getString( + R.string.label_network_key_in_use, + key.name + ), + duration = SnackbarDuration.Short, + ) + } } else { onSwiped(key) - true } }, - positionalThreshold = { it * 0.5f } + content = { key.Row() } ) - SwipeDismissItem( - dismissState = dismissState, content = { key.Row() } - ) - - if (shouldNotDismiss) { - LaunchedEffect(snackbarHostState) { - showSnackbar( - scope = scope, - snackbarHostState = snackbarHostState, - message = context.getString(R.string.error_cannot_delete_primary_network_key), - duration = SnackbarDuration.Short, - onDismissed = { shouldNotDismiss = false } - ) - } - } } \ No newline at end of file diff --git a/feature/config-network-keys/src/main/res/values/strings.xml b/feature/config-network-keys/src/main/res/values/strings.xml index 4f11b1cef..bcfd35e45 100644 --- a/feature/config-network-keys/src/main/res/values/strings.xml +++ b/feature/config-network-keys/src/main/res/values/strings.xml @@ -3,7 +3,7 @@ No Network Keys available Go to settings and add a Network Key to the network. Go to settings and add a Network Key to the network configuration. - Cannot delete the primary key. + Cannot delete, %1$s is in use. Cannot delete the last network key or a key in use. Network key deleted. Undo diff --git a/feature/groups/src/main/java/no/nordicsemi/android/nrfmesh/feature/groups/GroupDetailPane.kt b/feature/groups/src/main/java/no/nordicsemi/android/nrfmesh/feature/groups/GroupDetailPane.kt index 38b46010a..e9442a424 100644 --- a/feature/groups/src/main/java/no/nordicsemi/android/nrfmesh/feature/groups/GroupDetailPane.kt +++ b/feature/groups/src/main/java/no/nordicsemi/android/nrfmesh/feature/groups/GroupDetailPane.kt @@ -37,22 +37,18 @@ internal fun GroupDetailPane( send = send ) - else -> PlaceHolder( - content = { - if (models.isNotEmpty()) { - PlaceHolder( - modifier = Modifier.fillMaxSize(), - imageVector = Icons.Outlined.Info, - text = stringResource(id = R.string.label_select_model_rationale) - ) - } else { - PlaceHolder( - modifier = Modifier.fillMaxSize(), - imageVector = Icons.Outlined.Info, - text = stringResource(id = R.string.label_no_models_subscribed_rationale) - ) - } - } - ) + else -> if (models.isNotEmpty()) { + PlaceHolder( + modifier = Modifier.fillMaxSize(), + imageVector = Icons.Outlined.Info, + text = stringResource(id = R.string.label_select_model_rationale) + ) + } else { + PlaceHolder( + modifier = Modifier.fillMaxSize(), + imageVector = Icons.Outlined.Info, + text = stringResource(id = R.string.label_no_models_subscribed_rationale) + ) + } } } \ No newline at end of file diff --git a/feature/ivindex/src/main/java/no/nordicsemi/android/nrfmesh/feature/ivindex/IvIndexRoute.kt b/feature/ivindex/src/main/java/no/nordicsemi/android/nrfmesh/feature/ivindex/IvIndexRoute.kt index 5291e2c05..4455952a4 100644 --- a/feature/ivindex/src/main/java/no/nordicsemi/android/nrfmesh/feature/ivindex/IvIndexRoute.kt +++ b/feature/ivindex/src/main/java/no/nordicsemi/android/nrfmesh/feature/ivindex/IvIndexRoute.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.DirectionsRun +import androidx.compose.material.icons.outlined.ChangeCircle import androidx.compose.material.icons.outlined.FormatListNumbered import androidx.compose.material.icons.outlined.Science import androidx.compose.material3.AlertDialogDefaults @@ -99,6 +100,7 @@ private fun IvIndex( MeshOutlinedButton( text = stringResource(R.string.label_change_iv_index), enabled = isIvIndexChangeAllowed, + buttonIcon = Icons.Outlined.ChangeCircle, onClick = { showIvIndexDialog = true } ) } diff --git a/feature/models/src/main/java/no/nordicsemi/android/nrfmesh/feature/model/common/Subscriptions.kt b/feature/models/src/main/java/no/nordicsemi/android/nrfmesh/feature/model/common/Subscriptions.kt index b4b1094c2..d765b473a 100644 --- a/feature/models/src/main/java/no/nordicsemi/android/nrfmesh/feature/model/common/Subscriptions.kt +++ b/feature/models/src/main/java/no/nordicsemi/android/nrfmesh/feature/model/common/Subscriptions.kt @@ -1,6 +1,9 @@ package no.nordicsemi.android.nrfmesh.feature.model.common +import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.background 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.Spacer @@ -8,31 +11,30 @@ 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.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.outlined.Delete import androidx.compose.material.icons.outlined.DeleteSweep import androidx.compose.material.icons.outlined.GroupWork import androidx.compose.material.icons.outlined.Refresh +import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.SnackbarResult +import androidx.compose.material3.SwipeToDismissBox +import androidx.compose.material3.SwipeToDismissBoxValue import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberSwipeToDismissBoxState import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue @@ -52,7 +54,6 @@ import no.nordicsemi.android.nrfmesh.core.ui.ElevatedCardItem import no.nordicsemi.android.nrfmesh.core.ui.MeshAlertDialog import no.nordicsemi.android.nrfmesh.core.ui.MeshOutlinedButton import no.nordicsemi.android.nrfmesh.core.ui.SectionTitle -import no.nordicsemi.android.nrfmesh.core.ui.SwipeDismissItem import no.nordicsemi.android.nrfmesh.feature.models.R import no.nordicsemi.kotlin.mesh.core.messages.AcknowledgedConfigMessage import no.nordicsemi.kotlin.mesh.core.messages.foundation.configuration.ConfigModelSubscriptionAdd @@ -265,20 +266,64 @@ private fun SubscriptionRow( send: (AcknowledgedConfigMessage) -> Unit, ) { val context = LocalContext.current - var shouldDismiss by remember { mutableStateOf(false) } - var showSnackbar by remember { mutableStateOf(false) } - val dismissState = rememberSwipeToDismissBoxState( - confirmValueChange = { - shouldDismiss = handleValueChange(model = model, address = address) - if (!shouldDismiss) { - showSnackbar = true + val dismissState = rememberSwipeToDismissBoxState() + SwipeToDismissBox( + modifier = Modifier.padding(horizontal = 16.dp), + state = dismissState, + backgroundContent = { + val color by animateColorAsState( + when (dismissState.targetValue) { + SwipeToDismissBoxValue.Settled, + SwipeToDismissBoxValue.StartToEnd, + SwipeToDismissBoxValue.EndToStart, + -> if (!shouldDismiss( + model = model, + address = address + ) + ) Color.Gray else Color.Red + } + ) + Box( + modifier = Modifier + .fillMaxSize() + .background(color = color, shape = CardDefaults.elevatedShape) + .padding(horizontal = 16.dp), + contentAlignment = if (dismissState.dismissDirection == SwipeToDismissBoxValue.StartToEnd) + Alignment.CenterStart + else Alignment.CenterEnd + ) { + Icon(imageVector = Icons.Outlined.Delete, contentDescription = "null") + } + }, + onDismiss = { + if (!shouldDismiss(model = model, address = address)) { + scope.launch { + snackbarHostState.currentSnackbarData?.dismiss() + dismissState.reset() + snackbarHostState.showSnackbar( + message = context.getString(R.string.label_primary_element_subscription_error) + ) + } + } else { + scope.launch { + send( + if (address is VirtualAddress) { + ConfigModelSubscriptionVirtualAddressDelete( + elementAddress = model.parentElement?.unicastAddress!!, + address = address, + model = model + ) + } else { + ConfigModelSubscriptionDelete( + elementAddress = model.parentElement?.unicastAddress!!, + address = address.address, + model = model + ) + } + ) + } } - shouldDismiss }, - positionalThreshold = { it * 0.5f } - ) - SwipeDismissItem( - dismissState = dismissState, content = { ElevatedCardItem( imageVector = Icons.Outlined.GroupWork, @@ -290,44 +335,9 @@ private fun SubscriptionRow( ) } ) - if (shouldDismiss) { - LaunchedEffect(snackbarHostState) { - scope.launch { - send( - if (address is VirtualAddress) { - ConfigModelSubscriptionVirtualAddressDelete( - elementAddress = model.parentElement?.unicastAddress!!, - address = address, - model = model - ) - } else { - ConfigModelSubscriptionDelete( - elementAddress = model.parentElement?.unicastAddress!!, - address = address.address, - model = model - ) - } - ) - } - } - } - - if (showSnackbar) { - LaunchedEffect(snackbarHostState) { - scope.launch { - snackbarHostState.showSnackbar( - message = context.getString(R.string.label_primary_element_subscription_error) - ).also { - if (it == SnackbarResult.Dismissed) { - showSnackbar = false - } - } - } - } - } } -private fun handleValueChange(model: Model, address: SubscriptionAddress) = +private fun shouldDismiss(model: Model, address: SubscriptionAddress) = when (model.parentElement?.isPrimary == true) { true -> address !is AllNodes else -> true diff --git a/feature/models/src/main/java/no/nordicsemi/android/nrfmesh/feature/model/configurationserver/HeartbeatPublicationContent.kt b/feature/models/src/main/java/no/nordicsemi/android/nrfmesh/feature/model/configurationserver/HeartbeatPublicationContent.kt index 8bc014178..2ed5e8150 100644 --- a/feature/models/src/main/java/no/nordicsemi/android/nrfmesh/feature/model/configurationserver/HeartbeatPublicationContent.kt +++ b/feature/models/src/main/java/no/nordicsemi/android/nrfmesh/feature/model/configurationserver/HeartbeatPublicationContent.kt @@ -174,6 +174,7 @@ internal fun HeartBeatPublicationContent( title = stringResource(R.string.label_heartbeat_publication) ) MeshOutlinedButton( + enabled = destination != null, onClick = { send( ConfigHeartbeatPublicationSet( @@ -207,6 +208,7 @@ internal fun HeartBeatPublicationContent( SectionTitle(title = stringResource(R.string.label_destination)) DestinationRow( network = model.parentElement?.parentNode?.network, + model = model, destination = destination, onDestinationSelected = { destination = it }, onAddGroupClicked = onAddGroupClicked @@ -272,15 +274,7 @@ private fun NetworkKeysRow( contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding, text = { MeshSingleLineListItem( - leadingComposable = { - Icon( - modifier = Modifier - .padding(horizontal = 8.dp) - .padding(end = 8.dp), - imageVector = Icons.Outlined.VpnKey, - contentDescription = null - ) - }, + imageVector = Icons.Outlined.VpnKey, title = key.name, ) }, @@ -301,6 +295,7 @@ private fun NetworkKeysRow( @Composable private fun DestinationRow( network: MeshNetwork?, + model: Model, destination: HeartbeatPublicationDestination?, onDestinationSelected: (HeartbeatPublicationDestination) -> Unit, onAddGroupClicked: () -> Unit, @@ -336,6 +331,7 @@ private fun DestinationRow( ) HeartbeatPublicationDestinationsDropdownMenu( network = network, + model = model, expanded = expanded, onDismissed = { expanded = !expanded }, onDestinationSelected = { diff --git a/feature/models/src/main/java/no/nordicsemi/android/nrfmesh/feature/model/configurationserver/HeartbeatPublicationDestinationsDropdownMenu.kt b/feature/models/src/main/java/no/nordicsemi/android/nrfmesh/feature/model/configurationserver/HeartbeatPublicationDestinationsDropdownMenu.kt index 537cd77d7..464716f3c 100644 --- a/feature/models/src/main/java/no/nordicsemi/android/nrfmesh/feature/model/configurationserver/HeartbeatPublicationDestinationsDropdownMenu.kt +++ b/feature/models/src/main/java/no/nordicsemi/android/nrfmesh/feature/model/configurationserver/HeartbeatPublicationDestinationsDropdownMenu.kt @@ -12,7 +12,6 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExposedDropdownMenuBoxScope import androidx.compose.material3.ExposedDropdownMenuDefaults import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -20,22 +19,27 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import no.nordicsemi.android.nrfmesh.core.common.name import no.nordicsemi.android.nrfmesh.core.ui.MeshSingleLineListItem +import no.nordicsemi.android.nrfmesh.core.ui.MeshTwoLineListItem import no.nordicsemi.android.nrfmesh.feature.models.R import no.nordicsemi.kotlin.mesh.core.model.HeartbeatPublicationDestination import no.nordicsemi.kotlin.mesh.core.model.MeshNetwork +import no.nordicsemi.kotlin.mesh.core.model.Model +import no.nordicsemi.kotlin.mesh.core.model.VirtualAddress import no.nordicsemi.kotlin.mesh.core.model.fixedGroupAddresses @OptIn(ExperimentalMaterial3Api::class) @Composable internal fun ExposedDropdownMenuBoxScope.HeartbeatPublicationDestinationsDropdownMenu( network: MeshNetwork?, + model: Model, expanded: Boolean, onDismissed: () -> Unit, onDestinationSelected: (HeartbeatPublicationDestination) -> Unit, onAddGroupClicked: () -> Unit, ) { - val elements = network?.nodes.orEmpty().flatMap { it.elements } - val groups = network?.groups.orEmpty().map { it.address as HeartbeatPublicationDestination } + val node = model.parentElement?.parentNode ?: return + val otherNodes = network?.nodes?.filter { it != node }.orEmpty() + val groups = network?.groups?.filter { it.address !is VirtualAddress }.orEmpty() DropdownMenu( modifier = Modifier .exposedDropdownSize() @@ -45,31 +49,19 @@ internal fun ExposedDropdownMenuBoxScope.HeartbeatPublicationDestinationsDropdow content = { Text( modifier = Modifier.padding(start = 16.dp, top = 8.dp), - text = stringResource(R.string.label_elements) + text = stringResource(R.string.label_unicast_destinations) ) - elements.forEach { element -> + otherNodes.forEach { otherNode -> DropdownMenuItem( modifier = Modifier.fillMaxWidth(), contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding, text = { MeshSingleLineListItem( - leadingComposable = { - Icon( - modifier = Modifier - .padding(horizontal = 8.dp) - .padding(end = 8.dp), - imageVector = Icons.Outlined.SportsScore, - contentDescription = null - ) - }, - title = element.name?.let { - "$it: 0x${element.unicastAddress.toHexString()}" - } ?: element.unicastAddress.toHexString() + imageVector = Icons.Outlined.SportsScore, + title = otherNode.name, ) }, - onClick = { - onDestinationSelected(element.unicastAddress) - } + onClick = { onDestinationSelected(otherNode.primaryUnicastAddress) } ) } HorizontalDivider() @@ -83,37 +75,26 @@ internal fun ExposedDropdownMenuBoxScope.HeartbeatPublicationDestinationsDropdow contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding, text = { MeshSingleLineListItem( - leadingComposable = { - Icon( - modifier = Modifier - .padding(horizontal = 8.dp) - .padding(end = 8.dp), - imageVector = Icons.Outlined.SportsScore, - contentDescription = null - ) - }, + imageVector = Icons.Outlined.SportsScore, title = network - ?.group(address = destination.address)?.name - ?: destination.toHexString(), + ?.group(address = destination.address.address)?.name + ?: destination.address.address.toHexString( + format = HexFormat { + number.prefix = "0x" + upperCase = true + } + ), ) }, - onClick = { onDestinationSelected(destination) } + onClick = { onDestinationSelected(destination.address as HeartbeatPublicationDestination) } ) } DropdownMenuItem( modifier = Modifier.fillMaxWidth(), contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding, text = { - MeshSingleLineListItem( - leadingComposable = { - Icon( - modifier = Modifier - .padding(horizontal = 8.dp) - .padding(end = 8.dp), - imageVector = Icons.Outlined.Add, - contentDescription = null - ) - }, + MeshTwoLineListItem( + imageVector = Icons.Outlined.Add, title = stringResource(R.string.add_group) ) }, @@ -130,15 +111,7 @@ internal fun ExposedDropdownMenuBoxScope.HeartbeatPublicationDestinationsDropdow contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding, text = { MeshSingleLineListItem( - leadingComposable = { - Icon( - modifier = Modifier - .padding(horizontal = 8.dp) - .padding(end = 8.dp), - imageVector = Icons.Outlined.SportsScore, - contentDescription = null - ) - }, + imageVector = Icons.Outlined.SportsScore, title = destination.name(), ) }, diff --git a/feature/models/src/main/java/no/nordicsemi/android/nrfmesh/feature/model/configurationserver/HeartbeatSubscriptionContent.kt b/feature/models/src/main/java/no/nordicsemi/android/nrfmesh/feature/model/configurationserver/HeartbeatSubscriptionContent.kt index 12d1899eb..de23da445 100644 --- a/feature/models/src/main/java/no/nordicsemi/android/nrfmesh/feature/model/configurationserver/HeartbeatSubscriptionContent.kt +++ b/feature/models/src/main/java/no/nordicsemi/android/nrfmesh/feature/model/configurationserver/HeartbeatSubscriptionContent.kt @@ -212,6 +212,7 @@ internal fun HeartBeatSubscriptionContent( title = stringResource(R.string.label_heartbeat_subscription) ) MeshOutlinedButton( + enabled = source != null && destination != null, onClick = { send( ConfigHeartbeatSubscriptionSet( @@ -331,6 +332,7 @@ private fun SourceRow( ) HeartbeatSubscriptionSourcesDropdownMenu( network = network, + model = model, expanded = expanded, onDismissed = { expanded = !expanded }, onSourceSelected = { @@ -383,6 +385,7 @@ private fun DestinationRow( ) HeartbeatSubscriptionDestinationsDropdownMenu( network = network, + model = model, expanded = expanded, onDismissed = { expanded = !expanded }, onDestinationSelected = { diff --git a/feature/models/src/main/java/no/nordicsemi/android/nrfmesh/feature/model/configurationserver/HeartbeatSubscriptionDestinationsDropdownMenu.kt b/feature/models/src/main/java/no/nordicsemi/android/nrfmesh/feature/model/configurationserver/HeartbeatSubscriptionDestinationsDropdownMenu.kt index 35766935f..447a7de05 100644 --- a/feature/models/src/main/java/no/nordicsemi/android/nrfmesh/feature/model/configurationserver/HeartbeatSubscriptionDestinationsDropdownMenu.kt +++ b/feature/models/src/main/java/no/nordicsemi/android/nrfmesh/feature/model/configurationserver/HeartbeatSubscriptionDestinationsDropdownMenu.kt @@ -21,23 +21,29 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import no.nordicsemi.android.nrfmesh.core.common.name import no.nordicsemi.android.nrfmesh.core.ui.MeshSingleLineListItem +import no.nordicsemi.android.nrfmesh.core.ui.MeshTwoLineListItem import no.nordicsemi.android.nrfmesh.feature.models.R import no.nordicsemi.kotlin.mesh.core.model.HeartbeatPublicationDestination import no.nordicsemi.kotlin.mesh.core.model.HeartbeatSubscriptionDestination import no.nordicsemi.kotlin.mesh.core.model.MeshNetwork +import no.nordicsemi.kotlin.mesh.core.model.Model import no.nordicsemi.kotlin.mesh.core.model.fixedGroupAddresses @OptIn(ExperimentalMaterial3Api::class) @Composable internal fun ExposedDropdownMenuBoxScope.HeartbeatSubscriptionDestinationsDropdownMenu( network: MeshNetwork?, + model: Model, expanded: Boolean, onDismissed: () -> Unit, onDestinationSelected: (HeartbeatSubscriptionDestination) -> Unit, - onAddGroupClicked: () -> Unit + onAddGroupClicked: () -> Unit, ) { - val elements = network?.nodes.orEmpty().flatMap { it.elements } - val groups = network?.groups.orEmpty().map { it.address as HeartbeatPublicationDestination } + val groups = network + ?.groups + .orEmpty() + .filter { it.address is HeartbeatSubscriptionDestination } + .map { it.address as HeartbeatSubscriptionDestination } DropdownMenu( modifier = Modifier .exposedDropdownSize() @@ -47,29 +53,19 @@ internal fun ExposedDropdownMenuBoxScope.HeartbeatSubscriptionDestinationsDropdo content = { Text( modifier = Modifier.padding(start = 16.dp, top = 8.dp), - text = stringResource(R.string.label_elements) + text = stringResource(R.string.label_unicast_destinations) ) - elements.forEach { element -> + model.parentElement?.parentNode?.let { node -> DropdownMenuItem( modifier = Modifier.fillMaxWidth(), contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding, text = { MeshSingleLineListItem( - leadingComposable = { - Icon( - modifier = Modifier - .padding(horizontal = 8.dp) - .padding(end = 8.dp), - imageVector = Icons.Outlined.SportsScore, - contentDescription = null - ) - }, - title = element.name?.let { - "$it: 0x${element.unicastAddress.toHexString()}" - } ?: element.unicastAddress.toHexString() + imageVector = Icons.Outlined.SportsScore, + title = node.name ) }, - onClick = { onDestinationSelected(element.unicastAddress) } + onClick = { onDestinationSelected(node.primaryUnicastAddress) } ) } HorizontalDivider() @@ -83,21 +79,13 @@ internal fun ExposedDropdownMenuBoxScope.HeartbeatSubscriptionDestinationsDropdo contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding, text = { MeshSingleLineListItem( - leadingComposable = { - Icon( - modifier = Modifier - .padding(horizontal = 8.dp) - .padding(end = 8.dp), - imageVector = Icons.Outlined.GroupWork, - contentDescription = null - ) - }, + imageVector = Icons.Outlined.GroupWork, title = network ?.group(address = destination.address)?.name ?: destination.toHexString(), ) }, - onClick = { onDestinationSelected(destination as HeartbeatSubscriptionDestination) } + onClick = { onDestinationSelected(destination) } ) } DropdownMenuItem( @@ -105,15 +93,7 @@ internal fun ExposedDropdownMenuBoxScope.HeartbeatSubscriptionDestinationsDropdo contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding, text = { MeshSingleLineListItem( - leadingComposable = { - Icon( - modifier = Modifier - .padding(horizontal = 8.dp) - .padding(end = 8.dp), - imageVector = Icons.Outlined.Add, - contentDescription = null - ) - }, + imageVector = Icons.Outlined.Add, title = stringResource(R.string.add_group) ) }, @@ -130,21 +110,11 @@ internal fun ExposedDropdownMenuBoxScope.HeartbeatSubscriptionDestinationsDropdo contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding, text = { MeshSingleLineListItem( - leadingComposable = { - Icon( - modifier = Modifier - .padding(horizontal = 8.dp) - .padding(end = 8.dp), - imageVector = Icons.Outlined.GroupWork, - contentDescription = null - ) - }, + imageVector = Icons.Outlined.GroupWork, title = destination.name(), ) }, - onClick = { - onDestinationSelected(destination) - } + onClick = { onDestinationSelected(destination) } ) } } diff --git a/feature/models/src/main/java/no/nordicsemi/android/nrfmesh/feature/model/configurationserver/HeartbeatSubscriptionSourcesDropdownMenu.kt b/feature/models/src/main/java/no/nordicsemi/android/nrfmesh/feature/model/configurationserver/HeartbeatSubscriptionSourcesDropdownMenu.kt index 5fa539f63..33e4ffcea 100644 --- a/feature/models/src/main/java/no/nordicsemi/android/nrfmesh/feature/model/configurationserver/HeartbeatSubscriptionSourcesDropdownMenu.kt +++ b/feature/models/src/main/java/no/nordicsemi/android/nrfmesh/feature/model/configurationserver/HeartbeatSubscriptionSourcesDropdownMenu.kt @@ -5,11 +5,13 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.SportsScore +import androidx.compose.material.icons.outlined.Start import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExposedDropdownMenuBoxScope import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -17,19 +19,24 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import no.nordicsemi.android.nrfmesh.core.ui.MeshSingleLineListItem +import no.nordicsemi.android.nrfmesh.core.ui.MeshTwoLineListItem import no.nordicsemi.android.nrfmesh.feature.models.R import no.nordicsemi.kotlin.mesh.core.model.HeartbeatSubscriptionSource import no.nordicsemi.kotlin.mesh.core.model.MeshNetwork +import no.nordicsemi.kotlin.mesh.core.model.Model +import no.nordicsemi.kotlin.mesh.core.model.UnicastAddress @OptIn(ExperimentalMaterial3Api::class) @Composable internal fun ExposedDropdownMenuBoxScope.HeartbeatSubscriptionSourcesDropdownMenu( network: MeshNetwork?, + model: Model, expanded: Boolean, onDismissed: () -> Unit, - onSourceSelected: (HeartbeatSubscriptionSource) -> Unit + onSourceSelected: (HeartbeatSubscriptionSource) -> Unit, ) { - val elements = network?.nodes.orEmpty().flatMap { it.elements } + val node = model.parentElement?.parentNode ?: return + val otherNodes = network?.nodes?.filter { it != node }.orEmpty() DropdownMenu( modifier = Modifier .exposedDropdownSize() @@ -39,31 +46,19 @@ internal fun ExposedDropdownMenuBoxScope.HeartbeatSubscriptionSourcesDropdownMen content = { Text( modifier = Modifier.padding(start = 16.dp, top = 8.dp), - text = stringResource(R.string.label_elements) + text = stringResource(R.string.label_unicast_sources) ) - elements.forEach { element -> + otherNodes.forEach { otherNode -> DropdownMenuItem( modifier = Modifier.fillMaxWidth(), contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding, text = { MeshSingleLineListItem( - leadingComposable = { - Icon( - modifier = Modifier - .padding(horizontal = 8.dp) - .padding(end = 8.dp), - imageVector = Icons.Outlined.SportsScore, - contentDescription = null - ) - }, - title = element.name?.let { - "$it: 0x${element.unicastAddress.toHexString()}" - } ?: element.unicastAddress.toHexString() + imageVector = Icons.Outlined.Start, + title = otherNode.name ) }, - onClick = { - onSourceSelected(element.unicastAddress) - } + onClick = { onSourceSelected(otherNode.primaryUnicastAddress) } ) } } diff --git a/feature/models/src/main/java/no/nordicsemi/android/nrfmesh/feature/model/vendor/VendorModelControls.kt b/feature/models/src/main/java/no/nordicsemi/android/nrfmesh/feature/model/vendor/VendorModelControls.kt index afb82d565..db503a5c4 100644 --- a/feature/models/src/main/java/no/nordicsemi/android/nrfmesh/feature/model/vendor/VendorModelControls.kt +++ b/feature/models/src/main/java/no/nordicsemi/android/nrfmesh/feature/model/vendor/VendorModelControls.kt @@ -147,7 +147,7 @@ private fun Request( ) } }, - regex = Regex("[0-9A-Fa-f]{0,4}"), + regex = Regex("^[0-9A-Fa-f]?$"), ) } MeshOutlinedHexTextField( @@ -167,7 +167,7 @@ private fun Request( } ), label = { Text(text = stringResource(R.string.label_parameters)) }, - regex = Regex("[0-9A-Fa-f]{0,4}"), + regex = Regex("[0-9A-Fa-f]"), ) MeshSingleLineListItem( modifier = Modifier.fillMaxSize(), @@ -248,7 +248,7 @@ private fun Request( ) } }, - regex = Regex("[0-9A-Fa-f]{0,4}"), + regex = Regex("^[0-9A-Fa-f]?$"), ) } MeshSingleLineListItem( diff --git a/feature/models/src/main/res/values/strings.xml b/feature/models/src/main/res/values/strings.xml index f5b07fb4c..d9a77cda0 100644 --- a/feature/models/src/main/res/values/strings.xml +++ b/feature/models/src/main/res/values/strings.xml @@ -126,4 +126,7 @@ 6-bit Response Opcode 64-bit Transmic Force segmentation + Unknown + Unicast Destinations + Unicast Sources \ No newline at end of file diff --git a/feature/network-keys/src/main/java/no/nordicsemi/android/nrfmesh/feature/network/keys/NetworkKeysRoute.kt b/feature/network-keys/src/main/java/no/nordicsemi/android/nrfmesh/feature/network/keys/NetworkKeysRoute.kt index 1f40876e0..ca4276c78 100644 --- a/feature/network-keys/src/main/java/no/nordicsemi/android/nrfmesh/feature/network/keys/NetworkKeysRoute.kt +++ b/feature/network-keys/src/main/java/no/nordicsemi/android/nrfmesh/feature/network/keys/NetworkKeysRoute.kt @@ -1,7 +1,12 @@ package no.nordicsemi.android.nrfmesh.feature.network.keys import android.content.Context -import androidx.compose.foundation.layout.Arrangement +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxSize @@ -10,6 +15,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.outlined.Delete import androidx.compose.material.icons.outlined.VpnKey import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api @@ -21,45 +27,46 @@ import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarResult +import androidx.compose.material3.SwipeToDismissBox +import androidx.compose.material3.SwipeToDismissBoxValue import androidx.compose.material3.Text import androidx.compose.material3.rememberSwipeToDismissBoxState import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -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.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.delay import kotlinx.coroutines.launch import no.nordicsemi.android.nrfmesh.core.data.models.NetworkKeyData +import no.nordicsemi.android.nrfmesh.core.ui.MeshNoItemsAvailable import no.nordicsemi.android.nrfmesh.core.ui.NetworkKeyRow import no.nordicsemi.android.nrfmesh.core.ui.SectionTitle -import no.nordicsemi.android.nrfmesh.core.ui.SwipeDismissItem -import no.nordicsemi.android.nrfmesh.core.ui.isDismissed import no.nordicsemi.kotlin.mesh.core.model.KeyIndex import no.nordicsemi.kotlin.mesh.core.model.NetworkKey @Composable internal fun NetworkKeysRoute( + snackbarHostState: SnackbarHostState, highlightSelectedItem: Boolean, + selectedKeyIndex: KeyIndex?, onAddKeyClicked: () -> NetworkKey, keys: List, onNetworkKeyClicked: (KeyIndex) -> Unit, + navigateToKey: (KeyIndex) -> Unit, onSwiped: (NetworkKeyData) -> Unit, onUndoClicked: (NetworkKeyData) -> Unit, remove: (NetworkKeyData) -> Unit, ) { val scope = rememberCoroutineScope() val context = LocalContext.current - val snackbarHostState = remember { SnackbarHostState() } - var selectedKeyIndex by rememberSaveable { mutableStateOf(null) } Scaffold( snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, floatingActionButton = { @@ -71,42 +78,64 @@ internal fun NetworkKeysRoute( runCatching { onAddKeyClicked() }.onSuccess { - selectedKeyIndex = it.index.toInt() - onNetworkKeyClicked(it.index) + navigateToKey(it.index) } }, expanded = true ) } ) { paddingValues -> - LazyColumn( + Column( modifier = Modifier .fillMaxSize() - .consumeWindowInsets(paddingValues), - verticalArrangement = Arrangement.spacedBy(space = 8.dp) + .consumeWindowInsets(paddingValues = paddingValues) ) { - item { - SectionTitle( - modifier = Modifier.padding(top = 8.dp), - title = stringResource(R.string.label_network_keys) - ) - } - items(items = keys, key = { (it.index + 1u).toInt() }) { key -> - val isSelected = highlightSelectedItem && key.index.toInt() == selectedKeyIndex - SwipeToDismissKey( - scope = scope, - context = context, - snackbarHostState = snackbarHostState, - key = key, - isSelected = isSelected, - onNetworkKeyClicked = { - selectedKeyIndex = it.toInt() - onNetworkKeyClicked(it) - }, - onSwiped = onSwiped, - onUndoClicked = onUndoClicked, - remove = remove + when (keys.isEmpty()) { + true -> MeshNoItemsAvailable( + modifier = Modifier.fillMaxSize(), + imageVector = Icons.Outlined.VpnKey, + title = stringResource(R.string.no_network_keys_available), ) + + false -> + LazyColumn( + modifier = Modifier + .fillMaxSize(), + contentPadding = PaddingValues(horizontal = 16.dp), + // Removed in favor of padding in SwipeToDismissKey so that hiding an item will not leave any gaps + //verticalArrangement = Arrangement.spacedBy(space = 8.dp) + ) { + item { + SectionTitle( + modifier = Modifier.padding(vertical = 8.dp), + title = stringResource(R.string.label_network_keys) + ) + } + items(items = keys, key = { it.id }) { key -> + val isSelected = + highlightSelectedItem && key.index == selectedKeyIndex + var visibility by remember { mutableStateOf(true) } + AnimatedVisibility(visibility) { + SwipeToDismissKey( + scope = scope, + context = context, + snackbarHostState = snackbarHostState, + key = key, + isSelected = isSelected, + onNetworkKeyClicked = onNetworkKeyClicked, + onSwiped = { + visibility = false + onSwiped(it) + }, + onUndoClicked = { + visibility = true + onUndoClicked(it) + }, + remove = remove + ) + } + } + } } } } @@ -125,20 +154,72 @@ private fun SwipeToDismissKey( onUndoClicked: (NetworkKeyData) -> Unit, remove: (NetworkKeyData) -> Unit, ) { - // Hold the current state from the Swipe to Dismiss composable - val dismissState = rememberSwipeToDismissBoxState( - confirmValueChange = { - handleValueChange( - scope = scope, - context = context, - snackbarHostState = snackbarHostState, - key = key + val dismissState = rememberSwipeToDismissBoxState() + SwipeToDismissBox( + // Added instead of using Arrangement.spacedBy to avoid leaving gaps when an item is swiped away. + modifier = Modifier.padding(bottom = 8.dp), + state = dismissState, + backgroundContent = { + val color by animateColorAsState( + when (dismissState.targetValue) { + SwipeToDismissBoxValue.Settled, + SwipeToDismissBoxValue.StartToEnd, + SwipeToDismissBoxValue.EndToStart, + -> if (key.isInUse) Color.Gray else Color.Red + } ) + Box( + modifier = Modifier + .fillMaxSize() + .background(color = color, shape = CardDefaults.elevatedShape) + .padding(horizontal = 16.dp), + contentAlignment = if (dismissState.dismissDirection == SwipeToDismissBoxValue.StartToEnd) + Alignment.CenterStart + else Alignment.CenterEnd + ) { + Icon(imageVector = Icons.Outlined.Delete, contentDescription = "null") + } + }, + onDismiss = { + snackbarHostState.currentSnackbarData?.dismiss() + if (key.isPrimary) { + scope.launch { + dismissState.reset() + snackbarHostState.showSnackbar( + message = context.getString(R.string.label_cannot_delete_the_primary_network_key) + ) + } + } else if (key.isInUse) { + scope.launch { + dismissState.reset() + snackbarHostState.showSnackbar( + message = context.getString( + R.string.label_cannot_delete_is_in_use, + key.name + ) + ) + } + } else { + scope.launch { onSwiped(key) } + scope.launch { + val result = snackbarHostState.showSnackbar( + message = context.getString(R.string.label_key_deleted, key.name), + actionLabel = context.getString(R.string.action_undo), + withDismissAction = true, + duration = SnackbarDuration.Short + ) + + when (result) { + SnackbarResult.ActionPerformed -> { + dismissState.reset() + onUndoClicked(key) + } + + SnackbarResult.Dismissed -> remove(key) + } + } + } }, - positionalThreshold = { it * 0.5f } - ) - SwipeDismissItem( - dismissState = dismissState, content = { NetworkKeyRow( onClick = { onNetworkKeyClicked(key.index) }, @@ -149,36 +230,10 @@ private fun SwipeToDismissKey( else -> CardDefaults.outlinedCardColors() }, - imageVector = Icons.Outlined.VpnKey, - title = key.name + title = key.name, ) } ) - - if (dismissState.isDismissed()) { - LaunchedEffect(Unit) { - scope.launch { - delay(250) - onSwiped(key) - } - scope.launch { - snackbarHostState.showSnackbar( - message = context.getString(R.string.label_network_key_deleted), - actionLabel = context.getString(R.string.action_undo), - withDismissAction = true, - duration = SnackbarDuration.Short - ).also { - when (it) { - SnackbarResult.Dismissed -> remove(key) - SnackbarResult.ActionPerformed -> { - dismissState.reset() - onUndoClicked(key) - } - } - } - } - } - } } private fun handleValueChange( diff --git a/feature/network-keys/src/main/java/no/nordicsemi/android/nrfmesh/feature/network/keys/NetworkKeysViewModel.kt b/feature/network-keys/src/main/java/no/nordicsemi/android/nrfmesh/feature/network/keys/NetworkKeysViewModel.kt index 9252c8b29..9cab5d484 100644 --- a/feature/network-keys/src/main/java/no/nordicsemi/android/nrfmesh/feature/network/keys/NetworkKeysViewModel.kt +++ b/feature/network-keys/src/main/java/no/nordicsemi/android/nrfmesh/feature/network/keys/NetworkKeysViewModel.kt @@ -6,6 +6,9 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import no.nordicsemi.android.nrfmesh.core.data.CoreDataRepository import no.nordicsemi.android.nrfmesh.core.data.models.NetworkKeyData @@ -19,29 +22,32 @@ class NetworkKeysViewModel @Inject internal constructor( ) : ViewModel() { private lateinit var network: MeshNetwork - private var selectedKeyIndex: KeyIndex? = null private val _uiState = MutableStateFlow(NetworkKeysScreenUiState(listOf())) val uiState: StateFlow = _uiState.asStateFlow() init { - viewModelScope.launch { - repository.network.collect { network -> - this@NetworkKeysViewModel.network = network - val state = _uiState.value - val keys = network.networkKeys.map { NetworkKeyData(it) } - _uiState.value = state.copy( - keys = keys, - keysToBeRemoved = keys.filter { it in state.keysToBeRemoved } - ) - } - } + observeNetwork() } override fun onCleared() { - removeAllKeys() + removeKeys() super.onCleared() } + private fun observeNetwork() { + repository.network.onEach { network -> + this@NetworkKeysViewModel.network = network + _uiState.update { state -> + state.copy( + keys = network.networkKeys + .map { NetworkKeyData(key = it) } + // Filter out the keys that are marked for deletion. + .filter { it !in state.keysToBeRemoved }, + ) + } + }.launchIn(scope = viewModelScope) + } + /** * Adds a network key to the network. */ @@ -53,14 +59,9 @@ class NetworkKeysViewModel @Inject internal constructor( * * @param key Network key to be deleted. */ - - fun onSwiped(key: NetworkKeyData) { - viewModelScope.launch { - val state = _uiState.value - _uiState.value = state.copy( - keys = state.keys - key, - keysToBeRemoved = state.keysToBeRemoved + key - ) + internal fun onSwiped(key: NetworkKeyData) { + _uiState.update { state -> + state.copy(keysToBeRemoved = state.keysToBeRemoved + key) } } @@ -70,14 +71,9 @@ class NetworkKeysViewModel @Inject internal constructor( * * @param key Network key to be reverted. */ - fun onUndoSwipe(key: NetworkKeyData) { - viewModelScope.launch { - val state = _uiState.value - _uiState.value = state.copy( - keys = (state.keys + key) - .sortedBy { it.index }, - keysToBeRemoved = state.keysToBeRemoved - key - ) + internal fun onUndoSwipe(key: NetworkKeyData) { + _uiState.update { state -> + state.copy(keysToBeRemoved = state.keysToBeRemoved - key) } } @@ -87,32 +83,27 @@ class NetworkKeysViewModel @Inject internal constructor( * @param key Key to be removed. */ internal fun remove(key: NetworkKeyData) { - viewModelScope.launch { - val state = _uiState.value - runCatching { - network.run { - networkKey(index = key.index) - ?.let { remove(key = it) } - save() - } - } - _uiState.value = state.copy(keysToBeRemoved = state.keysToBeRemoved - key) + _uiState.update { state -> + state.copy( + keys = state.keys - key, + keysToBeRemoved = state.keysToBeRemoved - key + ) } + network.removeNetworkKeyWithIndex(index = key.index) + // In addition lets remove the keys queued for deletion as well. + removeKeys() } /** * Removes all keys that are queued for deletion. */ - private fun removeAllKeys() { + private fun removeKeys() { runCatching { _uiState.value.keysToBeRemoved.forEach { keyData -> - network.run { - networkKey(index = keyData.index) - ?.let { remove(key = it) } - } + network.removeNetworkKeyWithIndex(index = keyData.index) } - save() } + save() } /** @@ -125,15 +116,15 @@ class NetworkKeysViewModel @Inject internal constructor( } internal fun selectKeyIndex(keyIndex: KeyIndex) { - selectedKeyIndex = keyIndex + _uiState.update { state -> + state.copy(selectedKeyIndex = keyIndex) + } } - - internal fun isCurrentlySelectedKey(keyIndex: KeyIndex): Boolean = - keyIndex == selectedKeyIndex } @ConsistentCopyVisibility data class NetworkKeysScreenUiState internal constructor( val keys: List = listOf(), val keysToBeRemoved: List = listOf(), + val selectedKeyIndex: KeyIndex? = null ) diff --git a/feature/network-keys/src/main/java/no/nordicsemi/android/nrfmesh/feature/network/keys/navigation/NetworkKeysDestination.kt b/feature/network-keys/src/main/java/no/nordicsemi/android/nrfmesh/feature/network/keys/navigation/NetworkKeysDestination.kt index 60096bbae..ae54aaea7 100644 --- a/feature/network-keys/src/main/java/no/nordicsemi/android/nrfmesh/feature/network/keys/navigation/NetworkKeysDestination.kt +++ b/feature/network-keys/src/main/java/no/nordicsemi/android/nrfmesh/feature/network/keys/navigation/NetworkKeysDestination.kt @@ -1,6 +1,7 @@ package no.nordicsemi.android.nrfmesh.feature.network.keys.navigation import android.os.Parcelable +import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.hilt.navigation.compose.hiltViewModel @@ -17,23 +18,31 @@ data object NetworkKeysContent : Parcelable @Composable fun NetworkKeysScreenRoute( + snackbarHostState: SnackbarHostState, highlightSelectedItem: Boolean, onNetworkKeyClicked: (KeyIndex) -> Unit, + navigateToKey: (KeyIndex) -> Unit, navigateUp: () -> Unit ) { val viewModel = hiltViewModel() val uiState by viewModel.uiState.collectAsStateWithLifecycle() NetworkKeysRoute( + snackbarHostState = snackbarHostState, highlightSelectedItem = highlightSelectedItem, + selectedKeyIndex = uiState.selectedKeyIndex, keys = uiState.keys, onAddKeyClicked = viewModel::addNetworkKey, onNetworkKeyClicked = { - viewModel.selectKeyIndex(it) + viewModel.selectKeyIndex(keyIndex = it) onNetworkKeyClicked(it) }, + navigateToKey = { + viewModel.selectKeyIndex(keyIndex = it) + navigateToKey(it) + }, onSwiped = { viewModel.onSwiped(it) - if(viewModel.isCurrentlySelectedKey(it.index)) { + if(uiState.selectedKeyIndex == it.index) { navigateUp() } }, diff --git a/feature/network-keys/src/main/res/values/strings.xml b/feature/network-keys/src/main/res/values/strings.xml index a7ad881c5..f42b1626f 100644 --- a/feature/network-keys/src/main/res/values/strings.xml +++ b/feature/network-keys/src/main/res/values/strings.xml @@ -30,4 +30,8 @@ Switch to a different key to delete the currently selected key. Network Key Add Key + Cannot delete, %1$s is in use. + Cannot delete the primary network key. + %1$s deleted + No network keys available \ No newline at end of file diff --git a/feature/nodes/src/main/java/no/nordicsemi/android/nrfmesh/feature/nodes/node/NodeExtraPane.kt b/feature/nodes/src/main/java/no/nordicsemi/android/nrfmesh/feature/nodes/node/NodeExtraPane.kt index b0ff556cc..beb377fe9 100644 --- a/feature/nodes/src/main/java/no/nordicsemi/android/nrfmesh/feature/nodes/node/NodeExtraPane.kt +++ b/feature/nodes/src/main/java/no/nordicsemi/android/nrfmesh/feature/nodes/node/NodeExtraPane.kt @@ -56,6 +56,7 @@ internal fun NodeExtraPane( ) is ApplicationKeysContent -> ApplicationKeysScreenRoute( + snackbarHostState = snackbarHostState, highlightSelectedItem = false, onApplicationKeyClicked = { send( diff --git a/feature/nodes/src/main/java/no/nordicsemi/android/nrfmesh/feature/nodes/node/NodeListPane.kt b/feature/nodes/src/main/java/no/nordicsemi/android/nrfmesh/feature/nodes/node/NodeListPane.kt index a5827c11b..1473288be 100644 --- a/feature/nodes/src/main/java/no/nordicsemi/android/nrfmesh/feature/nodes/node/NodeListPane.kt +++ b/feature/nodes/src/main/java/no/nordicsemi/android/nrfmesh/feature/nodes/node/NodeListPane.kt @@ -147,7 +147,6 @@ internal fun NodeListPane( ResetRow(messageState = messageState, navigateBack = navigateBack, send = send) SectionTitle(title = stringResource(id = R.string.label_remove_node)) RemoveNode( - messageState = messageState, navigateBack = navigateBack, removeNode = removeNode ) @@ -506,7 +505,6 @@ private fun ResetRow( @Composable private fun RemoveNode( - messageState: MessageState, removeNode: () -> Unit, navigateBack: () -> Unit, ) { @@ -523,8 +521,7 @@ private fun RemoveNode( text = stringResource(R.string.label_remove), buttonIcon = Icons.Outlined.DeleteForever, buttonIconTint = Color.Red, - textColor = Color.Red, - enabled = !messageState.isInProgress() + textColor = Color.Red ) } if (showResetDialog) { diff --git a/feature/provisioners/build.gradle.kts b/feature/provisioners/build.gradle.kts index e7e779883..b4214e5c3 100644 --- a/feature/provisioners/build.gradle.kts +++ b/feature/provisioners/build.gradle.kts @@ -27,7 +27,6 @@ dependencies { implementation(project(":core:common")) implementation(project(":core:data")) implementation(project(":core:navigation")) - implementation(project(":feature:ranges")) implementation(project(":mesh:core")) } \ No newline at end of file diff --git a/feature/provisioners/src/main/java/no/nordicsemi/android/nrfmesh/feature/provisioners/ProvisionersRoute.kt b/feature/provisioners/src/main/java/no/nordicsemi/android/nrfmesh/feature/provisioners/ProvisionersRoute.kt index 1227b12eb..d8cb638f0 100644 --- a/feature/provisioners/src/main/java/no/nordicsemi/android/nrfmesh/feature/provisioners/ProvisionersRoute.kt +++ b/feature/provisioners/src/main/java/no/nordicsemi/android/nrfmesh/feature/provisioners/ProvisionersRoute.kt @@ -3,8 +3,12 @@ package no.nordicsemi.android.nrfmesh.feature.provisioners import android.content.Context +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.defaultMinSize @@ -15,8 +19,8 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.PersonPin import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.outlined.Delete import androidx.compose.material.icons.outlined.PersonOutline -import androidx.compose.material3.CardColors import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExtendedFloatingActionButton @@ -27,30 +31,28 @@ import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarResult +import androidx.compose.material3.SwipeToDismissBox +import androidx.compose.material3.SwipeToDismissBoxValue import androidx.compose.material3.Text import androidx.compose.material3.rememberSwipeToDismissBoxState import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -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.graphics.Color -import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.delay import kotlinx.coroutines.launch import no.nordicsemi.android.nrfmesh.core.data.models.ProvisionerData import no.nordicsemi.android.nrfmesh.core.ui.ElevatedCardItem +import no.nordicsemi.android.nrfmesh.core.ui.MeshNoItemsAvailable import no.nordicsemi.android.nrfmesh.core.ui.SectionTitle -import no.nordicsemi.android.nrfmesh.core.ui.SwipeDismissItem -import no.nordicsemi.android.nrfmesh.core.ui.isDismissed import no.nordicsemi.kotlin.mesh.core.model.Provisioner import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.Uuid @@ -59,30 +61,10 @@ import kotlin.uuid.Uuid @Composable internal fun ProvisionersRoute( highlightSelectedItem: Boolean, + selectedProvisionerUuid: Uuid?, provisioners: List, onAddProvisionerClicked: () -> Provisioner, - onSwiped: (ProvisionerData) -> Unit, - onUndoClicked: (ProvisionerData) -> Unit, - remove: (ProvisionerData) -> Unit, - navigateToProvisioner: (Uuid) -> Unit, -) { - Provisioners( - highlightSelectedItem = highlightSelectedItem, - provisioners = provisioners, - addProvisioner = onAddProvisionerClicked, - onSwiped = onSwiped, - onUndoClicked = onUndoClicked, - remove = remove, - navigateToProvisioner = navigateToProvisioner - ) -} - -@OptIn(ExperimentalUuidApi::class) -@Composable -private fun Provisioners( - highlightSelectedItem: Boolean, - provisioners: List, - addProvisioner: () -> Provisioner, + onProvisionerClicked: (Uuid) -> Unit, onSwiped: (ProvisionerData) -> Unit, onUndoClicked: (ProvisionerData) -> Unit, remove: (ProvisionerData) -> Unit, @@ -91,7 +73,6 @@ private fun Provisioners( val context = LocalContext.current val scope = rememberCoroutineScope() val snackbarHostState = remember { SnackbarHostState() } - var selectedUuid by rememberSaveable { mutableStateOf(null) } Scaffold( modifier = Modifier.background(color = Color.Red), contentWindowInsets = WindowInsets(top = 8.dp), @@ -103,9 +84,8 @@ private fun Provisioners( icon = { Icon(imageVector = Icons.Outlined.Add, contentDescription = null) }, onClick = { runCatching { - addProvisioner() + onAddProvisionerClicked() }.onSuccess { - selectedUuid = it.uuid.toString() navigateToProvisioner(it.uuid) } }, @@ -113,44 +93,66 @@ private fun Provisioners( ) } ) { paddingValues -> - LazyColumn( + Column( modifier = Modifier .fillMaxSize() - .consumeWindowInsets(paddingValues = paddingValues), - verticalArrangement = Arrangement.spacedBy(space = 8.dp) + .consumeWindowInsets(paddingValues = paddingValues) ) { - itemsIndexed( - items = provisioners, - key = { _, item -> item.uuid } - ) { index, item -> - if (index == 0) { - SectionTitle( - modifier = Modifier.padding(vertical = 8.dp), - title = stringResource(id = R.string.label_this_provisioner) - ) - } - if (index == 1) { - SectionTitle( - modifier = Modifier.padding(bottom = 8.dp), - title = stringResource(id = R.string.label_other_provisioner) - ) - } - SwipeToDismissProvisioner( - index = index, - provisioner = item, - scope = scope, - context = context, - snackbarHostState = snackbarHostState, - isSelected = selectedUuid == item.uuid.toString() && highlightSelectedItem, - navigateToProvisioner = { - selectedUuid = it.toString() - navigateToProvisioner(it) - }, - onSwiped = onSwiped, - onUndoClicked = onUndoClicked, - remove = remove, - isOnlyProvisioner = { provisioners.size == 1 } + when (provisioners.isEmpty()) { + true -> MeshNoItemsAvailable( + modifier = Modifier.fillMaxSize(), + imageVector = Icons.Outlined.PersonOutline, + title = stringResource(id = R.string.label_no_provisioners_available) ) + + false -> LazyColumn( + modifier = Modifier + .fillMaxSize() + .consumeWindowInsets(paddingValues = paddingValues), + contentPadding = PaddingValues(horizontal = 16.dp), + // Removed in favor of padding in SwipeToDismissProvisioner so that hiding an item will not leave any gaps + //verticalArrangement = Arrangement.spacedBy(space = 8.dp) + ) { + itemsIndexed( + items = provisioners, + key = { _, item -> item.id } + ) { index, item -> + var visibility by remember { mutableStateOf(true) } + if (index == 0) { + SectionTitle( + modifier = Modifier.padding(vertical = 8.dp), + title = stringResource(id = R.string.label_this_provisioner) + ) + } + if (index == 1) { + SectionTitle( + modifier = Modifier.padding(bottom = 8.dp), + title = stringResource(id = R.string.label_other_provisioner) + ) + } + AnimatedVisibility(visibility) { + SwipeToDismissProvisioner( + index = index, + provisioner = item, + scope = scope, + context = context, + snackbarHostState = snackbarHostState, + isSelected = selectedProvisionerUuid == item.uuid && highlightSelectedItem, + onProvisionerClicked = onProvisionerClicked, + onSwiped = { + visibility = false + onSwiped(it) + }, + onUndoClicked = { + visibility = true + onUndoClicked(it) + }, + remove = remove, + isOnlyProvisioner = provisioners.size == 1, + ) + } + } + } } } } @@ -163,31 +165,86 @@ private fun SwipeToDismissProvisioner( context: Context, snackbarHostState: SnackbarHostState, provisioner: ProvisionerData, - navigateToProvisioner: (Uuid) -> Unit, + onProvisionerClicked: (Uuid) -> Unit, onSwiped: (ProvisionerData) -> Unit, onUndoClicked: (ProvisionerData) -> Unit, remove: (ProvisionerData) -> Unit, isSelected: Boolean = false, - isOnlyProvisioner: () -> Boolean, + isOnlyProvisioner: Boolean, index: Int, ) { - val dismissState = rememberSwipeToDismissBoxState( - confirmValueChange = { - handleValueChange( - scope = scope, - context = context, - snackbarHostState = snackbarHostState, - isOnlyProvisioner = isOnlyProvisioner + val dismissState = rememberSwipeToDismissBoxState() + SwipeToDismissBox( + // Added instead of using Arrangement.spacedBy to avoid leaving gaps when an item is swiped away. + modifier = Modifier.padding(bottom = 8.dp), + state = dismissState, + backgroundContent = { + val color by animateColorAsState( + when (dismissState.targetValue) { + SwipeToDismissBoxValue.Settled, + SwipeToDismissBoxValue.StartToEnd, + SwipeToDismissBoxValue.EndToStart, + -> if (isOnlyProvisioner) Color.Gray else Color.Red + } ) + Box( + modifier = Modifier + .fillMaxSize() + .background(color = color, shape = CardDefaults.elevatedShape) + .padding(horizontal = 16.dp), + contentAlignment = if (dismissState.dismissDirection == SwipeToDismissBoxValue.StartToEnd) + Alignment.CenterStart + else Alignment.CenterEnd + ) { + Icon(imageVector = Icons.Outlined.Delete, contentDescription = "null") + } + }, + onDismiss = { + snackbarHostState.currentSnackbarData?.dismiss() + if (isOnlyProvisioner) { + // The following functions are invoked in their own coroutine to ensure + // that they are executed sequentially + scope.launch { + dismissState.reset() + } + scope.launch { + snackbarHostState.showSnackbar( + message = context.getString( + R.string.error_cannot_delete_last_provisioner, + provisioner.name + ) + ) + } + } else { + scope.launch { + onSwiped(provisioner) + } + scope.launch { + val result = snackbarHostState.showSnackbar( + message = context.getString( + R.string.label_provisioner_deleted, + provisioner.name + ), + actionLabel = context.getString(R.string.action_undo), + withDismissAction = true, + duration = SnackbarDuration.Short + ) + + when (result) { + SnackbarResult.ActionPerformed -> { + dismissState.reset() + onUndoClicked(provisioner) + } + + SnackbarResult.Dismissed -> remove(provisioner) + } + } + } }, - positionalThreshold = { it * 0.5f } - ) - SwipeDismissItem( - dismissState = dismissState, content = { ElevatedCardItem( colors = isSelected.selectedColor(), - onClick = { navigateToProvisioner(provisioner.uuid) }, + onClick = { onProvisionerClicked(provisioner.uuid) }, imageVector = index.toImageVector(), title = provisioner.name, subtitle = provisioner.address?.let { @@ -201,63 +258,19 @@ private fun SwipeToDismissProvisioner( ) } ) - if (dismissState.isDismissed()) { - LaunchedEffect(snackbarHostState) { - scope.launch { - delay(250) - onSwiped(provisioner) - } - scope.launch { - snackbarHostState.showSnackbar( - message = context.getString(R.string.label_provisioner_deleted), - actionLabel = context.getString(R.string.action_undo), - withDismissAction = true, - duration = SnackbarDuration.Short, - ).also { - when (it) { - SnackbarResult.Dismissed -> remove(provisioner) - SnackbarResult.ActionPerformed -> { - dismissState.reset() - onUndoClicked(provisioner) - } - } - } - } - } - } -} - -private fun handleValueChange( - scope: CoroutineScope, - context: Context, - snackbarHostState: SnackbarHostState, - isOnlyProvisioner: () -> Boolean, -): Boolean = when { - isOnlyProvisioner() -> { - scope.launch { - snackbarHostState.showSnackbar( - message = context.getString(R.string.error_cannot_delete_last_provisioner) - ) - } - false - } - - else -> true } @Composable -private fun Boolean.selectedColor(): CardColors { - return when (this) { - true -> CardDefaults.outlinedCardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant - ) +private fun Boolean.selectedColor() = when (this) { + true -> CardDefaults.outlinedCardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) - else -> CardDefaults.outlinedCardColors() - } + else -> CardDefaults.outlinedCardColors() } @Composable -private fun Int.toImageVector(): ImageVector = when (this.toInt() == 0) { +private fun Int.toImageVector() = when (this == 0) { true -> Icons.Filled.PersonPin false -> Icons.Outlined.PersonOutline } \ No newline at end of file diff --git a/feature/provisioners/src/main/java/no/nordicsemi/android/nrfmesh/feature/provisioners/ProvisionersViewModel.kt b/feature/provisioners/src/main/java/no/nordicsemi/android/nrfmesh/feature/provisioners/ProvisionersViewModel.kt index 0f067005b..6a46c425c 100644 --- a/feature/provisioners/src/main/java/no/nordicsemi/android/nrfmesh/feature/provisioners/ProvisionersViewModel.kt +++ b/feature/provisioners/src/main/java/no/nordicsemi/android/nrfmesh/feature/provisioners/ProvisionersViewModel.kt @@ -25,22 +25,32 @@ internal class ProvisionersViewModel @Inject internal constructor( ) : ViewModel() { private lateinit var network: MeshNetwork - private var selectedProvisioner: Uuid? = null private val _uiState = MutableStateFlow(ProvisionersScreenUiState()) val uiState: StateFlow = _uiState.asStateFlow() init { - repository.network.onEach { network -> - this.network = network - val provisioners = network.provisioners.map { ProvisionerData(it) } - _uiState.value = ProvisionersScreenUiState(provisioners = provisioners) - }.launchIn(viewModelScope) + observeNetwork() } override fun onCleared() { - super.onCleared() removeProvisioners() + super.onCleared() + } + + private fun observeNetwork() { + repository.network.onEach { network -> + this.network = network + _uiState.update { state -> + state.copy( + provisioners = network.provisioners + .map { ProvisionerData(provisioner = it) } + // Filter out the provisioners that are marked for deletion. + .filter { it !in state.provisionersToBeRemoved }, + + ) + } + }.launchIn(scope = viewModelScope) } /** @@ -72,13 +82,8 @@ internal class ProvisionersViewModel @Inject internal constructor( * @param provisioner Provisioner to be deleted. */ internal fun onSwiped(provisioner: ProvisionerData) { - viewModelScope.launch { - _uiState.update { - it.copy( - provisioners = it.provisioners - provisioner, - provisionersToBeRemoved = it.provisionersToBeRemoved + provisioner - ) - } + _uiState.update { + it.copy(provisionersToBeRemoved = it.provisionersToBeRemoved + provisioner) } } @@ -89,53 +94,35 @@ internal class ProvisionersViewModel @Inject internal constructor( * @param provisioner Scene to be reverted. */ internal fun onUndoSwipe(provisioner: ProvisionerData) { - viewModelScope.launch { - val state = _uiState.value - network.provisioners - .indexOfFirst { it.uuid == provisioner.uuid } - .takeIf { it >= 0 } - ?.let { index -> - val provisioners = state.provisioners - .toMutableList() - .also { it.add(index, provisioner) } - .toList() - _uiState.value = state.copy( - provisioners = provisioners, - provisionersToBeRemoved = state.provisionersToBeRemoved - provisioner - ) - } + _uiState.update { + it.copy(provisionersToBeRemoved = it.provisionersToBeRemoved - provisioner) } } /** * Remove a given scene from the network. * - * @param item Scene to be removed. + * @param provisioner Scene to be removed. */ - internal fun remove(item: ProvisionerData) { - viewModelScope.launch { - val state = _uiState.value - network.run { - provisioner(uuid = item.uuid)?.let { provisioner -> - remove(provisioner) - save() - } - } - _uiState.value = state.copy( - provisionersToBeRemoved = state.provisionersToBeRemoved - item + internal fun remove(provisioner: ProvisionerData) { + _uiState.update { state -> + state.copy( + provisioners = state.provisioners - provisioner, + provisionersToBeRemoved = state.provisionersToBeRemoved - provisioner ) } + network.removeProvisionerWithUuid(uuid = provisioner.uuid) + // In addition lets remove the provisioners queued for deletion as well. + removeProvisioners() } /** * Removes the provisioners that are queued for deletion. */ private fun removeProvisioners() { - _uiState.value.provisionersToBeRemoved.forEach { item -> - network.run { - provisioner(uuid = item.uuid)?.let { provisioner -> - remove(provisioner) - } + runCatching { + _uiState.value.provisionersToBeRemoved.forEach { provisioner -> + network.removeProvisionerWithUuid(uuid = provisioner.uuid) } } save() @@ -150,14 +137,14 @@ internal class ProvisionersViewModel @Inject internal constructor( @OptIn(ExperimentalUuidApi::class) fun selectProvisioner(uuid: Uuid) { - selectedProvisioner = uuid + _uiState.update { it.copy(selectedProvisionerUuid = uuid) } } - - fun isCurrentlySelectedProvisioner(uuid: Uuid) = selectedProvisioner == uuid } +@OptIn(ExperimentalUuidApi::class) @ConsistentCopyVisibility data class ProvisionersScreenUiState internal constructor( val provisioners: List = listOf(), val provisionersToBeRemoved: List = listOf(), + val selectedProvisionerUuid: Uuid? = null, ) \ No newline at end of file diff --git a/feature/provisioners/src/main/java/no/nordicsemi/android/nrfmesh/feature/provisioners/navigation/ProvisionerDestination.kt b/feature/provisioners/src/main/java/no/nordicsemi/android/nrfmesh/feature/provisioners/navigation/ProvisionerDestination.kt index 988a07fd4..21b644bfe 100644 --- a/feature/provisioners/src/main/java/no/nordicsemi/android/nrfmesh/feature/provisioners/navigation/ProvisionerDestination.kt +++ b/feature/provisioners/src/main/java/no/nordicsemi/android/nrfmesh/feature/provisioners/navigation/ProvisionerDestination.kt @@ -1,11 +1,12 @@ package no.nordicsemi.android.nrfmesh.feature.provisioners.navigation import android.os.Parcelable +import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable import kotlinx.parcelize.Parcelize import kotlinx.serialization.Serializable import no.nordicsemi.android.nrfmesh.core.data.models.ProvisionerData -import no.nordicsemi.android.nrfmesh.feature.provisioners.ProvisionerRoute +import no.nordicsemi.android.nrfmesh.feature.provisioners.provisioner.ProvisionerRoute import no.nordicsemi.kotlin.mesh.core.model.Provisioner import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.Uuid @@ -17,19 +18,20 @@ data class ProvisionerContent(val uuid: Uuid) : Parcelable @Composable fun ProvisionerScreenRoute( + snackbarHostState: SnackbarHostState, index: Int, provisioner: Provisioner, provisionerData: ProvisionerData, - otherProvisioners: List, moveProvisioner: (Provisioner, Int) -> Unit, save: () -> Unit ) { ProvisionerRoute( + snackbarHostState = snackbarHostState, index = index, provisioner = provisioner, provisionerData = provisionerData, - otherProvisioners = otherProvisioners, moveProvisioner = moveProvisioner, save = save ) -} \ No newline at end of file +} + diff --git a/feature/provisioners/src/main/java/no/nordicsemi/android/nrfmesh/feature/provisioners/navigation/ProvisionersDestination.kt b/feature/provisioners/src/main/java/no/nordicsemi/android/nrfmesh/feature/provisioners/navigation/ProvisionersDestination.kt index 1df43b23d..341db873a 100644 --- a/feature/provisioners/src/main/java/no/nordicsemi/android/nrfmesh/feature/provisioners/navigation/ProvisionersDestination.kt +++ b/feature/provisioners/src/main/java/no/nordicsemi/android/nrfmesh/feature/provisioners/navigation/ProvisionersDestination.kt @@ -3,6 +3,7 @@ package no.nordicsemi.android.nrfmesh.feature.provisioners.navigation import android.os.Parcelable +import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.hilt.navigation.compose.hiltViewModel @@ -21,7 +22,9 @@ data object ProvisionersContent : Parcelable @OptIn(ExperimentalUuidApi::class) @Composable fun ProvisionersScreenRoute( + snackbarHostState: SnackbarHostState, highlightSelectedItem: Boolean, + onProvisionerClicked: (Uuid) -> Unit, navigateToProvisioner: (Uuid) -> Unit, navigateUp: () -> Unit, ) { @@ -29,19 +32,24 @@ fun ProvisionersScreenRoute( val uiState by viewModel.uiState.collectAsStateWithLifecycle() ProvisionersRoute( highlightSelectedItem = highlightSelectedItem, + selectedProvisionerUuid = uiState.selectedProvisionerUuid, provisioners = uiState.provisioners, onAddProvisionerClicked = viewModel::addProvisioner, + onProvisionerClicked = { + viewModel.selectProvisioner(uuid = it) + onProvisionerClicked(it) + }, + navigateToProvisioner = { + viewModel.selectProvisioner(uuid = it) + navigateToProvisioner(it) + }, onSwiped = { - viewModel.onSwiped(it) - if (viewModel.isCurrentlySelectedProvisioner(uuid = it.uuid)) { + viewModel.onSwiped(provisioner = it) + if (uiState.selectedProvisionerUuid == it.uuid) { navigateUp() } }, onUndoClicked = viewModel::onUndoSwipe, - remove = viewModel::remove, - navigateToProvisioner = { - viewModel.selectProvisioner(uuid = it) - navigateToProvisioner(it) - } + remove = viewModel::remove ) } \ No newline at end of file diff --git a/feature/provisioners/src/main/java/no/nordicsemi/android/nrfmesh/feature/provisioners/ProvisionerRoute.kt b/feature/provisioners/src/main/java/no/nordicsemi/android/nrfmesh/feature/provisioners/provisioner/ProvisionerRoute.kt similarity index 64% rename from feature/provisioners/src/main/java/no/nordicsemi/android/nrfmesh/feature/provisioners/ProvisionerRoute.kt rename to feature/provisioners/src/main/java/no/nordicsemi/android/nrfmesh/feature/provisioners/provisioner/ProvisionerRoute.kt index faf083b39..68b878903 100644 --- a/feature/provisioners/src/main/java/no/nordicsemi/android/nrfmesh/feature/provisioners/ProvisionerRoute.kt +++ b/feature/provisioners/src/main/java/no/nordicsemi/android/nrfmesh/feature/provisioners/provisioner/ProvisionerRoute.kt @@ -1,12 +1,11 @@ -package no.nordicsemi.android.nrfmesh.feature.provisioners +package no.nordicsemi.android.nrfmesh.feature.provisioners.provisioner +import android.content.Context import androidx.compose.animation.Crossfade -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.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -29,16 +28,17 @@ import androidx.compose.material3.Checkbox import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.ModalBottomSheetProperties import androidx.compose.material3.OutlinedCard -import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarDuration -import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -57,6 +57,7 @@ import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import no.nordicsemi.android.nrfmesh.core.common.convertToString import no.nordicsemi.android.nrfmesh.core.data.models.ProvisionerData @@ -64,11 +65,12 @@ import no.nordicsemi.android.nrfmesh.core.ui.AddressRangeLegendsForProvisioner import no.nordicsemi.android.nrfmesh.core.ui.ElevatedCardItem import no.nordicsemi.android.nrfmesh.core.ui.ElevatedCardItemTextField import no.nordicsemi.android.nrfmesh.core.ui.MeshAlertDialog -import no.nordicsemi.android.nrfmesh.core.ui.MeshOutlinedTextField +import no.nordicsemi.android.nrfmesh.core.ui.MeshOutlinedHexTextField import no.nordicsemi.android.nrfmesh.core.ui.MeshTwoLineListItem import no.nordicsemi.android.nrfmesh.core.ui.SectionTitle -import no.nordicsemi.android.nrfmesh.feature.ranges.AllocatedRanges -import no.nordicsemi.android.nrfmesh.feature.ranges.RangesScreen +import no.nordicsemi.android.nrfmesh.feature.provisioners.R +import no.nordicsemi.android.nrfmesh.feature.provisioners.provisioner.ranges.AllocatedRanges +import no.nordicsemi.android.nrfmesh.feature.provisioners.provisioner.ranges.RangesScreen import no.nordicsemi.kotlin.mesh.core.model.GroupAddress import no.nordicsemi.kotlin.mesh.core.model.GroupRange import no.nordicsemi.kotlin.mesh.core.model.Provisioner @@ -85,115 +87,113 @@ import kotlin.uuid.ExperimentalUuidApi @OptIn(ExperimentalUuidApi::class) @Composable internal fun ProvisionerRoute( + snackbarHostState: SnackbarHostState, index: Int, provisioner: Provisioner, provisionerData: ProvisionerData, - otherProvisioners: List, moveProvisioner: (Provisioner, Int) -> Unit, save: () -> Unit, ) { - val snackbarHostState = remember { SnackbarHostState() } + val scope = rememberCoroutineScope() val keyboardController = LocalSoftwareKeyboardController.current var isCurrentlyEditable by rememberSaveable(inputs = arrayOf(provisioner.uuid)) { mutableStateOf(true) } - Scaffold( - modifier = Modifier.background(color = Color.Red), - snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, - ) { paddingValues -> - Column( - modifier = Modifier - .fillMaxSize() - .consumeWindowInsets(paddingValues = paddingValues) - .verticalScroll(state = rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(space = 8.dp) - ) { - SectionTitle( - modifier = Modifier.padding(top = 8.dp), - title = stringResource(id = R.string.label_provisioner) - ) - Name( - name = provisionerData.name, - onNameChanged = { - provisioner.name = it - save() - }, - isCurrentlyEditable = isCurrentlyEditable, - onEditableStateChanged = { isCurrentlyEditable = !isCurrentlyEditable } - ) - UnicastAddress( + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(state = rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(space = 8.dp) + ) { + SectionTitle( + modifier = Modifier.padding(top = 8.dp), + title = stringResource(id = R.string.label_provisioner) + ) + Name( + name = provisionerData.name, + onNameChanged = { + provisioner.name = it + save() + }, + isCurrentlyEditable = isCurrentlyEditable, + onEditableStateChanged = { isCurrentlyEditable = !isCurrentlyEditable } + ) + UnicastAddress( + snackbarHostState = snackbarHostState, + keyboardController = keyboardController, + provisioner = provisioner, + address = provisionerData.address, + isCurrentlyEditable = isCurrentlyEditable, + onEditableStateChanged = { isCurrentlyEditable = !isCurrentlyEditable }, + save = save, + ) + if (provisionerData.hasConfigurationCapabilities) { + DeviceKey(key = provisionerData.deviceKey) + } + if (index != 0) { + MoveProvisioner( + context = LocalContext.current, + scope = scope, snackbarHostState = snackbarHostState, - keyboardController = keyboardController, + index = index, provisioner = provisioner, - address = provisionerData.address, - isCurrentlyEditable = isCurrentlyEditable, - onEditableStateChanged = { isCurrentlyEditable = !isCurrentlyEditable }, - save = save, - ) - if (provisionerData.hasConfigurationCapabilities) { - DeviceKey(key = provisionerData.deviceKey) - } - if(index != 0) { - MoveProvisioner(index = index, provisioner = provisioner, moveProvisioner = moveProvisioner) - } - SectionTitle(title = stringResource(R.string.label_allocated_ranges)) - UnicastRanges( - snackbarHostState = snackbarHostState, - provisionerData = provisionerData, - otherRanges = otherProvisioners.flatMap { it.allocatedUnicastRanges }, - allocate = { - runCatching { provisioner.allocate(it) } - .onSuccess { save() } - }, - updateRange = { range, newRange -> - runCatching { provisioner.update(range = range, newRange = newRange) } - .onSuccess { save() } - }, - onRemoved = { - // TODO show snackbar - runCatching { provisioner.remove(range = it) } - .onSuccess { save() } - } - ) - GroupRanges( - snackbarHostState = snackbarHostState, - provisionerData = provisionerData, - otherRanges = otherProvisioners.flatMap { it.allocatedGroupRanges }, - allocate = { - runCatching { provisioner.allocate(range = it) } - .onSuccess { save() } - }, - updateRange = { range, newRange -> - runCatching { provisioner.update(range = range, newRange = newRange) } - .onSuccess { save() } - }, - onRemoved = { - // TODO show snackbar - runCatching { provisioner.remove(range = it) } - .onSuccess { save() } - } - ) - SceneRanges( - snackbarHostState = snackbarHostState, - provisionerData = provisionerData, - otherRanges = otherProvisioners.flatMap { it.allocatedSceneRanges }, - allocate = { - runCatching { provisioner.allocate(it) } - .onSuccess { save() } - }, - updateRange = { range, newRange -> - runCatching { provisioner.update(range = range, newRange = newRange) } - .onSuccess { save() } - }, - onRemoved = { - // TODO show snackbar - runCatching { provisioner.remove(range = it) } - .onSuccess { save() } - } + moveProvisioner = moveProvisioner ) - AddressRangeLegendsForProvisioner() - Spacer(modifier = Modifier.size(16.dp)) } + SectionTitle(title = stringResource(R.string.label_allocated_ranges)) + UnicastRanges( + scope = scope, + snackbarHostState = snackbarHostState, + provisioner = provisioner, + provisionerData = provisionerData, + otherRanges = provisioner.otherUnicastRanges, + allocate = { + runCatching { provisioner.allocate(it) } + .onSuccess { save() } + }, + onRemoved = { + // TODO show snackbar + runCatching { provisioner.remove(range = it) } + .onSuccess { save() } + }, + save = save + ) + GroupRanges( + scope = scope, + snackbarHostState = snackbarHostState, + provisioner = provisioner, + provisionerData = provisionerData, + otherRanges = provisioner.otherGroupRanges, + allocate = { + runCatching { provisioner.allocate(range = it) } + .onSuccess { save() } + }, + onRemoved = { + // TODO show snackbar + runCatching { provisioner.remove(range = it) } + .onSuccess { save() } + }, + save = save + ) + SceneRanges( + scope = scope, + snackbarHostState = snackbarHostState, + provisioner = provisioner, + provisionerData = provisionerData, + otherRanges = provisioner.otherSceneRanges, + allocate = { + runCatching { provisioner.allocate(it) } + .onSuccess { save() } + }, + onRemoved = { + // TODO show snackbar + runCatching { provisioner.remove(range = it) } + .onSuccess { save() } + }, + save = save + ) + AddressRangeLegendsForProvisioner() + Spacer(modifier = Modifier.size(16.dp)) } } @@ -250,11 +250,11 @@ private fun UnicastAddress( modifier = Modifier.padding(start = 12.dp), imageVector = Icons.Outlined.Lan, contentDescription = null, - tint = LocalContentColor.current.copy(alpha = 0.6f) + tint = MaterialTheme.colorScheme.primary ) Crossfade(targetState = onEditClick, label = "Address") { state -> when (state) { - true -> MeshOutlinedTextField( + true -> MeshOutlinedHexTextField( modifier = Modifier.padding(horizontal = 16.dp), onFocus = onEditClick, value = value, @@ -289,7 +289,7 @@ private fun UnicastAddress( Icon( imageVector = Icons.Outlined.DeleteSweep, contentDescription = null, - tint = LocalContentColor.current.copy(alpha = 0.6f) + tint = MaterialTheme.colorScheme.primary ) } ) @@ -297,7 +297,7 @@ private fun UnicastAddress( keyboardOptions = KeyboardOptions( capitalization = KeyboardCapitalization.Characters ), - regex = Regex("[0-9A-Fa-f]{0,4}"), + regex = Regex("^[0-9A-Fa-f]{0,4}$"), isError = error, content = { IconButton( @@ -305,26 +305,25 @@ private fun UnicastAddress( onClick = { onEditClick = !onEditClick onEditableStateChanged() - value = TextFieldValue( - text = initialValue, - selection = TextRange(initialValue.length) - ) + value = if (provisioner.hasConfigurationCapabilities) { + TextFieldValue( + text = address?.toHexString() ?: "", + selection = TextRange( + index = (address?.toHexString() ?: "").length + ) + ) + } else { + TextFieldValue( + text = initialValue, + selection = TextRange(index = initialValue.length) + ) + } }, content = { Icon( imageVector = Icons.Outlined.Close, contentDescription = null, - tint = LocalContentColor.current.copy(alpha = 0.6f) - ) - } - ) - IconButton( - onClick = { onUnassignClick = !onUnassignClick }, - content = { - Icon( - imageVector = Icons.Outlined.RemoveModerator, - contentDescription = null, - tint = Color.Red.copy(alpha = 0.6f) + tint = MaterialTheme.colorScheme.primary ) } ) @@ -362,7 +361,17 @@ private fun UnicastAddress( Icon( imageVector = Icons.Outlined.Check, contentDescription = null, - tint = LocalContentColor.current.copy(alpha = 0.6f) + tint = MaterialTheme.colorScheme.primary + ) + } + ) + IconButton( + onClick = { onUnassignClick = !onUnassignClick }, + content = { + Icon( + imageVector = Icons.Outlined.RemoveModerator, + contentDescription = null, + tint = Color.Red ) } ) @@ -389,7 +398,7 @@ private fun UnicastAddress( Icon( imageVector = Icons.Outlined.Edit, contentDescription = null, - tint = LocalContentColor.current.copy(alpha = 0.6f) + tint = MaterialTheme.colorScheme.primary ) } ) @@ -443,6 +452,9 @@ private fun DeviceKey(key: String?) { @Composable fun MoveProvisioner( + context: Context, + scope: CoroutineScope, + snackbarHostState: SnackbarHostState, index: Int, provisioner: Provisioner, moveProvisioner: (Provisioner, Int) -> Unit, @@ -456,7 +468,16 @@ fun MoveProvisioner( Checkbox( checked = checked, onCheckedChange = { - moveProvisioner(provisioner, if(it) 0 else index) + moveProvisioner(provisioner, if (it) 0 else index) + scope.launch { + snackbarHostState.showSnackbar( + message = context.getString( + R.string.message_local_provisioner_set, + provisioner.name + ), + duration = SnackbarDuration.Short + ) + } } ) } @@ -466,18 +487,21 @@ fun MoveProvisioner( @OptIn(ExperimentalMaterial3Api::class, ExperimentalUuidApi::class) @Composable private fun UnicastRanges( + scope: CoroutineScope, snackbarHostState: SnackbarHostState, + provisioner: Provisioner, provisionerData: ProvisionerData, otherRanges: List, allocate: (Range) -> Unit, - updateRange: (Range, Range) -> Unit, onRemoved: (Range) -> Unit, + save: () -> Unit, ) { - val scope = rememberCoroutineScope() + val sheetState = rememberModalBottomSheetState() var showUnicastRanges by rememberSaveable { mutableStateOf(false) } var ranges by remember(key1 = provisionerData.uuid) { - mutableStateOf>(provisionerData.unicastRanges) + mutableStateOf(provisionerData.unicastRanges.toMutableList()) } + val overlaps by derivedStateOf { ranges.overlaps(other = otherRanges) } OutlinedCard(modifier = Modifier.padding(horizontal = 16.dp)) { AllocatedRanges( imageVector = Icons.Outlined.Lan, @@ -489,33 +513,55 @@ private fun UnicastRanges( } if (showUnicastRanges) { ModalBottomSheet( + sheetState = sheetState, + properties = ModalBottomSheetProperties( + shouldDismissOnBackPress = !overlaps, + shouldDismissOnClickOutside = !overlaps + ), onDismissRequest = { showUnicastRanges = false }, + sheetGesturesEnabled = !overlaps, content = { RangesScreen( snackbarHostState = snackbarHostState, title = stringResource(id = R.string.label_unicast_ranges), ranges = ranges, otherRanges = otherRanges, + overlaps = overlaps, addRange = { start, end -> - scope.launch { - val range = (UnicastAddress(start)..UnicastAddress(end)) - ranges = ranges + range - if (!ranges.overlaps(otherRanges)) { - allocate(range) - } - } + val range = UnicastAddress(address = start)..UnicastAddress(address = end) + ranges = ranges.plus(other = range).toMutableList() }, - onRangeUpdated = { range, start, end -> - updateRange(range, (UnicastAddress(start)..UnicastAddress(end))) + onRangeUpdated = { start, end -> + val newRange = + UnicastAddress(address = start)..UnicastAddress(address = end) + ranges = ranges.plus(other = newRange).toMutableList() }, - onSwiped = { range -> - ranges = ranges - range as UnicastRange - onRemoved(range) - }, - onUndoClicked = {}, - remove = onRemoved, + onSwiped = { ranges = ranges.minus(other = it).toMutableList() }, isValidBound = { UnicastAddress.isValid(address = it) }, - resolve = { ranges = ranges - otherRanges } + resolve = { + ranges = ranges.minus(other = otherRanges).toMutableList() + + }, + save = { + runCatching { + provisioner.allocate(ranges = ranges) + }.onSuccess { + scope.launch { + sheetState.hide() + }.invokeOnCompletion { + if(!sheetState.isVisible){ + showUnicastRanges = false + } + } + }.onFailure { + scope.launch { + snackbarHostState.showSnackbar( + message = it.message ?: "Failed to allocate ranges", + duration = SnackbarDuration.Short + ) + } + } + } ) } ) @@ -525,17 +571,20 @@ private fun UnicastRanges( @OptIn(ExperimentalMaterial3Api::class, ExperimentalUuidApi::class) @Composable private fun GroupRanges( + scope: CoroutineScope, snackbarHostState: SnackbarHostState, + provisioner: Provisioner, provisionerData: ProvisionerData, otherRanges: List, allocate: (Range) -> Unit, - updateRange: (Range, Range) -> Unit, onRemoved: (Range) -> Unit, + save: () -> Unit, ) { var showGroupRanges by rememberSaveable { mutableStateOf(false) } var ranges by remember(key1 = provisionerData.uuid) { - mutableStateOf>(provisionerData.groupRanges) + mutableStateOf(provisionerData.groupRanges.toMutableList()) } + val overlaps by derivedStateOf { ranges.overlaps(other = otherRanges) } OutlinedCard(modifier = Modifier.padding(horizontal = 16.dp)) { AllocatedRanges( imageVector = Icons.Outlined.GroupWork, @@ -554,24 +603,32 @@ private fun GroupRanges( title = stringResource(id = R.string.label_group_ranges), ranges = ranges, otherRanges = otherRanges, + overlaps = overlaps, addRange = { start, end -> - val range = (GroupAddress(start)..GroupAddress(end)) - ranges = ranges + range - if (!ranges.overlaps(otherRanges)) { - allocate(range) - } + val range = GroupAddress(address = start)..GroupAddress(address = end) + ranges = ranges.plus(other = range).toMutableList() }, - onRangeUpdated = { range, start, end -> - updateRange(range, (GroupAddress(start)..GroupAddress(end))) + onRangeUpdated = { start, end -> + val newRange = GroupAddress(address = start)..GroupAddress(address = end) + ranges = ranges.plus(other = newRange).toMutableList() }, - onSwiped = { range -> - ranges = ranges - range as GroupRange - onRemoved(range) - }, - onUndoClicked = {}, - remove = onRemoved, + onSwiped = { ranges = ranges.minus(other = it).toMutableList() }, isValidBound = { GroupAddress.isValid(address = it) }, - resolve = { ranges = ranges - otherRanges } + resolve = { ranges = ranges.minus(other = otherRanges).toMutableList() }, + save = { + runCatching { + provisioner.allocate(ranges = ranges) + }.onSuccess { + save() + }.onFailure { + scope.launch { + snackbarHostState.showSnackbar( + message = it.message ?: "Failed to allocate ranges", + duration = SnackbarDuration.Short + ) + } + } + } ) } ) @@ -581,19 +638,20 @@ private fun GroupRanges( @OptIn(ExperimentalMaterial3Api::class, ExperimentalUuidApi::class) @Composable private fun SceneRanges( + scope: CoroutineScope, snackbarHostState: SnackbarHostState, + provisioner: Provisioner, provisionerData: ProvisionerData, otherRanges: List, allocate: (Range) -> Unit, - updateRange: (Range, Range) -> Unit, onRemoved: (Range) -> Unit, + save: () -> Unit, ) { var showSceneRanges by rememberSaveable { mutableStateOf(false) } var ranges by remember(key1 = provisionerData.uuid) { - mutableStateOf>( - provisionerData.sceneRanges - ) + mutableStateOf(provisionerData.sceneRanges.toMutableList()) } + val overlaps by derivedStateOf { ranges.overlaps(other = otherRanges) } OutlinedCard(modifier = Modifier.padding(horizontal = 16.dp)) { AllocatedRanges( imageVector = Icons.Outlined.AutoAwesome, @@ -612,24 +670,32 @@ private fun SceneRanges( title = stringResource(id = R.string.label_scene_ranges), ranges = ranges, otherRanges = otherRanges, + overlaps = overlaps, addRange = { start, end -> val range = SceneRange(firstScene = start, lastScene = end) - ranges = ranges + range - if (!ranges.overlaps(otherRanges)) { - allocate(range) - } - }, - onRangeUpdated = { range, start, end -> - updateRange(range, SceneRange(firstScene = start, lastScene = end)) + ranges = ranges.plus(other = range).toMutableList() }, - onSwiped = { range -> - ranges = ranges - range as SceneRange - onRemoved(range) + onRangeUpdated = { start, end -> + val newRange = SceneRange(firstScene = start, lastScene = end) + ranges = ranges.plus(other = newRange).toMutableList() }, - onUndoClicked = {}, - remove = onRemoved, + onSwiped = { ranges = ranges.minus(other = it).toMutableList() }, isValidBound = { Scene.isValid(sceneNumber = it) }, - resolve = { ranges = ranges - otherRanges } + resolve = { ranges = ranges.minus(other = otherRanges).toMutableList() }, + save = { + runCatching { + provisioner.allocate(ranges = ranges) + }.onSuccess { + save() + }.onFailure { + scope.launch { + snackbarHostState.showSnackbar( + message = it.message ?: "Failed to allocate ranges", + duration = SnackbarDuration.Short + ) + } + } + } ) } ) diff --git a/feature/ranges/src/main/java/no/nordicsemi/android/nrfmesh/feature/ranges/AllocatedRanges.kt b/feature/provisioners/src/main/java/no/nordicsemi/android/nrfmesh/feature/provisioners/provisioner/ranges/AllocatedRanges.kt similarity index 94% rename from feature/ranges/src/main/java/no/nordicsemi/android/nrfmesh/feature/ranges/AllocatedRanges.kt rename to feature/provisioners/src/main/java/no/nordicsemi/android/nrfmesh/feature/provisioners/provisioner/ranges/AllocatedRanges.kt index 63602f424..b39a5e885 100644 --- a/feature/ranges/src/main/java/no/nordicsemi/android/nrfmesh/feature/ranges/AllocatedRanges.kt +++ b/feature/provisioners/src/main/java/no/nordicsemi/android/nrfmesh/feature/provisioners/provisioner/ranges/AllocatedRanges.kt @@ -1,4 +1,4 @@ -package no.nordicsemi.android.nrfmesh.feature.ranges +package no.nordicsemi.android.nrfmesh.feature.provisioners.provisioner.ranges import androidx.compose.foundation.Canvas import androidx.compose.foundation.background @@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material3.Icon -import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -29,6 +28,7 @@ import no.nordicsemi.kotlin.mesh.core.model.maxUnicastAddress import no.nordicsemi.kotlin.mesh.core.model.minGroupAddress import no.nordicsemi.kotlin.mesh.core.model.minSceneNumber import no.nordicsemi.kotlin.mesh.core.model.minUnicastAddress +import no.nordicsemi.kotlin.mesh.core.model.overlap @Composable fun AllocatedRanges( @@ -36,7 +36,7 @@ fun AllocatedRanges( title: String, ranges: List, otherRanges: List, - onClick: () -> Unit + onClick: () -> Unit, ) { TwoLineRangeListItem( modifier = Modifier.clickable { onClick() }, @@ -67,7 +67,7 @@ fun AllocatedRanges( // Mark conflicting ranges markRanges( color = conflictingColor, - ranges = ranges.intersect(otherRanges.toSet()).toList() + ranges = ranges.overlap(other = otherRanges) ) } } @@ -80,7 +80,7 @@ internal fun AllocatedRange( title: String, range: Range, otherRanges: List, - onClick: (Range) -> Unit + onClick: (Range) -> Unit, ) { TwoLineRangeListItem( modifier = Modifier.clickable { onClick(range) }, @@ -106,7 +106,7 @@ internal fun AllocatedRange( // Mark own ranges markRange(color = ownRangeColor, range = range) // Mark conflicting ranges - markRanges(color = conflictingColor, ranges = otherRanges) + markRanges(color = conflictingColor, ranges = range.overlap(other = otherRanges)) } } ) diff --git a/feature/ranges/src/main/java/no/nordicsemi/android/nrfmesh/feature/ranges/RangesScreen.kt b/feature/provisioners/src/main/java/no/nordicsemi/android/nrfmesh/feature/provisioners/provisioner/ranges/RangesScreen.kt similarity index 65% rename from feature/ranges/src/main/java/no/nordicsemi/android/nrfmesh/feature/ranges/RangesScreen.kt rename to feature/provisioners/src/main/java/no/nordicsemi/android/nrfmesh/feature/provisioners/provisioner/ranges/RangesScreen.kt index 830e980d4..c229303e4 100644 --- a/feature/ranges/src/main/java/no/nordicsemi/android/nrfmesh/feature/ranges/RangesScreen.kt +++ b/feature/provisioners/src/main/java/no/nordicsemi/android/nrfmesh/feature/provisioners/provisioner/ranges/RangesScreen.kt @@ -1,12 +1,17 @@ -package no.nordicsemi.android.nrfmesh.feature.ranges +package no.nordicsemi.android.nrfmesh.feature.provisioners.provisioner.ranges import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState import androidx.compose.foundation.BorderStroke +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.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -17,34 +22,33 @@ import androidx.compose.material.icons.outlined.Add import androidx.compose.material.icons.outlined.AutoAwesome import androidx.compose.material.icons.outlined.AutoFixHigh import androidx.compose.material.icons.outlined.Clear +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.Gavel import androidx.compose.material.icons.outlined.GroupWork import androidx.compose.material.icons.outlined.Lan import androidx.compose.material.icons.outlined.SwapHoriz +import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedCard -import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.SnackbarResult +import androidx.compose.material3.SwipeToDismissBox +import androidx.compose.material3.SwipeToDismissBoxValue import androidx.compose.material3.Text import androidx.compose.material3.rememberSwipeToDismissBoxState import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.rememberUpdatedState 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.graphics.Color -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp @@ -53,17 +57,14 @@ import kotlinx.coroutines.launch import no.nordicsemi.android.nrfmesh.core.ui.AddressRangeLegendsForRanges import no.nordicsemi.android.nrfmesh.core.ui.MeshAlertDialog import no.nordicsemi.android.nrfmesh.core.ui.MeshNoItemsAvailable +import no.nordicsemi.android.nrfmesh.core.ui.MeshOutlinedButton import no.nordicsemi.android.nrfmesh.core.ui.MeshOutlinedHexTextField import no.nordicsemi.android.nrfmesh.core.ui.SectionTitle -import no.nordicsemi.android.nrfmesh.core.ui.SwipeDismissItem -import no.nordicsemi.android.nrfmesh.core.ui.isDismissed -import no.nordicsemi.kotlin.data.toByteArray -import no.nordicsemi.kotlin.data.toHexString +import no.nordicsemi.android.nrfmesh.feature.provisioners.R import no.nordicsemi.kotlin.mesh.core.model.GroupRange import no.nordicsemi.kotlin.mesh.core.model.Range import no.nordicsemi.kotlin.mesh.core.model.SceneRange import no.nordicsemi.kotlin.mesh.core.model.UnicastRange -import no.nordicsemi.kotlin.mesh.core.model.overlaps @OptIn(ExperimentalMaterial3Api::class, ExperimentalStdlibApi::class) @Composable @@ -72,15 +73,14 @@ fun RangesScreen( title: String, ranges: List, otherRanges: List, + overlaps: Boolean, isValidBound: (UShort) -> Boolean, addRange: (start: UShort, end: UShort) -> Unit, - onRangeUpdated: (Range, UShort, UShort) -> Unit, - onUndoClicked: (Range) -> Unit, + onRangeUpdated: (UShort, UShort) -> Unit, onSwiped: (Range) -> Unit, - remove: (Range) -> Unit, resolve: () -> Unit, + save: () -> Unit, ) { - val context = LocalContext.current var showAddRangeDialog by remember { mutableStateOf(false) } var rangeToEdit by remember { mutableStateOf(null) } Column { @@ -93,72 +93,93 @@ fun RangesScreen( Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp) + .padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(space = 8.dp), ) { - OutlinedButton( + MeshOutlinedButton( + buttonIcon = Icons.Outlined.Add, + text = stringResource(R.string.label_add_range), onClick = { rangeToEdit = null showAddRangeDialog = true } - ) { - Row( - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically - ) { - Icon(imageVector = Icons.Outlined.Add, contentDescription = null) - Text( - modifier = Modifier.padding(start = 8.dp), - text = stringResource(R.string.label_add_range) - ) - } - } - Spacer(modifier = Modifier.size(8.dp)) - AnimatedVisibility(visible = ranges.overlaps(otherRanges)) { - OutlinedButton( - onClick = resolve, + ) + AnimatedVisibility(visible = overlaps) { + MeshOutlinedButton( border = BorderStroke(width = 1.dp, color = Color.Red), - ) { - Row( - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = Icons.Outlined.AutoFixHigh, - contentDescription = null, - tint = Color.Red - ) - Text( - modifier = Modifier.padding(start = 8.dp), - text = stringResource(R.string.label_resolve), - color = Color.Red - ) - } - } + buttonIcon = Icons.Outlined.AutoFixHigh, + buttonIconTint = Color.Red, + text = stringResource(R.string.label_resolve), + textColor = Color.Red, + onClick = resolve + ) } + MeshOutlinedButton( + enabled = !overlaps, + buttonIcon = Icons.Outlined.Gavel, + text = stringResource(R.string.label_allocate), + onClick = save + ) } } LazyColumn( modifier = Modifier .fillMaxWidth() .weight(0.6f, false), - verticalArrangement = Arrangement.spacedBy(8.dp) + verticalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(horizontal = 16.dp) ) { if (ranges.isNotEmpty()) { - items(items = ranges) { range -> + items(items = ranges, key = { it.hashCode() }) { range -> // Hold the current state from the Swipe to Dismiss composable - val currentItem by rememberUpdatedState(newValue = range) - val dismissState = rememberSwipeToDismissBoxState( - positionalThreshold = { it * 0.5f } - ) - SwipeDismissItem( - dismissState = dismissState, + // val currentItem by rememberUpdatedState(newValue = range) + val dismissState = rememberSwipeToDismissBoxState() + SwipeToDismissBox( + state = dismissState, + backgroundContent = { + val color by animateColorAsState( + when (dismissState.targetValue) { + SwipeToDismissBoxValue.Settled, + SwipeToDismissBoxValue.StartToEnd, + SwipeToDismissBoxValue.EndToStart, + -> Color.Red + } + ) + Box( + modifier = Modifier + .fillMaxSize() + .background(color = color, shape = CardDefaults.elevatedShape) + .padding(horizontal = 16.dp), + contentAlignment = if (dismissState.dismissDirection == SwipeToDismissBoxValue.StartToEnd) + Alignment.CenterStart + else Alignment.CenterEnd + ) { + Icon( + imageVector = Icons.Outlined.Delete, + contentDescription = "null" + ) + } + }, + onDismiss = { onSwiped(range) }, content = { OutlinedCard { AllocatedRange( imageVector = range.toImageVector(), - title = "0x${ - range.low.toByteArray().toHexString() - } - 0x${range.high.toByteArray().toHexString()}", + title = "${ + range.low.toHexString( + format = HexFormat { + number.prefix = "0x" + upperCase = true + } + ) + } - ${ + range.high.toHexString( + format = HexFormat { + number.prefix = "0x" + upperCase = true + } + ) + }", range = range, otherRanges = otherRanges.filter { it.overlap(range) != null }, onClick = { @@ -169,29 +190,11 @@ fun RangesScreen( } } ) - if (dismissState.isDismissed()) { - LaunchedEffect(snackbarHostState) { - onSwiped(currentItem) - snackbarHostState.showSnackbar( - message = context.getString(R.string.label_range_deleted), - actionLabel = context.getString(R.string.action_undo), - withDismissAction = true, - duration = SnackbarDuration.Long - ).also { - when (it) { - SnackbarResult.Dismissed -> remove(currentItem) - SnackbarResult.ActionPerformed -> { - dismissState.reset() - onUndoClicked(currentItem) - } - } - } - } - } } } else { item { MeshNoItemsAvailable( + modifier = Modifier.fillMaxSize(), imageVector = Icons.Outlined.AutoAwesome, title = stringResource(R.string.no_ranges_currently_added) ) @@ -215,9 +218,7 @@ fun RangesScreen( onDismissRequest = { showAddRangeDialog = false }, range = range, isValidBound = isValidBound, - onConfirmClicked = { oldRange, low, high -> - onRangeUpdated(oldRange, low, high) - } + onConfirmClicked = onRangeUpdated ) } ?: AddRangeDialog( isValidBound = isValidBound, @@ -234,6 +235,8 @@ private fun AddRangeDialog( onDismissRequest: () -> Unit, onConfirmClicked: (start: UShort, end: UShort) -> Unit, ) { + var low by remember { mutableStateOf(null) } + var high by remember { mutableStateOf(null) } var start by rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue("")) } @@ -245,7 +248,8 @@ private fun AddRangeDialog( var endInFocus by rememberSaveable { mutableStateOf(false) } var invalidStart by rememberSaveable { mutableStateOf(false) } var invalidEnd by rememberSaveable { mutableStateOf(false) } - var supportingErrorText by rememberSaveable { mutableStateOf("") } + var supportingErrorTextStart by rememberSaveable { mutableStateOf("") } + var supportingErrorTextEnd by rememberSaveable { mutableStateOf("") } MeshAlertDialog( onDismissRequest = { onDismissRequest() }, properties = DialogProperties(usePlatformDefaultWidth = true), @@ -265,7 +269,7 @@ private fun AddRangeDialog( title = stringResource(R.string.title_new_range), error = invalidStart || invalidEnd || - start.text.trim().isBlank() || + start.text.isBlank() || end.text.trim().isBlank(), content = { Column { @@ -286,8 +290,21 @@ private fun AddRangeDialog( if (start.text.trim().isNotEmpty()) { runCatching { invalidStart = !isValidBound(start.text.toUShort(radix = 16)) + if (!invalidStart) { + supportingErrorTextStart = "" + invalidStart = false + } + }.onSuccess { + if (!invalidStart) { + low = start.text.toUShort(radix = 16) + if (high != null && low!! > high!!) { + supportingErrorTextStart = + "Lower bound must be <= upper bound" + invalidStart = true + } + } }.onFailure { throwable -> - supportingErrorText = throwable.message ?: "" + supportingErrorTextStart = throwable.message ?: "" invalidStart = true } } @@ -304,17 +321,17 @@ private fun AddRangeDialog( } ) { Icon(imageVector = Icons.Outlined.Clear, contentDescription = null) } }, - regex = Regex("[0-9A-Fa-f]{0,4}"), + regex = Regex("^[0-9A-Fa-f]{0,4}$"), isError = invalidStart, supportingText = { if (invalidStart) Text( - text = supportingErrorText, + text = supportingErrorTextStart, color = MaterialTheme.colorScheme.error ) } ) - Spacer(modifier = Modifier.size(16.dp)) + Spacer(modifier = Modifier.size(8.dp)) MeshOutlinedHexTextField( modifier = Modifier.clickable { startInFocus = false @@ -330,8 +347,21 @@ private fun AddRangeDialog( if (end.text.trim().isNotEmpty()) { runCatching { invalidEnd = !isValidBound(end.text.toUShort(radix = 16)) + if (!invalidEnd) { + supportingErrorTextEnd = "" + invalidEnd = false + } + }.onSuccess { + if (!invalidEnd) { + high = end.text.toUShort(radix = 16) + if (low != null && high!! < low!!) { + supportingErrorTextEnd = + "Upper bound must be >= to lower bound" + invalidEnd = true + } + } }.onFailure { throwable -> - supportingErrorText = throwable.message ?: "" + supportingErrorTextEnd = throwable.message ?: "" invalidEnd = true } } @@ -348,12 +378,12 @@ private fun AddRangeDialog( } ) { Icon(imageVector = Icons.Outlined.Clear, contentDescription = null) } }, - regex = Regex("[0-9A-Fa-f]{0,4}"), + regex = Regex("^[0-9A-Fa-f]{0,4}$"), isError = invalidEnd, supportingText = { if (invalidEnd) Text( - text = supportingErrorText, + text = supportingErrorTextEnd, color = MaterialTheme.colorScheme.error ) } @@ -369,37 +399,43 @@ private fun UpdateRangeDialog( onDismissRequest: () -> Unit, range: Range, isValidBound: (UShort) -> Boolean, - onConfirmClicked: (Range, UShort, UShort) -> Unit, + onConfirmClicked: (UShort, UShort) -> Unit, ) { + var low by remember { mutableStateOf(null) } + var high by remember { mutableStateOf(null) } var start by rememberSaveable(stateSaver = TextFieldValue.Saver) { - mutableStateOf(TextFieldValue(range.low.toByteArray().toHexString())) + mutableStateOf( + TextFieldValue( + range.low.toHexString(format = HexFormat.UpperCase) + ) + ) } var end by rememberSaveable(stateSaver = TextFieldValue.Saver) { - mutableStateOf(TextFieldValue(range.high.toByteArray().toHexString())) + mutableStateOf( + TextFieldValue( + range.high.toHexString(format = HexFormat.UpperCase) + ) + ) } var startInFocus by rememberSaveable { mutableStateOf(false) } var endInFocus by rememberSaveable { mutableStateOf(false) } var invalidStart by rememberSaveable { mutableStateOf(false) } var invalidEnd by rememberSaveable { mutableStateOf(false) } - var supportingErrorText by rememberSaveable { mutableStateOf("") } + var supportingErrorTextStart by rememberSaveable { mutableStateOf("") } + var supportingErrorTextEnd by rememberSaveable { mutableStateOf("") } MeshAlertDialog( - onDismissRequest = { onDismissRequest() }, - properties = DialogProperties(usePlatformDefaultWidth = true), + onDismissRequest = onDismissRequest, onConfirmClick = { - val startValue = start.text.trim() - val endValue = end.text.trim() - if (startValue.isNotBlank() && endValue.isNotBlank()) { - onDismissRequest() - onConfirmClicked( - range, - startValue.toUShort(radix = 16), - endValue.toUShort(radix = 16) - ) + if (start.text.trim().isNotBlank() && end.text.trim().isNotBlank()) { + val startValue = start.text.trim().toUShort(radix = 16) + val endValue = end.text.trim().toUShort(radix = 16) + runCatching { onConfirmClicked(startValue, endValue) } + .onSuccess { onDismissRequest() } } }, onDismissClick = { onDismissRequest() }, icon = Icons.Outlined.SwapHoriz, - title = stringResource(R.string.title_new_range), + title = stringResource(R.string.title_edit_range), error = invalidStart || invalidEnd || start.text .trim() @@ -429,11 +465,33 @@ private fun UpdateRangeDialog( if (start.text.trim().isNotEmpty()) { runCatching { invalidStart = !isValidBound(start.text.toUShort(radix = 16)) + if (!invalidStart) { + supportingErrorTextStart = "" + invalidStart = false + } + }.onSuccess { + if (!invalidStart) { + low = start.text.toUShort(radix = 16) + if (high != null && low!! > high!!) { + supportingErrorTextStart = + "Lower bound must be <= upper bound" + invalidStart = true + } + } }.onFailure { throwable -> - supportingErrorText = throwable.message ?: "" + supportingErrorTextStart = throwable.message ?: "" invalidStart = true } } + if (start.text.trim().isNotEmpty()) { + runCatching { + invalidStart = !isValidBound(start.text.toUShort(radix = 16)) + }.onFailure { throwable -> + supportingErrorTextStart = throwable.message ?: "" + invalidStart = true + } + } + }, internalTrailingIcon = { IconButton( @@ -446,12 +504,12 @@ private fun UpdateRangeDialog( } ) { Icon(imageVector = Icons.Outlined.Clear, contentDescription = null) } }, - regex = Regex("[0-9A-Fa-f]{0,4}"), + regex = Regex("^[0-9A-Fa-f]{0,4}$"), isError = invalidStart, supportingText = { if (invalidStart) Text( - text = supportingErrorText, + text = supportingErrorTextStart, color = MaterialTheme.colorScheme.error ) } @@ -474,7 +532,7 @@ private fun UpdateRangeDialog( runCatching { invalidEnd = !isValidBound(end.text.toUShort(radix = 16)) }.onFailure { throwable -> - supportingErrorText = throwable.message ?: "" + supportingErrorTextEnd = throwable.message ?: "" invalidEnd = true } } @@ -495,7 +553,7 @@ private fun UpdateRangeDialog( supportingText = { if (invalidEnd) Text( - text = supportingErrorText, + text = supportingErrorTextEnd, color = MaterialTheme.colorScheme.error ) } diff --git a/feature/provisioners/src/main/res/values/strings.xml b/feature/provisioners/src/main/res/values/strings.xml index a8330ab87..050206cf4 100644 --- a/feature/provisioners/src/main/res/values/strings.xml +++ b/feature/provisioners/src/main/res/values/strings.xml @@ -9,7 +9,7 @@ Address Undo Error cannot delete a scene in use. - Provisioner deleted. + %1$s deleted. Unknown error when adding a provisioner. No provisioners currently added. Unicast Address @@ -18,10 +18,6 @@ Device Key TTL Allocated Ranges - Unicast Range - Group Range - Scene Range - Unknown Unassign Address Unassigning the address of the provisioner will @@ -32,10 +28,33 @@ Group Ranges Scene Ranges Provisioner - Cannot delete, a mesh network must have at least one provisioner + Cannot delete %1$s, a mesh network must have at least one provisioner Switch to a different provisioner to delete the currently selected provisioner Configuration capabilities disabled This Provisioner Other Provisioners Set as Local Provisioner + No provisioners currently added + %1$s has been set as the local provisioner. + + Unicast Range + Group Range + Scene Range + Unknown + + Add Range + No range currently added. + Range deleted. + New Range + Enter lower and upper bounds as a 4–character + hexadecimal value. \n\nValid range: 0x0001 – 0x7FFF + Overlapping ranges merge automatically. + Lower bound + Upper bound + Resolve + Edit Ranges + Edit Range + Add Range + Save + Allocate \ No newline at end of file diff --git a/feature/provisioning/src/main/java/no/nordicsemi/android/nrfmesh/feature/provisioning/DeviceCapabilities.kt b/feature/provisioning/src/main/java/no/nordicsemi/android/nrfmesh/feature/provisioning/DeviceCapabilities.kt index ac3f146ce..b3a8797c5 100644 --- a/feature/provisioning/src/main/java/no/nordicsemi/android/nrfmesh/feature/provisioning/DeviceCapabilities.kt +++ b/feature/provisioning/src/main/java/no/nordicsemi/android/nrfmesh/feature/provisioning/DeviceCapabilities.kt @@ -30,6 +30,7 @@ import androidx.compose.material3.ExposedDropdownMenuDefaults import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -270,7 +271,8 @@ private fun NetworkKeyRow( content = { Icon( imageVector = Icons.Outlined.ArrowDropDown, - contentDescription = null + contentDescription = null, + tint = MaterialTheme.colorScheme.primary ) } ) diff --git a/feature/provisioning/src/main/java/no/nordicsemi/android/nrfmesh/feature/provisioning/ProvisioningViewModel.kt b/feature/provisioning/src/main/java/no/nordicsemi/android/nrfmesh/feature/provisioning/ProvisioningViewModel.kt index 5f8135b77..03b06b00b 100644 --- a/feature/provisioning/src/main/java/no/nordicsemi/android/nrfmesh/feature/provisioning/ProvisioningViewModel.kt +++ b/feature/provisioning/src/main/java/no/nordicsemi/android/nrfmesh/feature/provisioning/ProvisioningViewModel.kt @@ -99,7 +99,6 @@ class ProvisioningViewModel @Inject constructor( identifyNode(unprovisionedDevice = device, bearer = pbGattBearer) } }.onCompletion { - println("What happened here: $it") _uiState.value = _uiState.value.copy( provisionerState = Disconnected(unprovisionedDevice = device) ) diff --git a/feature/proxy/src/main/java/no/nordicsemi/android/nrfmesh/feature/proxy/ProxyRoute.kt b/feature/proxy/src/main/java/no/nordicsemi/android/nrfmesh/feature/proxy/ProxyRoute.kt index 1555b433b..472d09515 100644 --- a/feature/proxy/src/main/java/no/nordicsemi/android/nrfmesh/feature/proxy/ProxyRoute.kt +++ b/feature/proxy/src/main/java/no/nordicsemi/android/nrfmesh/feature/proxy/ProxyRoute.kt @@ -1,6 +1,9 @@ package no.nordicsemi.android.nrfmesh.feature.proxy +import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.Row @@ -15,14 +18,19 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Add import androidx.compose.material.icons.outlined.AutoAwesome +import androidx.compose.material.icons.outlined.Delete import androidx.compose.material.icons.outlined.Lan +import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedButton import androidx.compose.material3.SegmentedButton import androidx.compose.material3.SegmentedButtonDefaults import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.SwipeToDismissBox +import androidx.compose.material3.SwipeToDismissBoxValue import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState @@ -38,6 +46,7 @@ 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.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch @@ -52,7 +61,6 @@ import no.nordicsemi.android.nrfmesh.core.ui.MeshIconButton import no.nordicsemi.android.nrfmesh.core.ui.MeshMessageStatusDialog import no.nordicsemi.android.nrfmesh.core.ui.MeshOutlinedButton import no.nordicsemi.android.nrfmesh.core.ui.SectionTitle -import no.nordicsemi.android.nrfmesh.core.ui.SwipeDismissItem import no.nordicsemi.android.nrfmesh.core.ui.isCompactWidth import no.nordicsemi.android.nrfmesh.feature.scanner.ScannerContent import no.nordicsemi.kotlin.ble.client.android.ScanResult @@ -397,17 +405,33 @@ private fun SwipeToDismissAddress( address: ProxyFilterAddress, onSwiped: (ProxyFilterAddress) -> Unit, ) { - val dismissState = rememberSwipeToDismissBoxState( - confirmValueChange = { - onSwiped(address) - true - } - ) - SwipeDismissItem( - dismissState = dismissState, - content = { - AddressRow(name = address.name(nodes = nodes, groups = groups)) - } + val dismissState = rememberSwipeToDismissBoxState() + SwipeToDismissBox( + modifier = Modifier.padding(horizontal = 16.dp), + state = dismissState, + backgroundContent = { + val color by animateColorAsState( + when (dismissState.targetValue) { + SwipeToDismissBoxValue.Settled, + SwipeToDismissBoxValue.StartToEnd, + SwipeToDismissBoxValue.EndToStart, + -> Color.Red + } + ) + Box( + modifier = Modifier + .fillMaxSize() + .background(color = color, shape = CardDefaults.elevatedShape) + .padding(horizontal = 16.dp), + contentAlignment = if (dismissState.dismissDirection == SwipeToDismissBoxValue.StartToEnd) + Alignment.CenterStart + else Alignment.CenterEnd + ) { + Icon(imageVector = Icons.Outlined.Delete, contentDescription = "null") + } + }, + onDismiss = { onSwiped(address) }, + content = { AddressRow(name = address.name(nodes = nodes, groups = groups)) } ) } diff --git a/feature/proxy/src/main/java/no/nordicsemi/android/nrfmesh/feature/proxy/ProxyViewModel.kt b/feature/proxy/src/main/java/no/nordicsemi/android/nrfmesh/feature/proxy/ProxyViewModel.kt index a06b93c22..d41684b70 100644 --- a/feature/proxy/src/main/java/no/nordicsemi/android/nrfmesh/feature/proxy/ProxyViewModel.kt +++ b/feature/proxy/src/main/java/no/nordicsemi/android/nrfmesh/feature/proxy/ProxyViewModel.kt @@ -7,6 +7,7 @@ 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 kotlinx.coroutines.launch import no.nordicsemi.android.nrfmesh.core.common.Completed import no.nordicsemi.android.nrfmesh.core.common.Failed @@ -43,44 +44,60 @@ internal class ProxyViewModel @Inject internal constructor( private fun observeNetwork() { repository.network.onEach { meshNetwork = it - _uiState.value = _uiState.value.copy( - nodes = it.nodes.toList(), - networkKeys = it.networkKeys.toList(), - groups = it.groups.toList() - ) + _uiState.update { + it.copy( + nodes = meshNetwork?.nodes?.toList() ?: emptyList(), + networkKeys = meshNetwork?.networkKeys?.toList() ?: emptyList(), + groups = meshNetwork?.groups?.toList() ?: emptyList() + ) + } }.launchIn(scope = viewModelScope) - repository.proxyConnectionStateFlow.onEach { - _uiState.value = _uiState.value.copy(proxyConnectionState = it) + repository.proxyConnectionStateFlow.onEach { proxyConnectionState -> + _uiState.update { + it.copy(proxyConnectionState = proxyConnectionState) + } }.launchIn(scope = viewModelScope) // Setup initial state - _uiState.value = _uiState.value.copy( - filterType = repository.proxyFilter.type, - addresses = repository.proxyFilter.addresses.toList(), - ) + _uiState.update { + it.copy( + filterType = repository.proxyFilter.type, + addresses = repository.proxyFilter.addresses.toList(), + ) + } - repository.proxyFilter.proxyFilterStateFlow.onEach { - when (it) { + repository.proxyFilter.proxyFilterStateFlow.onEach { proxyFilterState -> + println("ProxyViewModel: proxyFilterState: $proxyFilterState") + when (proxyFilterState) { is ProxyFilterState.ProxyFilterUpdated -> { val addresses = mutableListOf() - addresses.addAll(it.addresses) - _uiState.value = _uiState.value.copy( - filterType = it.type, - addresses = addresses.toList(), - isProxyLimitReached = false - ) + addresses.addAll(proxyFilterState.addresses) + _uiState.update { + it.copy( + filterType = proxyFilterState.type, + addresses = proxyFilterState.addresses.toList(), + isProxyLimitReached = false + ) + } } is ProxyFilterState.ProxyFilterLimitReached -> { - _uiState.value = _uiState.value.copy( - filterType = it.type, - isProxyLimitReached = true - ) + _uiState.update { + it.copy( + filterType = proxyFilterState.type, + isProxyLimitReached = true + ) + } } is ProxyFilterState.ProxyFilterUpdateAcknowledged -> { - + _uiState.update { + it.copy( + filterType = proxyFilterState.type, + addresses = repository.proxyFilter.addresses.toList(), + ) + } } ProxyFilterState.Unknown -> { @@ -126,27 +143,33 @@ internal class ProxyViewModel @Inject internal constructor( } internal fun send(message: ProxyConfigurationMessage) { - _uiState.value = _uiState.value.copy(messageState = Sending(message = message)) + _uiState.update { + it.copy(messageState = Sending(message = message)) + } viewModelScope.launch { try { repository.send(message).let { response -> if (message is RemoveAddressesFromFilter) { val addresses = _uiState.value.addresses.toMutableList() if (addresses.removeAll(message.addresses)) { - _uiState.value = _uiState.value.copy(addresses = addresses.toList()) + _uiState.update { + it.copy(addresses = addresses.toList()) + } } } - _uiState.value = _uiState.value.copy( - messageState = Completed( - message = message, - response = response as ConfigResponse - ), - ) + _uiState.update { + it.copy( + messageState = Completed( + message = message, + response = response as ConfigResponse + ), + ) + } } } catch (e: Exception) { - _uiState.value = _uiState.value.copy( - messageState = Failed(message = message, error = e) - ) + _uiState.update { + it.copy(messageState = Failed(message = message, error = e)) + } } } } diff --git a/feature/proxy/src/main/res/values/strings.xml b/feature/proxy/src/main/res/values/strings.xml index 7ccc1d52a..80927e586 100644 --- a/feature/proxy/src/main/res/values/strings.xml +++ b/feature/proxy/src/main/res/values/strings.xml @@ -10,7 +10,7 @@ Connect to network manually Unknown - Enabling automatic connection will automatically connect to a proxy node. + Enabling this will automatically connect to a proxy node. Disable automatic connection to connect manually. Disabled Disconnect diff --git a/feature/ranges/.gitignore b/feature/ranges/.gitignore deleted file mode 100644 index 42afabfd2..000000000 --- a/feature/ranges/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/build \ No newline at end of file diff --git a/feature/ranges/build.gradle.kts b/feature/ranges/build.gradle.kts deleted file mode 100644 index 9c6b5c914..000000000 --- a/feature/ranges/build.gradle.kts +++ /dev/null @@ -1,29 +0,0 @@ -plugins { - // https://github.com/NordicSemiconductor/Android-Gradle-Plugins/blob/main/plugins/src/main/kotlin/AndroidFeatureConventionPlugin.kt - alias(libs.plugins.nordic.feature) -} - -android { - namespace = "no.nordicsemi.android.nrfmesh.feature.ranges" -} - -dependencies { - - implementation(nordic.kotlin.data) - - testImplementation(libs.junit4) - testImplementation(libs.kotlin.junit) - testImplementation(libs.androidx.test.ext) - testImplementation(libs.androidx.test.rules) - - androidTestImplementation(libs.junit4) - androidTestImplementation(libs.kotlin.junit) - androidTestImplementation(libs.androidx.test.ext) - androidTestImplementation(libs.androidx.test.rules) - - implementation(project(":core:ui")) - implementation(project(":core:data")) - implementation(project(":core:navigation")) - implementation(project(":mesh:core")) - -} \ No newline at end of file diff --git a/feature/ranges/src/main/AndroidManifest.xml b/feature/ranges/src/main/AndroidManifest.xml deleted file mode 100644 index 44008a433..000000000 --- a/feature/ranges/src/main/AndroidManifest.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/feature/ranges/src/main/java/no/nordicsemi/android/nrfmesh/feature/ranges/GroupRangesViewModel.kt b/feature/ranges/src/main/java/no/nordicsemi/android/nrfmesh/feature/ranges/GroupRangesViewModel.kt deleted file mode 100644 index 506c32144..000000000 --- a/feature/ranges/src/main/java/no/nordicsemi/android/nrfmesh/feature/ranges/GroupRangesViewModel.kt +++ /dev/null @@ -1,43 +0,0 @@ -package no.nordicsemi.android.nrfmesh.feature.ranges - -import androidx.lifecycle.SavedStateHandle -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.update -import no.nordicsemi.android.nrfmesh.core.data.CoreDataRepository -import no.nordicsemi.kotlin.mesh.core.model.GroupAddress -import no.nordicsemi.kotlin.mesh.core.model.Range -import no.nordicsemi.kotlin.mesh.core.model.plus -import javax.inject.Inject - -@HiltViewModel -internal class GroupRangesViewModel @Inject internal constructor( - savedStateHandle: SavedStateHandle, - repository: CoreDataRepository -) : RangesViewModel(savedStateHandle = savedStateHandle, repository = repository) { - - override fun getAllocatedRanges(): List = provisioner.allocatedGroupRanges - - override fun getOtherRanges(): List = getOtherProvisioners() - .flatMap { it.allocatedGroupRanges } - .toList() - - override fun addRange(start: UInt, end: UInt) { - val range = GroupAddress(start.toUShort())..GroupAddress(end.toUShort()) - _uiState.update { - it.copy(ranges = it.ranges + range) - } - if (!_uiState.value.conflicts) { - allocate() - save() - } - } - - override fun onRangeUpdated(range: Range, low: UShort, high: UShort) { - updateRange(range, GroupAddress(address = low)..GroupAddress(address = high)) - } - - override fun isValidBound(bound: UShort): Boolean = when { - GroupAddress.isValid(address = bound) -> true - else -> throw Throwable("Invalid group address") - } -} \ No newline at end of file diff --git a/feature/ranges/src/main/java/no/nordicsemi/android/nrfmesh/feature/ranges/RangesViewModel.kt b/feature/ranges/src/main/java/no/nordicsemi/android/nrfmesh/feature/ranges/RangesViewModel.kt deleted file mode 100644 index 59e736ae2..000000000 --- a/feature/ranges/src/main/java/no/nordicsemi/android/nrfmesh/feature/ranges/RangesViewModel.kt +++ /dev/null @@ -1,229 +0,0 @@ -package no.nordicsemi.android.nrfmesh.feature.ranges - -import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import no.nordicsemi.android.nrfmesh.core.data.CoreDataRepository -import no.nordicsemi.android.nrfmesh.core.navigation.MeshNavigationDestination -import no.nordicsemi.kotlin.mesh.core.model.MeshNetwork -import no.nordicsemi.kotlin.mesh.core.model.Provisioner -import no.nordicsemi.kotlin.mesh.core.model.Range -import no.nordicsemi.kotlin.mesh.core.model.minus -import no.nordicsemi.kotlin.mesh.core.model.overlaps -import no.nordicsemi.kotlin.mesh.core.model.plus -import kotlin.uuid.ExperimentalUuidApi -import kotlin.uuid.Uuid - -@OptIn(ExperimentalUuidApi::class) -@Suppress("ConvertArgumentToSet") -internal abstract class RangesViewModel( - savedStateHandle: SavedStateHandle, - private val repository: CoreDataRepository -) : ViewModel() { - - protected lateinit var network: MeshNetwork - protected lateinit var provisioner: Provisioner - - private val uuid: Uuid = checkNotNull(savedStateHandle[MeshNavigationDestination.ARG]).let { - Uuid.parse(uuidString = it as String) - } - - protected val _uiState = MutableStateFlow(RangesScreenUiState(listOf())) - val uiState: StateFlow = _uiState.stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5_000), - initialValue = RangesScreenUiState() - ) - - init { - viewModelScope.launch { - repository.network.collect { network -> - this@RangesViewModel.network = network - val ranges = network.provisioner(uuid)?.let { provisioner -> - this@RangesViewModel.provisioner = provisioner - getAllocatedRanges() - } ?: emptyList() - - _uiState.update { state -> - state.copy( - ranges = ranges, - otherRanges = getOtherRanges(), - rangesToBeRemoved = ranges.filter { it in state.rangesToBeRemoved } - ) - } - } - } - } - - override fun onCleared() { - super.onCleared() - // Remove the ranges that were swiped for removal. - removeRanges() - } - - /** - * Returns the list of ranges allocated to the provisioner. - * @return list of ranges. - */ - protected abstract fun getAllocatedRanges(): List - - /** - * Returns the list of ranges allocated to other provisioners in the network. - * @return list of ranges of other provisioners. - */ - protected abstract fun getOtherRanges(): List - - /** - * Adds a range to the network. - * - * @param start Start address of the range. - * @param end End address of the range. - */ - internal abstract fun addRange(start: UInt, end: UInt) - - /** - * Checks if the given bound is valid. - * - * @param bound Bound to be checked. - * @return true if valid, false otherwise. - */ - internal abstract fun isValidBound(bound: UShort): Boolean - - /** - * Invoked when the user updates a given range - * - * @param range Range to be updated. - * @param low Low address of the new range. - * @param high High address of the new range. - */ - internal abstract fun onRangeUpdated(range: Range, low: UShort, high: UShort) - - /** - * Updates the given range with the given new range. - * - * @param range Range to be updated. - * @param newRange New range to be updated with. - */ - protected fun updateRange(range: Range, newRange: Range) { - _uiState.update { state -> - state.copy(ranges = state.ranges.map { - if (it == range) newRange else it - }) - } - if (!_uiState.value.conflicts) { - provisioner.update(range, newRange) - } - } - - /** - * Returns the list of other provisioners in the network. - */ - protected fun getOtherProvisioners(): List = network.provisioners - .filter { it.uuid != uuid } - - /** - * Resolves any conflicting ranges with the other ranges. - */ - internal fun resolve() { - _uiState.update { - it.copy(ranges = it.ranges - it.otherRanges) - } - } - - protected fun allocate() { - _uiState.value.ranges - .filter { it !in getAllocatedRanges() } - .forEach { - runCatching { - provisioner.allocate(it) - } - } - } - - /** - * Invoked when a range is swiped to be deleted. The given range is added to a list - * of ranges to be deleted. - * - * @param range Range to be deleted. - */ - internal fun onSwiped(range: Range) { - viewModelScope.launch { - _uiState.update { - it.copy(rangesToBeRemoved = it.rangesToBeRemoved + range) - } - } - } - - /** - * Invoked when a ranges that is swiped to be deleted is undone. When invoked the given - * range is removed from the list of ranges to be deleted. - * - * @param range Scene to be reverted. - */ - internal fun onUndoSwipe(range: Range) { - viewModelScope.launch { - _uiState.update { - it.copy(rangesToBeRemoved = it.rangesToBeRemoved - range) - } - } - } - - /** - * Remove a given range from the provisioner. - * - * @param range Range to be removed. - */ - internal fun remove(range: Range) { - _uiState.update { - it.copy(rangesToBeRemoved = it.rangesToBeRemoved - range) - } - provisioner.remove(range) - save() - } - - /** - * Removes the ranges that are queued for deletion. - */ - private fun removeRanges() { - _uiState.value.rangesToBeRemoved.forEach { - provisioner.remove(it) - } - // Resolve any conflicts if they are not resolved already. - resolve() - // Allocate the newly added ranges to the provisioner. - allocate() - save() - } - - /** - * Saves the network. - */ - protected fun save() { - viewModelScope.launch { repository.save() } - } - - /*internal fun isValidBound(range: Range, bound: UShort): Boolean = when (range) { - is UnicastRange -> if (UnicastAddress.isValid(address = bound)) true - else throw Throwable("Invalid unicast address") - is GroupRange -> if (GroupAddress.isValid(address = bound)) true - else throw Throwable("Invalid group address") - is SceneRange -> if (!Scene.isValid(sceneNumber = bound)) true - else throw Throwable("Invalid scene number") - }*/ -} - -@ConsistentCopyVisibility -data class RangesScreenUiState internal constructor( - val ranges: List = listOf(), - val otherRanges: List = listOf(), - val rangesToBeRemoved: List = listOf() -) { - val conflicts: Boolean - get() = ranges.overlaps(otherRanges) -} diff --git a/feature/ranges/src/main/java/no/nordicsemi/android/nrfmesh/feature/ranges/SceneRangesViewModel.kt b/feature/ranges/src/main/java/no/nordicsemi/android/nrfmesh/feature/ranges/SceneRangesViewModel.kt deleted file mode 100644 index 4fdcc2247..000000000 --- a/feature/ranges/src/main/java/no/nordicsemi/android/nrfmesh/feature/ranges/SceneRangesViewModel.kt +++ /dev/null @@ -1,44 +0,0 @@ -package no.nordicsemi.android.nrfmesh.feature.ranges - -import androidx.lifecycle.SavedStateHandle -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.update -import no.nordicsemi.android.nrfmesh.core.data.CoreDataRepository -import no.nordicsemi.kotlin.mesh.core.model.Range -import no.nordicsemi.kotlin.mesh.core.model.Scene -import no.nordicsemi.kotlin.mesh.core.model.SceneRange -import no.nordicsemi.kotlin.mesh.core.model.plus -import javax.inject.Inject - -@HiltViewModel -internal class SceneRangesViewModel @Inject internal constructor( - savedStateHandle: SavedStateHandle, - repository: CoreDataRepository -) : RangesViewModel(savedStateHandle = savedStateHandle, repository = repository) { - - override fun getAllocatedRanges(): List = provisioner.allocatedSceneRanges - - override fun getOtherRanges(): List = getOtherProvisioners() - .flatMap { it.allocatedSceneRanges } - .toList() - - override fun addRange(start: UInt, end: UInt) { - val range = SceneRange(start.toUShort(), end.toUShort()) - _uiState.update { - it.copy(ranges = it.ranges + range) - } - if (!_uiState.value.conflicts) { - allocate() - save() - } - } - - override fun onRangeUpdated(range: Range, low: UShort, high: UShort) { - updateRange(range, SceneRange(low, high)) - } - - override fun isValidBound(bound: UShort): Boolean = when { - Scene.isValid(sceneNumber = bound) -> true - else -> throw Throwable("Invalid unicast address") - } -} \ No newline at end of file diff --git a/feature/ranges/src/main/java/no/nordicsemi/android/nrfmesh/feature/ranges/UnicastRangesViewModel.kt b/feature/ranges/src/main/java/no/nordicsemi/android/nrfmesh/feature/ranges/UnicastRangesViewModel.kt deleted file mode 100644 index 7edc40470..000000000 --- a/feature/ranges/src/main/java/no/nordicsemi/android/nrfmesh/feature/ranges/UnicastRangesViewModel.kt +++ /dev/null @@ -1,45 +0,0 @@ -package no.nordicsemi.android.nrfmesh.feature.ranges - -import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.viewModelScope -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import no.nordicsemi.android.nrfmesh.core.data.CoreDataRepository -import no.nordicsemi.kotlin.mesh.core.model.Range -import no.nordicsemi.kotlin.mesh.core.model.UnicastAddress -import no.nordicsemi.kotlin.mesh.core.model.plus -import javax.inject.Inject - -@HiltViewModel -internal class UnicastRangesViewModel @Inject internal constructor( - savedStateHandle: SavedStateHandle, - repository: CoreDataRepository -) : RangesViewModel(savedStateHandle = savedStateHandle, repository = repository) { - - override fun getAllocatedRanges(): List = provisioner.allocatedUnicastRanges - - override fun getOtherRanges(): List = getOtherProvisioners() - .flatMap { it.allocatedUnicastRanges } - .toList() - - override fun addRange(start: UInt, end: UInt) { - viewModelScope.launch { - val range = (UnicastAddress(start.toUShort())..UnicastAddress(end.toUShort())) - _uiState.update { it.copy(ranges = it.ranges + range) } - if (!_uiState.value.conflicts) { - allocate() - save() - } - } - } - - override fun onRangeUpdated(range: Range, low: UShort, high: UShort) { - updateRange(range, UnicastAddress(address = low)..UnicastAddress(address = high)) - } - - override fun isValidBound(bound: UShort): Boolean = when { - UnicastAddress.isValid(address = bound) -> true - else -> throw Throwable("Invalid unicast address") - } -} \ No newline at end of file diff --git a/feature/ranges/src/main/res/values/strings.xml b/feature/ranges/src/main/res/values/strings.xml deleted file mode 100644 index 1c82d9ac1..000000000 --- a/feature/ranges/src/main/res/values/strings.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - Allocated Ranges - Unicast Range - Group Range - Scene Range - Unknown - - Add Range - No range currently added. - Range deleted. - New Range - Enter lower and upper bounds as a 4–character - hexadecimal value. \n\nValid range: 0x0001 – 0x7FFF - Overlapping ranges merge automatically. - Lower bound - Upper bound - Resolve - Edit Ranges - Undo - Unicast Ranges - Group Ranges - Scene Ranges - Add Range - \ No newline at end of file diff --git a/feature/scanner/src/main/java/no/nordicsemi/android/nrfmesh/feature/scanner/ScannerContent.kt b/feature/scanner/src/main/java/no/nordicsemi/android/nrfmesh/feature/scanner/ScannerContent.kt index 5fd44ad13..83a878e94 100644 --- a/feature/scanner/src/main/java/no/nordicsemi/android/nrfmesh/feature/scanner/ScannerContent.kt +++ b/feature/scanner/src/main/java/no/nordicsemi/android/nrfmesh/feature/scanner/ScannerContent.kt @@ -55,31 +55,15 @@ fun ScannerContent( UnprovisionedDevice .from(advertisementData = scanResult.advertisingData.raw) .let { device -> - OutlinedCard( - modifier = Modifier - .height(height = 80.dp) - .padding(horizontal = 8.dp) - .padding(bottom = 8.dp) - ) { - MeshTwoLineListItem( - leadingComposable = { - Row { - Spacer(modifier = Modifier.size(size = 16.dp)) - CircularIcon( - painter = rememberVectorPainter(Icons.Outlined.Bluetooth), - iconSize = 24.dp - ) - Spacer(modifier = Modifier.size(size = 16.dp)) - } - }, - title = when { - scanResult.advertisingData.name.isNullOrEmpty() -> device.name - else -> scanResult.advertisingData.name - ?: stringResource(R.string.label_unknown_device) - }, - subtitle = device.uuid.toString().uppercase() - ) - } + DeviceListItem( + iconPainter = rememberVectorPainter(Icons.Outlined.Bluetooth), + title = when { + scanResult.advertisingData.name.isNullOrEmpty() -> device.name + else -> scanResult.advertisingData.name + ?: stringResource(R.string.label_unknown_device) + }, + subtitle = device.uuid.toString().uppercase() + ) } }.onFailure { println("Failed to parse device: ${it.localizedMessage}") @@ -90,52 +74,27 @@ fun ScannerContent( scanResult.advertisingData.serviceData[MeshProxyService.uuid] ?.takeIf { it.isNotEmpty() } ?.run { - OutlinedCard( - modifier = Modifier - .height(height = 80.dp) - .padding(horizontal = 8.dp) - .padding(bottom = 8.dp) - ) { - nodeIdentity()?.matches(nodes = nodes)?.let { - MeshTwoLineListItem( - leadingComposable = { - Row { - Spacer(modifier = Modifier.size(size = 16.dp)) - CircularIcon( - painter = rememberVectorPainter(Icons.Outlined.WavingHand), - iconSize = 24.dp - ) - Spacer(modifier = Modifier.size(size = 16.dp)) - } - }, - title = it.name, - subtitle = it.primaryUnicastAddress.address.toHexString( - format = HexFormat { - number.prefix = "Address: 0x" - upperCase = true - } - ) - ) - } ?: run { - MeshTwoLineListItem( - leadingComposable = { - Row { - Spacer(modifier = Modifier.size(size = 16.dp)) - CircularIcon( - painter = painterResource(no.nordicsemi.android.common.scanner.R.drawable.ic_mesh), - iconSize = 36.dp - ) - Spacer(modifier = Modifier.size(size = 16.dp)) - } - }, - title = scanResult.advertisingData.name - ?: scanResult.peripheral.name - ?: stringResource(R.string.label_unknown_device), - subtitle = networkIdentity() - ?.createMatchingDescription(networkKeys = networkKeys) - ?: return@OutlinedCard, + nodeIdentity()?.matches(nodes = nodes)?.let { + DeviceListItem( + iconPainter = rememberVectorPainter(Icons.Outlined.WavingHand), + title = it.name, + subtitle = it.primaryUnicastAddress.address.toHexString( + format = HexFormat { + number.prefix = "Address: 0x" + upperCase = true + } ) - } + ) + } ?: run { + DeviceListItem( + iconPainter = painterResource(no.nordicsemi.android.common.scanner.R.drawable.ic_mesh), + title = scanResult.advertisingData.name + ?: scanResult.peripheral.name + ?: stringResource(R.string.label_unknown_device), + subtitle = networkIdentity() + ?.createMatchingDescription(networkKeys = networkKeys) + ?: return@run, + ) } } diff --git a/feature/scenes/src/main/java/no/nordicsemi/android/nrfmesh/feature/scenes/SceneRoute.kt b/feature/scenes/src/main/java/no/nordicsemi/android/nrfmesh/feature/scenes/SceneRoute.kt index 74be01d25..10361e017 100644 --- a/feature/scenes/src/main/java/no/nordicsemi/android/nrfmesh/feature/scenes/SceneRoute.kt +++ b/feature/scenes/src/main/java/no/nordicsemi/android/nrfmesh/feature/scenes/SceneRoute.kt @@ -46,9 +46,7 @@ fun Name(name: String, onNameChanged: (String) -> Unit) { imageVector = Icons.Outlined.Badge, title = stringResource(id = R.string.label_name), subtitle = name, - onValueChanged = onNameChanged, - isEditable = true, - onEditableStateChanged = { }, + onValueChanged = onNameChanged ) } @@ -59,6 +57,11 @@ fun Number(number: SceneNumber) { modifier = Modifier.padding(horizontal = 16.dp), imageVector = Icons.Outlined.AutoAwesome, title = stringResource(id = R.string.label_scene_number), - subtitle = number.toHexString() + subtitle = number.toHexString( + format = HexFormat { + this.number.prefix = "0x" + upperCase = true + } + ) ) } \ No newline at end of file diff --git a/feature/scenes/src/main/java/no/nordicsemi/android/nrfmesh/feature/scenes/ScenesRoute.kt b/feature/scenes/src/main/java/no/nordicsemi/android/nrfmesh/feature/scenes/ScenesRoute.kt index 6039d70b2..f62e9a798 100644 --- a/feature/scenes/src/main/java/no/nordicsemi/android/nrfmesh/feature/scenes/ScenesRoute.kt +++ b/feature/scenes/src/main/java/no/nordicsemi/android/nrfmesh/feature/scenes/ScenesRoute.kt @@ -3,9 +3,12 @@ package no.nordicsemi.android.nrfmesh.feature.scenes import android.content.Context +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxSize @@ -15,6 +18,7 @@ import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Add import androidx.compose.material.icons.outlined.AutoAwesome +import androidx.compose.material.icons.outlined.Delete import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExtendedFloatingActionButton @@ -25,17 +29,17 @@ import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarResult -import androidx.compose.material3.Surface +import androidx.compose.material3.SwipeToDismissBox +import androidx.compose.material3.SwipeToDismissBoxValue import androidx.compose.material3.Text import androidx.compose.material3.rememberSwipeToDismissBoxState import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -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.graphics.Color import androidx.compose.ui.platform.LocalContext @@ -47,50 +51,27 @@ import no.nordicsemi.android.nrfmesh.core.data.models.SceneData import no.nordicsemi.android.nrfmesh.core.ui.ElevatedCardItem import no.nordicsemi.android.nrfmesh.core.ui.MeshNoItemsAvailable import no.nordicsemi.android.nrfmesh.core.ui.SectionTitle -import no.nordicsemi.android.nrfmesh.core.ui.SwipeDismissItem -import no.nordicsemi.android.nrfmesh.core.ui.isDismissed -import no.nordicsemi.android.nrfmesh.core.ui.showSnackbar import no.nordicsemi.kotlin.mesh.core.exception.NoSceneRangeAllocated +import no.nordicsemi.kotlin.mesh.core.model.KeyIndex import no.nordicsemi.kotlin.mesh.core.model.Scene import no.nordicsemi.kotlin.mesh.core.model.SceneNumber @Composable internal fun ScenesRoute( + snackbarHostState: SnackbarHostState, highlightSelectedItem: Boolean, + selectedSceneNumber: SceneNumber?, scenes: List, - onAddSceneClicked: () -> Scene?, - navigateToScene: (SceneNumber) -> Unit, - onSwiped: (SceneData) -> Unit, - onUndoClicked: (SceneData) -> Unit, - remove: (SceneData) -> Unit -) { - Scenes( - highlightSelectedItem = highlightSelectedItem, - scenes = scenes, - onAddSceneClicked = onAddSceneClicked, - navigateToScene = navigateToScene, - onSwiped = onSwiped, - onUndoClicked = onUndoClicked, - remove = remove - ) -} - -@Composable -private fun Scenes( - highlightSelectedItem: Boolean, - scenes: List, - onAddSceneClicked: () -> Scene?, + onAddSceneClicked: () -> Scene, + onSceneClicked: (KeyIndex) -> Unit, navigateToScene: (SceneNumber) -> Unit, onSwiped: (SceneData) -> Unit, onUndoClicked: (SceneData) -> Unit, - remove: (SceneData) -> Unit + remove: (SceneData) -> Unit, ) { val context = LocalContext.current val scope = rememberCoroutineScope() - val snackbarHostState = remember { SnackbarHostState() } - var selectedSceneNumber by rememberSaveable { mutableStateOf(null) } Scaffold( - modifier = Modifier.background(color = Color.Red), snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, floatingActionButton = { ExtendedFloatingActionButton( @@ -101,22 +82,19 @@ private fun Scenes( runCatching { onAddSceneClicked() }.onSuccess { scene -> - scene?.number?.let { - selectedSceneNumber = it.toInt() - navigateToScene(it) - } + navigateToScene(scene.number) }.onFailure { - showSnackbar( - scope = scope, - snackbarHostState = snackbarHostState, - message = when (it) { - is NoSceneRangeAllocated -> it.message ?: context.getString( - R.string.error_allocate_scene_range_to_provisioner - ) + scope.launch { + snackbarHostState.showSnackbar( + message = when (it) { + is NoSceneRangeAllocated -> it.message ?: context.getString( + R.string.error_allocate_scene_range_to_provisioner + ) - else -> it.message ?: context.getString(R.string.unknown_error) - } - ) + else -> it.message ?: context.getString(R.string.unknown_error) + } + ) + } } }, expanded = true @@ -128,10 +106,6 @@ private fun Scenes( .fillMaxSize() .consumeWindowInsets(paddingValues = paddingValues) ) { - SectionTitle( - modifier = Modifier.padding(top = 8.dp), - title = stringResource(id = R.string.label_scenes) - ) when (scenes.isEmpty()) { true -> MeshNoItemsAvailable( modifier = Modifier.fillMaxSize(), @@ -141,29 +115,41 @@ private fun Scenes( false -> LazyColumn( modifier = Modifier - .fillMaxSize() - .padding(top = 8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) + .fillMaxSize(), + contentPadding = PaddingValues(horizontal = 16.dp), + // Removed in favor of padding in SwipeToDismissKey so that hiding an item will not leave any gaps + //verticalArrangement = Arrangement.spacedBy(space = 8.dp) ) { - items(items = scenes, key = { it.hashCode() }) { scene -> - val isSelected = highlightSelectedItem && scene.number.toInt() == selectedSceneNumber - SwipeToDismissScene( - context = context, - snackbarHostState = snackbarHostState, - scene = scene, - isSelected = isSelected, - navigateToScene = { - selectedSceneNumber = it.toInt() - navigateToScene(it) - }, - onSwiped = { - onSwiped(it) - remove(it) - }, - onUndoClicked = onUndoClicked, - remove = remove + item { + SectionTitle( + modifier = Modifier.padding(vertical = 8.dp), + title = stringResource(id = R.string.label_scenes) ) } + items(items = scenes, key = { it.id }) { scene -> + val isSelected = + highlightSelectedItem && scene.number == selectedSceneNumber + var visibility by remember { mutableStateOf(true) } + AnimatedVisibility(visible = visibility) { + SwipeToDismissScene( + scope = scope, + context = context, + snackbarHostState = snackbarHostState, + scene = scene, + isSelected = isSelected, + onSceneClicked = onSceneClicked, + onSwiped = { + visibility = false + onSwiped(it) + }, + onUndoClicked = { + visibility = true + onUndoClicked(it) + }, + remove = remove + ) + } + } } } } @@ -173,82 +159,101 @@ private fun Scenes( @OptIn(ExperimentalStdlibApi::class) @Composable private fun SwipeToDismissScene( + scope: CoroutineScope, context: Context, snackbarHostState: SnackbarHostState, scene: SceneData, isSelected: Boolean, - navigateToScene: (SceneNumber) -> Unit, + onSceneClicked: (SceneNumber) -> Unit, onSwiped: (SceneData) -> Unit, onUndoClicked: (SceneData) -> Unit, - remove: (SceneData) -> Unit + remove: (SceneData) -> Unit, ) { - val scope = rememberCoroutineScope() - val dismissState = rememberSwipeToDismissBoxState( - confirmValueChange = { - handleValueChange( - scope = scope, - context = context, - snackbarHostState = snackbarHostState, - scene = scene + val dismissState = rememberSwipeToDismissBoxState() + SwipeToDismissBox( + // Added instead of using Arrangement.spacedBy to avoid leaving gaps when an item is swiped away. + modifier = Modifier.padding(bottom = 8.dp), + state = dismissState, + backgroundContent = { + val color by animateColorAsState( + when (dismissState.targetValue) { + SwipeToDismissBoxValue.Settled, + SwipeToDismissBoxValue.StartToEnd, + SwipeToDismissBoxValue.EndToStart, + -> if (scene.isInUse) Color.Gray else Color.Red + } ) + Box( + modifier = Modifier + .fillMaxSize() + .background(color = color, shape = CardDefaults.elevatedShape) + .padding(horizontal = 16.dp), + contentAlignment = if (dismissState.dismissDirection == SwipeToDismissBoxValue.StartToEnd) + Alignment.CenterStart + else Alignment.CenterEnd + ) { + Icon(imageVector = Icons.Outlined.Delete, contentDescription = "null") + } }, - positionalThreshold = { it * 0.5f } - ) - SwipeDismissItem( - dismissState = dismissState, - content = { - Surface(color = MaterialTheme.colorScheme.background) { - ElevatedCardItem( - onClick = { navigateToScene(scene.number) }, - colors = when (isSelected) { - true -> CardDefaults.outlinedCardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant + onDismiss = { + snackbarHostState.currentSnackbarData?.dismiss() + if (scene.isInUse) { + // The following functions are invoked in their own coroutine to ensure + // that they are executed sequentially + scope.launch { + dismissState.reset() + snackbarHostState.showSnackbar( + message = context.getString( + R.string.label_cannot_delete_scene_in_use, + scene.name ) + ) + } + } else { + onSwiped(scene) + scope.launch { + val result = snackbarHostState.showSnackbar( + message = context.getString( + R.string.label_scene_deleted, + scene.name + ), + actionLabel = context.getString(R.string.action_undo), + withDismissAction = true, + duration = SnackbarDuration.Short + ) - else -> CardDefaults.outlinedCardColors() - }, - imageVector = Icons.Outlined.AutoAwesome, - title = scene.name, - subtitle = "0x${scene.number.toHexString()}" - ) - } - } - ) - if (dismissState.isDismissed()) { - LaunchedEffect(key1 = snackbarHostState) { - onSwiped(scene) - snackbarHostState.showSnackbar( - message = context.getString(R.string.label_scene_deleted), - actionLabel = context.getString(R.string.action_undo), - withDismissAction = true, - duration = SnackbarDuration.Short - ).also { - when (it) { - SnackbarResult.Dismissed -> remove(scene) - SnackbarResult.ActionPerformed -> { - dismissState.reset() - onUndoClicked(scene) + when (result) { + SnackbarResult.ActionPerformed -> { + onUndoClicked(scene) + dismissState.reset() + } + + SnackbarResult.Dismissed -> remove(scene) } } } - } - } -} + }, + content = { + ElevatedCardItem( + onClick = { onSceneClicked(scene.number) }, + colors = when (isSelected) { + true -> CardDefaults.outlinedCardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) -private fun handleValueChange( - scope: CoroutineScope, - context: Context, - snackbarHostState: SnackbarHostState, - scene: SceneData -): Boolean = when { - scene.isInUse -> { - scope.launch { - snackbarHostState.showSnackbar( - message = context.getString(R.string.error_cannot_delete_scene_in_use) + else -> CardDefaults.outlinedCardColors() + }, + imageVector = Icons.Outlined.AutoAwesome, + title = scene.name, + subtitle = "Scene number: ${ + scene.number.toHexString( + format = HexFormat { + number.prefix = "0x" + upperCase = true + } + ) + }" ) } - false - } - - else -> true + ) } \ No newline at end of file diff --git a/feature/scenes/src/main/java/no/nordicsemi/android/nrfmesh/feature/scenes/ScenesViewModel.kt b/feature/scenes/src/main/java/no/nordicsemi/android/nrfmesh/feature/scenes/ScenesViewModel.kt index 371600420..4a97ee504 100644 --- a/feature/scenes/src/main/java/no/nordicsemi/android/nrfmesh/feature/scenes/ScenesViewModel.kt +++ b/feature/scenes/src/main/java/no/nordicsemi/android/nrfmesh/feature/scenes/ScenesViewModel.kt @@ -6,10 +6,13 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import no.nordicsemi.android.nrfmesh.core.data.CoreDataRepository import no.nordicsemi.android.nrfmesh.core.data.models.SceneData +import no.nordicsemi.kotlin.mesh.core.exception.NoSceneNumberAvailable import no.nordicsemi.kotlin.mesh.core.model.MeshNetwork import no.nordicsemi.kotlin.mesh.core.model.SceneNumber import javax.inject.Inject @@ -20,24 +23,11 @@ internal class ScenesViewModel @Inject internal constructor( ) : ViewModel() { private lateinit var network: MeshNetwork - private var selectedSceneNumber: SceneNumber? = null - private val _uiState = MutableStateFlow(ScenesScreenUiState()) val uiState: StateFlow = _uiState.asStateFlow() init { - viewModelScope.launch { - repository.network.collect { network -> - this@ScenesViewModel.network = network - _uiState.update { state -> - val scenes = network.scenes.map { SceneData(scene = it) } - state.copy( - scenes = scenes, - scenesToBeRemoved = scenes.filter { it in state.scenesToBeRemoved } - ) - } - } - } + observeNetwork() } override fun onCleared() { @@ -45,14 +35,26 @@ internal class ScenesViewModel @Inject internal constructor( super.onCleared() } + private fun observeNetwork() { + repository.network.onEach { network -> + this.network = network + _uiState.update { state -> + state.copy( + scenes = network.scenes + .map { SceneData(scene = it) } + // Filter out the scenes that are marked for deletion. + .filter { it !in state.scenesToBeRemoved } + ) + } + }.launchIn(scope = viewModelScope) + } + /** * Adds a scene to the network. */ - internal fun addScene() = network.nextAvailableScene()?.let { - network.add(name = "nRF Scene", number = it).also { - save() - } - } + internal fun addScene() = network + .add(name = "Scene ${network.scenes.size + 1}", provisioner = network.provisioners.first()) + .also { repository.save() } /** * Invoked when a scene is swiped to be deleted. The given scene is added to a list of scenes @@ -61,12 +63,8 @@ internal class ScenesViewModel @Inject internal constructor( * @param scene Scene to be deleted. */ internal fun onSwiped(scene: SceneData) { - viewModelScope.launch { - val state = _uiState.value - _uiState.value = state.copy( - scenes = state.scenes - scene, - scenesToBeRemoved = state.scenesToBeRemoved + scene - ) + _uiState.update { state -> + state.copy(scenesToBeRemoved = state.scenesToBeRemoved + scene) } } @@ -77,13 +75,8 @@ internal class ScenesViewModel @Inject internal constructor( * @param scene Scene to be reverted. */ internal fun onUndoSwipe(scene: SceneData) { - viewModelScope.launch { - val state = _uiState.value - _uiState.value = state.copy( - scenes = (state.scenes + scene) - .sortedBy { it.number }, - scenesToBeRemoved = state.scenesToBeRemoved - scene - ) + _uiState.update { state -> + state.copy(scenesToBeRemoved = state.scenesToBeRemoved - scene) } } @@ -93,33 +86,24 @@ internal class ScenesViewModel @Inject internal constructor( * @param scene Scene to be removed. */ internal fun remove(scene: SceneData) { - viewModelScope.launch { - val state = _uiState.value - network.run { - runCatching { - val scene = network.scene(number = scene.number) - if (scene != null) { - remove(scene = scene) - } - save() - } - } - _uiState.value = state.copy(scenesToBeRemoved = state.scenesToBeRemoved - scene) + _uiState.update { state -> + state.copy( + scenes = state.scenes - scene, + scenesToBeRemoved = state.scenesToBeRemoved - scene + ) } + network.remove(sceneNumber = scene.number) + // We don't remove other scenes that are queued as we do in app keys or net keys + removeAllScenes() } /** - * Removes the scene from a network. + * Removes all the scenes that are queued for deletion. */ private fun removeAllScenes() { - _uiState.value.scenesToBeRemoved.forEach { - network.run { - runCatching { - val scene = network.scene(number = it.number) - if (scene != null) { - remove(scene = scene) - } - } + runCatching { + _uiState.value.scenesToBeRemoved.forEach { scene -> + network.remove(sceneNumber = scene.number) } } save() @@ -133,11 +117,9 @@ internal class ScenesViewModel @Inject internal constructor( } internal fun selectScene(number: SceneNumber) { - selectedSceneNumber = number - } - - internal fun isCurrentlySelectedScene(number: SceneNumber): Boolean { - return selectedSceneNumber == number + _uiState.update { state -> + state.copy(selectedSceneNumber = number) + } } } @@ -145,4 +127,5 @@ internal class ScenesViewModel @Inject internal constructor( data class ScenesScreenUiState internal constructor( val scenes: List = listOf(), val scenesToBeRemoved: List = listOf(), + val selectedSceneNumber: SceneNumber? = null, ) diff --git a/feature/scenes/src/main/java/no/nordicsemi/android/nrfmesh/feature/scenes/navigation/ScenesDestination.kt b/feature/scenes/src/main/java/no/nordicsemi/android/nrfmesh/feature/scenes/navigation/ScenesDestination.kt index cac9782d4..fee3c22dd 100644 --- a/feature/scenes/src/main/java/no/nordicsemi/android/nrfmesh/feature/scenes/navigation/ScenesDestination.kt +++ b/feature/scenes/src/main/java/no/nordicsemi/android/nrfmesh/feature/scenes/navigation/ScenesDestination.kt @@ -1,6 +1,7 @@ package no.nordicsemi.android.nrfmesh.feature.scenes.navigation import android.os.Parcelable +import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.hilt.navigation.compose.hiltViewModel @@ -17,23 +18,31 @@ data object ScenesContent : Parcelable @Composable fun ScenesScreenRoute( + snackbarHostState: SnackbarHostState, highlightSelectedItem: Boolean, + onSceneClicked: (SceneNumber) -> Unit, navigateToScene: (SceneNumber) -> Unit, navigateUp: () -> Unit, ) { val viewModel = hiltViewModel() val uiState by viewModel.uiState.collectAsStateWithLifecycle() ScenesRoute( + snackbarHostState = snackbarHostState, highlightSelectedItem = highlightSelectedItem, + selectedSceneNumber = uiState.selectedSceneNumber, scenes = uiState.scenes, onAddSceneClicked = viewModel::addScene, + onSceneClicked = { + viewModel.selectScene(number = it) + onSceneClicked(it) + }, navigateToScene = { viewModel.selectScene(it) navigateToScene(it) }, onSwiped = { - viewModel.onSwiped(it) - if(viewModel.isCurrentlySelectedScene(it.number)) { + viewModel.onSwiped(scene = it) + if(uiState.selectedSceneNumber == it.number) { navigateUp() } }, diff --git a/feature/scenes/src/main/res/values/strings.xml b/feature/scenes/src/main/res/values/strings.xml index d5263f9a2..037e5a872 100644 --- a/feature/scenes/src/main/res/values/strings.xml +++ b/feature/scenes/src/main/res/values/strings.xml @@ -8,8 +8,8 @@ Scene Number Cannot edit a scene that\'s in use. Undo - Error cannot delete a scene in use. - Scene deleted. + Cannot delete, %1$s is in use. + %1$s deleted. No scene numbers are available from the allocated scene range Error, please allocate a scene range to diff --git a/feature/settings/build.gradle.kts b/feature/settings/build.gradle.kts index 43e076bc3..8d444626c 100644 --- a/feature/settings/build.gradle.kts +++ b/feature/settings/build.gradle.kts @@ -35,11 +35,11 @@ dependencies { androidTestImplementation(libs.androidx.test.ext) androidTestImplementation(libs.androidx.test.rules) - implementation("androidx.compose.material3:material3:1.4.0-alpha18") - implementation("androidx.compose.material3:material3-window-size-class:1.4.0-alpha18") - implementation("androidx.compose.material3:material3-adaptive-navigation-suite:1.4.0-alpha18") + implementation("androidx.compose.material3:material3:1.5.0-alpha10") + implementation("androidx.compose.material3:material3-window-size-class:1.4.0") + implementation("androidx.compose.material3:material3-adaptive-navigation-suite:1.4.0") - implementation("androidx.compose.material3.adaptive:adaptive:1.1.0") - implementation("androidx.compose.material3.adaptive:adaptive-layout:1.1.0") - implementation("androidx.compose.material3.adaptive:adaptive-navigation:1.1.0") + implementation("androidx.compose.material3.adaptive:adaptive:1.2.0") + implementation("androidx.compose.material3.adaptive:adaptive-layout:1.2.0") + implementation("androidx.compose.material3.adaptive:adaptive-navigation-android:1.2.0") } \ No newline at end of file diff --git a/feature/settings/src/main/java/no/nordicsemi/android/nrfmesh/feature/settings/SettingsDetailsPane.kt b/feature/settings/src/main/java/no/nordicsemi/android/nrfmesh/feature/settings/SettingsDetailsPane.kt index 22bd100b0..e060c95f5 100644 --- a/feature/settings/src/main/java/no/nordicsemi/android/nrfmesh/feature/settings/SettingsDetailsPane.kt +++ b/feature/settings/src/main/java/no/nordicsemi/android/nrfmesh/feature/settings/SettingsDetailsPane.kt @@ -3,6 +3,7 @@ package no.nordicsemi.android.nrfmesh.feature.settings import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -30,6 +31,7 @@ import kotlin.uuid.Uuid @Composable internal fun SettingsDetailsPane( content: Any?, + snackbarHostState: SnackbarHostState, highlightSelectedItem: Boolean, navigateToProvisioner: (Uuid) -> Unit, navigateToNetworkKey: (KeyIndex) -> Unit, @@ -39,18 +41,23 @@ internal fun SettingsDetailsPane( ) { when (content) { is ProvisionersContent, is ProvisionerContent -> ProvisionersScreenRoute( + snackbarHostState = snackbarHostState, highlightSelectedItem = highlightSelectedItem, + onProvisionerClicked = navigateToProvisioner, navigateToProvisioner = navigateToProvisioner, navigateUp = navigateUp ) is NetworkKeysContent, is NetworkKeyContent -> NetworkKeysScreenRoute( + snackbarHostState = snackbarHostState, highlightSelectedItem = highlightSelectedItem, onNetworkKeyClicked = navigateToNetworkKey, + navigateToKey = navigateToNetworkKey, navigateUp = navigateUp ) is ApplicationKeysContent, is ApplicationKeyContent -> ApplicationKeysScreenRoute( + snackbarHostState = snackbarHostState, highlightSelectedItem = highlightSelectedItem, onApplicationKeyClicked = navigateToApplicationKey, navigateToKey = navigateToApplicationKey, @@ -58,7 +65,9 @@ internal fun SettingsDetailsPane( ) is ScenesContent, is SceneContent -> ScenesScreenRoute( + snackbarHostState = snackbarHostState, highlightSelectedItem = highlightSelectedItem, + onSceneClicked = navigateToScene, navigateToScene = navigateToScene, navigateUp = navigateUp ) diff --git a/feature/settings/src/main/java/no/nordicsemi/android/nrfmesh/feature/settings/SettingsExtraPane.kt b/feature/settings/src/main/java/no/nordicsemi/android/nrfmesh/feature/settings/SettingsExtraPane.kt index 8062e9f7e..4f13a1de5 100644 --- a/feature/settings/src/main/java/no/nordicsemi/android/nrfmesh/feature/settings/SettingsExtraPane.kt +++ b/feature/settings/src/main/java/no/nordicsemi/android/nrfmesh/feature/settings/SettingsExtraPane.kt @@ -3,6 +3,7 @@ package no.nordicsemi.android.nrfmesh.feature.settings import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -22,6 +23,7 @@ import kotlin.uuid.ExperimentalUuidApi @OptIn(ExperimentalUuidApi::class) @Composable internal fun SettingsExtraPane( + snackbarHostState: SnackbarHostState, network: MeshNetwork, settingsListData: SettingsListData, content: Any?, @@ -34,12 +36,12 @@ internal fun SettingsExtraPane( .firstOrNull { it.uuid == content.uuid } ?: return ProvisionerScreenRoute( + snackbarHostState = snackbarHostState, index = network.provisioners.indexOf(element = provisioner), provisioner = provisioner, provisionerData = settingsListData.provisioners .firstOrNull { it.uuid == content.uuid } ?: return, - otherProvisioners = network.provisioners.filter { it.uuid != content.uuid }, moveProvisioner = moveProvisioner, save = save ) @@ -51,13 +53,13 @@ internal fun SettingsExtraPane( ) is ApplicationKeyContent -> ApplicationKeyScreenRoute( - key = network.applicationKey(content.keyIndex) ?: return, + key = network.applicationKey(index = content.keyIndex) ?: return, networkKeys = network.networkKeys, save = save ) is SceneContent -> SceneScreenRoute( - scene = network.scenes.first { it.number == content.number }, + scene = network.scene(number = content.number) ?: return, save = save ) diff --git a/feature/settings/src/main/java/no/nordicsemi/android/nrfmesh/feature/settings/SettingsViewModel.kt b/feature/settings/src/main/java/no/nordicsemi/android/nrfmesh/feature/settings/SettingsViewModel.kt index c89801638..946b92cd2 100644 --- a/feature/settings/src/main/java/no/nordicsemi/android/nrfmesh/feature/settings/SettingsViewModel.kt +++ b/feature/settings/src/main/java/no/nordicsemi/android/nrfmesh/feature/settings/SettingsViewModel.kt @@ -10,6 +10,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import no.nordicsemi.android.nrfmesh.core.data.CoreDataRepository import no.nordicsemi.android.nrfmesh.core.data.storage.MeshSecurePropertiesStorage @@ -31,9 +32,9 @@ class SettingsViewModel @Inject constructor( private lateinit var network: MeshNetwork init { - _uiState.value = _uiState.value.copy( - selectedSetting = savedStateHandle.toRoute().selectedSetting - ) + _uiState.update { + it.copy(selectedSetting = savedStateHandle.toRoute().selectedSetting) + } observeNetworkState() } @@ -42,14 +43,15 @@ class SettingsViewModel @Inject constructor( */ private fun observeNetworkState() { repository.network.onEach { - val selectedSetting = _uiState.value.selectedSetting - _uiState.value = _uiState.value.copy( - networkState = MeshNetworkState.Success( - network = it, - settingsListData = SettingsListData(it) - ), - selectedSetting = selectedSetting - ) + _uiState.update { state -> + state.copy( + networkState = MeshNetworkState.Success( + network = it, + settingsListData = SettingsListData(it) + ), + selectedSetting = state.selectedSetting + ) + } network = it }.launchIn(scope = viewModelScope) } @@ -60,7 +62,7 @@ class SettingsViewModel @Inject constructor( * @param clickableSetting The setting that was clicked. */ internal fun onItemSelected(clickableSetting: ClickableSetting) { - _uiState.value = _uiState.value.copy(selectedSetting = clickableSetting) + _uiState.update { it.copy(selectedSetting = clickableSetting) } } /** diff --git a/feature/settings/src/main/java/no/nordicsemi/android/nrfmesh/feature/settings/navigation/SettingsListDetailsScreen.kt b/feature/settings/src/main/java/no/nordicsemi/android/nrfmesh/feature/settings/navigation/SettingsListDetailsScreen.kt index 33dc7ddbc..274559306 100644 --- a/feature/settings/src/main/java/no/nordicsemi/android/nrfmesh/feature/settings/navigation/SettingsListDetailsScreen.kt +++ b/feature/settings/src/main/java/no/nordicsemi/android/nrfmesh/feature/settings/navigation/SettingsListDetailsScreen.kt @@ -111,6 +111,7 @@ internal fun SettingsListDetailsScreen( val content = navigator.currentDestination?.contentKey SettingsDetailsPane( content = content, + snackbarHostState = appState.snackbarHostState, highlightSelectedItem = navigator.isDetailPaneVisible() && navigator.isExtraPaneVisible(), navigateToProvisioner = { @@ -161,6 +162,7 @@ internal fun SettingsListDetailsScreen( AnimatedPane { val content = navigator.currentDestination?.contentKey SettingsExtraPane( + snackbarHostState = appState.snackbarHostState, network = uiState.networkState.network, settingsListData = uiState.networkState.settingsListData, content = content, diff --git a/mesh/bearer-gatt/src/main/java/no/nordicsemi/kotlin/mesh/bearer/gatt/BaseGattBearer.kt b/mesh/bearer-gatt/src/main/java/no/nordicsemi/kotlin/mesh/bearer/gatt/BaseGattBearer.kt index a90174bf4..3ef0f9d7a 100644 --- a/mesh/bearer-gatt/src/main/java/no/nordicsemi/kotlin/mesh/bearer/gatt/BaseGattBearer.kt +++ b/mesh/bearer-gatt/src/main/java/no/nordicsemi/kotlin/mesh/bearer/gatt/BaseGattBearer.kt @@ -119,8 +119,8 @@ abstract class BaseGattBearer< if (servicesObserver != null) return // Observe the connection state centralManager.connect(peripheral = peripheral) - - return suspendCancellableCoroutine { continuation -> + configurePeripheral(peripheral) + suspendCancellableCoroutine { continuation -> var suspended = true // Start observing the discovered services servicesObserver = peripheral.services() @@ -142,6 +142,11 @@ abstract class BaseGattBearer< } } + @OptIn(ExperimentalUuidApi::class) + protected open suspend fun configurePeripheral(peripheral: P){ + // Empty + } + override suspend fun close() { onClosed() servicesObserver?.cancel() diff --git a/mesh/core/src/main/java/no/nordicsemi/kotlin/mesh/core/MeshNetworkManager.kt b/mesh/core/src/main/java/no/nordicsemi/kotlin/mesh/core/MeshNetworkManager.kt index 858c5bf62..dd195634b 100644 --- a/mesh/core/src/main/java/no/nordicsemi/kotlin/mesh/core/MeshNetworkManager.kt +++ b/mesh/core/src/main/java/no/nordicsemi/kotlin/mesh/core/MeshNetworkManager.kt @@ -1084,10 +1084,11 @@ class MeshNetworkManager( * @throws IllegalStateException This method throws when the mesh network has not been created. */ @Throws(NoNetwork::class, IllegalStateException::class) - suspend fun send(message: ProxyConfigurationMessage): ProxyConfigurationMessage = + suspend fun send(message: ProxyConfigurationMessage): ProxyConfigurationMessage? = networkManager?.send(message) ?: run { logger?.e(category = LogCategory.PROXY) { "Error: Mesh Network not created" } - throw NoNetwork() + return null + //throw NoNetwork() } /** diff --git a/mesh/core/src/main/java/no/nordicsemi/kotlin/mesh/core/exception/MeshNetworkException.kt b/mesh/core/src/main/java/no/nordicsemi/kotlin/mesh/core/exception/MeshNetworkException.kt index 684ee02c4..6609156b4 100644 --- a/mesh/core/src/main/java/no/nordicsemi/kotlin/mesh/core/exception/MeshNetworkException.kt +++ b/mesh/core/src/main/java/no/nordicsemi/kotlin/mesh/core/exception/MeshNetworkException.kt @@ -67,6 +67,9 @@ class SceneInUse : MeshNetworkException() /** Thrown when no scene range is allocated to a provisioner. */ class NoSceneRangeAllocated : MeshNetworkException() +/** Thrown when no scene number is available to be allocated. */ +class NoSceneNumberAvailable : MeshNetworkException() + /** Thrown when at least one network key is not selected when exporting a partial network. */ class AtLeastOneNetworkKeyMustBeSelected : MeshNetworkException() diff --git a/mesh/core/src/main/java/no/nordicsemi/kotlin/mesh/core/messages/foundation/configuration/ConfigCompositionDataStatus.kt b/mesh/core/src/main/java/no/nordicsemi/kotlin/mesh/core/messages/foundation/configuration/ConfigCompositionDataStatus.kt index df0b79258..5b8bdbe59 100644 --- a/mesh/core/src/main/java/no/nordicsemi/kotlin/mesh/core/messages/foundation/configuration/ConfigCompositionDataStatus.kt +++ b/mesh/core/src/main/java/no/nordicsemi/kotlin/mesh/core/messages/foundation/configuration/ConfigCompositionDataStatus.kt @@ -213,7 +213,7 @@ data class Page0( val index = 0 // Read models. - val element = Element(_name = "Element ${elementNo++}", location = location) + val element = Element(_name = "Element ${elements.size + 1}", location = location) .apply { this.index = index } for (i in offset until offset + sigModelsByteCount step 2) { diff --git a/mesh/core/src/main/java/no/nordicsemi/kotlin/mesh/core/model/Address.kt b/mesh/core/src/main/java/no/nordicsemi/kotlin/mesh/core/model/Address.kt index 09a55cd07..f752a83ad 100644 --- a/mesh/core/src/main/java/no/nordicsemi/kotlin/mesh/core/model/Address.kt +++ b/mesh/core/src/main/java/no/nordicsemi/kotlin/mesh/core/model/Address.kt @@ -194,7 +194,6 @@ data class VirtualAddress( PublicationAddress, SubscriptionAddress, HeartbeatPublicationDestination, - HeartbeatSubscriptionDestination, ProxyFilterAddress { @OptIn(ExperimentalUuidApi::class) diff --git a/mesh/core/src/main/java/no/nordicsemi/kotlin/mesh/core/model/ApplicationKey.kt b/mesh/core/src/main/java/no/nordicsemi/kotlin/mesh/core/model/ApplicationKey.kt index 48cd470c5..04aa8cd1c 100644 --- a/mesh/core/src/main/java/no/nordicsemi/kotlin/mesh/core/model/ApplicationKey.kt +++ b/mesh/core/src/main/java/no/nordicsemi/kotlin/mesh/core/model/ApplicationKey.kt @@ -10,6 +10,7 @@ import no.nordicsemi.kotlin.mesh.core.exception.KeyInUse import no.nordicsemi.kotlin.mesh.core.model.MeshNetwork.Companion.onChange import no.nordicsemi.kotlin.mesh.core.model.serialization.KeySerializer import no.nordicsemi.kotlin.mesh.crypto.Crypto +import kotlin.uuid.ExperimentalUuidApi /** * Application Keys are used to secure communications at the upper transport layer. @@ -20,11 +21,9 @@ import no.nordicsemi.kotlin.mesh.crypto.Crypto * represents the NetKey index for this network key. * @property name Human-readable name for the application functionality associated * with this application key. - * @property boundNetKeyIndex The boundNetKey property contains a corresponding Network Key index - * of the network key in the mesh network. * @property key 128-bit application key. * @property oldKey OldKey property contains the previous application key. - * @property boundNetworkKey Network key to which this application key is bound to. + * @property boundNetworkKey Network key to which this application key is bound to. * @param _key 128-bit application key. */ @ConsistentCopyVisibility @@ -40,13 +39,17 @@ data class ApplicationKey internal constructor( var name: String get() = _name set(value) { - require(value.isNotBlank()) { "Name cannot be empty!" } + require(value.isNotBlank()) { "Name cannot be empty." } onChange(oldValue = _name, newValue = value) { network?.updateTimestamp() } _name = value } + /** + * The boundNetKey property contains a corresponding Network Key index of the network key in + * the mesh network. + */ @SerialName("boundNetKey") - var boundNetKeyIndex: KeyIndex = 0u + internal var boundNetKeyIndex: KeyIndex = 0u set(value) { require(value.isValidKeyIndex()) { "Key index must be in range from 0 to 4095." } onChange(oldValue = field, newValue = value) { network?.updateTimestamp() } @@ -56,7 +59,7 @@ data class ApplicationKey internal constructor( var key: ByteArray get() = _key internal set(value) { - require(value.size == 16) { "Key must be 16-bytes long!" } + require(value.size == 16) { "Key must be 16-bytes long." } onChange(oldValue = _key, newValue = value) { oldKey = _key oldAid = aid @@ -79,6 +82,8 @@ data class ApplicationKey internal constructor( @Transient internal var network: MeshNetwork? = null + // Returns the Network Key to which this Application Key is bound to. This property should not + // by null as the MeshNetwork is assigned when adding a Network Key via the public api. val boundNetworkKey: NetworkKey get() = network!!.networkKey(index = boundNetKeyIndex)!! @@ -86,12 +91,11 @@ data class ApplicationKey internal constructor( internal var oldAid: Byte? = null + @OptIn(ExperimentalUuidApi::class) val isInUse: Boolean get() = network?.run { - // The application key in used when it is known by any of the nodes in the network. - _nodes.any { node -> - node.elements.flatMap { it.models }.any { it.bind.contains(element = index) } - } + nodes.filter { it != localProvisioner } + .any { it.knows(key = this@ApplicationKey) } } ?: false init { diff --git a/mesh/core/src/main/java/no/nordicsemi/kotlin/mesh/core/model/MeshNetwork.kt b/mesh/core/src/main/java/no/nordicsemi/kotlin/mesh/core/model/MeshNetwork.kt index 6990a0e38..723996982 100644 --- a/mesh/core/src/main/java/no/nordicsemi/kotlin/mesh/core/model/MeshNetwork.kt +++ b/mesh/core/src/main/java/no/nordicsemi/kotlin/mesh/core/model/MeshNetwork.kt @@ -19,6 +19,7 @@ import no.nordicsemi.kotlin.mesh.core.exception.KeyIndexOutOfRange import no.nordicsemi.kotlin.mesh.core.exception.NoAddressesAvailable import no.nordicsemi.kotlin.mesh.core.exception.NoGroupRangeAllocated import no.nordicsemi.kotlin.mesh.core.exception.NoNetworkKeysAdded +import no.nordicsemi.kotlin.mesh.core.exception.NoSceneNumberAvailable import no.nordicsemi.kotlin.mesh.core.exception.NoSceneRangeAllocated import no.nordicsemi.kotlin.mesh.core.exception.NoUnicastRangeAllocated import no.nordicsemi.kotlin.mesh.core.exception.NodeAlreadyExists @@ -435,6 +436,21 @@ data class MeshNetwork internal constructor( removeProvisioner(_provisioners.indexOf(provisioner)) } + /** + * Removes the provisioner with the given Uuid from the list of provisioners in the network. + * + * @param uuid Uuid of the provisioner to be removed. + * @throws DoesNotBelongToNetwork if the the provisioner does not belong to this network. + * @throws CannotRemove if there is only one provisioner. + * @throws NoSuchElementException if a provisioner with the given Uuid was not found. + */ + @Throws(DoesNotBelongToNetwork::class, CannotRemove::class) + fun removeProvisionerWithUuid(uuid: Uuid) { + provisioner(uuid)?.let { provisioner -> + remove(provisioner) + } + } + /** * Moves the provisioner from the given 'from' index to the specified 'to' index. * @@ -675,7 +691,10 @@ data class MeshNetwork internal constructor( * * @param index KeyIndex of the Application Key to be removed. * @param force If true, the Application Key will be removed even if it is in use. + * @throws [DoesNotBelongToNetwork] if the key does not belong to this network. + * @throws [KeyInUse] if the key is known to any node in the */ + @Throws(DoesNotBelongToNetwork::class, KeyInUse::class) fun removeApplicationKeyWithIndex(index: KeyIndex, force: Boolean = false) { removeApplicationKeyAtIndex( index = applicationKeys.indexOfFirst { it.index == index }, @@ -689,7 +708,10 @@ data class MeshNetwork internal constructor( * * @param index index of the Application Key in the list of Application Keys. * @param force If true, the Application Key will be removed even if it is in use. + * @throws [DoesNotBelongToNetwork] if the key does not belong to this network. + * @throws [KeyInUse] if the key is known to any node in the */ + @Throws(DoesNotBelongToNetwork::class, KeyInUse::class) fun removeApplicationKeyAtIndex(index: Int, force: Boolean = false) { // Return as no op if the key does not exist val key = applicationKeys.getOrNull(index) ?: return @@ -901,7 +923,7 @@ data class MeshNetwork internal constructor( /** * Adds a given Scene with the given name and the scene number to the mesh network. * - * @param name Name of the scene. + * @param name Name of the scene. * @param number Scene number. * @throws [SceneAlreadyExists] If the scene already exists. */ @@ -911,13 +933,28 @@ data class MeshNetwork internal constructor( return Scene(_name = name, number = number).apply { network = this@MeshNetwork }.also { scene -> - _scenes.apply { - add(scene) - }.sortBy { it.number } + _scenes + .apply { add(scene) } + .sortBy { it.number } updateTimestamp() } } + /** + * Adds a given Scene with the given name to the mesh network for a given provisioner + * + * @param name Name of the scene. + * @param provisioner Provisioner for whom the scene is being added. + * @throws [NoSceneNumberAvailable] If there is no scene number available for the provisioner. + * @throws [SceneAlreadyExists] If the scene already exists. + */ + @Throws(NoSceneNumberAvailable::class, SceneAlreadyExists::class) + fun add(name: String, provisioner: Provisioner): Scene { + val nextSceneNumber = + nextAvailableScene(provisioner = provisioner) ?: throw NoSceneNumberAvailable() + return add(name = name, number = nextSceneNumber) + } + /** * Adds a given [Scene] to the list of scenes in the mesh network. * @@ -939,13 +976,27 @@ data class MeshNetwork internal constructor( * @throws [DoesNotBelongToNetwork] If the scene does not belong to the network. * @throws [SceneInUse] If the scene is already in use. */ - @Throws(DoesNotBelongToNetwork::class) + @Throws(DoesNotBelongToNetwork::class, SceneInUse::class) fun remove(scene: Scene) { require(scene.network == this) { throw DoesNotBelongToNetwork() } require(!scene.isInUse) { throw SceneInUse() } _scenes.remove(scene).also { updateTimestamp() } } + /** + * Removes a scene with the given scene number from the network. + * + * @param sceneNumber Scene number of the scene to be removed. + * @throws [DoesNotBelongToNetwork] If the scene does not belong to the network. + * @throws [SceneInUse] If the scene is already in use. + */ + @Throws(DoesNotBelongToNetwork::class, SceneInUse::class) + fun remove(sceneNumber: SceneNumber) { + scene(number = sceneNumber)?.let { scene -> + remove(scene = scene) + } + } + /** * Checks if the address range is available for use. * diff --git a/mesh/core/src/main/java/no/nordicsemi/kotlin/mesh/core/model/Model.kt b/mesh/core/src/main/java/no/nordicsemi/kotlin/mesh/core/model/Model.kt index 3e9749b3e..82c0da34c 100644 --- a/mesh/core/src/main/java/no/nordicsemi/kotlin/mesh/core/model/Model.kt +++ b/mesh/core/src/main/java/no/nordicsemi/kotlin/mesh/core/model/Model.kt @@ -6,7 +6,6 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.Transient import no.nordicsemi.kotlin.mesh.core.ModelEventHandler -import kotlin.UShort import kotlin.uuid.ExperimentalUuidApi /** @@ -137,16 +136,15 @@ class Model internal constructor( val subscribe: List get() { // A model may be additionally subscribed to any special address - // except from All Nodes. - if (!_subscribe.contains(AllNodes)) { - // Models on the primary Element are always subscribed to the All Nodes - // address. - if (parentElement?.isPrimary == true) { - _subscribe.add(AllNodes as SubscriptionAddress) - } - } - return _subscribe + // except from All Nodes and Models on the primary Element are always subscribed to the + // All Nodes address. + return _subscribe.takeIf{ + !it.contains(element = AllNodes) && parentElement?.isPrimary == true + }?.let { + _subscribe + AllNodes as SubscriptionAddress + } ?: _subscribe } + var publish: Publish? get() = _publish internal set(value) { diff --git a/mesh/core/src/main/java/no/nordicsemi/kotlin/mesh/core/model/NetworkKey.kt b/mesh/core/src/main/java/no/nordicsemi/kotlin/mesh/core/model/NetworkKey.kt index e0cebf505..0e1baa669 100644 --- a/mesh/core/src/main/java/no/nordicsemi/kotlin/mesh/core/model/NetworkKey.kt +++ b/mesh/core/src/main/java/no/nordicsemi/kotlin/mesh/core/model/NetworkKey.kt @@ -14,6 +14,7 @@ import no.nordicsemi.kotlin.mesh.crypto.SecurityCredentials import kotlin.time.Clock import kotlin.time.ExperimentalTime import kotlin.time.Instant +import kotlin.uuid.ExperimentalUuidApi /** * AThe network key object represents the state of the mesh network key that is used for securing @@ -130,7 +131,7 @@ data class NetworkKey internal constructor( } } - var timestamp : Instant = Clock.System.now() + var timestamp: Instant = Clock.System.now() internal set @Transient @@ -167,18 +168,18 @@ data class NetworkKey internal constructor( var network: MeshNetwork? = null internal set + @OptIn(ExperimentalUuidApi::class) val isInUse: Boolean get() = network?.run { // A network key is in use if at least one application key is bound to it. // OR // The network key is known by any of the nodes in the network. - _applicationKeys.any { applicationKey -> - applicationKey.boundNetKeyIndex == index - } || _nodes.any { node -> - node.netKeys.any { nodeKey -> - nodeKey.index == index - } - } + val hasBoundAppKey = _applicationKeys + .any { it.boundNetworkKey == this@NetworkKey } + val knownByRemoteNode = nodes + .filter { it != localProvisioner } + .any { it.knows(key = this@NetworkKey) } + hasBoundAppKey || knownByRemoteNode } ?: false init { diff --git a/mesh/core/src/main/java/no/nordicsemi/kotlin/mesh/core/model/Node.kt b/mesh/core/src/main/java/no/nordicsemi/kotlin/mesh/core/model/Node.kt index a269652bc..891c2ba0a 100644 --- a/mesh/core/src/main/java/no/nordicsemi/kotlin/mesh/core/model/Node.kt +++ b/mesh/core/src/main/java/no/nordicsemi/kotlin/mesh/core/model/Node.kt @@ -215,8 +215,8 @@ data class Node internal constructor( network?.updateTimestamp() } - val primaryElement: Element? - get() = elements.firstOrNull() + val primaryElement: Element + get() = elements.first() val elementsCount: Int get() = elements.size diff --git a/mesh/core/src/main/java/no/nordicsemi/kotlin/mesh/core/model/Provisioner.kt b/mesh/core/src/main/java/no/nordicsemi/kotlin/mesh/core/model/Provisioner.kt index 06e3e6959..6bc0d3b5b 100644 --- a/mesh/core/src/main/java/no/nordicsemi/kotlin/mesh/core/model/Provisioner.kt +++ b/mesh/core/src/main/java/no/nordicsemi/kotlin/mesh/core/model/Provisioner.kt @@ -32,6 +32,7 @@ import kotlin.uuid.Uuid * capabilities. * @property isLocal Returns true if the Provisioner is set as the local * Provisioner. + * @property * * @constructor Creates a Provisioner object. */ @@ -83,6 +84,21 @@ data class Provisioner internal constructor( val isLocal: Boolean get() = network?.provisioners?.firstOrNull { it.uuid == uuid } != null + val otherUnicastRanges: List + get() = network?.provisioners + ?.filter { it != this } + ?.flatMap { it._allocatedUnicastRanges } ?: emptyList() + + val otherGroupRanges: List + get() = network?.provisioners + ?.filter { it != this } + ?.flatMap { it._allocatedGroupRanges } ?: emptyList() + + val otherSceneRanges: List + get() = network?.provisioners + ?.filter { it != this } + ?.flatMap { it._allocatedSceneRanges } ?: emptyList() + @Transient internal var network: MeshNetwork? = null @@ -166,9 +182,28 @@ data class Provisioner internal constructor( /** * Allocates a list of ranges to a given provisioner. + * + * @param ranges List of ranges to be allocated. + * @throws OverlappingProvisionerRanges if any of the given ranges overlap with another + * provisioner's allocated ranges. */ + @Throws(OverlappingProvisionerRanges::class, RangeAlreadyAllocated::class) fun allocate(ranges: List) { - ranges.forEach { allocate(it) } + when (ranges.firstOrNull()) { + is UnicastRange -> if (ranges.overlaps(otherUnicastRanges)) + throw OverlappingProvisionerRanges() + + is GroupRange -> if (ranges.overlaps(otherGroupRanges)) + throw OverlappingProvisionerRanges() + + is SceneRange -> if (ranges.overlaps(otherSceneRanges)) + throw OverlappingProvisionerRanges() + + else -> return + } + ranges.forEach { + allocate(range = it) + } } /** @@ -182,11 +217,9 @@ data class Provisioner internal constructor( // If the provisioner is not a part of network we don't have to validate for overlapping // unicast ranges. This will be validated when the provisioner is added to the network. network?.let { network -> - require( - network.provisioners - .filter { it.uuid != uuid } - .none { it._allocatedUnicastRanges.overlaps(range) } - ) { throw OverlappingProvisionerRanges() } + require(!range.overlaps(otherRanges = otherSceneRanges)) { + throw OverlappingProvisionerRanges() + } require(!hasAllocatedRange(range)) { throw RangeAlreadyAllocated() } _allocatedUnicastRanges.add(range).also { network.updateTimestamp() } } ?: run { @@ -205,10 +238,7 @@ data class Provisioner internal constructor( // If the provisioner is not a part of network we don't have to validate for overlapping // group ranges. This will be validated when the provisioner is added to the network. network?.let { network -> - require( - network.provisioners - .filter { it.uuid != uuid } - .none { it._allocatedGroupRanges.overlaps(range) }) { + require(!range.overlaps(otherRanges = otherGroupRanges)) { throw OverlappingProvisionerRanges() } require(!hasAllocatedRange(range)) { throw RangeAlreadyAllocated() } @@ -227,10 +257,7 @@ data class Provisioner internal constructor( // If the provisioner is not a part of network we don't have to validate for overlapping // scene ranges. This will be validated when the provisioner is added to the network. network?.let { network -> - require( - network.provisioners - .filter { it.uuid != uuid } - .none { it._allocatedSceneRanges.overlaps(range) }) { + require(!range.overlaps(otherRanges = otherSceneRanges)) { throw OverlappingProvisionerRanges() } require(!hasAllocatedRange(range)) { throw RangeAlreadyAllocated() } @@ -260,7 +287,7 @@ data class Provisioner internal constructor( */ fun update(range: UnicastRange, newRange: UnicastRange) { _allocatedUnicastRanges.indexOf(range).takeIf { it != -1 }?.let { - _allocatedUnicastRanges[it] = newRange + allocate(range = newRange) } } @@ -272,7 +299,7 @@ data class Provisioner internal constructor( */ fun update(range: GroupRange, newRange: GroupRange) { _allocatedGroupRanges.indexOf(range).takeIf { it != -1 }?.let { - _allocatedGroupRanges[it] = newRange + allocate(range = newRange) } } @@ -284,7 +311,7 @@ data class Provisioner internal constructor( */ fun update(range: SceneRange, newRange: SceneRange) { _allocatedSceneRanges.indexOf(range).takeIf { it != -1 }?.let { - _allocatedSceneRanges[it] = newRange + allocate(range = newRange) } } diff --git a/mesh/core/src/main/java/no/nordicsemi/kotlin/mesh/core/model/Range.kt b/mesh/core/src/main/java/no/nordicsemi/kotlin/mesh/core/model/Range.kt index ba2ef5e4b..856db8290 100644 --- a/mesh/core/src/main/java/no/nordicsemi/kotlin/mesh/core/model/Range.kt +++ b/mesh/core/src/main/java/no/nordicsemi/kotlin/mesh/core/model/Range.kt @@ -451,7 +451,7 @@ fun List.merged(): List { val result = mutableListOf() var accumulator: Range? = null - for (range in sortedBy { it.low }) { + for (range in sortedWith { r0, r1 -> r0.low.compareTo(r1.low) }) { if (accumulator == null) { accumulator = range } diff --git a/settings.gradle.kts b/settings.gradle.kts index 2dd8129b2..ea24b9570 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -20,7 +20,7 @@ dependencyResolutionManagement { from("no.nordicsemi.android.gradle:version-catalog:2.11") } create("nordic") { - from("no.nordicsemi.android:version-catalog:2025.11.02") + from("no.nordicsemi.android:version-catalog:2025.12.00") } } } @@ -43,7 +43,6 @@ include(":feature:bind-app-keys") include(":feature:config-application-keys") include(":feature:provisioners") include(":feature:provisioning") -include(":feature:ranges") include(":feature:scenes") include(":feature:ivindex") include(":feature:scanner")