Compare commits
2 Commits
a2b5880da9
...
13b80be884
Author | SHA1 | Date | |
---|---|---|---|
13b80be884 | |||
5c7d3d8a7a |
3
.gitignore
vendored
3
.gitignore
vendored
@ -9,4 +9,7 @@
|
||||
|
||||
out/
|
||||
build/
|
||||
|
||||
!gradle-wrapper.jar
|
||||
|
||||
/demo/device-collective/mapCache/
|
||||
|
@ -7,6 +7,7 @@
|
||||
- PLC4X bindings
|
||||
- Shortcuts to access all Controls devices in a magix network.
|
||||
- `DeviceClient` properly evaluates lifecycle and logs
|
||||
- `PeerConnection` API for direct device-device binary sharing
|
||||
|
||||
### Changed
|
||||
- Constructor properties return `DeviceState` in order to be able to subscribe to them
|
||||
|
@ -166,12 +166,18 @@ public data class ActionResultMessage(
|
||||
}
|
||||
|
||||
/**
|
||||
* Notifies listeners that a new binary with given [binaryID] is available. The binary itself could not be provided via [DeviceMessage] API.
|
||||
* Notifies listeners that a new binary with given [contentId] and [contentMeta] is available.
|
||||
*
|
||||
* [contentMeta] includes public information that could be shared with loop subscribers. It should not contain sensitive data.
|
||||
*
|
||||
* The binary itself could not be provided via [DeviceMessage] API.
|
||||
* [space.kscience.controls.peer.PeerConnection] must be used instead
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("binary.notification")
|
||||
public data class BinaryNotificationMessage(
|
||||
val binaryID: String,
|
||||
val contentId: String,
|
||||
val contentMeta: Meta,
|
||||
override val sourceDevice: Name,
|
||||
override val targetDevice: Name? = null,
|
||||
override val comment: String? = null,
|
||||
|
@ -0,0 +1,42 @@
|
||||
package space.kscience.controls.peer
|
||||
|
||||
import space.kscience.dataforge.io.Envelope
|
||||
import space.kscience.dataforge.meta.Meta
|
||||
import space.kscience.dataforge.names.Name
|
||||
|
||||
/**
|
||||
* A manager that allows direct synchronous sending and receiving binary data
|
||||
*/
|
||||
public interface PeerConnection {
|
||||
/**
|
||||
* Receive an [Envelope] from a device with name [deviceName] on a given [address] with given [contentId].
|
||||
*
|
||||
* The address depends on the specifics of given [PeerConnection]. For example, it could be a TCP/IP port or
|
||||
* magix endpoint name.
|
||||
*
|
||||
* Depending on [PeerConnection] implementation, the resulting [Envelope] could be lazy loaded
|
||||
*
|
||||
* Additional metadata in [requestMeta] could be required for authentication.
|
||||
*/
|
||||
public suspend fun receive(
|
||||
address: String,
|
||||
deviceName: Name,
|
||||
contentId: String,
|
||||
requestMeta: Meta = Meta.EMPTY,
|
||||
): Envelope
|
||||
|
||||
/**
|
||||
* Send an [envelope] to a device with name [deviceName] on a given [address]
|
||||
*
|
||||
* The address depends on the specifics of given [PeerConnection]. For example, it could be a TCP/IP port or
|
||||
* magix endpoint name.
|
||||
*
|
||||
* Additional metadata in [requestMeta] could be required for authentication.
|
||||
*/
|
||||
public suspend fun send(
|
||||
address: String,
|
||||
deviceName: Name,
|
||||
envelope: Envelope,
|
||||
requestMeta: Meta = Meta.EMPTY,
|
||||
)
|
||||
}
|
12
controls-visualisation-compose/src/commonMain/kotlin/misc.kt
Normal file
12
controls-visualisation-compose/src/commonMain/kotlin/misc.kt
Normal file
@ -0,0 +1,12 @@
|
||||
package space.kscience.controls.compose
|
||||
|
||||
import androidx.compose.ui.Modifier
|
||||
|
||||
public inline fun Modifier.conditional(
|
||||
condition: Boolean,
|
||||
modifier: Modifier.() -> Modifier,
|
||||
): Modifier = if (condition) {
|
||||
then(modifier(Modifier))
|
||||
} else {
|
||||
this
|
||||
}
|
@ -15,6 +15,7 @@ import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.isActive
|
||||
import space.kscience.controls.constructor.*
|
||||
import space.kscience.controls.constructor.devices.LimitSwitch
|
||||
import space.kscience.controls.constructor.devices.StepDrive
|
||||
import space.kscience.controls.constructor.devices.angle
|
||||
import space.kscience.controls.constructor.models.Leadscrew
|
||||
@ -31,10 +32,18 @@ private class Plotter(
|
||||
context: Context,
|
||||
xDrive: StepDrive,
|
||||
yDrive: StepDrive,
|
||||
xStartLimit: LimitSwitch,
|
||||
xEndLimit: LimitSwitch,
|
||||
yStartLimit: LimitSwitch,
|
||||
yEndLimit: LimitSwitch,
|
||||
val paint: suspend (Color) -> Unit,
|
||||
) : DeviceConstructor(context) {
|
||||
val xDrive by device(xDrive)
|
||||
val yDrive by device(yDrive)
|
||||
val xStartLimit by device(xStartLimit)
|
||||
val xEndLimit by device(xEndLimit)
|
||||
val yStartLimit by device(yStartLimit)
|
||||
val yEndLimit by device(yEndLimit)
|
||||
|
||||
public fun moveToXY(x: Number, y: Number) {
|
||||
xDrive.target.value = x.toLong()
|
||||
@ -108,7 +117,15 @@ private class PlotterModel(
|
||||
|
||||
val xy: DeviceState<XY<Meters>> = combineState(x, y) { x, y -> XY(x, y) }
|
||||
|
||||
val plotter = Plotter(context, xDrive, yDrive) { color ->
|
||||
val plotter = Plotter(
|
||||
context = context,
|
||||
xDrive = xDrive,
|
||||
yDrive = yDrive,
|
||||
xStartLimit = LimitSwitch(context,x.atStart),
|
||||
xEndLimit = LimitSwitch(context,x.atEnd),
|
||||
yStartLimit = LimitSwitch(context,x.atStart),
|
||||
yEndLimit = LimitSwitch(context,x.atEnd),
|
||||
) { color ->
|
||||
println("Point X: ${x.value.value}, Y: ${y.value.value}, color: $color")
|
||||
callback(PlotterPoint(x.value, y.value, color))
|
||||
}
|
||||
|
@ -37,6 +37,6 @@ kotlin.explicitApi = ExplicitApiMode.Disabled
|
||||
|
||||
compose.desktop {
|
||||
application {
|
||||
mainClass = "space.kscience.controls.demo.map.MainKt"
|
||||
mainClass = "space.kscience.controls.demo.collective.MainKt"
|
||||
}
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
@file:OptIn(DFExperimental::class)
|
||||
|
||||
package space.kscience.controls.demo.map
|
||||
package space.kscience.controls.demo.collective
|
||||
|
||||
import space.kscience.controls.api.Device
|
||||
import space.kscience.controls.constructor.DeviceConstructor
|
||||
@ -10,17 +10,20 @@ import space.kscience.controls.spec.DeviceSpec
|
||||
import space.kscience.dataforge.context.Context
|
||||
import space.kscience.dataforge.meta.MetaConverter
|
||||
import space.kscience.dataforge.meta.Scheme
|
||||
import space.kscience.dataforge.meta.SchemeSpec
|
||||
import space.kscience.dataforge.meta.string
|
||||
import space.kscience.dataforge.misc.DFExperimental
|
||||
import space.kscience.maps.coordinates.Gmc
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
class RemoteDeviceConfiguration : Scheme() {
|
||||
companion object : SchemeSpec<RemoteDeviceConfiguration>(::RemoteDeviceConfiguration)
|
||||
class CollectiveDeviceConfiguration(deviceId: DeviceId) : Scheme() {
|
||||
var deviceId by string(deviceId)
|
||||
var description by string()
|
||||
}
|
||||
|
||||
|
||||
interface RemoteDevice : Device {
|
||||
interface CollectiveDevice : Device {
|
||||
|
||||
public val id: DeviceId
|
||||
|
||||
suspend fun getPosition(): Gmc
|
||||
|
||||
@ -28,8 +31,10 @@ interface RemoteDevice : Device {
|
||||
|
||||
suspend fun setVelocity(value: GmcVelocity)
|
||||
|
||||
suspend fun listVisible(): Collection<DeviceId>
|
||||
|
||||
companion object : DeviceSpec<RemoteDevice>() {
|
||||
|
||||
companion object : DeviceSpec<CollectiveDevice>() {
|
||||
val position by property<Gmc>(
|
||||
converter = MetaConverter.serializable(),
|
||||
read = { getPosition() }
|
||||
@ -44,15 +49,18 @@ interface RemoteDevice : Device {
|
||||
}
|
||||
|
||||
|
||||
class RemoteDeviceConstructor(
|
||||
class CollectiveDeviceConstructor(
|
||||
context: Context,
|
||||
val configuration: RemoteDeviceConfiguration,
|
||||
val configuration: CollectiveDeviceConfiguration,
|
||||
position: MutableDeviceState<Gmc>,
|
||||
velocity: MutableDeviceState<GmcVelocity>,
|
||||
) : DeviceConstructor(context, configuration.meta), RemoteDevice {
|
||||
private val listVisible: suspend () -> Collection<DeviceId>,
|
||||
) : DeviceConstructor(context, configuration.meta), CollectiveDevice {
|
||||
|
||||
val position = registerAsProperty(RemoteDevice.position, position.debounce(500.milliseconds))
|
||||
val velocity = registerAsProperty(RemoteDevice.velocity, velocity)
|
||||
override val id: DeviceId get() = configuration.deviceId
|
||||
|
||||
val position = registerAsProperty(CollectiveDevice.position, position.sample(500.milliseconds))
|
||||
val velocity = registerAsProperty(CollectiveDevice.velocity, velocity)
|
||||
|
||||
override suspend fun getPosition(): Gmc = position.value
|
||||
|
||||
@ -61,4 +69,6 @@ class RemoteDeviceConstructor(
|
||||
override suspend fun setVelocity(value: GmcVelocity) {
|
||||
velocity.value = value
|
||||
}
|
||||
|
||||
override suspend fun listVisible(): Collection<DeviceId> = listVisible.invoke()
|
||||
}
|
@ -1,43 +1,59 @@
|
||||
package space.kscience.controls.demo.map
|
||||
package space.kscience.controls.demo.collective
|
||||
|
||||
import space.kscience.controls.constructor.ModelConstructor
|
||||
import space.kscience.controls.constructor.MutableDeviceState
|
||||
import space.kscience.controls.constructor.onTimer
|
||||
import space.kscience.dataforge.context.Context
|
||||
import space.kscience.maps.coordinates.Gmc
|
||||
import space.kscience.maps.coordinates.*
|
||||
|
||||
|
||||
typealias RemoteDeviceId = String
|
||||
typealias DeviceId = String
|
||||
|
||||
|
||||
data class RemoteDeviceState(
|
||||
val id: RemoteDeviceId,
|
||||
val configuration: RemoteDeviceConfiguration,
|
||||
internal data class VirtualDeviceState(
|
||||
val id: DeviceId,
|
||||
val configuration: CollectiveDeviceConfiguration,
|
||||
val position: MutableDeviceState<Gmc>,
|
||||
val velocity: MutableDeviceState<GmcVelocity>,
|
||||
)
|
||||
|
||||
public fun RemoteDeviceState(
|
||||
id: RemoteDeviceId,
|
||||
internal fun VirtualDeviceState(
|
||||
id: DeviceId,
|
||||
position: Gmc,
|
||||
configuration: RemoteDeviceConfiguration.() -> Unit = {},
|
||||
) = RemoteDeviceState(
|
||||
configuration: CollectiveDeviceConfiguration.() -> Unit = {},
|
||||
) = VirtualDeviceState(
|
||||
id,
|
||||
RemoteDeviceConfiguration(configuration),
|
||||
CollectiveDeviceConfiguration(id).apply(configuration),
|
||||
MutableDeviceState(position),
|
||||
MutableDeviceState(GmcVelocity.zero)
|
||||
)
|
||||
|
||||
|
||||
class DeviceCollectiveModel(
|
||||
internal class DeviceCollectiveModel(
|
||||
context: Context,
|
||||
val deviceStates: Collection<RemoteDeviceState>,
|
||||
val deviceStates: Collection<VirtualDeviceState>,
|
||||
val visibilityRange: Distance,
|
||||
) : ModelConstructor(context) {
|
||||
|
||||
/**
|
||||
* Propagate movement
|
||||
*/
|
||||
private val movement = onTimer { prev, next ->
|
||||
val delta = (next - prev)
|
||||
deviceStates.forEach { state ->
|
||||
state.position.value = state.position.value.moveWith(state.velocity.value, delta)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun locateVisible(id: DeviceId): Map<DeviceId, GmcCurve> {
|
||||
val coordinatesSnapshot = deviceStates.associate { it.id to it.position.value }
|
||||
|
||||
val selected = coordinatesSnapshot[id] ?: error("Can't find device with id $id")
|
||||
|
||||
val allCurves = coordinatesSnapshot
|
||||
.filterKeys { it != id }
|
||||
.mapValues { GeoEllipsoid.WGS84.curveBetween(selected, it.value) }
|
||||
|
||||
return allCurves.filterValues { it.distance in 0.kilometers..visibilityRange }
|
||||
}
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
package space.kscience.controls.demo.map
|
||||
package space.kscience.controls.demo.collective
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import space.kscience.kmath.geometry.Angle
|
||||
|
@ -1,4 +1,4 @@
|
||||
package space.kscience.controls.demo.map
|
||||
package space.kscience.controls.demo.collective
|
||||
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
@ -7,7 +7,7 @@ import space.kscience.controls.constructor.DeviceState
|
||||
import kotlin.time.Duration
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
class DebounceDeviceState<T>(
|
||||
class SampleDeviceState<T>(
|
||||
val origin: DeviceState<T>,
|
||||
val interval: Duration,
|
||||
) : DeviceState<T> {
|
||||
@ -17,4 +17,4 @@ class DebounceDeviceState<T>(
|
||||
override fun toString(): String = "DebounceDeviceState($value, interval=$interval)"
|
||||
}
|
||||
|
||||
fun <T> DeviceState<T>.debounce(interval: Duration) = DebounceDeviceState(this, interval)
|
||||
fun <T> DeviceState<T>.sample(interval: Duration) = SampleDeviceState(this, interval)
|
@ -1,4 +1,4 @@
|
||||
package space.kscience.controls.demo.map
|
||||
package space.kscience.controls.demo.collective
|
||||
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
@ -20,31 +20,32 @@ private val radius = 0.01.degrees
|
||||
|
||||
|
||||
internal fun generateModel(context: Context): DeviceCollectiveModel {
|
||||
val devices: List<RemoteDeviceState> = buildList {
|
||||
repeat(100) {
|
||||
add(
|
||||
RemoteDeviceState(
|
||||
"device[$it]",
|
||||
val devices: List<VirtualDeviceState> = List(100) { index ->
|
||||
val id = "device[$index]"
|
||||
|
||||
VirtualDeviceState(
|
||||
id = id,
|
||||
Gmc(
|
||||
center.latitude + radius * Random.nextDouble(),
|
||||
center.longitude + radius * Random.nextDouble()
|
||||
)
|
||||
)
|
||||
)
|
||||
) {
|
||||
deviceId = id
|
||||
description = "Virtual remote device $id"
|
||||
}
|
||||
}
|
||||
|
||||
val model = DeviceCollectiveModel(context, devices)
|
||||
val model = DeviceCollectiveModel(context, devices, 0.2.kilometers)
|
||||
|
||||
return model
|
||||
}
|
||||
|
||||
fun RemoteDevice.moveInCircles(): Job = launch {
|
||||
fun CollectiveDevice.moveInCircles(): Job = launch {
|
||||
var bearing = Random.nextDouble(-PI, PI).radians
|
||||
write(RemoteDevice.velocity, GmcVelocity(bearing, deviceVelocity))
|
||||
write(CollectiveDevice.velocity, GmcVelocity(bearing, deviceVelocity))
|
||||
while (isActive) {
|
||||
delay(500)
|
||||
bearing += 5.degrees
|
||||
write(RemoteDevice.velocity, GmcVelocity(bearing, deviceVelocity))
|
||||
write(CollectiveDevice.velocity, GmcVelocity(bearing, deviceVelocity))
|
||||
}
|
||||
}
|
@ -1,27 +1,42 @@
|
||||
package space.kscience.controls.demo.map
|
||||
@file:OptIn(ExperimentalFoundationApi::class, ExperimentalSplitPaneApi::class)
|
||||
|
||||
package space.kscience.controls.demo.collective
|
||||
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.Card
|
||||
import androidx.compose.material.Checkbox
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Window
|
||||
import androidx.compose.ui.window.application
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.engine.cio.CIO
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import org.jetbrains.compose.splitpane.ExperimentalSplitPaneApi
|
||||
import org.jetbrains.compose.splitpane.HorizontalSplitPane
|
||||
import org.jetbrains.compose.splitpane.rememberSplitPaneState
|
||||
import space.kscience.controls.compose.conditional
|
||||
import space.kscience.controls.manager.DeviceManager
|
||||
import space.kscience.controls.spec.propertyFlow
|
||||
import space.kscience.controls.spec.useProperty
|
||||
import space.kscience.dataforge.context.Context
|
||||
import space.kscience.dataforge.context.request
|
||||
import space.kscience.maps.compose.MapView
|
||||
import space.kscience.maps.compose.OpenStreetMapTileProvider
|
||||
import space.kscience.maps.features.ViewConfig
|
||||
import space.kscience.maps.features.circle
|
||||
import space.kscience.maps.features.color
|
||||
import space.kscience.maps.features.rectangle
|
||||
import space.kscience.maps.features.*
|
||||
import java.nio.file.Path
|
||||
|
||||
|
||||
@ -34,7 +49,6 @@ fun rememberDeviceManager(): DeviceManager = remember {
|
||||
context.request(DeviceManager)
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
fun App() {
|
||||
val scope = rememberCoroutineScope()
|
||||
@ -47,14 +61,24 @@ fun App() {
|
||||
generateModel(deviceManager.context)
|
||||
}
|
||||
|
||||
val devices: Map<RemoteDeviceId, RemoteDevice> = remember {
|
||||
val devices: Map<DeviceId, CollectiveDevice> = remember {
|
||||
collectiveModel.deviceStates.associate {
|
||||
val device = RemoteDeviceConstructor(deviceManager.context, it.configuration, it.position, it.velocity)
|
||||
val device = CollectiveDeviceConstructor(
|
||||
context = deviceManager.context,
|
||||
configuration = it.configuration,
|
||||
position = it.position,
|
||||
velocity = it.velocity
|
||||
) {
|
||||
collectiveModel.locateVisible(it.id).keys
|
||||
}
|
||||
device.moveInCircles()
|
||||
it.id to device
|
||||
}
|
||||
}
|
||||
|
||||
var selectedDeviceId by remember { mutableStateOf<DeviceId?>(null) }
|
||||
var showOnlyVisible by remember { mutableStateOf(false) }
|
||||
|
||||
val mapTileProvider = remember {
|
||||
OpenStreetMapTileProvider(
|
||||
client = HttpClient(CIO),
|
||||
@ -62,6 +86,10 @@ fun App() {
|
||||
)
|
||||
}
|
||||
|
||||
HorizontalSplitPane(
|
||||
splitPaneState = rememberSplitPaneState(0.9f)
|
||||
) {
|
||||
first(400.dp) {
|
||||
MapView(
|
||||
mapTileProvider = mapTileProvider,
|
||||
config = ViewConfig()
|
||||
@ -69,14 +97,82 @@ fun App() {
|
||||
collectiveModel.deviceStates.forEach { device ->
|
||||
circle(device.position.value, id = device.id + ".position").color(Color.Red)
|
||||
device.position.valueFlow.onEach {
|
||||
circle(device.position.value, id = device.id + ".position").color(Color.Red)
|
||||
circle(device.position.value, id = device.id + ".position", size = 3.dp)
|
||||
.color(Color.Red)
|
||||
.modifyAttribute(AlphaAttribute, 0.5f) // does not work right now
|
||||
}.launchIn(scope)
|
||||
}
|
||||
|
||||
devices.forEach { (id, device) ->
|
||||
device.propertyFlow(RemoteDevice.position).onEach { position ->
|
||||
rectangle(position, id = id).color(Color.Blue)
|
||||
}.launchIn(scope)
|
||||
device.useProperty(CollectiveDevice.position, scope = scope) { position ->
|
||||
|
||||
val activeDevice = selectedDeviceId?.let { devices[it] }
|
||||
|
||||
if (activeDevice == null || id == selectedDeviceId || !showOnlyVisible || id in activeDevice.listVisible()) {
|
||||
rectangle(
|
||||
position,
|
||||
id = id,
|
||||
size = if (selectedDeviceId == id) DpSize(10.dp, 10.dp) else DpSize(5.dp, 5.dp)
|
||||
).color(if (selectedDeviceId == id) Color.Magenta else Color.Blue)
|
||||
.modifyAttribute(AlphaAttribute, if (selectedDeviceId == id) 1f else 0.5f)
|
||||
.onClick { selectedDeviceId = id }
|
||||
} else {
|
||||
removeFeature(id)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
second(200.dp) {
|
||||
Column {
|
||||
selectedDeviceId?.let { id ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(8.dp)
|
||||
.border(2.dp, Color.DarkGray)
|
||||
) {
|
||||
Card(
|
||||
elevation = 16.dp,
|
||||
) {
|
||||
Text(
|
||||
text = "Выбран: $id",
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(10.dp).fillMaxWidth()
|
||||
)
|
||||
}
|
||||
devices[id]?.let {
|
||||
Text(it.meta.toString(), Modifier.padding(10.dp))
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(10.dp).fillMaxWidth()) {
|
||||
Text("Показать только видимые")
|
||||
Checkbox(showOnlyVisible, { showOnlyVisible = it })
|
||||
}
|
||||
}
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
devices.forEach { (id, device) ->
|
||||
Card(
|
||||
elevation = 16.dp,
|
||||
modifier = Modifier.padding(8.dp).onClick {
|
||||
selectedDeviceId = id
|
||||
}.conditional(id == selectedDeviceId) {
|
||||
border(2.dp, Color.Blue)
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
text = id,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(10.dp).fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user