diff --git a/client/src/main/kotlin/io/spine/chords/client/Client.kt b/client/src/main/kotlin/io/spine/chords/client/Client.kt index 3d618727..72b46c89 100644 --- a/client/src/main/kotlin/io/spine/chords/client/Client.kt +++ b/client/src/main/kotlin/io/spine/chords/client/Client.kt @@ -1,5 +1,5 @@ /* - * Copyright 2024, TeamDev. All rights reserved. + * Copyright 2025, TeamDev. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -36,7 +36,8 @@ import io.spine.base.EventMessageField import io.spine.client.CompositeEntityStateFilter import io.spine.client.CompositeQueryFilter import io.spine.core.UserId -import java.util.concurrent.CompletableFuture +import kotlin.time.Duration +import kotlinx.coroutines.CoroutineScope /** * Provides an API for interacting with the application server. @@ -44,7 +45,8 @@ import java.util.concurrent.CompletableFuture public interface Client { /** - * The ID of the user on whose behalf this `Client` should send requests to the server. + * The ID of the user on whose behalf this `Client` should send requests to + * the server. */ public val userId: UserId? @@ -54,9 +56,10 @@ import java.util.concurrent.CompletableFuture * as well. * * @param entityClass A class of entities that should be read and observed. - * @param targetList A [MutableState] that contains a list whose content should be - * populated and kept up to date by this function. - * @param extractId A callback that should read the value of the entity's ID. + * @param targetList A [MutableState] that contains a list whose content + * should be populated and kept up to date by this function. + * @param extractId A callback that should read the value of + * the entity's ID. */ public fun readAndObserve( entityClass: Class, @@ -65,18 +68,23 @@ import java.util.concurrent.CompletableFuture ) /** - * Reads all entities of type [entityClass] that match the given [queryFilters] and invokes the - * [onNext] callback with the initial list of entities. Then sets up observation to receive - * future updates to the entities, filtering the observed updates using the provided [observeFilters]. - * Each time any entity that matches the [observeFilters] changes, the [onNext] callback - * will be invoked again with the updated list of entities. + * Reads all entities of type [entityClass] that match the given + * [queryFilters] and invokes the [onNext] callback with the initial list of + * entities. Then sets up observation to receive future updates to the + * entities, filtering the observed updates using the provided + * [observeFilters]. Each time any entity that matches the [observeFilters] + * changes, the [onNext] callback will be invoked again with the updated + * list of entities. * * @param entityClass A class of entities that should be read and observed. * @param extractId A callback that should read the value of the entity's ID. - * @param queryFilters Filters to apply when querying the initial list of entities. - * @param observeFilters Filters to apply when observing updates to the entities. - * @param onNext A callback function that is called with the list of entities after the initial - * query completes, and each time any of the observed entities is updated. + * @param queryFilters Filters to apply when querying the initial list + * of entities. + * @param observeFilters Filters to apply when observing updates to + * the entities. + * @param onNext A callback function that is called with the list of + * entities after the initial query completes, and each time any of the + * observed entities is updated. */ public fun readAndObserve( entityClass: Class, @@ -100,68 +108,74 @@ import java.util.concurrent.CompletableFuture /** * Posts a command to the server. * - * @param cmd A command that has to be posted. - * @throws CommandPostingError If some error has occurred during posting and - * acknowledging the command on the server. + * @param command A command that has to be posted. + * @throws ServerCommunicationException If a net work error has occurred + * when posting a command. + * @throws ServerError If the command couldn't be acknowledged due to an + * error on the server. */ - public fun command(cmd: CommandMessage) + public fun postCommand(command: C) /** - * Posts a command to the server and awaits for the specified event - * to arrive. + * Posts the given [command], and runs handlers for any of the consequences + * registered in [setupConsequences]. * - * @param cmd A command that has to be posted. - * @param event A class of event that has to be waited for after posting - * the command. - * @param field A field that should be used for identifying the event to be - * awaited for. - * @param fieldValue A value of the field that identifies the event to be - * awaited for. - * @return An event specified in the parameters, which was emitted in - * response to the command. - * @throws kotlinx.coroutines.TimeoutCancellationException - * If the event doesn't arrive within a reasonable timeout defined - * by the implementation. + * All registered command consequence handlers except event handlers are + * invoked synchronously before this suspending method returns. Event + * handlers are invoked in the provided [coroutineScope]. + * + * See the description and an example of specifying command consequence + * handlers in the [CommandConsequencesScope] documentation. + * + * @param command The command that should be posted. + * @param coroutineScope The coroutine scope in which event handlers are to + * be invoked. + * @param setupConsequences A lambda, which sets up handlers for command's + * consequences using the API in [CommandConsequencesScope] on which it + * is invoked. + * @return An object, which allows managing (e.g. cancelling) all event + * subscriptions made by this method as specified with the + * [setupConsequences] parameter. + * @see CommandConsequencesScope */ - public suspend fun command( - cmd: CommandMessage, - event: Class, - field: EventMessageField, - fieldValue: Message - ): E + public suspend fun postCommand( + command: C, + coroutineScope: CoroutineScope, + setupConsequences: CommandConsequencesScope.() -> Unit + ): EventSubscriptions /** - * Subscribes to an event with a given class and a given field value (which + * Subscribes to events with a given class and a given field value (which * would typically be the event's unique identifier field). * - * @param event A class of event that has to be subscribed to. - * @param field A field that should be used for identifying the event to be + * The subscription remains active by waiting for events that satisfy the + * specified criteria until the [cancel][EventSubscription.cancel] method + * is invoked in the returned [EventSubscription] instance. + * + * @param event A class of events that have to be subscribed to. + * @param field A field that should be used for identifying the events to be * subscribed to. - * @param fieldValue A value of the field that identifies the event to be + * @param fieldValue A value of the field that identifies the events to be * subscribed to. - * @return A [CompletableFuture] instance that is completed when the event - * specified by the parameters arrives. - */ - public fun subscribeToEvent( - event: Class, - field: EventMessageField, - fieldValue: Message - ): EventSubscription - - /** - * Observes the provided event. - * - * @param event A class of event to observe. - * @param onEmit A callback triggered when the desired event is emitted. - * @param field A field used for identifying the observed event. - * @param fieldValue An identifying field value of the observed event. + * @param onNetworkError A callback triggered if network communication error + * occurs during subscribing or waiting for events. This callback can + * either be invoked synchronously communication fails while subscribing + * to events, or asynchronously, if the communication error happens after + * the subscription has been made. In either of these cases, the returned + * `EventSubscription` is transitioned into an inactive state and stops + * receiving events. + * @param onEvent An optional callback, which will be invoked when the + * specified event is emitted. + * @return An [EventSubscription] object, which represents the subscription + * that was made. */ - public fun observeEvent( + public fun onEvent( event: Class, field: EventMessageField, fieldValue: Message, - onEmit: (E) -> Unit - ) + onNetworkError: ((Throwable) -> Unit)? = null, + onEvent: (E) -> Unit + ): EventSubscription } /** @@ -172,35 +186,58 @@ import java.util.concurrent.CompletableFuture public interface EventSubscription { /** - * Awaits for the event to arrive, and returns the respective event. + * Returns `true`, if the subscription is active (waiting for events). + */ + public val active: Boolean + + /** + * Starts the countdown period [timeout] of waiting for the next event, and + * invokes the provided [onTimeout] handler if the event is not emitted + * during this period of time. + * + * If an event that matches the subscription criteria is not emitted a + * the [timeout] period since this method is invoked, the [onTimeout] + * callback is invoked, and the subscription is cancelled. * - * If invoked after the event has already arrived, this method returns - * immediately with the respective event. + * @param timeout A maximum period of time that the subscribed event should + * be waited for. + * @param timeoutCoroutineScope A [CoroutineScope] used to launch + * a coroutine for waiting a [timeout] period and invoking + * the [onTimeout] callback. + * @param onTimeout A callback, which will be invoked if event is not + * emitted within the [timeout] period after this method is called. + */ + public fun withTimeout( + timeout: Duration, + timeoutCoroutineScope: CoroutineScope, + onTimeout: suspend () -> Unit + ) + + /** + * Cancels the subscription. * - * @return An event that is expected to arrive with this subscription. - * @throws kotlinx.coroutines.TimeoutCancellationException - * If the event doesn't arrive within a reasonable timeout defined - * by the implementation. + * After a subscription is canceled, it stops receiving notifications about + * emitted events. */ - public suspend fun awaitEvent(): E + public fun cancel() } /** - * Signifies an error that has occurred during the process of posting the - * command and acknowledging it on the server. + * Represents a set of related subscriptions, e.g. the ones made as a result of + * [Client.postCommand] method. */ -public open class CommandPostingError(message: String? = null, cause: Throwable? = null) : - RuntimeException(message, cause) -{ - public companion object { - private const val serialVersionUID: Long = 3555883899622560720L - } +public interface EventSubscriptions { + + /** + * Cancels all subscriptions represented by this object. + */ + public fun cancelAll() } /** - * Signifies an error that has occurred when delivering events. + * Signifies a failure that has occurred while communicating with the server. */ -public class StreamingError(error: Throwable) : CommandPostingError(cause = error) { +public class ServerCommunicationException(cause: Throwable) : RuntimeException(cause) { public companion object { private const val serialVersionUID: Long = -5438430153458733051L } @@ -208,8 +245,10 @@ public class StreamingError(error: Throwable) : CommandPostingError(cause = erro /** * Signifies an error that has occurred on the server (e.g. a validation error). + * + * @property error Information about the error that has occurred on the server. */ -public class ServerError(public val error: Error) : CommandPostingError(error.message) { +public class ServerError(public val error: Error) : RuntimeException(error.message) { public companion object { private const val serialVersionUID: Long = -5438430153458733051L } diff --git a/client/src/main/kotlin/io/spine/chords/client/CommandConsequencesScope.kt b/client/src/main/kotlin/io/spine/chords/client/CommandConsequencesScope.kt new file mode 100644 index 00000000..d21769aa --- /dev/null +++ b/client/src/main/kotlin/io/spine/chords/client/CommandConsequencesScope.kt @@ -0,0 +1,323 @@ +/* + * Copyright 2025, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package io.spine.chords.client + +import com.google.protobuf.Message +import io.spine.base.CommandMessage +import io.spine.base.EventMessage +import io.spine.base.EventMessageField +import io.spine.chords.client.appshell.client +import io.spine.chords.core.appshell.app +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +/** + * Defines a DSL for registering handlers of command consequences. + * + * An instance of `CommandConsequencesScope` is typically expected to serve as + * a receiver of a function, which configures command consequence handlers. Such + * a function is typically a part of APIs that post a command, such as + * [Client.postCommand], + * [CommandMessageForm][io.spine.chords.client.form.CommandMessageForm], + * [CommandDialog][io.spine.chords.client.layout.CommandDialog], etc. + * + * `CommandConsequencesScope` exposes the following properties and functions: + * - The [command] property contains the command message, which is being posted + * in this scope. + * - The [onBeforePost] function can be used to register a callback, which is + * invoked before the command is posted, and the + * [onPostServerError]/[onAcknowledge] functions register callbacks invoked + * if the command could not be acknowledged due to an error on the server, + * and if the command has been acknowledged respectively. + * - The [onEvent] function can be used to subscribe to certain events, which + * should or can be emitted as a consequence of posting the command [C]. It + * can in particular be used for subscribing to rejection events. + * - [onNetworkError] registers a callback invoked if a network error occurs + * either when posting a command or when observing some of the subscribed + * events. + * + * Note that in case of identifying a network error all active event + * subscriptions are cancelled and no further events are received. + * - [cancelAllSubscriptions] can be used to cancel all event subscriptions that + * have been made with the [onEvent] function. + * + * Here's an example of configuring `CommandConsequencesScope` when using + * the [Client.postCommand] function: + * + * ``` + * val command: ImportItem = createCommand() + * val coroutineScope = rememberCoroutineScope() + * val inProgress: Boolean by remember { mutableStateOf(false) } + * + * app.client.postCommand(command, coroutineScope, { + * onBeforePost { + * inProgress = true + * } + * onPostServerError { + * showMessage("Unexpected server error has occurred.") + * inProgress = false + * } + * onEvent( + * ItemImported::class.java, + * ItemImported.Field.itemId(), + * command.itemId + * ) { + * showMessage("Item imported") + * inProgress = false + * }.withTimeout(30.seconds) { + * showMessage("The operation takes unexpectedly long to process. " + + * "Please check the status of its execution later.") + * inProgress = false + * } + * onEvent( + * ItemAlreadyExists::class.java, + * ItemAlreadyExists.Field.itemId(), + * command.itemId + * ) { + * showMessage("Item already exists: ${command.itemName.value}") + * inProgress = false + * } + * onNetworkError { + * showMessage("Server connection failed.") + * inProgress = false + * } + * }) + * ``` + * + * @param C A type of command message, whose consequences are configured in + * this scope. + */ +public interface CommandConsequencesScope { + + /** + * The command whose consequences are being specified and processed in + * this scope. + */ + public val command: C + + /** + * Registers the callback, which is invoked before the command is posted. + * + * @param handler A callback, to be invoked before the command is posted. + */ + public fun onBeforePost(handler: suspend () -> Unit) + + /** + * Registers the callback, which is invoked if the server acknowledges + * the command. + * + * @param handler A callback to be invoked, whose [ServerCommunicationException] parameter + * holds the exception that has signaled the failure. + */ + public fun onAcknowledge(handler: suspend () -> Unit) + + /** + * Registers the callback, which is invoked if an error occurs on the server + * while acknowledging the command. + * + * @param handler A callback to be invoked, whose [ServerError] parameter + * receives the exception that has signaled the failure. + */ + public fun onPostServerError(handler: suspend (ServerError) -> Unit) + + /** + * Subscribes to events of type [eventType], which have its [field] equal + * to [fieldValue]. + * + * The subscription remains active by waiting for events that satisfy the + * specified criteria until the [cancel][EventSubscription.cancel] method + * is invoked in the returned [EventSubscription] instance, or until all + * subscriptions made within this instance are cancelled using the + * [cancelAllSubscriptions] function. + * + * @param eventType A type of event that should be subscribed to. + * @param field A field whose value should identify an event being + * subscribed to. + * @param fieldValue A value of event's [field] that identifies an event + * being subscribed to. + * @return An [EventSubscription] instance, which can be used to manage this + * subscription, e.g. add a timeout to it using the [withTimeout] + * function, or cancel the subscription using the + * [cancel][EventSubscription.cancel] function. + */ + public fun onEvent( + eventType: Class, + field: EventMessageField, + fieldValue: Message, + eventHandler: suspend (E) -> Unit + ): EventSubscription + + /** + * Limits the time of waiting for the event to [timeout]. + * + * If the event is not emitted during [timeout] since this method is invoked + * then [timeoutHandler] is invoked, and the event subscription + * is cancelled. + * + * @param timeout A maximum period of time that the event is waited for + * since the moment of invoking this function. + * @param timeoutHandler A callback, which should be invoked if an event + * is not emitted within the specified [timeout] period after invoking + * this method. + */ + public fun EventSubscription.withTimeout( + timeout: Duration = 20.seconds, + timeoutHandler: suspend () -> Unit) + + /** + * Registers the callback, which is invoked if a network communication + * failure occurs while posting the command or waiting for + * subscribed events. + * + * @param handler A callback to be invoked, whose + * [ServerCommunicationException] parameter holds the exception that has + * signaled the failure. + */ + public fun onNetworkError(handler: suspend (ServerCommunicationException) -> Unit) + + /** + * Cancels all active event subscriptions that have been made in + * this scope. + */ + public fun cancelAllSubscriptions() +} + +/** + * An implementation of [CommandConsequencesScope]. + * + * @param command A command whose consequences are being configured. + * @param coroutineScope [CoroutineScope] that should be used for launching + * suspending event handlers. + */ +@Suppress( + // Considering all functions to be appropriate. + "TooManyFunctions" +) +internal class CommandConsequencesScopeImpl( + override val command: C, + private val coroutineScope: CoroutineScope +) : CommandConsequencesScope { + + /** + * Allows to manage subscriptions made in this scope. + */ + val subscriptions: EventSubscriptions = object : EventSubscriptions { + override fun cancelAll() { + eventSubscriptions.forEach { it.cancel() } + } + } + + /** + * Returns `true` if all event subscriptions that have been made using the + * [onEvent] method are active. + */ + internal val allSubscriptionsActive: Boolean get() = eventSubscriptions.all { it.active } + + private val eventSubscriptions: MutableList> = ArrayList() + private var beforePostHandlers: List Unit> = ArrayList() + private var postNetworkErrorHandlers: List Unit> = + ArrayList() + private var postServerErrorHandlers: List Unit> = ArrayList() + private var acknowledgeHandlers: List Unit> = ArrayList() + + override fun onBeforePost(handler: suspend () -> Unit) { + beforePostHandlers += handler + } + + override fun onNetworkError(handler: suspend (ServerCommunicationException) -> Unit) { + postNetworkErrorHandlers += handler + } + + override fun onPostServerError(handler: suspend (ServerError) -> Unit) { + postServerErrorHandlers += handler + } + + override fun onAcknowledge(handler: suspend () -> Unit) { + acknowledgeHandlers += handler + } + + override fun onEvent( + eventType: Class, + field: EventMessageField, + fieldValue: Message, + eventHandler: suspend (E) -> Unit + ): EventSubscription { + val subscription = app.client.onEvent( + eventType, field, fieldValue, { + coroutineScope.launch { + triggerNetworkErrorHandlers(ServerCommunicationException(it)) + } + }, { + coroutineScope.launch { eventHandler(it) } + }) + eventSubscriptions += subscription + return subscription + } + + override fun EventSubscription.withTimeout( + timeout: Duration, + timeoutHandler: suspend () -> Unit + ) = withTimeout(timeout, coroutineScope, timeoutHandler) + + internal suspend fun triggerBeforePostHandlers() { + beforePostHandlers.forEach { it() } + } + + internal suspend fun triggerAcknowledgeHandlers() { + acknowledgeHandlers.forEach { it() } + } + + internal suspend fun triggerNetworkErrorHandlers(e: ServerCommunicationException) { + cancelAllSubscriptions() + if (postNetworkErrorHandlers.isEmpty()) { + throw IllegalStateException( + "No `onNetworkError` handlers are registered for command: " + + command.javaClass.simpleName, + e + ) + } + postNetworkErrorHandlers.forEach { it(e) } + } + + internal suspend fun triggerServerErrorHandlers(e: ServerError) { + cancelAllSubscriptions() + if (postServerErrorHandlers.isEmpty()) { + throw IllegalStateException( + "No `onPostServerError` handlers are registered for command: " + + command.javaClass.simpleName, + e + ) + } + postServerErrorHandlers.forEach { it(e) } + } + + override fun cancelAllSubscriptions() { + subscriptions.cancelAll() + } +} diff --git a/client/src/main/kotlin/io/spine/chords/client/DesktopClient.kt b/client/src/main/kotlin/io/spine/chords/client/DesktopClient.kt index 38cd34b5..54f2f53f 100644 --- a/client/src/main/kotlin/io/spine/chords/client/DesktopClient.kt +++ b/client/src/main/kotlin/io/spine/chords/client/DesktopClient.kt @@ -1,5 +1,5 @@ /* - * Copyright 2024, TeamDev. All rights reserved. + * Copyright 2025, TeamDev. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -30,6 +30,8 @@ import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf import com.google.protobuf.Message import io.grpc.ManagedChannelBuilder +import io.grpc.Status.Code.UNAVAILABLE +import io.grpc.StatusRuntimeException import io.spine.base.CommandMessage import io.spine.base.EntityState import io.spine.base.Error @@ -39,17 +41,14 @@ import io.spine.client.ClientRequest import io.spine.client.CompositeEntityStateFilter import io.spine.client.CompositeQueryFilter import io.spine.client.EventFilter.eq +import io.spine.client.Subscription import io.spine.core.UserId -import java.util.concurrent.CompletableFuture -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.future.await -import kotlinx.coroutines.withTimeout - -/** - * A period during which a server should provide a reaction in a normally - * functioning system (e.g., emit an event in response to a command). - */ -private const val ReactionTimeoutMillis = 15_000L +import kotlin.time.Duration +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch /** * Provides API to interact with the application server via gRPC. @@ -90,9 +89,10 @@ public class DesktopClient( * as well. * * @param entityClass A class of entities that should be read and observed. - * @param targetList A [MutableState] that contains a list whose content should be - * populated and kept up to date by this function. - * @param extractId A callback that should read the value of the entity's ID. + * @param targetList A [MutableState] that contains a list whose content + * should be populated and kept up to date by this function. + * @param extractId A callback that should read the value of + * the entity's ID. */ public override fun readAndObserve( entityClass: Class, @@ -110,18 +110,24 @@ public class DesktopClient( } /** - * Reads all entities of type [entityClass] that match the given [queryFilters] and invokes the - * [onNext] callback with the initial list of entities. Then sets up observation to receive - * future updates to the entities, filtering the observed updates using the provided [observeFilters]. - * Each time any entity that matches the [observeFilters] changes, the [onNext] callback - * will be invoked again with the updated list of entities. + * Reads all entities of type [entityClass] that match the given + * [queryFilters] and invokes the [onNext] callback with the initial list of + * entities. Then sets up observation to receive future updates to the + * entities, filtering the observed updates using the provided + * [observeFilters]. Each time any entity that matches the [observeFilters] + * changes, the [onNext] callback will be invoked again with the updated + * list of entities. * * @param entityClass A class of entities that should be read and observed. - * @param extractId A callback that should read the value of the entity's ID. - * @param queryFilters Filters to apply when querying the initial list of entities. - * @param observeFilters Filters to apply when observing updates to the entities. - * @param onNext A callback function that is called with the list of entities after the initial - * query completes, and each time any of the observed entities is updated. + * @param extractId A callback that should read the value of the + * entity's ID. + * @param queryFilters Filters to apply when querying the initial list + * of entities. + * @param observeFilters Filters to apply when observing updates to + * the entities. + * @param onNext A callback function that is called with the list of + * entities after the initial query completes, and each time any of the + * observed entities is updated. */ public override fun readAndObserve( entityClass: Class, @@ -165,104 +171,107 @@ public class DesktopClient( } /** - * Posts a command to the server. + * Posts the given [command] to the server. * - * @param cmd A command that has to be posted. - * @throws CommandPostingError If some error has occurred during posting and - * acknowledging the command on the server. + * @param command A command that has to be posted. + * @throws ServerError If the command could not be acknowledged due to an + * error on the server. + * @throws ServerCommunicationException In case of a network communication + * failure that has occurred during posting of the command. It is unknown + * whether the command has been acknowledged or no in this case. */ - override fun command(cmd: CommandMessage) { - var error: CommandPostingError? = null - clientRequest() - .command(cmd) - .onServerError { msg, err: Error -> - error = ServerError(err) - } - .onStreamingError { err: Throwable -> - error = StreamingError(err) + override fun postCommand(command: C) { + var error: Throwable? = null + try { + clientRequest() + .command(command) + .onServerError { msg, err: Error -> + error = ServerError(err) + } + .onStreamingError { err: Throwable -> + error = ServerCommunicationException(err) + } + .postAndForget() + } catch (e: StatusRuntimeException) { + if (e.status.code == UNAVAILABLE) { + throw ServerCommunicationException(e) + } else { + throw e } - .postAndForget() + } if (error != null) { throw error!! } } /** - * Posts a command to the server and awaits for the specified event - * to arrive. + * Posts the given [command], and runs handlers for any of the consequences + * registered in [setupConsequences]. * - * @param cmd A command that has to be posted. - * @param event A class of event that has to be waited for after posting - * the command. - * @param field A field that should be used for identifying the event to be - * awaited for. - * @param fieldValue A value of the field that identifies the event to be - * awaited for. - * @return An event specified in the parameters, which was emitted in - * response to the command. - * @throws kotlinx.coroutines.TimeoutCancellationException - * If the event doesn't arrive within a reasonable timeout defined - * by the implementation. - */ - override suspend fun command( - cmd: CommandMessage, - event: Class, - field: EventMessageField, - fieldValue: Message - ): E = coroutineScope { - val eventSubscription = subscribeToEvent(event, field, fieldValue) - command(cmd) - eventSubscription.awaitEvent() - } - - /** - * Subscribes to an event with a given class and a given field value (which - * would typically be the event's unique identifier field). + * All registered command consequence handlers except event handlers are + * invoked synchronously before this suspending method returns. Event + * handlers are invoked in the provided [coroutineScope]. * - * @param event A class of event that has to be subscribed to. - * @param field A field that should be used for identifying the event to be - * subscribed to. - * @param fieldValue A value of the field that identifies the event to be - * subscribed to. - * @return A [CompletableFuture] instance that is completed when the event - * specified by the parameters arrives. + * @param command The command that should be posted. + * @param coroutineScope The coroutine scope in which event handlers are to + * be invoked. + * @param setupConsequences A lambda, which sets up handlers for command's + * consequences using the API in [CommandConsequencesScope] on which it + * is invoked. + * @return An object, which allows managing (e.g. cancelling) all event + * subscriptions made by this method as specified with the + * [setupConsequences] parameter. + * @see CommandConsequencesScope */ - override fun subscribeToEvent( - event: Class, - field: EventMessageField, - fieldValue: Message - ): EventSubscription { - val eventReceival = CompletableFuture() - observeEvent( - event = event, - field = field, - fieldValue = fieldValue) { evt -> - eventReceival.complete(evt) + public override suspend fun postCommand( + command: C, + coroutineScope: CoroutineScope, + setupConsequences: CommandConsequencesScope.() -> Unit + ): EventSubscriptions { + val scope = CommandConsequencesScopeImpl(command, coroutineScope) + try { + scope.setupConsequences() + val allSubscriptionsSuccessful = scope.allSubscriptionsActive + if (allSubscriptionsSuccessful) { + scope.triggerBeforePostHandlers() + postCommand(command) + scope.triggerAcknowledgeHandlers() + } else { + scope.subscriptions.cancelAll() } - return FutureEventSubscription(eventReceival) + } catch (e: ServerError) { + scope.triggerServerErrorHandlers(e) + } catch (e: ServerCommunicationException) { + scope.triggerNetworkErrorHandlers(e) + } + return scope.subscriptions } - /** - * Observes the provided event. - * - * @param event A class of event to observe. - * @param onEmit A callback triggered when the desired event is emitted. - * @param field A field used for identifying the observed event. - * @param fieldValue An identifying field value of the observed event. - */ - override fun observeEvent( + override fun onEvent( event: Class, field: EventMessageField, fieldValue: Message, - onEmit: (E) -> Unit - ) { - clientRequest() - .subscribeToEvent(event) - .where(eq(field, fieldValue)) - .observe { evt -> - onEmit(evt) - } - .post() + onNetworkError: ((Throwable) -> Unit)?, + onEvent: (E) -> Unit + ): EventSubscription { + val eventSubscription = EventSubscriptionImpl(spineClient) + try { + eventSubscription.subscription = clientRequest() + .subscribeToEvent(event) + .where(eq(field, fieldValue)) + .observe { evt -> + eventSubscription.onEvent() + onEvent(evt) + } + .onStreamingError({ err -> + eventSubscription.cancel() + onNetworkError?.invoke(err) + }) + .post() + } catch (e: StatusRuntimeException) { + onNetworkError?.invoke(e) + } + return eventSubscription } /** @@ -287,8 +296,8 @@ public class DesktopClient( * * @param targetList A [MutableState] that contains a list to be updated. * @param entity An item that has to be merged into the list. - * @param extractId A function that, given a list item, or a value of [entity], - * retrieves its ID. + * @param extractId A function that, given a list item, or a value of + * [entity], retrieves its ID. */ private fun updateList( targetList: MutableState>, @@ -313,17 +322,59 @@ public class DesktopClient( } /** - * An [EventSubscription] implementation that is based on - * a [CompletableFuture] instance. + * An [EventSubscription] implementation. * - * @param future A `CompletableFuture`, which is expected to provide - * the respective event. + * @param spineClient A Spine Event Engine's [Client][io.spine.client.Client] + * instance where the subscription is being registered. */ -private class FutureEventSubscription( - private val future: CompletableFuture +private class EventSubscriptionImpl( + private val spineClient: io.spine.client.Client ) : EventSubscription { + override val active: Boolean get() = subscription != null - override suspend fun awaitEvent(): E { - return withTimeout(ReactionTimeoutMillis) { future.await() } + /** + * A Spine [Subscription], which was made to supply events of type [E], or + * `null` if it either hasn't been made yet, or cancelled already. + */ + var subscription: Subscription? = null + + private var timeoutJob: Job? = null + + override fun withTimeout( + timeout: Duration, + timeoutCoroutineScope: CoroutineScope, + onTimeout: suspend () -> Unit, + ) { + check(timeoutJob == null) { + "`withTimeout` cannot be used more than once for" + + "the same `EventSubscription`" + } + timeoutJob = timeoutCoroutineScope.launch { + delay(timeout) + if (timeoutJob != null) { + cancel() + onTimeout() + } + } + } + + /** + * Invoked internally for the subsctiption to perform any operations, which + * have to be performed whenever an expected event [E] is emitted. + */ + fun onEvent() { + timeoutJob?.cancel() + timeoutJob = null + } + + override fun cancel() { + if (subscription != null) { + spineClient.subscriptions().cancel(subscription!!) + subscription = null + } + if (timeoutJob != null) { + timeoutJob?.cancel() + timeoutJob = null + } } } diff --git a/client/src/main/kotlin/io/spine/chords/client/form/CommandMessageForm.kt b/client/src/main/kotlin/io/spine/chords/client/form/CommandMessageForm.kt index 689bfad8..0e303b33 100644 --- a/client/src/main/kotlin/io/spine/chords/client/form/CommandMessageForm.kt +++ b/client/src/main/kotlin/io/spine/chords/client/form/CommandMessageForm.kt @@ -1,5 +1,5 @@ /* - * Copyright 2024, TeamDev. All rights reserved. + * Copyright 2025, TeamDev. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -28,26 +28,20 @@ package io.spine.chords.client.form import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState -import androidx.compose.runtime.State -import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue import io.spine.base.CommandMessage -import io.spine.base.EventMessage -import io.spine.chords.client.CommandPostingError -import io.spine.chords.client.EventSubscription +import io.spine.chords.client.CommandConsequencesScope +import io.spine.chords.client.EventSubscriptions import io.spine.chords.client.appshell.client import io.spine.chords.core.ComponentProps import io.spine.chords.core.appshell.app -import io.spine.chords.core.layout.MessageDialog.Companion.showMessage import io.spine.chords.proto.form.FormPartScope import io.spine.chords.proto.form.MessageForm import io.spine.chords.proto.form.MessageFormSetupBase import io.spine.chords.proto.form.MultipartFormScope import io.spine.protobuf.ValidatingBuilder import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.TimeoutCancellationException /** * A form that allows entering a value of a command message and posting @@ -86,7 +80,11 @@ import kotlinx.coroutines.TimeoutCancellationException * // that appears appropriate as long as the form's `postCommand` * // method is invoked when the form needs to be posted. * Button( - * onClick = { form.postCommand() } + * onClick = { + * if (form.valueValid.value) { + * form.postCommand() + * } + * } * ) { * Text("Login") * } @@ -144,7 +142,11 @@ import kotlinx.coroutines.TimeoutCancellationException * } * * Button( - * onClick = { form.postCommand() } + * onClick = { + * if (form.valueValid.value) { + * form.postCommand() + * } + * } * ) { * Text("Login") * } @@ -300,51 +302,42 @@ public class CommandMessageForm : MessageForm() { } /** - * A function, which, given a command message that is about to be posted, - * should subscribe to a respective event that is expected to arrive in - * response to handling that command. - */ - public lateinit var eventSubscription: (C) -> EventSubscription - - /** - * A state, which reports whether the form is currently in progress of - * posting the command. + * A function, which, should register handlers for consequences of + * command [C] posted by the form. + * + * The command, which is going to be posted and whose consequence handlers + * should be registered can be obtained from the + * [command][CommandConsequencesScope.command] property available in the + * function's scope, and handlers can be registered using the + * [`onXXX`][CommandConsequencesScope] functions available in the + * function's scope. * - * More precisely, it is set to `true`, if the command has been posted using - * the [postCommand] method, but no response or timeout error has been - * received yet, and `false` otherwise. + * See [CommandConsequencesScope] for the description of API available + * within the scope of [commandConsequences] function. * - * This property is backed by a [State] object, so it can be used as part of - * a composition, which will be updated automatically when this property - * is changed. E.g. it can be used to disable the respective "Post" button - * to prevent making it possible to post command duplicates. + * @see CommandConsequencesScope + * @see postCommand + * @see cancelActiveSubscriptions */ - public var posting: Boolean by mutableStateOf(false) + public lateinit var commandConsequences: CommandConsequencesScope.() -> Unit /** - * Specifies whether field editors should be disabled when the command is - * being posted (when [posting] equals `true`). + * [CoroutineScope] owned by this form's composition used for running + * form-related suspending calls. */ - public var disableOnPosting: Boolean = true + private lateinit var coroutineScope: CoroutineScope /** - * This overridden implementation ensures that the editors are disabled when - * the command is being posted (when [posting] is `true`). - * - * Note: if `disableOnPosting` is `false`, no automatic disabling is - * performed during posting the command. + * Event subscriptions, which were made by [commandConsequences] as a result + * of command posted during dialog form's submission. */ - override val shouldEnableEditors: Boolean - get() = super.shouldEnableEditors && (!posting || !disableOnPosting) + private var activeSubscriptions: MutableList = ArrayList() + - private lateinit var coroutineScope: CoroutineScope override fun initialize() { super.initialize() - check(this::eventSubscription.isInitialized) { - "CommandMessageForm's `eventSubscription` property must " + - "be specified." - } + requireProperty(this::commandConsequences.isInitialized, "commandConsequences") } @Composable @@ -354,142 +347,62 @@ public class CommandMessageForm : MessageForm() { } /** - * Posts the command based on all currently entered data and awaits - * the feedback upon processing the command. + * Posts the command based on all currently entered data. + * + * Note that this method can only be invoked when the data entered within + * the form is valid (when `valueValid.value == false`). + * + * Here's a typical usage example: + * ``` + * // Make sure that validation messages are up to date before + * // submitting the form. + * commandMessageForm.updateValidationDisplay(true) * - * Here's a more detailed description about the sequence of actions - * performed by this method: - * - Validates the data entered in the form and builds the respective - * command message. In case if any validation errors are encountered, this - * method skips further stages, and just makes the respective validation - * errors to be displayed. - * - Posts the command that was validated and built. - * - Awaits for an event that should arrive upon successful handling of - * the command, as defined by the [eventSubscription] - * constructor's parameters. - * - If the event doesn't arrive in a predefined timeout that is considered - * an adequate delay from user's perspective, this method - * throws [TimeoutCancellationException]. + * // Submit the form if it is valid currently. + * if (commandMessageForm.valueValid.value) { + * commandMessageForm.postCommand() + * } + * ``` * - * @param outcomeHandler Specifies the way that command outcome is - * handled, e.g. the way how unexpected outcomes are processed. - * @return `true` if the command was successfully built without any - * validation errors, and `false` if the command message could not be - * successfully built from the currently entered data (validation errors - * are displayed to the user in this case). - * @throws TimeoutCancellationException If the event doesn't arrive within - * a reasonable timeout defined by the implementation. - * @throws IllegalStateException If the method is invoked while - * the [postCommand] invocation is still being handled (when [posting] is - * still `true`). + * Note that the [updateValidationDisplay] invocation is technically not + * required to check if the form is valid because the form is always + * validated on-the-fly automatically, and its [valueValid] property always + * contains an up-to-date value. Nevertheless, it would typically be useful + * to invoke it before [postCommand] to improve user's experience when the + * form's [validationDisplayMode] property has a value of + * [MANUAL][io.spine.chords.proto.form.ValidationDisplayMode.MANUAL]. + * + * @return An object, which allows managing (e.g. cancelling) all + * subscriptions made by the [commandConsequences] callback. + * @throws IllegalStateException If the form is not valid when this method + * is invoked (e.g. when `valueValid.value == false`). + * @see commandConsequences + * @see cancelActiveSubscriptions */ - public suspend fun postCommand( - outcomeHandler: CommandOutcomeHandler = DefaultOutcomeHandler() - ): Boolean { - if (posting) { - throw IllegalStateException("Cannot invoke `postCommand`, while" + - "waiting for handling the previously posted command.") - } + public suspend fun postCommand(): EventSubscriptions { updateValidationDisplay(true) - if (!valueValid.value) { - return false + check(valueValid.value) { + "`postCommand` cannot be invoked on an invalid form`" } val command = value.value check(command != null) { "CommandMessageForm's value should be not null since it was just " + "checked to be valid within postCommand." } - val subscription = eventSubscription(command) - return try { - posting = true - try { - app.client.command(command) - val event = subscription.awaitEvent() - outcomeHandler.onEvent(event) - true - } catch (e: CommandPostingError) { - outcomeHandler.onPostingError(e) - false - } - } catch ( - @Suppress( - // A timeout condition is handled by `outcomeHandler`. - "SwallowedException" - ) - e: TimeoutCancellationException - ) { - outcomeHandler.onTimeout(command) - false - } finally { - posting = false - } - } -} - -/** - * An object, which is capable of handling different kinds of outcomes that can - * follow as a result of posting a command. - */ -public interface CommandOutcomeHandler { - /** - * Invoked upon receiving an event that has - * the command [C]. - */ - public fun onEvent(event: EventMessage) - - /** - * Invoked if no event has been received in response to the command [C] - * in a reasonable period of time defined by the implementation. - */ - public suspend fun onTimeout(command: C) + return app.client.postCommand(command, coroutineScope, commandConsequences) + } /** - * Invoked if an error has occurred during posting and acknowledging - * the command on the server. + * Cancels all event subscriptions that have been made as a result of + * invoking this form's [postCommand] method. * - * @param error The exception that signifies the error that has occurred. - * @see io.spine.chords.client.Client.command + * @see postCommand + * @see commandConsequences */ - public suspend fun onPostingError(error: CommandPostingError) -} - -/** - * A default implementation for [CommandOutcomeHandler], which is expected to - * be suitable in most typical cases. - * - * @param eventHandler An optional callback, which will be invoked when - * an event that is expected as an outcome of the command [C] is emitted. - * @param timeoutMessage A lambda, which, given a command that was posted with - * no subsequent response received in time, should provide a message that - * should be displayed to the user in this case. If not specified, a default - * message is displayed. - * @param postingErrorMessage A lambda, which, given a [CommandPostingError] - * that has occurred when posting the command, returns the text that should - * be displayed to the user. - */ -public class DefaultOutcomeHandler( - private val eventHandler: ((EventMessage) -> Unit)? = null, - private val timeoutMessage: ((C) -> String)? = null, - private val postingErrorMessage: ((CommandPostingError) -> String)? = null -) : CommandOutcomeHandler { - override fun onEvent(event: EventMessage) { - eventHandler?.invoke(event) - } - - override suspend fun onTimeout(command: C) { - val message = timeoutMessage?.invoke(command) ?: ( - "Timed out waiting for an event in response to " + - "the command ${command.javaClass.simpleName}" - ) - showMessage(message) + public fun cancelActiveSubscriptions() { + activeSubscriptions.forEach { it.cancelAll() } + activeSubscriptions.clear() } - override suspend fun onPostingError(error: CommandPostingError) { - val message = postingErrorMessage?.invoke(error) ?: ( - "An error has occurred when posting or acknowledging " + - "the command ${error.message}" - ) - showMessage(message) - } } diff --git a/client/src/main/kotlin/io/spine/chords/client/layout/CommandDialog.kt b/client/src/main/kotlin/io/spine/chords/client/layout/CommandDialog.kt index 17fade40..f7ff8dbf 100644 --- a/client/src/main/kotlin/io/spine/chords/client/layout/CommandDialog.kt +++ b/client/src/main/kotlin/io/spine/chords/client/layout/CommandDialog.kt @@ -1,5 +1,5 @@ /* - * Copyright 2024, TeamDev. All rights reserved. + * Copyright 2025, TeamDev. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -33,8 +33,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment.Companion.CenterHorizontally import androidx.compose.ui.Modifier import io.spine.base.CommandMessage -import io.spine.base.EventMessage -import io.spine.chords.client.EventSubscription +import io.spine.chords.client.CommandConsequencesScope import io.spine.chords.client.form.CommandMessageForm import io.spine.chords.core.layout.Dialog import io.spine.chords.core.layout.SubmitOrCancelDialog @@ -70,7 +69,8 @@ public abstract class CommandDialog onBeforeBuild = ::beforeBuild, props = { validationDisplayMode = MANUAL - eventSubscription = ::subscribeToEvent + commandConsequences = { commandConsequences() } + enabled = !submitting } ) { Column( @@ -104,16 +104,27 @@ public abstract class CommandDialog protected abstract fun createCommandBuilder(): B /** - * A function, which, given a command message that is about to be posted, - * should subscribe to a respective event that is expected to arrive in - * response to handling that command. + * A function, which, should register handlers for consequences of + * command [C] posted by the dialog. * - * @param command A command, which is going to be posted. - * @return A subscription to the event that is expected to arrive in - * response to handling [command]. + * The command, which is going to be posted and whose consequence handlers + * should be registered can be obtained from the + * [command][CommandConsequencesScope.command] property available in the + * function's scope, and handlers can be registered using the + * [`onXXX`][CommandConsequencesScope] functions available in the + * function's scope. + * + * Event subscriptions made by this function are automatically cancelled + * when the dialog is closed. They can also be cancelled explicitly by + * calling [cancelActiveSubscriptions] if they need to be canceled before + * the dialog is closed. + * + * @receiver [CommandConsequencesScope], which provides an API for + * registering command's consequences. + * @see submitContent + * @see cancelActiveSubscriptions */ - protected abstract fun subscribeToEvent(command: C): - EventSubscription + protected abstract fun CommandConsequencesScope.commandConsequences() /** * Allows to programmatically amend the command message builder before @@ -127,9 +138,39 @@ public abstract class CommandDialog protected open fun beforeBuild(builder: B) {} /** - * Posts the command message [C] created in this dialog. + * Cancels any active subscriptions made by [commandConsequences] and closes + * the dialog. + * + * @see submitContent + * @see commandConsequences + */ + override fun close() { + cancelActiveSubscriptions() + super.close() + } + + /** + * Posts the command message [C] created in this dialog, and processes + * the respective command's consequences specified in [commandConsequences]. + * + * @see commandConsequences + */ + protected override suspend fun submitContent() { + commandMessageForm.updateValidationDisplay(true) + if (!commandMessageForm.valueValid.value) { + return + } + commandMessageForm.postCommand() + } + + /** + * Cancels any active event subscriptions that have been made by this + * dialog's submission(s) up to now. + * + * @see submitContent + * @see commandConsequences */ - protected override suspend fun submitContent(): Boolean { - return commandMessageForm.postCommand() + protected fun cancelActiveSubscriptions() { + commandMessageForm.cancelActiveSubscriptions() } } diff --git a/client/src/main/kotlin/io/spine/chords/client/layout/CommandWizard.kt b/client/src/main/kotlin/io/spine/chords/client/layout/CommandWizard.kt index 05d6ff43..922309bd 100644 --- a/client/src/main/kotlin/io/spine/chords/client/layout/CommandWizard.kt +++ b/client/src/main/kotlin/io/spine/chords/client/layout/CommandWizard.kt @@ -1,5 +1,5 @@ /* - * Copyright 2024, TeamDev. All rights reserved. + * Copyright 2025, TeamDev. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -30,8 +30,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable import com.google.protobuf.Message import io.spine.base.CommandMessage -import io.spine.base.EventMessage -import io.spine.chords.client.EventSubscription +import io.spine.chords.client.CommandConsequencesScope import io.spine.chords.client.form.CommandMessageForm import io.spine.chords.core.layout.AbstractWizardPage import io.spine.chords.core.layout.Wizard @@ -74,7 +73,8 @@ public abstract class CommandWizard + protected abstract fun CommandConsequencesScope.commandConsequences() /** * Allows to programmatically amend the command message builder before @@ -135,14 +146,54 @@ public abstract class CommandWizard - if (dialog.cancelAvailableInternal && event matches cancelShortcutKey.down) { - dialog.cancel() - } - if (dialog.submitAvailableInternal && event matches submitShortcutKey.up) { - dialog.submit() - } - false - } - ) { - Column( - modifier = Modifier - .fillMaxSize() - .background(colorScheme.background), - ) { - Column( - modifier = Modifier - .fillMaxSize() - .padding(dialog.look.padding), - ) { - dialog.windowContentInternal() - dialog.nestedDialog?.Content() - } - } - } - } -} - -/** - * A [PopupPositionProvider], which makes a lightweight popup to appear at - * the window's center. - */ -private val centerWindowPositionProvider = object : PopupPositionProvider { - override fun calculatePosition( - anchorBounds: IntRect, - windowSize: IntSize, - layoutDirection: LayoutDirection, - popupContentSize: IntSize - ): IntOffset = Zero -} - -/** - * A [DialogDisplayMode] implementation, which ensures displaying a dialog - * as a lightweight modal popup inside the current desktop window. - * - * @param backdropColor The color of the surface that covers the entire content - * of the current desktop window behind the dialog's modal popup displayed in - * this window. - */ -internal class LightweightDisplayMode( - private val backdropColor: Color = Black.copy(alpha = 0.5f) -) : DialogDisplayMode() { - - @Composable - override fun dialogWindow(dialog: Dialog) { - Popup( - popupPositionProvider = centerWindowPositionProvider, - properties = PopupProperties(focusable = true), - onPreviewKeyEvent = { false }, - onKeyEvent = cancelShortcutHandler { - if (dialog.cancelAvailableInternal) { - dialog.cancel() - } - } - ) { - Box( - modifier = Modifier - .fillMaxSize() - .background(backdropColor), - contentAlignment = Center - ) { - val modifier = if (dialog.isBottomDialog) { - Modifier.pointerInput(dialog) { - detectTapGestures(onPress = {}) - } - } else { - Modifier - } - Box(modifier = modifier) { - dialogFrame(dialog) - } - } - } - } - - @Composable - private fun dialogFrame(dialog: Dialog) { - Column( - modifier = Modifier - .clip(shapes.large) - .size(dialog.dialogWidth, dialog.dialogHeight) - .background(colorScheme.background), - ) { - Column( - modifier = Modifier - .fillMaxSize() - .padding(dialog.look.padding), - ) { - DialogTitle(dialog.title, dialog.look.titlePadding) - dialog.windowContentInternal() - dialog.nestedDialog ?.Content() - } - } - } - - /** - * Creates a key event handler function that executes a provided [cancelHandler] - * callback whenever the `Escape` key is pressed. - */ - private fun cancelShortcutHandler(cancelHandler: () -> Unit): (KeyEvent) -> Boolean = { event -> - if (event matches cancelShortcutKey.down) { - cancelHandler() - true - } else { - false - } - } -} /** * A class that should be used for creating companion objects of @@ -829,24 +667,6 @@ public open class DialogSetup( } } - -/** - * The title of the dialog. - * - * @param text The text to be title. - */ -@Composable -private fun DialogTitle( - text: String, - padding: PaddingValues -) { - Text( - modifier = Modifier.padding(padding), - text = text, - style = typography.headlineLarge - ) -} - /** * The action button of the dialog. * diff --git a/core/src/main/kotlin/io/spine/chords/core/layout/MessageDialog.kt b/core/src/main/kotlin/io/spine/chords/core/layout/MessageDialog.kt index 082f03f7..35fb0dd1 100644 --- a/core/src/main/kotlin/io/spine/chords/core/layout/MessageDialog.kt +++ b/core/src/main/kotlin/io/spine/chords/core/layout/MessageDialog.kt @@ -1,5 +1,5 @@ /* - * Copyright 2024, TeamDev. All rights reserved. + * Copyright 2025, TeamDev. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -71,8 +71,8 @@ public class MessageDialog : Dialog() { init { submitAvailable = true - dialogWidth = 430.dp - dialogHeight = 210.dp + width = 550.dp + height = 205.dp } /** @@ -101,10 +101,8 @@ public class MessageDialog : Dialog() { dialogClosure.await() } - override suspend fun submitContent(): Boolean { - // No custom logic is required when the user acknowledges - // the displayed message. - return true + override suspend fun submitContent() { + close() } /** diff --git a/core/src/main/kotlin/io/spine/chords/core/layout/WindowType.kt b/core/src/main/kotlin/io/spine/chords/core/layout/WindowType.kt new file mode 100644 index 00000000..6f83eb08 --- /dev/null +++ b/core/src/main/kotlin/io/spine/chords/core/layout/WindowType.kt @@ -0,0 +1,289 @@ +/* + * Copyright 2025, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package io.spine.chords.core.layout + +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.MaterialTheme.colorScheme +import androidx.compose.material3.MaterialTheme.shapes +import androidx.compose.material3.MaterialTheme.typography +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment.Companion.Center +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Color.Companion.Gray +import androidx.compose.ui.input.key.KeyEvent +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntOffset.Companion.Zero +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.window.DialogState +import androidx.compose.ui.window.DialogWindow +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupPositionProvider +import androidx.compose.ui.window.PopupProperties +import io.spine.chords.core.keyboard.matches + +/** + * Defines the way that a dialog is displayed on the screen (e.g. as a separate + * desktop window, or as a lightweight modal popup). + */ +public sealed class WindowType { + + /** + * Renders the dialog window according to the display mode defined by this + * object. + * + * @param dialog The [Dialog] that is being displayed. + */ + @Composable + public abstract fun dialogWindow(dialog: Dialog) + + /** + * A [WindowType] implementation, which ensures displaying a dialog + * as a separate desktop window. + * + * @param resizable Specifies whether the window can be resized by the user. + */ + public open class DesktopWindow( + public val resizable: Boolean = false + ) : WindowType() { + + @Composable + override fun dialogWindow(dialog: Dialog) { + DialogWindow( + title = dialog.title, + resizable = resizable, + state = DialogState( + size = DpSize(dialog.width, dialog.height) + ), + onCloseRequest = { dialog.close() }, + onKeyEvent = { event -> + if (dialog.cancelAvailableInternal && event matches cancelShortcutKey.down) { + dialog.cancel() + } + if (dialog.submitAvailableInternal && event matches submitShortcutKey.up) { + dialog.submit() + } + false + } + ) { + Column( + modifier = Modifier + .fillMaxSize() + .background(colorScheme.background), + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(dialog.look.padding), + ) { + dialog.windowContentInternal() + dialog.nestedDialog?.Content() + } + } + } + } + + /** + * A default reusable instance of [DesktopWindow], which can be used if + * no additional customizations are required. + * + * Here's an example of how it can be used: + * ``` + * Dialog { + * windowType = DesktopWindow + * ... + * } + * ``` + * + * If any customizations are required (e.g. if you need to make the + * window resizable), just create a new [DesktopWindow] instance with + * respective parameters, like this: + * ``` + * Dialog { + * windowType = DesktopWindow(resizable = true) + * ... + * } + * ``` + */ + public companion object : DesktopWindow( + resizable = false + ) + } + + /** + * A [WindowType] implementation, which ensures displaying a dialog + * as a lightweight modal popup inside the current desktop window. + * + * @param backdropColor The color of the surface that covers the entire + * content of the current desktop window behind the dialog's modal popup + * displayed in this window. + */ + public open class LightweightWindow( + public val backdropColor: Color = Gray.copy(alpha = 0.5f) + ) : WindowType() { + + @Composable + override fun dialogWindow(dialog: Dialog) { + Popup( + popupPositionProvider = centerWindowPositionProvider, + properties = PopupProperties(focusable = true), + onPreviewKeyEvent = { false }, + onKeyEvent = cancelShortcutHandler { + if (dialog.cancelAvailableInternal) { + dialog.cancel() + } + } + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(backdropColor), + contentAlignment = Center + ) { + val modifier = if (dialog.isBottomDialog) { + Modifier.pointerInput(dialog) { + detectTapGestures(onPress = {}) + } + } else { + Modifier + } + Box(modifier = modifier) { + dialogFrame(dialog) + } + } + } + } + + @Composable + private fun dialogFrame(dialog: Dialog) { + Column( + modifier = Modifier + .clip(shapes.large) + .size(dialog.width, dialog.height) + .background(colorScheme.background), + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(dialog.look.padding), + ) { + DialogTitle(dialog.title, dialog.look.titlePadding) + dialog.windowContentInternal() + dialog.nestedDialog ?.Content() + } + } + } + + /** + * Creates a key event handler function that executes a provided + * [cancelHandler] callback whenever the `Escape` key is pressed. + */ + private fun cancelShortcutHandler( + cancelHandler: () -> Unit + ): (KeyEvent) -> Boolean = { event -> + if (event matches cancelShortcutKey.down) { + cancelHandler() + true + } else { + false + } + } + + /** + * A default reusable instance of [LightweightWindow], which can be used if + * no additional customizations are required. + * + * Here's an example of how it can be used: + * ``` + * Dialog { + * windowType = LightweightWindow + * ... + * } + * ``` + * + * If any customizations are required (e.g. if you need to change the + * backdrop color), just create a new [LightweightWindow] instance with + * respective parameters, like this: + * ``` + * Dialog { + * windowType = LightweightWindow( + * backdropColor = White.copy(alpha = 0.5f) + * ) + * ... + * } + * ``` + */ + public companion object : LightweightWindow() + + } +} + +/** + * A [PopupPositionProvider], which makes a lightweight popup to appear at + * the window's center. + * + * @see WindowType.LightweightWindow + */ +private val centerWindowPositionProvider = object : PopupPositionProvider { + override fun calculatePosition( + anchorBounds: IntRect, + windowSize: IntSize, + layoutDirection: LayoutDirection, + popupContentSize: IntSize + ): IntOffset = Zero +} + +/** + * The title of the dialog for lightweight windows. + * + * @param text The text to be displayed as the window's title. + * @see WindowType.LightweightWindow + */ +@Composable +private fun DialogTitle( + text: String, + padding: PaddingValues +) { + Text( + modifier = Modifier.padding(padding), + text = text, + style = typography.headlineLarge + ) +} diff --git a/core/src/main/kotlin/io/spine/chords/core/layout/Wizard.kt b/core/src/main/kotlin/io/spine/chords/core/layout/Wizard.kt index 06d039a1..0d3c4e71 100644 --- a/core/src/main/kotlin/io/spine/chords/core/layout/Wizard.kt +++ b/core/src/main/kotlin/io/spine/chords/core/layout/Wizard.kt @@ -1,5 +1,5 @@ /* - * Copyright 2024, TeamDev. All rights reserved. + * Copyright 2025, TeamDev. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -85,7 +85,7 @@ private object WizardContentSize { } /** - * The base class for creating multi-step form component known as wizard. + * The base class for creating a multi-step form component known as a wizard. * * To create a concrete wizard you need to extend the class * and override all abstract methods that configure the data needed for the wizard. @@ -99,9 +99,10 @@ private object WizardContentSize { public abstract class Wizard : Component() { /** - * The text to be the title of the wizard. + * The text to be the title of the wizard, or `null`, if the wizard's title + * shouldn't be displayed at all. */ - protected abstract val title: String + protected abstract val title: String? /** * A callback that should be handled to close the wizard (exclude it from @@ -124,10 +125,14 @@ public abstract class Wizard : Component() { * Specifies whether the wizard is in the submission state, which means * that an asynchronous form submission has started, but not completed yet. */ - private var submitting: Boolean by mutableStateOf(false) + protected var submitting: Boolean by mutableStateOf(false) private var currentPageIndex by mutableStateOf(0) - private val pages by lazy { createPages() } + + /** + * A list of pages present in the wizard. + */ + protected val pages: List by lazy { createPages() } /** * Creates the list of pages of which the wizard consists. @@ -141,15 +146,23 @@ public abstract class Wizard : Component() { * * This action is executed when the user completes and submits the wizard. * - * `onCloseRequest` is triggerred right after the `submit` action, - * so it is not needed to configure it manually. + * Note, the wizard is not closed automatically when [submit] is invoked, so + * the implementation has to ensure that [close] is invoked as soon as the + * submission process succeeds. * * @return `true`, if submission was performed successfully, and the wizard * can be closed now, and `false` if submission didn't succeed (e.g. if * some validation errors were identified), and the wizard still needs to * be kept open. */ - protected abstract suspend fun submit(): Boolean + protected abstract suspend fun submit() + + /** + * Closes the wizard. + */ + public open fun close() { + onCloseRequest?.invoke() + } @Composable override fun content() { @@ -165,7 +178,9 @@ public abstract class Wizard : Component() { .padding(32.dp), verticalArrangement = spacedBy(16.dp) ) { - Title(title) + if (title != null) { + Title(title!!) + } Column( Modifier .weight(1F) @@ -173,7 +188,7 @@ public abstract class Wizard : Component() { submitPage(currentPage, coroutineScope) } .on(Alt(DirectionLeft.key).up) { - goToPreviousPage() + handlePreviousClick() } .on(Alt(DirectionRight.key).up) { if (!isOnLastPage()) { @@ -190,7 +205,7 @@ public abstract class Wizard : Component() { } NavigationPanel( onNextClick = { handleNextClick(currentPage) }, - onBackClick = { goToPreviousPage() }, + onBackClick = { handlePreviousClick() }, onFinishClick = { coroutineScope.launch { handleFinishClick(currentPage) @@ -220,37 +235,22 @@ public abstract class Wizard : Component() { private suspend fun Wizard.handleFinishClick(currentPage: WizardPage) { if (currentPage.validate()) { - submitting = true - val submittedSuccessfully = try { - submit() - } finally { - submitting = false - } - if (submittedSuccessfully) { - onCloseRequest?.invoke() - } + submit() } } private fun handleNextClick(currentPage: WizardPage) { if (currentPage.validate()) { - goToNextPage() - } - } - - /** - * Navigates the wizard to the next page. - */ - private fun goToNextPage() { - if (!isOnLastPage()) { - currentPageIndex += 1 + if (!isOnLastPage()) { + currentPageIndex += 1 + } } } /** * Navigates the wizard to the previous page. */ - private fun goToPreviousPage() { + private fun handlePreviousClick() { if (!isOnFirstPage()) { currentPageIndex -= 1 } @@ -289,19 +289,20 @@ private fun Title(text: String) { /** * The panel with control buttons of the wizard. * - * @param onNextClick - * a callback triggered when the user clicks on the "Next" button. - * @param onBackClick - * a callback triggered when the user clicks on the "Back" button. - * @param onFinishClick - * a callback triggered when the user clicks on the "Finish" button. - * this callback triggers in a separate coroutine. - * @param onCancelClick - * a callback triggered when the user clicks on the "Cancel" button. - * @param isOnFirstPage - * is a wizard's currently displayed page the first one. - * @param isOnLastPage - * is a wizard's currently displayed page the last one. + * @param onNextClick A callback triggered when the user clicks on + * the "Next" button. + * @param onBackClick A callback triggered when the user clicks on + * the "Back" button. + * @param onFinishClick A callback triggered when the user clicks on + * the "Finish" button. This callback is triggered in a separate coroutine. + * @param onCancelClick A callback triggered when the user clicks on + * the "Cancel" button. + * @param isOnFirstPage Specifies whether the wizard's currently displayed page + * is the first one. + * @param isOnLastPage Specifies whether the wizard's currently displayed page + * is the last one. + * @param submitting Specifies whether wizard's submission is currently + * in progress. */ @Composable private fun NavigationPanel( diff --git a/dependencies.md b/dependencies.md index 77f0fc16..4f214db7 100644 --- a/dependencies.md +++ b/dependencies.md @@ -1,6 +1,6 @@ -# Dependencies of `io.spine.chords:spine-chords-client:2.0.0-SNAPSHOT.59` +# Dependencies of `io.spine.chords:spine-chords-client:2.0.0-SNAPSHOT.60` ## Runtime 1. **Group** : cafe.adriel.voyager. **Name** : voyager-core. **Version** : 1.0.1.**No license information found** @@ -121,7 +121,7 @@ * **Project URL:** [https://github.com/JetBrains/compose-jb](https://github.com/JetBrains/compose-jb) * **License:** [The Apache Software License, Version 2.0](https://www.apache.org/licenses/LICENSE-2.0.txt) -1. **Group** : org.jetbrains.compose.desktop. **Name** : desktop-jvm-macos-x64. **Version** : 1.5.12.**No license information found** +1. **Group** : org.jetbrains.compose.desktop. **Name** : desktop-jvm-windows-x64. **Version** : 1.5.12.**No license information found** 1. **Group** : org.jetbrains.compose.foundation. **Name** : foundation. **Version** : 1.5.12.**No license information found** 1. **Group** : org.jetbrains.compose.foundation. **Name** : foundation-desktop. **Version** : 1.5.12. * **Project URL:** [https://github.com/JetBrains/compose-jb](https://github.com/JetBrains/compose-jb) @@ -240,7 +240,7 @@ * **Project URL:** [https://www.github.com/JetBrains/skiko](https://www.github.com/JetBrains/skiko) * **License:** [The Apache License, Version 2.0](http://www.apache.org/licenses/LICENSE-2.0.txt) -1. **Group** : org.jetbrains.skiko. **Name** : skiko-awt-runtime-macos-x64. **Version** : 0.7.85.4. +1. **Group** : org.jetbrains.skiko. **Name** : skiko-awt-runtime-windows-x64. **Version** : 0.7.85.4. * **Project URL:** [https://www.github.com/JetBrains/skiko](https://www.github.com/JetBrains/skiko) * **License:** [The Apache License, Version 2.0](http://www.apache.org/licenses/LICENSE-2.0.txt) @@ -770,7 +770,7 @@ * **Project URL:** [https://github.com/JetBrains/compose-jb](https://github.com/JetBrains/compose-jb) * **License:** [The Apache Software License, Version 2.0](https://www.apache.org/licenses/LICENSE-2.0.txt) -1. **Group** : org.jetbrains.compose.desktop. **Name** : desktop-jvm-macos-x64. **Version** : 1.5.12.**No license information found** +1. **Group** : org.jetbrains.compose.desktop. **Name** : desktop-jvm-windows-x64. **Version** : 1.5.12.**No license information found** 1. **Group** : org.jetbrains.compose.foundation. **Name** : foundation. **Version** : 1.5.12.**No license information found** 1. **Group** : org.jetbrains.compose.foundation. **Name** : foundation-desktop. **Version** : 1.5.12. * **Project URL:** [https://github.com/JetBrains/compose-jb](https://github.com/JetBrains/compose-jb) @@ -1012,7 +1012,7 @@ * **Project URL:** [https://www.github.com/JetBrains/skiko](https://www.github.com/JetBrains/skiko) * **License:** [The Apache License, Version 2.0](http://www.apache.org/licenses/LICENSE-2.0.txt) -1. **Group** : org.jetbrains.skiko. **Name** : skiko-awt-runtime-macos-x64. **Version** : 0.7.85.4. +1. **Group** : org.jetbrains.skiko. **Name** : skiko-awt-runtime-windows-x64. **Version** : 0.7.85.4. * **Project URL:** [https://www.github.com/JetBrains/skiko](https://www.github.com/JetBrains/skiko) * **License:** [The Apache License, Version 2.0](http://www.apache.org/licenses/LICENSE-2.0.txt) @@ -1086,12 +1086,12 @@ The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Fri Jan 03 11:15:45 CET 2025** using [Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under [Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE). +This report was generated on **Thu Feb 06 16:21:57 GMT+02:00 2025** using [Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under [Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE). -# Dependencies of `io.spine.chords:spine-chords-codegen-tests:2.0.0-SNAPSHOT.59` +# Dependencies of `io.spine.chords:spine-chords-codegen-tests:2.0.0-SNAPSHOT.60` ## Runtime 1. **Group** : com.google.android. **Name** : annotations. **Version** : 4.1.1.4. @@ -1945,12 +1945,12 @@ This report was generated on **Fri Jan 03 11:15:45 CET 2025** using [Gradle-Lice The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Fri Jan 03 11:15:47 CET 2025** using [Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under [Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE). +This report was generated on **Thu Feb 06 16:22:01 GMT+02:00 2025** using [Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under [Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE). -# Dependencies of `io.spine.chords:spine-chords-core:2.0.0-SNAPSHOT.59` +# Dependencies of `io.spine.chords:spine-chords-core:2.0.0-SNAPSHOT.60` ## Runtime 1. **Group** : cafe.adriel.voyager. **Name** : voyager-core. **Version** : 1.0.1. @@ -2041,7 +2041,7 @@ This report was generated on **Fri Jan 03 11:15:47 CET 2025** using [Gradle-Lice * **Project URL:** [https://github.com/JetBrains/compose-jb](https://github.com/JetBrains/compose-jb) * **License:** [The Apache Software License, Version 2.0](https://www.apache.org/licenses/LICENSE-2.0.txt) -1. **Group** : org.jetbrains.compose.desktop. **Name** : desktop-jvm-macos-x64. **Version** : 1.5.12.**No license information found** +1. **Group** : org.jetbrains.compose.desktop. **Name** : desktop-jvm-windows-x64. **Version** : 1.5.12.**No license information found** 1. **Group** : org.jetbrains.compose.foundation. **Name** : foundation. **Version** : 1.5.12.**No license information found** 1. **Group** : org.jetbrains.compose.foundation. **Name** : foundation-desktop. **Version** : 1.5.12. * **Project URL:** [https://github.com/JetBrains/compose-jb](https://github.com/JetBrains/compose-jb) @@ -2159,7 +2159,7 @@ This report was generated on **Fri Jan 03 11:15:47 CET 2025** using [Gradle-Lice * **Project URL:** [https://www.github.com/JetBrains/skiko](https://www.github.com/JetBrains/skiko) * **License:** [The Apache License, Version 2.0](http://www.apache.org/licenses/LICENSE-2.0.txt) -1. **Group** : org.jetbrains.skiko. **Name** : skiko-awt-runtime-macos-x64. **Version** : 0.7.85.4. +1. **Group** : org.jetbrains.skiko. **Name** : skiko-awt-runtime-windows-x64. **Version** : 0.7.85.4. * **Project URL:** [https://www.github.com/JetBrains/skiko](https://www.github.com/JetBrains/skiko) * **License:** [The Apache License, Version 2.0](http://www.apache.org/licenses/LICENSE-2.0.txt) @@ -2664,7 +2664,7 @@ This report was generated on **Fri Jan 03 11:15:47 CET 2025** using [Gradle-Lice * **Project URL:** [https://github.com/JetBrains/compose-jb](https://github.com/JetBrains/compose-jb) * **License:** [The Apache Software License, Version 2.0](https://www.apache.org/licenses/LICENSE-2.0.txt) -1. **Group** : org.jetbrains.compose.desktop. **Name** : desktop-jvm-macos-x64. **Version** : 1.5.12.**No license information found** +1. **Group** : org.jetbrains.compose.desktop. **Name** : desktop-jvm-windows-x64. **Version** : 1.5.12.**No license information found** 1. **Group** : org.jetbrains.compose.foundation. **Name** : foundation. **Version** : 1.5.12.**No license information found** 1. **Group** : org.jetbrains.compose.foundation. **Name** : foundation-desktop. **Version** : 1.5.12. * **Project URL:** [https://github.com/JetBrains/compose-jb](https://github.com/JetBrains/compose-jb) @@ -2909,7 +2909,7 @@ This report was generated on **Fri Jan 03 11:15:47 CET 2025** using [Gradle-Lice * **Project URL:** [https://www.github.com/JetBrains/skiko](https://www.github.com/JetBrains/skiko) * **License:** [The Apache License, Version 2.0](http://www.apache.org/licenses/LICENSE-2.0.txt) -1. **Group** : org.jetbrains.skiko. **Name** : skiko-awt-runtime-macos-x64. **Version** : 0.7.85.4. +1. **Group** : org.jetbrains.skiko. **Name** : skiko-awt-runtime-windows-x64. **Version** : 0.7.85.4. * **Project URL:** [https://www.github.com/JetBrains/skiko](https://www.github.com/JetBrains/skiko) * **License:** [The Apache License, Version 2.0](http://www.apache.org/licenses/LICENSE-2.0.txt) @@ -2983,12 +2983,12 @@ This report was generated on **Fri Jan 03 11:15:47 CET 2025** using [Gradle-Lice The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Fri Jan 03 11:15:48 CET 2025** using [Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under [Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE). +This report was generated on **Thu Feb 06 16:22:04 GMT+02:00 2025** using [Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under [Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE). -# Dependencies of `io.spine.chords:spine-chords-proto:2.0.0-SNAPSHOT.59` +# Dependencies of `io.spine.chords:spine-chords-proto:2.0.0-SNAPSHOT.60` ## Runtime 1. **Group** : cafe.adriel.voyager. **Name** : voyager-core. **Version** : 1.0.1.**No license information found** @@ -3077,7 +3077,7 @@ This report was generated on **Fri Jan 03 11:15:48 CET 2025** using [Gradle-Lice * **Project URL:** [https://github.com/JetBrains/compose-jb](https://github.com/JetBrains/compose-jb) * **License:** [The Apache Software License, Version 2.0](https://www.apache.org/licenses/LICENSE-2.0.txt) -1. **Group** : org.jetbrains.compose.desktop. **Name** : desktop-jvm-macos-x64. **Version** : 1.5.12.**No license information found** +1. **Group** : org.jetbrains.compose.desktop. **Name** : desktop-jvm-windows-x64. **Version** : 1.5.12.**No license information found** 1. **Group** : org.jetbrains.compose.foundation. **Name** : foundation. **Version** : 1.5.12.**No license information found** 1. **Group** : org.jetbrains.compose.foundation. **Name** : foundation-desktop. **Version** : 1.5.12. * **Project URL:** [https://github.com/JetBrains/compose-jb](https://github.com/JetBrains/compose-jb) @@ -3192,7 +3192,7 @@ This report was generated on **Fri Jan 03 11:15:48 CET 2025** using [Gradle-Lice * **Project URL:** [https://www.github.com/JetBrains/skiko](https://www.github.com/JetBrains/skiko) * **License:** [The Apache License, Version 2.0](http://www.apache.org/licenses/LICENSE-2.0.txt) -1. **Group** : org.jetbrains.skiko. **Name** : skiko-awt-runtime-macos-x64. **Version** : 0.7.85.4. +1. **Group** : org.jetbrains.skiko. **Name** : skiko-awt-runtime-windows-x64. **Version** : 0.7.85.4. * **Project URL:** [https://www.github.com/JetBrains/skiko](https://www.github.com/JetBrains/skiko) * **License:** [The Apache License, Version 2.0](http://www.apache.org/licenses/LICENSE-2.0.txt) @@ -3691,7 +3691,7 @@ This report was generated on **Fri Jan 03 11:15:48 CET 2025** using [Gradle-Lice * **Project URL:** [https://github.com/JetBrains/compose-jb](https://github.com/JetBrains/compose-jb) * **License:** [The Apache Software License, Version 2.0](https://www.apache.org/licenses/LICENSE-2.0.txt) -1. **Group** : org.jetbrains.compose.desktop. **Name** : desktop-jvm-macos-x64. **Version** : 1.5.12.**No license information found** +1. **Group** : org.jetbrains.compose.desktop. **Name** : desktop-jvm-windows-x64. **Version** : 1.5.12.**No license information found** 1. **Group** : org.jetbrains.compose.foundation. **Name** : foundation. **Version** : 1.5.12.**No license information found** 1. **Group** : org.jetbrains.compose.foundation. **Name** : foundation-desktop. **Version** : 1.5.12. * **Project URL:** [https://github.com/JetBrains/compose-jb](https://github.com/JetBrains/compose-jb) @@ -3933,7 +3933,7 @@ This report was generated on **Fri Jan 03 11:15:48 CET 2025** using [Gradle-Lice * **Project URL:** [https://www.github.com/JetBrains/skiko](https://www.github.com/JetBrains/skiko) * **License:** [The Apache License, Version 2.0](http://www.apache.org/licenses/LICENSE-2.0.txt) -1. **Group** : org.jetbrains.skiko. **Name** : skiko-awt-runtime-macos-x64. **Version** : 0.7.85.4. +1. **Group** : org.jetbrains.skiko. **Name** : skiko-awt-runtime-windows-x64. **Version** : 0.7.85.4. * **Project URL:** [https://www.github.com/JetBrains/skiko](https://www.github.com/JetBrains/skiko) * **License:** [The Apache License, Version 2.0](http://www.apache.org/licenses/LICENSE-2.0.txt) @@ -4007,12 +4007,12 @@ This report was generated on **Fri Jan 03 11:15:48 CET 2025** using [Gradle-Lice The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Fri Jan 03 11:15:49 CET 2025** using [Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under [Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE). +This report was generated on **Thu Feb 06 16:22:06 GMT+02:00 2025** using [Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under [Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE). -# Dependencies of `io.spine.chords:spine-chords-proto-values:2.0.0-SNAPSHOT.59` +# Dependencies of `io.spine.chords:spine-chords-proto-values:2.0.0-SNAPSHOT.60` ## Runtime 1. **Group** : com.google.code.findbugs. **Name** : jsr305. **Version** : 3.0.2. @@ -4806,12 +4806,12 @@ This report was generated on **Fri Jan 03 11:15:49 CET 2025** using [Gradle-Lice The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Fri Jan 03 11:15:50 CET 2025** using [Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under [Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE). +This report was generated on **Thu Feb 06 16:22:09 GMT+02:00 2025** using [Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under [Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE). -# Dependencies of `io.spine.chords:spine-chords-runtime:2.0.0-SNAPSHOT.59` +# Dependencies of `io.spine.chords:spine-chords-runtime:2.0.0-SNAPSHOT.60` ## Runtime 1. **Group** : com.google.code.findbugs. **Name** : jsr305. **Version** : 3.0.2. @@ -5575,4 +5575,4 @@ This report was generated on **Fri Jan 03 11:15:50 CET 2025** using [Gradle-Lice The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Fri Jan 03 11:15:51 CET 2025** using [Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under [Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE). \ No newline at end of file +This report was generated on **Thu Feb 06 16:22:12 GMT+02:00 2025** using [Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under [Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE). \ No newline at end of file diff --git a/pom.xml b/pom.xml index e0d281b7..e0770ae4 100644 --- a/pom.xml +++ b/pom.xml @@ -10,7 +10,7 @@ all modules and does not describe the project structure per-subproject. --> io.spine.chords Chords -2.0.0-SNAPSHOT.59 +2.0.0-SNAPSHOT.60 2015 @@ -79,7 +79,7 @@ all modules and does not describe the project structure per-subproject. org.jetbrains.compose.desktop - desktop-jvm-macos-x64 + desktop-jvm-windows-x64 1.5.12 compile diff --git a/version.gradle.kts b/version.gradle.kts index c013fabd..2d2614c0 100644 --- a/version.gradle.kts +++ b/version.gradle.kts @@ -27,4 +27,4 @@ /** * The version of all Chords libraries. */ -val chordsVersion: String by extra("2.0.0-SNAPSHOT.59") +val chordsVersion: String by extra("2.0.0-SNAPSHOT.60")