From ba9934ef4ff222440480c0af333560884c9b62d6 Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Fri, 3 Jul 2026 16:28:49 +0530 Subject: [PATCH 1/2] chore(examples): migrate LiveObjects example app to the path-based API The example app targeted the removed io.ably.lib.objects callback API (getRootAsync, createCounterAsync, LiveMapValue.asLiveCounter). Rewrite its bridge layer (Utils.kt) against the public path-based interfaces in io.ably.lib.liveobjects, entered via channel.object.get() which returns the root LiveMapPathObject once objects are SYNCED. PathObjects reference a location and re-resolve on every call, which simplifies the app considerably: - get-or-create is a single atomic publish: root.set(key, LiveMapValue.of(LiveCounter.create())) replaces the old free-floating create + link two-step - the root-map subscription that watched for "Reset all" replacing a counter (and rebound to the new instance) is gone entirely - a path subscription follows the replacement automatically - mutations call the CompletableFuture API directly, fire-and-forget: the path subscription refreshes Compose state on ack, so the *Coroutine wrapper extensions and their scope.launch call sites are removed UI, test tags, channel setup and the instrumentation tests are unchanged. Verified with :examples:assembleDebug, :examples:compileDebugAndroidTestKotlin and manually on an emulator. Co-Authored-By: Claude Fable 5 --- .../src/main/kotlin/com/ably/example/Utils.kt | 286 ++++++------------ .../ably/example/screen/ColorVotingScreen.kt | 30 +- .../example/screen/TaskManagementScreen.kt | 36 +-- 3 files changed, 116 insertions(+), 236 deletions(-) diff --git a/examples/src/main/kotlin/com/ably/example/Utils.kt b/examples/src/main/kotlin/com/ably/example/Utils.kt index 70838c3c3..5e056e389 100644 --- a/examples/src/main/kotlin/com/ably/example/Utils.kt +++ b/examples/src/main/kotlin/com/ably/example/Utils.kt @@ -1,211 +1,108 @@ +/** + * The LiveObjects bridge layer for the example app: Compose observers plus coroutine + * wrappers over the path-based API (`io.ably.lib.liveobjects`). + * + * The app works exclusively with [io.ably.lib.liveobjects.path.PathObject]s — references + * to a *location* in the channel's objects graph, not to a particular object. A PathObject + * resolves its path lazily on every call, so a stored reference stays valid even when the + * object at that location is replaced (e.g. "Reset all" swaps in brand-new counters), and + * a path subscription automatically observes whatever object currently lives there. The + * identity-bound alternative, [io.ably.lib.liveobjects.instance.Instance] (obtained via + * `pathObject.instance()`), tracks one specific object wherever it moves — not needed here, + * since every screen wants location semantics. + * + * ably-java's typed flavour of the API: the base PathObject exposes only type-agnostic + * methods; type-specific reads/writes live on sub-type views reached via `as*` casts + * (`asLiveCounter()`, `asLiveMap()`, `asString()`, ...). The casts never throw — a + * wrong-typed view degrades gracefully (reads return null/empty, writes fail with an + * AblyException). The root obtained from `channel.object.get()` is already a + * [LiveMapPathObject], so no cast is needed there. + */ package com.ably.example -import androidx.compose.runtime.* -import io.ably.lib.objects.ObjectsCallback -import io.ably.lib.objects.RealtimeObjects -import io.ably.lib.objects.type.counter.LiveCounter -import io.ably.lib.objects.type.counter.LiveCounterUpdate -import io.ably.lib.objects.type.map.LiveMap -import io.ably.lib.objects.type.map.LiveMapUpdate -import io.ably.lib.objects.type.map.LiveMapUpdate.Change.UPDATED -import io.ably.lib.objects.type.map.LiveMapValue +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import io.ably.lib.liveobjects.path.types.LiveCounterPathObject +import io.ably.lib.liveobjects.path.types.LiveMapPathObject +import io.ably.lib.liveobjects.value.LiveCounter +import io.ably.lib.liveobjects.value.LiveMap +import io.ably.lib.liveobjects.value.LiveMapValue import io.ably.lib.realtime.AblyRealtime import io.ably.lib.realtime.Channel import io.ably.lib.realtime.ChannelState import io.ably.lib.realtime.ChannelStateListener -import io.ably.lib.types.AblyException import io.ably.lib.types.ChannelMode import io.ably.lib.types.ChannelOptions -import io.ably.lib.types.ErrorInfo -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.launch -import kotlinx.coroutines.suspendCancellableCoroutine -import kotlin.coroutines.resume - -private suspend fun RealtimeObjects.getRootCoroutines(): LiveMap = suspendCancellableCoroutine { continuation -> - getRootAsync(object : ObjectsCallback { - override fun onSuccess(result: LiveMap?) { - continuation.resume(result!!) - } - - override fun onError(exception: AblyException?) { - continuation.cancel(exception) - } - }) +import kotlinx.coroutines.future.await + +/** + * Returns the counter path object at [key] under [root], creating and linking a fresh + * zero-value counter when nothing exists at that path yet. Path objects re-resolve on + * every call, so the returned reference stays valid even if another client replaces the + * counter object at this key. + */ +suspend fun getOrCreateCounter(root: LiveMapPathObject, key: String): LiveCounterPathObject { + val path = root.get(key) + if (!path.exists()) { + // Creates the counter and links it under root in a single published operation + root.set(key, LiveMapValue.of(LiveCounter.create())).await() + } + return path.asLiveCounter() } -private suspend fun RealtimeObjects.createCounterCoroutine(): LiveCounter = - suspendCancellableCoroutine { continuation -> - createCounterAsync(object : ObjectsCallback { - override fun onSuccess(result: LiveCounter?) { - continuation.resume(result!!) - } - - override fun onError(exception: AblyException?) { - continuation.cancel(exception) - } - }) - } - -private suspend fun RealtimeObjects.createMapCoroutine(): LiveMap = suspendCancellableCoroutine { continuation -> - createMapAsync(object : ObjectsCallback { - override fun onSuccess(result: LiveMap?) { - continuation.resume(result!!) - } - - override fun onError(exception: AblyException?) { - continuation.cancel(exception) - } - }) -} - -suspend fun LiveCounter.incrementCoroutine(amount: Int): Unit = supressCoroutineExceptions { - suspendCancellableCoroutine { continuation -> - incrementAsync(amount, object : ObjectsCallback { - override fun onSuccess(result: Void?) { - continuation.resume(Unit) - } - - override fun onError(exception: AblyException?) { - continuation.cancel(exception) - } - }) - } -} - -suspend fun LiveCounter.decrementCoroutine(amount: Int): Unit = supressCoroutineExceptions { - suspendCancellableCoroutine { continuation -> - decrementAsync(amount, object : ObjectsCallback { - override fun onSuccess(result: Void?) { - continuation.resume(Unit) - } - - override fun onError(exception: AblyException?) { - continuation.cancel(exception) - } - }) - } -} - -suspend fun Channel.updateOptions(options: ChannelOptions): Unit = supressCoroutineExceptions { - suspendCancellableCoroutine { continuation -> - setOptions(options, object : io.ably.lib.realtime.CompletionListener { - override fun onSuccess() { - continuation.resume(Unit) - } - - override fun onError(reason: ErrorInfo?) { - continuation.cancel(AblyException.fromErrorInfo(reason)) - } - }) - } -} - -suspend fun getOrCreateCounter(channel: Channel, root: LiveMap?, path: String): LiveCounter { - val mapValue = root?.get(path) - if (mapValue == null) { - val counter = channel.objects.createCounterCoroutine() - root?.setCoroutine(path, LiveMapValue.of(counter)) - return counter - } else { - return mapValue.asLiveCounter - } -} - -suspend fun getOrCreateMap(channel: Channel, root: LiveMap?, path: String): LiveMap { - val mapValue = root?.get(path) - if (mapValue == null) { - val map = channel.objects.createMapCoroutine() - root?.setCoroutine(path, LiveMapValue.of(map)) - return map - } else { - return mapValue.asLiveMap - } -} - -suspend fun LiveMap.setCoroutine(key: String, value: LiveMapValue) = supressCoroutineExceptions { - suspendCancellableCoroutine { continuation -> - setAsync(key, value, object : ObjectsCallback { - override fun onSuccess(result: Void?) { - continuation.resume(Unit) - } - - override fun onError(exception: AblyException?) { - continuation.cancel(exception) - } - }) - } -} - -suspend fun LiveMap.removeCoroutine(key: String) = supressCoroutineExceptions { - suspendCancellableCoroutine { continuation -> - removeAsync(key, object : ObjectsCallback { - override fun onSuccess(result: Void?) { - continuation.resume(Unit) - } - - override fun onError(exception: AblyException?) { - continuation.cancel(exception) - } - }) +/** + * Returns the map path object at [key] under [root], creating and linking a fresh empty + * map when nothing exists at that path yet. + */ +suspend fun getOrCreateMap(root: LiveMapPathObject, key: String): LiveMapPathObject { + val path = root.get(key) + if (!path.exists()) { + root.set(key, LiveMapValue.of(LiveMap.create())).await() } + return path.asLiveMap() } @Composable -fun observeCounter(channel: Channel, root: LiveMap?, path: String): CounterState { - var counter by remember { mutableStateOf(null) } +fun observeCounter(root: LiveMapPathObject?, key: String): CounterState { + var counter by remember { mutableStateOf(null) } var counterValue by remember { mutableStateOf(null) } LaunchedEffect(root) { - supressCoroutineExceptions { - counter = getOrCreateCounter(channel, root, path) + root?.let { + supressCoroutineExceptions { + counter = getOrCreateCounter(it, key) + } } } DisposableEffect(counter) { counterValue = counter?.value()?.toInt() - val listener: (LiveCounterUpdate) -> Unit = { - counter?.value()?.let { - counterValue = it.toInt() - } - } - - counter?.subscribe(listener) - - onDispose { - counter?.unsubscribe(listener) - } - } - - DisposableEffect(root) { - val listener: (LiveMapUpdate) -> Unit = { rootUpdate -> - val counterHasBeenRemoved = rootUpdate.update - .filter { (_, change) -> change == UPDATED } - .any { (keyName) -> keyName == path } - - if (counterHasBeenRemoved) root?.get(path)?.asLiveCounter?.let { counter = it } + // The path subscription fires both for increments on the counter and for the key + // being replaced with a new counter (e.g. "Reset all" on another device); the path + // re-resolves on read, so no explicit rebinding is needed. + val subscription = counter?.subscribe { + counterValue = counter?.value()?.toInt() } - root?.subscribe(listener) - onDispose { - root?.unsubscribe(listener) + subscription?.unsubscribe() } } return CounterState(counterValue, counter) { - coroutineScope { - launch { - counter = channel.objects.createCounterCoroutine().also { - root?.setCoroutine(path, LiveMapValue.of(it)) - } - } - } + // Reset by replacing the object at this key with a brand-new zero-value counter; + // fire-and-forget - the path subscription refreshes the displayed value on ack + root?.set(key, LiveMapValue.of(LiveCounter.create())) } } -data class CounterState(val value: Int?, val counter: LiveCounter?, val reset: suspend () -> Unit) +data class CounterState(val value: Int?, val counter: LiveCounterPathObject?, val reset: () -> Unit) @Composable fun observeChannelState(channel: Channel): ChannelState { @@ -227,31 +124,35 @@ fun observeChannelState(channel: Channel): ChannelState { } @Composable -fun observeMap(channel: Channel, root: LiveMap?, path: String): Pair, LiveMap?> { - var map by remember { mutableStateOf(null) } +fun observeMap(root: LiveMapPathObject?, key: String): Pair, LiveMapPathObject?> { + var map by remember { mutableStateOf(null) } var mapValue by remember { mutableStateOf>(mapOf()) } + fun readEntries(liveMap: LiveMapPathObject?): Map = + liveMap?.entries() + ?.mapNotNull { (entryKey, valuePath) -> valuePath.asString().value()?.let { entryKey to it } } + ?.toMap() + ?: mapOf() + LaunchedEffect(root) { - supressCoroutineExceptions { - map = getOrCreateMap(channel, root, path) + root?.let { + supressCoroutineExceptions { + map = getOrCreateMap(it, key) + } } } DisposableEffect(map) { - map?.entries()?.associate { (key, value) -> key to value.asString }?.let { - mapValue = it - } + mapValue = readEntries(map) - val listener: (LiveMapUpdate) -> Unit = { - map?.entries()?.associate { (key, value) -> key to value.asString }?.let { - mapValue = it - } + // Fires for every entry change on the map at this path (default subscription depth + // covers nested updates), after which entries are re-read from the resolved map. + val subscription = map?.subscribe { + mapValue = readEntries(map) } - map?.subscribe(listener) - onDispose { - map?.unsubscribe(listener) + subscription?.unsubscribe() } } @@ -259,14 +160,15 @@ fun observeMap(channel: Channel, root: LiveMap?, path: String): Pair(null) } var editingText by remember { mutableStateOf("") } - val scope = rememberCoroutineScope() - val channel = getRealtimeChannel(realtimeClient, "objects-live-map") val root = observeRootObject(channel) - val (taskIdToTask, liveTasks) = observeMap(channel, root, "tasks") + val (taskIdToTask, liveTasks) = observeMap(root, "tasks") val taskEntries = remember(taskIdToTask) { taskIdToTask.entries.sortedBy { it.key } @@ -82,11 +77,10 @@ fun TaskManagementScreen(realtimeClient: AblyRealtime) { Button( onClick = { if (taskText.isNotBlank()) { - scope.launch { - val taskId = "${System.currentTimeMillis()}_${Uuid.random().toHexString()}" - liveTasks?.setCoroutine(taskId, LiveMapValue.of(taskText.trim())) - taskText = "" - } + val taskId = "${System.currentTimeMillis()}_${Uuid.random().toHexString()}" + // Fire-and-forget: the map subscription refreshes the task list on ack + liveTasks?.set(taskId, LiveMapValue.of(taskText.trim())) + taskText = "" } }, modifier = Modifier.weight(1f) @@ -98,10 +92,8 @@ fun TaskManagementScreen(realtimeClient: AblyRealtime) { OutlinedButton( onClick = { - scope.launch { - taskIdToTask.forEach { task -> - liveTasks?.removeCoroutine(task.key) - } + taskIdToTask.forEach { task -> + liveTasks?.remove(task.key) } }, modifier = Modifier.weight(1f) @@ -157,20 +149,16 @@ fun TaskManagementScreen(realtimeClient: AblyRealtime) { editingText = task.value }, onSave = { - scope.launch { - liveTasks?.setCoroutine(task.key, LiveMapValue.of(editingText.trim())) - editingTaskId = null - editingText = "" - } + liveTasks?.set(task.key, LiveMapValue.of(editingText.trim())) + editingTaskId = null + editingText = "" }, onCancel = { editingTaskId = null editingText = "" }, onDelete = { - scope.launch { - liveTasks?.removeCoroutine(task.key) - } + liveTasks?.remove(task.key) } ) } From 4a173c85de3fa19c20e8a25283da14a90e977777 Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Fri, 3 Jul 2026 17:05:12 +0530 Subject: [PATCH 2/2] fix(examples): address review - type-safe get-or-create, sync status UI Review fixes for #1225: - getOrCreateCounter/getOrCreateMap now check the resolved type (getType() != LIVE_COUNTER/LIVE_MAP) instead of exists(), so a wrong-typed value at the key is replaced rather than returned as a view whose reads yield null and writes fail; the LWW resolution of the inherent check-then-set race is documented on both helpers - observeCounter/observeMap effects keyed on (root, key) so a changed key rebinds - Red vote card enable state now tracks redCounter (was greenCounter, a pre-existing copy/paste bug) - Add Task is disabled until the tasks map is bound, so input is never cleared without the task being enqueued - new ObjectsSyncStatusRow on both screens surfaces objects sync progress ("Objects syncing..." -> "Objects synced") via the channel.object.on(SYNCING/SYNCED) state API, with the initial state derived from root readiness since state events fire on transitions only Co-Authored-By: Claude Fable 5 --- .../src/main/kotlin/com/ably/example/Utils.kt | 46 ++++++++++--- .../ably/example/screen/ColorVotingScreen.kt | 4 +- .../ably/example/screen/ObjectsSyncStatus.kt | 67 +++++++++++++++++++ .../example/screen/TaskManagementScreen.kt | 5 ++ 4 files changed, 113 insertions(+), 9 deletions(-) create mode 100644 examples/src/main/kotlin/com/ably/example/screen/ObjectsSyncStatus.kt diff --git a/examples/src/main/kotlin/com/ably/example/Utils.kt b/examples/src/main/kotlin/com/ably/example/Utils.kt index 5e056e389..2a8c20c2b 100644 --- a/examples/src/main/kotlin/com/ably/example/Utils.kt +++ b/examples/src/main/kotlin/com/ably/example/Utils.kt @@ -27,8 +27,10 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import io.ably.lib.liveobjects.ValueType import io.ably.lib.liveobjects.path.types.LiveCounterPathObject import io.ably.lib.liveobjects.path.types.LiveMapPathObject +import io.ably.lib.liveobjects.state.ObjectStateEvent import io.ably.lib.liveobjects.value.LiveCounter import io.ably.lib.liveobjects.value.LiveMap import io.ably.lib.liveobjects.value.LiveMapValue @@ -42,13 +44,17 @@ import kotlinx.coroutines.future.await /** * Returns the counter path object at [key] under [root], creating and linking a fresh - * zero-value counter when nothing exists at that path yet. Path objects re-resolve on - * every call, so the returned reference stays valid even if another client replaces the - * counter object at this key. + * zero-value counter when the path is missing or holds a non-counter value. Path objects + * re-resolve on every call, so the returned reference stays valid even if another client + * replaces the counter object at this key. + * + * The check-then-set is not atomic: another client can create the same key in between. + * That is fine here - the colliding operations converge deterministically via + * last-write-wins, and both sides end up bound to whichever counter won. */ suspend fun getOrCreateCounter(root: LiveMapPathObject, key: String): LiveCounterPathObject { val path = root.get(key) - if (!path.exists()) { + if (path.type != ValueType.LIVE_COUNTER) { // Creates the counter and links it under root in a single published operation root.set(key, LiveMapValue.of(LiveCounter.create())).await() } @@ -57,11 +63,12 @@ suspend fun getOrCreateCounter(root: LiveMapPathObject, key: String): LiveCounte /** * Returns the map path object at [key] under [root], creating and linking a fresh empty - * map when nothing exists at that path yet. + * map when the path is missing or holds a non-map value. Same last-write-wins caveat as + * [getOrCreateCounter]. */ suspend fun getOrCreateMap(root: LiveMapPathObject, key: String): LiveMapPathObject { val path = root.get(key) - if (!path.exists()) { + if (path.type != ValueType.LIVE_MAP) { root.set(key, LiveMapValue.of(LiveMap.create())).await() } return path.asLiveMap() @@ -72,7 +79,7 @@ fun observeCounter(root: LiveMapPathObject?, key: String): CounterState { var counter by remember { mutableStateOf(null) } var counterValue by remember { mutableStateOf(null) } - LaunchedEffect(root) { + LaunchedEffect(root, key) { root?.let { supressCoroutineExceptions { counter = getOrCreateCounter(it, key) @@ -134,7 +141,7 @@ fun observeMap(root: LiveMapPathObject?, key: String): Pair, ?.toMap() ?: mapOf() - LaunchedEffect(root) { + LaunchedEffect(root, key) { root?.let { supressCoroutineExceptions { map = getOrCreateMap(it, key) @@ -159,6 +166,29 @@ fun observeMap(root: LiveMapPathObject?, key: String): Pair, return mapValue to map } +/** + * Observes the channel's objects synchronization state via `channel.object.on(event)`. + * Returns the most recent [ObjectStateEvent], or null before any event has been received + * (events fire on transitions only, so an already-completed sync emits nothing). + */ +@Composable +fun observeObjectsSyncState(channel: Channel): ObjectStateEvent? { + var syncState by remember { mutableStateOf(null) } + + DisposableEffect(channel) { + // There is no wildcard subscription - register one listener per event + val subscriptions = listOf(ObjectStateEvent.SYNCING, ObjectStateEvent.SYNCED).map { event -> + channel.`object`.on(event) { stateEvent -> syncState = stateEvent } + } + + onDispose { + subscriptions.forEach { it.unsubscribe() } + } + } + + return syncState +} + @Composable fun observeRootObject(channel: Channel): LiveMapPathObject? { val channelState = observeChannelState(channel) diff --git a/examples/src/main/kotlin/com/ably/example/screen/ColorVotingScreen.kt b/examples/src/main/kotlin/com/ably/example/screen/ColorVotingScreen.kt index a99d66ee6..6ea1d8c76 100644 --- a/examples/src/main/kotlin/com/ably/example/screen/ColorVotingScreen.kt +++ b/examples/src/main/kotlin/com/ably/example/screen/ColorVotingScreen.kt @@ -44,11 +44,13 @@ fun ColorVotingScreen(realtimeClient: AblyRealtime) { modifier = Modifier.padding(vertical = 16.dp) ) + ObjectsSyncStatusRow(channel, root) + ColorVoteCard( color = Color.Red, colorName = "Red", count = redCount ?: 0, - enabled = greenCounter != null, + enabled = redCounter != null, onVote = { // Fire-and-forget: the path subscription updates the displayed count on ack redCounter?.increment(1) diff --git a/examples/src/main/kotlin/com/ably/example/screen/ObjectsSyncStatus.kt b/examples/src/main/kotlin/com/ably/example/screen/ObjectsSyncStatus.kt new file mode 100644 index 000000000..19060902a --- /dev/null +++ b/examples/src/main/kotlin/com/ably/example/screen/ObjectsSyncStatus.kt @@ -0,0 +1,67 @@ +package com.ably.example.screen + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material3.CircularProgressIndicator +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.platform.testTag +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.ably.example.observeObjectsSyncState +import io.ably.lib.liveobjects.path.types.LiveMapPathObject +import io.ably.lib.liveobjects.state.ObjectStateEvent +import io.ably.lib.realtime.Channel + +/** + * Shows the channel's objects synchronization progress: a spinner with + * "Objects syncing..." while the initial sync (or a re-sync) is in flight, and a + * check mark with "Objects synced" once local state matches the channel. + * + * Sync-state events fire on transitions only, so a sync that completed before this + * composable subscribed emits nothing; a non-null [root] proves `channel.object.get()` + * has completed, which implies SYNCED. A later event (e.g. a re-sync after reattach) + * takes precedence over that inference. + */ +@Composable +fun ObjectsSyncStatusRow(channel: Channel, root: LiveMapPathObject?) { + val syncState = observeObjectsSyncState(channel) + val synced = syncState?.let { it == ObjectStateEvent.SYNCED } ?: (root != null) + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.testTag("objects_sync_status") + ) { + if (synced) { + Icon( + Icons.Default.CheckCircle, + contentDescription = "Objects synced", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(16.dp) + ) + Text( + text = "Objects synced", + fontSize = 12.sp, + color = MaterialTheme.colorScheme.primary + ) + } else { + CircularProgressIndicator( + modifier = Modifier.size(14.dp), + strokeWidth = 2.dp + ) + Text( + text = "Objects syncing...", + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} diff --git a/examples/src/main/kotlin/com/ably/example/screen/TaskManagementScreen.kt b/examples/src/main/kotlin/com/ably/example/screen/TaskManagementScreen.kt index 48928206a..a44ffdfd2 100644 --- a/examples/src/main/kotlin/com/ably/example/screen/TaskManagementScreen.kt +++ b/examples/src/main/kotlin/com/ably/example/screen/TaskManagementScreen.kt @@ -53,6 +53,8 @@ fun TaskManagementScreen(realtimeClient: AblyRealtime) { modifier = Modifier.fillMaxWidth() ) + ObjectsSyncStatusRow(channel, root) + Card( modifier = Modifier.fillMaxWidth(), elevation = CardDefaults.cardElevation(defaultElevation = 4.dp), @@ -75,6 +77,9 @@ fun TaskManagementScreen(realtimeClient: AblyRealtime) { horizontalArrangement = Arrangement.spacedBy(8.dp) ) { Button( + // Disabled until the tasks map is bound, so input is never cleared + // without the task actually being enqueued + enabled = liveTasks != null, onClick = { if (taskText.isNotBlank()) { val taskId = "${System.currentTimeMillis()}_${Uuid.random().toHexString()}"