From 5dcea219fa92d39dcb7b9a85e8a386dbaea2cb6e Mon Sep 17 00:00:00 2001 From: marchidalgo Date: Tue, 24 Mar 2026 10:20:11 +0100 Subject: [PATCH 01/19] Try this --- .../BSWInterfaceKit/Skip/AndroidAsyncView.kt | 219 ++++++++++++++++++ .../BSWInterfaceKit/Skip/AndroidBackButton.kt | 27 +++ .../Skip/AndroidSwiftViewModelHolder.kt | 83 +++++++ 3 files changed, 329 insertions(+) create mode 100644 Sources/BSWInterfaceKit/Skip/AndroidAsyncView.kt create mode 100644 Sources/BSWInterfaceKit/Skip/AndroidBackButton.kt create mode 100644 Sources/BSWInterfaceKit/Skip/AndroidSwiftViewModelHolder.kt diff --git a/Sources/BSWInterfaceKit/Skip/AndroidAsyncView.kt b/Sources/BSWInterfaceKit/Skip/AndroidAsyncView.kt new file mode 100644 index 00000000..13b9caea --- /dev/null +++ b/Sources/BSWInterfaceKit/Skip/AndroidAsyncView.kt @@ -0,0 +1,219 @@ +package bswinterface.kit + +import androidx.activity.compose.LocalOnBackPressedDispatcherOwner +import androidx.compose.animation.Crossfade +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +sealed interface AsyncPhase { + data object Idle : AsyncPhase + data object Loading : AsyncPhase + data class Loaded(val data: D) : AsyncPhase + data class Error(val throwable: Throwable) : AsyncPhase +} + +data class AsyncOperation( + var id: ID, + var phase: AsyncPhase, +) { + fun isLoaded(forId: ID): Boolean { + if (this.id != forId) return false + return phase is AsyncPhase.Loaded<*> + } +} + +@Composable +fun AsyncView( + id: ID, + instanceKey: Any = "", + isNotRootView: Boolean = true, + dataGenerator: suspend () -> Data, + hostedView: @Composable (Data) -> Unit, + errorView: @Composable (Throwable, onRetry: () -> Unit) -> Unit = { error, onRetry -> + DefaultAsyncErrorView(error = error, onRetry = onRetry) + }, + loadingView: @Composable () -> Unit = { DefaultAsyncLoadingView(isNotRootView) }, + debounceMillis: Long? = null +) { + val scope = rememberCoroutineScope() + val instanceIdentity = remember(instanceKey) { System.identityHashCode(instanceKey) } + val composedKey = "AsyncView:$instanceIdentity:$id" + + var operation by swiftViewModel( + key = composedKey, + ) { + mutableStateOf>( + AsyncOperation(id = id, phase = AsyncPhase.Idle), + ) + } + + val fetchData: () -> Unit = { + scope.launch { + if (operation.isLoaded(forId = id)) return@launch + + debounceMillis?.let { delay(it) } + + operation = operation.copy(id = id, phase = AsyncPhase.Loading) + try { + val result = dataGenerator() + operation = operation.copy(phase = AsyncPhase.Loaded(result)) + } catch (_: CancellationException) { + // ignore + } catch (t: Throwable) { + operation = operation.copy(phase = AsyncPhase.Error(t)) + } + } + } + + LaunchedEffect(id) { fetchData() } + + Crossfade(targetState = operation.phase) { phase -> + when (phase) { + is AsyncPhase.Idle, + is AsyncPhase.Loading -> loadingView() + is AsyncPhase.Loaded<*> -> hostedView((phase as AsyncPhase.Loaded).data) + is AsyncPhase.Error -> errorView(phase.throwable) { fetchData() } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DefaultAsyncErrorView( + error: Throwable, + onRetry: () -> Unit, +) { + val onBackPressedDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher + val displayMessage = remember(error) { error.toDisplayMessage() } + val onBackStack: () -> Unit = { + onBackPressedDispatcher?.onBackPressed() + } + + Scaffold( + modifier = Modifier.fillMaxWidth(), + contentWindowInsets = WindowInsets(0, 0, 0, 0), + topBar = { + TopAppBar( + windowInsets = WindowInsets(0, 0, 0, 0), + title = {}, + navigationIcon = { + BSWBackButton( + onClick = onBackStack, + tint = MaterialTheme.colorScheme.primary + ) + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .padding(paddingValues) + .fillMaxSize() + .windowInsetsPadding(WindowInsets.safeDrawing), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = displayMessage, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp), + textAlign = TextAlign.Center + ) + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Button(onClick = onRetry) { Text("Retry") } + } + } + } +} + +private fun Throwable.toDisplayMessage(): String { + val preferred = localizedMessage ?: message + val fallback = preferred ?: toString() + + val optionalRegex = Regex("""errorDescription:\s*Optional\("(.+)"\)""") + optionalRegex.find(fallback)?.groupValues?.getOrNull(1)?.let { extracted -> + if (extracted.isNotBlank()) return extracted + } + + return fallback +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DefaultAsyncLoadingView( + isNotRootView: Boolean = true +) { + val onBackPressedDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher + val onBackStack: () -> Unit = { + onBackPressedDispatcher?.onBackPressed() + } + + Scaffold( + modifier = Modifier.fillMaxWidth(), + contentWindowInsets = WindowInsets(0, 0, 0, 0), + topBar = { + if (isNotRootView) { + TopAppBar( + windowInsets = WindowInsets(0, 0, 0, 0), + title = {}, + navigationIcon = { + BSWBackButton( + onClick = onBackStack, + tint = MaterialTheme.colorScheme.primary + ) + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) + } + } + ) { paddingValues -> + Box( + modifier = + Modifier + .padding(paddingValues) + .fillMaxSize() + .windowInsetsPadding(WindowInsets.safeDrawing), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } +} diff --git a/Sources/BSWInterfaceKit/Skip/AndroidBackButton.kt b/Sources/BSWInterfaceKit/Skip/AndroidBackButton.kt new file mode 100644 index 00000000..b095819d --- /dev/null +++ b/Sources/BSWInterfaceKit/Skip/AndroidBackButton.kt @@ -0,0 +1,27 @@ +package bswinterface.kit + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color + +@Composable +fun BSWBackButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + tint: Color = Color.Unspecified, +) { + IconButton( + onClick = onClick, + modifier = modifier + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = null, + tint = tint + ) + } +} diff --git a/Sources/BSWInterfaceKit/Skip/AndroidSwiftViewModelHolder.kt b/Sources/BSWInterfaceKit/Skip/AndroidSwiftViewModelHolder.kt new file mode 100644 index 00000000..1fe64a80 --- /dev/null +++ b/Sources/BSWInterfaceKit/Skip/AndroidSwiftViewModelHolder.kt @@ -0,0 +1,83 @@ +package bswinterface.kit + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.currentCompositeKeyHashCode +import androidx.compose.runtime.remember +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.ViewModelStore +import androidx.lifecycle.ViewModelStoreOwner +import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner +import androidx.lifecycle.viewmodel.compose.viewModel + +class SwiftViewModelHolder(val swiftViewModel: SW) : ViewModel() + +class SwiftViewModelFactory( + private val creator: () -> SW, +) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): VM = SwiftViewModelHolder(creator()) as VM +} + +@PublishedApi +internal enum class SwiftViewModelRetention { + Composition, + Owner, +} + +@PublishedApi +internal val LocalSwiftViewModelRetention = + staticCompositionLocalOf { SwiftViewModelRetention.Owner } + +@Composable +fun WithSwiftViewModelOwnerRetention(content: @Composable () -> Unit) { + CompositionLocalProvider( + LocalSwiftViewModelRetention provides SwiftViewModelRetention.Owner, + ) { + content() + } +} + +@Composable +fun rememberScopedViewModelStoreOwner(scopeKey: Any?): ViewModelStoreOwner { + val viewModelStore = remember(scopeKey) { ViewModelStore() } + + DisposableEffect(viewModelStore) { + onDispose { + viewModelStore.clear() + } + } + + return remember(viewModelStore) { + object : ViewModelStoreOwner { + override val viewModelStore: ViewModelStore = viewModelStore + } + } +} + +@Composable +inline fun swiftViewModel( + key: String = SW::class.java.name, + crossinline factory: () -> SW, +): SW { + val owner = when (LocalSwiftViewModelRetention.current) { + SwiftViewModelRetention.Composition -> { + rememberScopedViewModelStoreOwner(scopeKey = currentCompositeKeyHashCode) + } + SwiftViewModelRetention.Owner -> { + checkNotNull(LocalViewModelStoreOwner.current) { + "No ViewModelStoreOwner was provided via LocalViewModelStoreOwner" + } + } + } + val holder: SwiftViewModelHolder = + viewModel( + viewModelStoreOwner = owner, + key = key, + factory = SwiftViewModelFactory { factory() }, + ) + return holder.swiftViewModel +} From 4c8f0c3fae85a5f6b66f9d2897f8a18b1158ba70 Mon Sep 17 00:00:00 2001 From: marchidalgo Date: Tue, 24 Mar 2026 10:33:43 +0100 Subject: [PATCH 02/19] Improve async view --- .../BSWInterfaceKit/Skip/AndroidAsyncView.kt | 43 +++++++++++-------- 1 file changed, 24 insertions(+), 19 deletions(-) diff --git a/Sources/BSWInterfaceKit/Skip/AndroidAsyncView.kt b/Sources/BSWInterfaceKit/Skip/AndroidAsyncView.kt index 13b9caea..692a44ae 100644 --- a/Sources/BSWInterfaceKit/Skip/AndroidAsyncView.kt +++ b/Sources/BSWInterfaceKit/Skip/AndroidAsyncView.kt @@ -55,19 +55,21 @@ data class AsyncOperation( @Composable fun AsyncView( id: ID, - instanceKey: Any = "", - isNotRootView: Boolean = true, + showBackButton: Boolean = true, dataGenerator: suspend () -> Data, hostedView: @Composable (Data) -> Unit, errorView: @Composable (Throwable, onRetry: () -> Unit) -> Unit = { error, onRetry -> - DefaultAsyncErrorView(error = error, onRetry = onRetry) + DefaultAsyncErrorView( + error = error, + onRetry = onRetry, + showBackButton = showBackButton + ) }, - loadingView: @Composable () -> Unit = { DefaultAsyncLoadingView(isNotRootView) }, + loadingView: @Composable () -> Unit = { DefaultAsyncLoadingView(showBackButton) }, debounceMillis: Long? = null ) { val scope = rememberCoroutineScope() - val instanceIdentity = remember(instanceKey) { System.identityHashCode(instanceKey) } - val composedKey = "AsyncView:$instanceIdentity:$id" + val composedKey = "AsyncView:$id" var operation by swiftViewModel( key = composedKey, @@ -112,6 +114,7 @@ fun AsyncView( fun DefaultAsyncErrorView( error: Throwable, onRetry: () -> Unit, + showBackButton: Boolean = true, ) { val onBackPressedDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher val displayMessage = remember(error) { error.toDisplayMessage() } @@ -123,19 +126,21 @@ fun DefaultAsyncErrorView( modifier = Modifier.fillMaxWidth(), contentWindowInsets = WindowInsets(0, 0, 0, 0), topBar = { - TopAppBar( - windowInsets = WindowInsets(0, 0, 0, 0), - title = {}, - navigationIcon = { - BSWBackButton( - onClick = onBackStack, - tint = MaterialTheme.colorScheme.primary + if (showBackButton) { + TopAppBar( + windowInsets = WindowInsets(0, 0, 0, 0), + title = {}, + navigationIcon = { + BSWBackButton( + onClick = onBackStack, + tint = MaterialTheme.colorScheme.primary + ) + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface ) - }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface ) - ) + } } ) { paddingValues -> Column( @@ -177,7 +182,7 @@ private fun Throwable.toDisplayMessage(): String { @OptIn(ExperimentalMaterial3Api::class) @Composable fun DefaultAsyncLoadingView( - isNotRootView: Boolean = true + showBackButton: Boolean = true ) { val onBackPressedDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher val onBackStack: () -> Unit = { @@ -188,7 +193,7 @@ fun DefaultAsyncLoadingView( modifier = Modifier.fillMaxWidth(), contentWindowInsets = WindowInsets(0, 0, 0, 0), topBar = { - if (isNotRootView) { + if (showBackButton) { TopAppBar( windowInsets = WindowInsets(0, 0, 0, 0), title = {}, From 5cd71776431a60209cdbd082b66f415c63b3302d Mon Sep 17 00:00:00 2001 From: marchidalgo Date: Tue, 24 Mar 2026 12:45:11 +0100 Subject: [PATCH 03/19] Add animations into AsyncView phases --- .../BSWInterfaceKit/Skip/AndroidAsyncView.kt | 35 +++++++++++++++++-- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/Sources/BSWInterfaceKit/Skip/AndroidAsyncView.kt b/Sources/BSWInterfaceKit/Skip/AndroidAsyncView.kt index 692a44ae..e4a5b1fb 100644 --- a/Sources/BSWInterfaceKit/Skip/AndroidAsyncView.kt +++ b/Sources/BSWInterfaceKit/Skip/AndroidAsyncView.kt @@ -1,7 +1,13 @@ package bswinterface.kit import androidx.activity.compose.LocalOnBackPressedDispatcherOwner -import androidx.compose.animation.Crossfade +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -35,6 +41,8 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.delay import kotlinx.coroutines.launch +private const val PHASE_CROSSFADE_DURATION_MS = 220 + sealed interface AsyncPhase { data object Idle : AsyncPhase data object Loading : AsyncPhase @@ -97,9 +105,30 @@ fun AsyncView( } } - LaunchedEffect(id) { fetchData() } + LaunchedEffect(id) { + fetchData() + } - Crossfade(targetState = operation.phase) { phase -> + AnimatedContent( + targetState = operation.phase, + transitionSpec = { + val shouldCrossfade = + initialState is AsyncPhase.Loading && + (targetState is AsyncPhase.Loaded<*> || targetState is AsyncPhase.Error) + + if (shouldCrossfade) { + ( + fadeIn(animationSpec = tween(durationMillis = PHASE_CROSSFADE_DURATION_MS)) togetherWith + fadeOut(animationSpec = tween(durationMillis = PHASE_CROSSFADE_DURATION_MS)) + ) + } else { + ( + EnterTransition.None togetherWith ExitTransition.None + ) + } + }, + label = "AsyncViewPhase" + ) { phase -> when (phase) { is AsyncPhase.Idle, is AsyncPhase.Loading -> loadingView() From 2f42fe14d66a04f0fe6a415032d28afc3fb5f61f Mon Sep 17 00:00:00 2001 From: marchidalgo Date: Tue, 24 Mar 2026 14:43:44 +0100 Subject: [PATCH 04/19] Add async button --- .../Skip/AndroidAsyncButton.kt | 407 ++++++++++++++++++ 1 file changed, 407 insertions(+) create mode 100644 Sources/BSWInterfaceKit/Skip/AndroidAsyncButton.kt diff --git a/Sources/BSWInterfaceKit/Skip/AndroidAsyncButton.kt b/Sources/BSWInterfaceKit/Skip/AndroidAsyncButton.kt new file mode 100644 index 00000000..e488bbf0 --- /dev/null +++ b/Sources/BSWInterfaceKit/Skip/AndroidAsyncButton.kt @@ -0,0 +1,407 @@ +package bswinterface.kit + +import android.os.SystemClock +import android.util.Log +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +enum class AsyncButtonHudKind { Loading, Success, Error } + +enum class AsyncButtonState { Idle, Loading } + +@Stable +data class AsyncButtonLoadingConfiguration( + val message: String? = null, + val style: Style = Style.Inline(tint = null) +) { + sealed interface Style { + data class Inline( + val tint: Color? = null, + val errorMessageMillis: Long = 1500L + ) : Style + + data class Blocking( + val dimsBackground: Boolean = true, + val scrimAlpha: Float = 0.35f, + val horizontalMargin: Dp = 32.dp, + val successMessage: String? = null, + val successMessageMillis: Long = 1200L, + val errorMessageMillis: Long = 1500L + ) : Style + } + + val isBlocking: Boolean get() = style is Style.Blocking +} + +val LocalAsyncButtonLoadingConfiguration = + staticCompositionLocalOf { AsyncButtonLoadingConfiguration() } + +val LocalAsyncButtonOperationKey = + staticCompositionLocalOf { null } + +@Composable +fun ProvideAsyncButtonLoadingConfiguration( + message: String? = null, + style: AsyncButtonLoadingConfiguration.Style = AsyncButtonLoadingConfiguration.Style.Inline(), + content: @Composable () -> Unit +) { + CompositionLocalProvider( + LocalAsyncButtonLoadingConfiguration provides AsyncButtonLoadingConfiguration(message, style), + content = content + ) +} + +@Composable +fun ProvideAsyncButtonOperationIdentifierKey( + key: String?, + content: @Composable () -> Unit +) { + CompositionLocalProvider( + LocalAsyncButtonOperationKey provides key, + content = content + ) +} + +fun normalizeAsyncButtonErrorMessage(raw: String?): String { + if (raw.isNullOrBlank()) return "Something went wrong" + val trimmed = raw.trim() + + val optionalQuoted = Regex("""Optional\("(.+)"\)""") + .find(trimmed)?.groupValues?.getOrNull(1) + if (!optionalQuoted.isNullOrBlank()) return optionalQuoted + + val optionalPlain = Regex("""Optional\((.+)\)""") + .find(trimmed)?.groupValues?.getOrNull(1) + if (!optionalPlain.isNullOrBlank()) return optionalPlain.trim().trim('"') + + return trimmed +} + +@Stable +class AsyncButtonController internal constructor( + loadingConfiguration: AsyncButtonLoadingConfiguration, + private var onClickImpl: () -> Unit +) { + var loadingConfiguration by mutableStateOf(loadingConfiguration) + internal set + + var state by mutableStateOf(AsyncButtonState.Idle) + internal set + + var hudKind by mutableStateOf(null) + internal set + + var hudText by mutableStateOf(null) + internal set + + val isLoading: Boolean get() = state == AsyncButtonState.Loading + + fun onClick() { + onClickImpl() + } + + internal fun updateOnClick(onClick: () -> Unit) { + onClickImpl = onClick + } +} + +@Composable +fun rememberAsyncButtonController( + action: suspend () -> Unit, + errorMessageResolver: (Throwable?) -> String = { throwable -> + normalizeAsyncButtonErrorMessage(throwable?.localizedMessage ?: throwable?.message) + } +): AsyncButtonController { + val loadingConfig = LocalAsyncButtonLoadingConfiguration.current + val operationKey = LocalAsyncButtonOperationKey.current + val scope = rememberCoroutineScope() + val latestAction by rememberUpdatedState(action) + val latestErrorMessageResolver by rememberUpdatedState(errorMessageResolver) + + val controller = remember { + AsyncButtonController( + loadingConfiguration = loadingConfig, + onClickImpl = {} + ) + } + + controller.loadingConfiguration = loadingConfig + controller.updateOnClick { + if (controller.state == AsyncButtonState.Loading) return@updateOnClick + + controller.state = AsyncButtonState.Loading + scope.launch { + val styleLocal = controller.loadingConfiguration.style + val startNanos = SystemClock.elapsedRealtimeNanos() + + if (styleLocal is AsyncButtonLoadingConfiguration.Style.Blocking) { + controller.hudKind = AsyncButtonHudKind.Loading + controller.hudText = controller.loadingConfiguration.message + } + + val result = runCatching { latestAction() } + + operationKey?.takeIf { it.isNotBlank() }?.let { key -> + val endNanos = SystemClock.elapsedRealtimeNanos() + val seconds = (endNanos - startNanos) / 1_000_000_000.0 + Log.d("AsyncOpTracer", "Loading async-button-$key took $seconds seconds") + } + + if (result.isFailure) { + val throwable = result.exceptionOrNull() + if (throwable is CancellationException) { + controller.hudKind = null + controller.hudText = null + controller.state = AsyncButtonState.Idle + return@launch + } + + val errorMillis = when (styleLocal) { + is AsyncButtonLoadingConfiguration.Style.Blocking -> styleLocal.errorMessageMillis + is AsyncButtonLoadingConfiguration.Style.Inline -> styleLocal.errorMessageMillis + } + + controller.hudKind = AsyncButtonHudKind.Error + controller.hudText = latestErrorMessageResolver(throwable) + delay(errorMillis) + controller.hudKind = null + controller.hudText = null + } else if (styleLocal is AsyncButtonLoadingConfiguration.Style.Blocking) { + val successMessage = styleLocal.successMessage + if (!successMessage.isNullOrBlank()) { + controller.hudKind = AsyncButtonHudKind.Success + controller.hudText = successMessage + delay(styleLocal.successMessageMillis) + } + controller.hudKind = null + controller.hudText = null + } + + controller.state = AsyncButtonState.Idle + } + } + + return controller +} + +@Composable +fun DefaultAsyncButtonProgressView( + style: AsyncButtonLoadingConfiguration.Style +) { + val (tint, size) = when (style) { + is AsyncButtonLoadingConfiguration.Style.Inline -> { + (style.tint ?: MaterialTheme.colorScheme.onPrimary) to 16.dp + } + is AsyncButtonLoadingConfiguration.Style.Blocking -> { + MaterialTheme.colorScheme.primary to 32.dp + } + } + + CircularProgressIndicator( + modifier = Modifier.size(size), + color = tint + ) +} + +@Composable +fun AndroidAsyncButton( + modifier: Modifier = Modifier, + enabled: Boolean = true, + bgColor: Color? = null, + disabledColor: Color? = null, + txtColor: Color? = null, + disableTextColor: Color? = null, + action: suspend () -> Unit, + errorMessageResolver: (Throwable?) -> String = { throwable -> + normalizeAsyncButtonErrorMessage(throwable?.localizedMessage ?: throwable?.message) + }, + progressView: @Composable (AsyncButtonLoadingConfiguration.Style) -> Unit = { style -> + DefaultAsyncButtonProgressView(style = style) + }, + label: @Composable RowScope.() -> Unit +) { + val controller = rememberAsyncButtonController( + action = action, + errorMessageResolver = errorMessageResolver + ) + + val backgroundColor = if (enabled) { + bgColor ?: MaterialTheme.colorScheme.primary + } else { + disabledColor ?: MaterialTheme.colorScheme.tertiary + } + + val contentColor = if (enabled) { + txtColor ?: MaterialTheme.colorScheme.onPrimary + } else { + disableTextColor ?: MaterialTheme.colorScheme.onTertiary + } + + val blockingStyle = + controller.loadingConfiguration.style as? AsyncButtonLoadingConfiguration.Style.Blocking + + AsyncButtonBlockingHudDialog( + visible = controller.hudKind != null, + text = controller.hudText, + kind = controller.hudKind ?: AsyncButtonHudKind.Loading, + scrimAlpha = if (blockingStyle?.dimsBackground != false) { + blockingStyle?.scrimAlpha ?: 0.35f + } else { + 0f + }, + horizontalMargin = blockingStyle?.horizontalMargin ?: 32.dp, + loadingView = { + progressView(controller.loadingConfiguration.style) + } + ) + + Button( + modifier = modifier, + onClick = controller::onClick, + enabled = enabled && !controller.isLoading, + colors = ButtonDefaults.buttonColors( + containerColor = backgroundColor, + contentColor = contentColor, + disabledContainerColor = backgroundColor, + disabledContentColor = contentColor + ), + shape = RoundedCornerShape(8.dp) + ) { + if (!controller.loadingConfiguration.isBlocking && controller.isLoading) { + AsyncButtonInlineLoadingView( + message = controller.loadingConfiguration.message, + style = controller.loadingConfiguration.style, + progressView = progressView + ) + } else { + label() + } + } +} + +@Composable +private fun AsyncButtonInlineLoadingView( + message: String?, + style: AsyncButtonLoadingConfiguration.Style, + progressView: @Composable (AsyncButtonLoadingConfiguration.Style) -> Unit +) { + Row(verticalAlignment = Alignment.CenterVertically) { + progressView(style) + if (!message.isNullOrBlank()) { + Spacer(Modifier.size(8.dp)) + Text(message, style = MaterialTheme.typography.bodySmall) + } + } +} + +@Composable +private fun AsyncButtonBlockingHudDialog( + visible: Boolean, + text: String?, + kind: AsyncButtonHudKind, + scrimAlpha: Float, + horizontalMargin: Dp, + loadingView: @Composable () -> Unit +) { + if (!visible) return + + Dialog( + onDismissRequest = {}, + properties = DialogProperties( + dismissOnBackPress = false, + dismissOnClickOutside = false, + usePlatformDefaultWidth = false + ) + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = scrimAlpha)) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) {} + ) { + Surface( + shape = RoundedCornerShape(16.dp), + tonalElevation = 8.dp, + shadowElevation = 8.dp, + modifier = Modifier + .align(Alignment.Center) + .padding(horizontal = horizontalMargin) + ) { + Column( + modifier = Modifier.padding(horizontal = 24.dp, vertical = 20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + when (kind) { + AsyncButtonHudKind.Loading -> loadingView() + AsyncButtonHudKind.Success -> Icon( + imageVector = Icons.Filled.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(32.dp) + ) + AsyncButtonHudKind.Error -> Icon( + imageVector = Icons.Filled.Close, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(32.dp) + ) + } + + text?.takeIf { it.isNotBlank() }?.let { + Text( + text = it, + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyMedium + ) + } + } + } + } + } +} From 549d635a7f52bfa7a3b00160dc96a90a77ff824e Mon Sep 17 00:00:00 2001 From: marchidalgo Date: Tue, 24 Mar 2026 14:53:00 +0100 Subject: [PATCH 05/19] Reneame --- .../Skip/AndroidAsyncButton.kt | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/Sources/BSWInterfaceKit/Skip/AndroidAsyncButton.kt b/Sources/BSWInterfaceKit/Skip/AndroidAsyncButton.kt index e488bbf0..3b266661 100644 --- a/Sources/BSWInterfaceKit/Skip/AndroidAsyncButton.kt +++ b/Sources/BSWInterfaceKit/Skip/AndroidAsyncButton.kt @@ -52,7 +52,7 @@ enum class AsyncButtonHudKind { Loading, Success, Error } enum class AsyncButtonState { Idle, Loading } @Stable -data class AsyncButtonLoadingConfiguration( +data class AndroidAsyncButtonLoadingConfiguration( val message: String? = null, val style: Style = Style.Inline(tint = null) ) { @@ -76,7 +76,7 @@ data class AsyncButtonLoadingConfiguration( } val LocalAsyncButtonLoadingConfiguration = - staticCompositionLocalOf { AsyncButtonLoadingConfiguration() } + staticCompositionLocalOf { AndroidAsyncButtonLoadingConfiguration() } val LocalAsyncButtonOperationKey = staticCompositionLocalOf { null } @@ -84,11 +84,11 @@ val LocalAsyncButtonOperationKey = @Composable fun ProvideAsyncButtonLoadingConfiguration( message: String? = null, - style: AsyncButtonLoadingConfiguration.Style = AsyncButtonLoadingConfiguration.Style.Inline(), + style: AndroidAsyncButtonLoadingConfiguration.Style = AndroidAsyncButtonLoadingConfiguration.Style.Inline(), content: @Composable () -> Unit ) { CompositionLocalProvider( - LocalAsyncButtonLoadingConfiguration provides AsyncButtonLoadingConfiguration(message, style), + LocalAsyncButtonLoadingConfiguration provides AndroidAsyncButtonLoadingConfiguration(message, style), content = content ) } @@ -121,7 +121,7 @@ fun normalizeAsyncButtonErrorMessage(raw: String?): String { @Stable class AsyncButtonController internal constructor( - loadingConfiguration: AsyncButtonLoadingConfiguration, + loadingConfiguration: AndroidAsyncButtonLoadingConfiguration, private var onClickImpl: () -> Unit ) { var loadingConfiguration by mutableStateOf(loadingConfiguration) @@ -176,7 +176,7 @@ fun rememberAsyncButtonController( val styleLocal = controller.loadingConfiguration.style val startNanos = SystemClock.elapsedRealtimeNanos() - if (styleLocal is AsyncButtonLoadingConfiguration.Style.Blocking) { + if (styleLocal is AndroidAsyncButtonLoadingConfiguration.Style.Blocking) { controller.hudKind = AsyncButtonHudKind.Loading controller.hudText = controller.loadingConfiguration.message } @@ -199,8 +199,8 @@ fun rememberAsyncButtonController( } val errorMillis = when (styleLocal) { - is AsyncButtonLoadingConfiguration.Style.Blocking -> styleLocal.errorMessageMillis - is AsyncButtonLoadingConfiguration.Style.Inline -> styleLocal.errorMessageMillis + is AndroidAsyncButtonLoadingConfiguration.Style.Blocking -> styleLocal.errorMessageMillis + is AndroidAsyncButtonLoadingConfiguration.Style.Inline -> styleLocal.errorMessageMillis } controller.hudKind = AsyncButtonHudKind.Error @@ -208,7 +208,7 @@ fun rememberAsyncButtonController( delay(errorMillis) controller.hudKind = null controller.hudText = null - } else if (styleLocal is AsyncButtonLoadingConfiguration.Style.Blocking) { + } else if (styleLocal is AndroidAsyncButtonLoadingConfiguration.Style.Blocking) { val successMessage = styleLocal.successMessage if (!successMessage.isNullOrBlank()) { controller.hudKind = AsyncButtonHudKind.Success @@ -228,13 +228,13 @@ fun rememberAsyncButtonController( @Composable fun DefaultAsyncButtonProgressView( - style: AsyncButtonLoadingConfiguration.Style + style: AndroidAsyncButtonLoadingConfiguration.Style ) { val (tint, size) = when (style) { - is AsyncButtonLoadingConfiguration.Style.Inline -> { + is AndroidAsyncButtonLoadingConfiguration.Style.Inline -> { (style.tint ?: MaterialTheme.colorScheme.onPrimary) to 16.dp } - is AsyncButtonLoadingConfiguration.Style.Blocking -> { + is AndroidAsyncButtonLoadingConfiguration.Style.Blocking -> { MaterialTheme.colorScheme.primary to 32.dp } } @@ -257,7 +257,7 @@ fun AndroidAsyncButton( errorMessageResolver: (Throwable?) -> String = { throwable -> normalizeAsyncButtonErrorMessage(throwable?.localizedMessage ?: throwable?.message) }, - progressView: @Composable (AsyncButtonLoadingConfiguration.Style) -> Unit = { style -> + progressView: @Composable (AndroidAsyncButtonLoadingConfiguration.Style) -> Unit = { style -> DefaultAsyncButtonProgressView(style = style) }, label: @Composable RowScope.() -> Unit @@ -280,7 +280,7 @@ fun AndroidAsyncButton( } val blockingStyle = - controller.loadingConfiguration.style as? AsyncButtonLoadingConfiguration.Style.Blocking + controller.loadingConfiguration.style as? AndroidAsyncButtonLoadingConfiguration.Style.Blocking AsyncButtonBlockingHudDialog( visible = controller.hudKind != null, @@ -324,8 +324,8 @@ fun AndroidAsyncButton( @Composable private fun AsyncButtonInlineLoadingView( message: String?, - style: AsyncButtonLoadingConfiguration.Style, - progressView: @Composable (AsyncButtonLoadingConfiguration.Style) -> Unit + style: AndroidAsyncButtonLoadingConfiguration.Style, + progressView: @Composable (AndroidAsyncButtonLoadingConfiguration.Style) -> Unit ) { Row(verticalAlignment = Alignment.CenterVertically) { progressView(style) From bc413fc240ebe870c261684ad2bd4c754edc3bbb Mon Sep 17 00:00:00 2001 From: marchidalgo Date: Tue, 24 Mar 2026 15:11:10 +0100 Subject: [PATCH 06/19] Use Android... name --- Sources/BSWInterfaceKit/Skip/AndroidAsyncView.kt | 14 +++++++------- .../Skip/AndroidSwiftViewModelHolder.kt | 16 ++++++++-------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/Sources/BSWInterfaceKit/Skip/AndroidAsyncView.kt b/Sources/BSWInterfaceKit/Skip/AndroidAsyncView.kt index e4a5b1fb..19674a34 100644 --- a/Sources/BSWInterfaceKit/Skip/AndroidAsyncView.kt +++ b/Sources/BSWInterfaceKit/Skip/AndroidAsyncView.kt @@ -61,23 +61,23 @@ data class AsyncOperation( } @Composable -fun AsyncView( +fun AndroidAsyncView( id: ID, showBackButton: Boolean = true, dataGenerator: suspend () -> Data, hostedView: @Composable (Data) -> Unit, errorView: @Composable (Throwable, onRetry: () -> Unit) -> Unit = { error, onRetry -> - DefaultAsyncErrorView( + AndroidDefaultAsyncErrorView( error = error, onRetry = onRetry, showBackButton = showBackButton ) }, - loadingView: @Composable () -> Unit = { DefaultAsyncLoadingView(showBackButton) }, + loadingView: @Composable () -> Unit = { AndroidDefaultAsyncLoadingView(showBackButton) }, debounceMillis: Long? = null ) { val scope = rememberCoroutineScope() - val composedKey = "AsyncView:$id" + val composedKey = "AndroidAsyncView:$id" var operation by swiftViewModel( key = composedKey, @@ -127,7 +127,7 @@ fun AsyncView( ) } }, - label = "AsyncViewPhase" + label = "AndroidAsyncViewPhase" ) { phase -> when (phase) { is AsyncPhase.Idle, @@ -140,7 +140,7 @@ fun AsyncView( @OptIn(ExperimentalMaterial3Api::class) @Composable -fun DefaultAsyncErrorView( +fun AndroidDefaultAsyncErrorView( error: Throwable, onRetry: () -> Unit, showBackButton: Boolean = true, @@ -210,7 +210,7 @@ private fun Throwable.toDisplayMessage(): String { @OptIn(ExperimentalMaterial3Api::class) @Composable -fun DefaultAsyncLoadingView( +fun AndroidDefaultAsyncLoadingView( showBackButton: Boolean = true ) { val onBackPressedDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher diff --git a/Sources/BSWInterfaceKit/Skip/AndroidSwiftViewModelHolder.kt b/Sources/BSWInterfaceKit/Skip/AndroidSwiftViewModelHolder.kt index 1fe64a80..9347b1f1 100644 --- a/Sources/BSWInterfaceKit/Skip/AndroidSwiftViewModelHolder.kt +++ b/Sources/BSWInterfaceKit/Skip/AndroidSwiftViewModelHolder.kt @@ -13,13 +13,13 @@ import androidx.lifecycle.ViewModelStoreOwner import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner import androidx.lifecycle.viewmodel.compose.viewModel -class SwiftViewModelHolder(val swiftViewModel: SW) : ViewModel() +class AndroidSwiftViewModelHolder(val swiftViewModel: SW) : ViewModel() -class SwiftViewModelFactory( +class AndroidSwiftViewModelFactory( private val creator: () -> SW, ) : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class): VM = SwiftViewModelHolder(creator()) as VM + override fun create(modelClass: Class): VM = AndroidSwiftViewModelHolder(creator()) as VM } @PublishedApi @@ -33,7 +33,7 @@ internal val LocalSwiftViewModelRetention = staticCompositionLocalOf { SwiftViewModelRetention.Owner } @Composable -fun WithSwiftViewModelOwnerRetention(content: @Composable () -> Unit) { +fun AndroidWithSwiftViewModelOwnerRetention(content: @Composable () -> Unit) { CompositionLocalProvider( LocalSwiftViewModelRetention provides SwiftViewModelRetention.Owner, ) { @@ -42,7 +42,7 @@ fun WithSwiftViewModelOwnerRetention(content: @Composable () -> Unit) { } @Composable -fun rememberScopedViewModelStoreOwner(scopeKey: Any?): ViewModelStoreOwner { +fun rememberAndroidScopedViewModelStoreOwner(scopeKey: Any?): ViewModelStoreOwner { val viewModelStore = remember(scopeKey) { ViewModelStore() } DisposableEffect(viewModelStore) { @@ -65,7 +65,7 @@ inline fun swiftViewModel( ): SW { val owner = when (LocalSwiftViewModelRetention.current) { SwiftViewModelRetention.Composition -> { - rememberScopedViewModelStoreOwner(scopeKey = currentCompositeKeyHashCode) + rememberAndroidScopedViewModelStoreOwner(scopeKey = currentCompositeKeyHashCode) } SwiftViewModelRetention.Owner -> { checkNotNull(LocalViewModelStoreOwner.current) { @@ -73,11 +73,11 @@ inline fun swiftViewModel( } } } - val holder: SwiftViewModelHolder = + val holder: AndroidSwiftViewModelHolder = viewModel( viewModelStoreOwner = owner, key = key, - factory = SwiftViewModelFactory { factory() }, + factory = AndroidSwiftViewModelFactory { factory() }, ) return holder.swiftViewModel } From af1c86d34536e514984abfcc922aa71eb6ae8b47 Mon Sep 17 00:00:00 2001 From: marchidalgo Date: Tue, 24 Mar 2026 15:49:58 +0100 Subject: [PATCH 07/19] Rename --- ...ndroidAsyncButton.kt => BSWAsyncButton.kt} | 34 +++++++++---------- .../{AndroidAsyncView.kt => BSWAsyncView.kt} | 14 ++++---- ...{AndroidBackButton.kt => BSWBackButton.kt} | 0 .../Skip/{AndroidHUD.kt => BSWHUD.kt} | 0 ...elHolder.kt => BSWSwiftViewModelHolder.kt} | 16 ++++----- 5 files changed, 32 insertions(+), 32 deletions(-) rename Sources/BSWInterfaceKit/Skip/{AndroidAsyncButton.kt => BSWAsyncButton.kt} (90%) rename Sources/BSWInterfaceKit/Skip/{AndroidAsyncView.kt => BSWAsyncView.kt} (96%) rename Sources/BSWInterfaceKit/Skip/{AndroidBackButton.kt => BSWBackButton.kt} (100%) rename Sources/BSWInterfaceKit/Skip/{AndroidHUD.kt => BSWHUD.kt} (100%) rename Sources/BSWInterfaceKit/Skip/{AndroidSwiftViewModelHolder.kt => BSWSwiftViewModelHolder.kt} (79%) diff --git a/Sources/BSWInterfaceKit/Skip/AndroidAsyncButton.kt b/Sources/BSWInterfaceKit/Skip/BSWAsyncButton.kt similarity index 90% rename from Sources/BSWInterfaceKit/Skip/AndroidAsyncButton.kt rename to Sources/BSWInterfaceKit/Skip/BSWAsyncButton.kt index 3b266661..c2845a43 100644 --- a/Sources/BSWInterfaceKit/Skip/AndroidAsyncButton.kt +++ b/Sources/BSWInterfaceKit/Skip/BSWAsyncButton.kt @@ -52,7 +52,7 @@ enum class AsyncButtonHudKind { Loading, Success, Error } enum class AsyncButtonState { Idle, Loading } @Stable -data class AndroidAsyncButtonLoadingConfiguration( +data class BSWAsyncButtonLoadingConfiguration( val message: String? = null, val style: Style = Style.Inline(tint = null) ) { @@ -76,7 +76,7 @@ data class AndroidAsyncButtonLoadingConfiguration( } val LocalAsyncButtonLoadingConfiguration = - staticCompositionLocalOf { AndroidAsyncButtonLoadingConfiguration() } + staticCompositionLocalOf { BSWAsyncButtonLoadingConfiguration() } val LocalAsyncButtonOperationKey = staticCompositionLocalOf { null } @@ -84,11 +84,11 @@ val LocalAsyncButtonOperationKey = @Composable fun ProvideAsyncButtonLoadingConfiguration( message: String? = null, - style: AndroidAsyncButtonLoadingConfiguration.Style = AndroidAsyncButtonLoadingConfiguration.Style.Inline(), + style: BSWAsyncButtonLoadingConfiguration.Style = BSWAsyncButtonLoadingConfiguration.Style.Inline(), content: @Composable () -> Unit ) { CompositionLocalProvider( - LocalAsyncButtonLoadingConfiguration provides AndroidAsyncButtonLoadingConfiguration(message, style), + LocalAsyncButtonLoadingConfiguration provides BSWAsyncButtonLoadingConfiguration(message, style), content = content ) } @@ -121,7 +121,7 @@ fun normalizeAsyncButtonErrorMessage(raw: String?): String { @Stable class AsyncButtonController internal constructor( - loadingConfiguration: AndroidAsyncButtonLoadingConfiguration, + loadingConfiguration: BSWAsyncButtonLoadingConfiguration, private var onClickImpl: () -> Unit ) { var loadingConfiguration by mutableStateOf(loadingConfiguration) @@ -176,7 +176,7 @@ fun rememberAsyncButtonController( val styleLocal = controller.loadingConfiguration.style val startNanos = SystemClock.elapsedRealtimeNanos() - if (styleLocal is AndroidAsyncButtonLoadingConfiguration.Style.Blocking) { + if (styleLocal is BSWAsyncButtonLoadingConfiguration.Style.Blocking) { controller.hudKind = AsyncButtonHudKind.Loading controller.hudText = controller.loadingConfiguration.message } @@ -199,8 +199,8 @@ fun rememberAsyncButtonController( } val errorMillis = when (styleLocal) { - is AndroidAsyncButtonLoadingConfiguration.Style.Blocking -> styleLocal.errorMessageMillis - is AndroidAsyncButtonLoadingConfiguration.Style.Inline -> styleLocal.errorMessageMillis + is BSWAsyncButtonLoadingConfiguration.Style.Blocking -> styleLocal.errorMessageMillis + is BSWAsyncButtonLoadingConfiguration.Style.Inline -> styleLocal.errorMessageMillis } controller.hudKind = AsyncButtonHudKind.Error @@ -208,7 +208,7 @@ fun rememberAsyncButtonController( delay(errorMillis) controller.hudKind = null controller.hudText = null - } else if (styleLocal is AndroidAsyncButtonLoadingConfiguration.Style.Blocking) { + } else if (styleLocal is BSWAsyncButtonLoadingConfiguration.Style.Blocking) { val successMessage = styleLocal.successMessage if (!successMessage.isNullOrBlank()) { controller.hudKind = AsyncButtonHudKind.Success @@ -228,13 +228,13 @@ fun rememberAsyncButtonController( @Composable fun DefaultAsyncButtonProgressView( - style: AndroidAsyncButtonLoadingConfiguration.Style + style: BSWAsyncButtonLoadingConfiguration.Style ) { val (tint, size) = when (style) { - is AndroidAsyncButtonLoadingConfiguration.Style.Inline -> { + is BSWAsyncButtonLoadingConfiguration.Style.Inline -> { (style.tint ?: MaterialTheme.colorScheme.onPrimary) to 16.dp } - is AndroidAsyncButtonLoadingConfiguration.Style.Blocking -> { + is BSWAsyncButtonLoadingConfiguration.Style.Blocking -> { MaterialTheme.colorScheme.primary to 32.dp } } @@ -246,7 +246,7 @@ fun DefaultAsyncButtonProgressView( } @Composable -fun AndroidAsyncButton( +fun BSWAsyncButton( modifier: Modifier = Modifier, enabled: Boolean = true, bgColor: Color? = null, @@ -257,7 +257,7 @@ fun AndroidAsyncButton( errorMessageResolver: (Throwable?) -> String = { throwable -> normalizeAsyncButtonErrorMessage(throwable?.localizedMessage ?: throwable?.message) }, - progressView: @Composable (AndroidAsyncButtonLoadingConfiguration.Style) -> Unit = { style -> + progressView: @Composable (BSWAsyncButtonLoadingConfiguration.Style) -> Unit = { style -> DefaultAsyncButtonProgressView(style = style) }, label: @Composable RowScope.() -> Unit @@ -280,7 +280,7 @@ fun AndroidAsyncButton( } val blockingStyle = - controller.loadingConfiguration.style as? AndroidAsyncButtonLoadingConfiguration.Style.Blocking + controller.loadingConfiguration.style as? BSWAsyncButtonLoadingConfiguration.Style.Blocking AsyncButtonBlockingHudDialog( visible = controller.hudKind != null, @@ -324,8 +324,8 @@ fun AndroidAsyncButton( @Composable private fun AsyncButtonInlineLoadingView( message: String?, - style: AndroidAsyncButtonLoadingConfiguration.Style, - progressView: @Composable (AndroidAsyncButtonLoadingConfiguration.Style) -> Unit + style: BSWAsyncButtonLoadingConfiguration.Style, + progressView: @Composable (BSWAsyncButtonLoadingConfiguration.Style) -> Unit ) { Row(verticalAlignment = Alignment.CenterVertically) { progressView(style) diff --git a/Sources/BSWInterfaceKit/Skip/AndroidAsyncView.kt b/Sources/BSWInterfaceKit/Skip/BSWAsyncView.kt similarity index 96% rename from Sources/BSWInterfaceKit/Skip/AndroidAsyncView.kt rename to Sources/BSWInterfaceKit/Skip/BSWAsyncView.kt index 19674a34..11881e01 100644 --- a/Sources/BSWInterfaceKit/Skip/AndroidAsyncView.kt +++ b/Sources/BSWInterfaceKit/Skip/BSWAsyncView.kt @@ -61,23 +61,23 @@ data class AsyncOperation( } @Composable -fun AndroidAsyncView( +fun BSWAsyncView( id: ID, showBackButton: Boolean = true, dataGenerator: suspend () -> Data, hostedView: @Composable (Data) -> Unit, errorView: @Composable (Throwable, onRetry: () -> Unit) -> Unit = { error, onRetry -> - AndroidDefaultAsyncErrorView( + BSWDefaultAsyncErrorView( error = error, onRetry = onRetry, showBackButton = showBackButton ) }, - loadingView: @Composable () -> Unit = { AndroidDefaultAsyncLoadingView(showBackButton) }, + loadingView: @Composable () -> Unit = { BSWDefaultAsyncLoadingView(showBackButton) }, debounceMillis: Long? = null ) { val scope = rememberCoroutineScope() - val composedKey = "AndroidAsyncView:$id" + val composedKey = "BSWAsyncView:$id" var operation by swiftViewModel( key = composedKey, @@ -127,7 +127,7 @@ fun AndroidAsyncView( ) } }, - label = "AndroidAsyncViewPhase" + label = "BSWAsyncViewPhase" ) { phase -> when (phase) { is AsyncPhase.Idle, @@ -140,7 +140,7 @@ fun AndroidAsyncView( @OptIn(ExperimentalMaterial3Api::class) @Composable -fun AndroidDefaultAsyncErrorView( +fun BSWDefaultAsyncErrorView( error: Throwable, onRetry: () -> Unit, showBackButton: Boolean = true, @@ -210,7 +210,7 @@ private fun Throwable.toDisplayMessage(): String { @OptIn(ExperimentalMaterial3Api::class) @Composable -fun AndroidDefaultAsyncLoadingView( +fun BSWDefaultAsyncLoadingView( showBackButton: Boolean = true ) { val onBackPressedDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher diff --git a/Sources/BSWInterfaceKit/Skip/AndroidBackButton.kt b/Sources/BSWInterfaceKit/Skip/BSWBackButton.kt similarity index 100% rename from Sources/BSWInterfaceKit/Skip/AndroidBackButton.kt rename to Sources/BSWInterfaceKit/Skip/BSWBackButton.kt diff --git a/Sources/BSWInterfaceKit/Skip/AndroidHUD.kt b/Sources/BSWInterfaceKit/Skip/BSWHUD.kt similarity index 100% rename from Sources/BSWInterfaceKit/Skip/AndroidHUD.kt rename to Sources/BSWInterfaceKit/Skip/BSWHUD.kt diff --git a/Sources/BSWInterfaceKit/Skip/AndroidSwiftViewModelHolder.kt b/Sources/BSWInterfaceKit/Skip/BSWSwiftViewModelHolder.kt similarity index 79% rename from Sources/BSWInterfaceKit/Skip/AndroidSwiftViewModelHolder.kt rename to Sources/BSWInterfaceKit/Skip/BSWSwiftViewModelHolder.kt index 9347b1f1..bb558d72 100644 --- a/Sources/BSWInterfaceKit/Skip/AndroidSwiftViewModelHolder.kt +++ b/Sources/BSWInterfaceKit/Skip/BSWSwiftViewModelHolder.kt @@ -13,13 +13,13 @@ import androidx.lifecycle.ViewModelStoreOwner import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner import androidx.lifecycle.viewmodel.compose.viewModel -class AndroidSwiftViewModelHolder(val swiftViewModel: SW) : ViewModel() +class BSWSwiftViewModelHolder(val swiftViewModel: SW) : ViewModel() -class AndroidSwiftViewModelFactory( +class BSWSwiftViewModelFactory( private val creator: () -> SW, ) : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class): VM = AndroidSwiftViewModelHolder(creator()) as VM + override fun create(modelClass: Class): VM = BSWSwiftViewModelHolder(creator()) as VM } @PublishedApi @@ -33,7 +33,7 @@ internal val LocalSwiftViewModelRetention = staticCompositionLocalOf { SwiftViewModelRetention.Owner } @Composable -fun AndroidWithSwiftViewModelOwnerRetention(content: @Composable () -> Unit) { +fun BSWWithSwiftViewModelOwnerRetention(content: @Composable () -> Unit) { CompositionLocalProvider( LocalSwiftViewModelRetention provides SwiftViewModelRetention.Owner, ) { @@ -42,7 +42,7 @@ fun AndroidWithSwiftViewModelOwnerRetention(content: @Composable () -> Unit) { } @Composable -fun rememberAndroidScopedViewModelStoreOwner(scopeKey: Any?): ViewModelStoreOwner { +fun rememberBSWScopedViewModelStoreOwner(scopeKey: Any?): ViewModelStoreOwner { val viewModelStore = remember(scopeKey) { ViewModelStore() } DisposableEffect(viewModelStore) { @@ -65,7 +65,7 @@ inline fun swiftViewModel( ): SW { val owner = when (LocalSwiftViewModelRetention.current) { SwiftViewModelRetention.Composition -> { - rememberAndroidScopedViewModelStoreOwner(scopeKey = currentCompositeKeyHashCode) + rememberBSWScopedViewModelStoreOwner(scopeKey = currentCompositeKeyHashCode) } SwiftViewModelRetention.Owner -> { checkNotNull(LocalViewModelStoreOwner.current) { @@ -73,11 +73,11 @@ inline fun swiftViewModel( } } } - val holder: AndroidSwiftViewModelHolder = + val holder: BSWSwiftViewModelHolder = viewModel( viewModelStoreOwner = owner, key = key, - factory = AndroidSwiftViewModelFactory { factory() }, + factory = BSWSwiftViewModelFactory { factory() }, ) return holder.swiftViewModel } From dcb816569289ca2b7af09ba683631a1142bf1743 Mon Sep 17 00:00:00 2001 From: marchidalgo Date: Tue, 24 Mar 2026 18:05:21 +0100 Subject: [PATCH 08/19] Add nobridge --- Sources/BSWInterfaceKit/SwiftUI/Views/AsyncButton.swift | 1 + Sources/BSWInterfaceKit/SwiftUI/Views/AsyncView.swift | 1 + 2 files changed, 2 insertions(+) diff --git a/Sources/BSWInterfaceKit/SwiftUI/Views/AsyncButton.swift b/Sources/BSWInterfaceKit/SwiftUI/Views/AsyncButton.swift index 437eb951..5a2227c0 100644 --- a/Sources/BSWInterfaceKit/SwiftUI/Views/AsyncButton.swift +++ b/Sources/BSWInterfaceKit/SwiftUI/Views/AsyncButton.swift @@ -41,6 +41,7 @@ import SwiftUI /// Use this button when the action requires asynchronous work, which will be shown using a `ProgressView`. /// /// In order to customize it's appereance, use the `.asyncButtonLoadingConfiguration` method +// SKIP @nobridge public struct AsyncButton: View { public typealias Action = () async throws -> Void diff --git a/Sources/BSWInterfaceKit/SwiftUI/Views/AsyncView.swift b/Sources/BSWInterfaceKit/SwiftUI/Views/AsyncView.swift index 56c28d37..4a1953d6 100644 --- a/Sources/BSWInterfaceKit/SwiftUI/Views/AsyncView.swift +++ b/Sources/BSWInterfaceKit/SwiftUI/Views/AsyncView.swift @@ -60,6 +60,7 @@ struct RecipeListView: View, PlaceholderDataProvider { /// `AsyncView` also makes use of SwiftUI's `redacted` modifier to show a placeholder view for the data. /// To do so, implement `generatePlaceholderData()` from `PlaceholderDataProvider` protocol /// +// SKIP @nobridge @MainActor public struct AsyncView: View { From 1f8ac9070f74c38ac6241dab6272d39b22985c4a Mon Sep 17 00:00:00 2001 From: marchidalgo Date: Tue, 24 Mar 2026 18:05:21 +0100 Subject: [PATCH 09/19] Add nobridge --- Sources/BSWInterfaceKit/SwiftUI/Views/AsyncButton.swift | 4 ++++ Sources/BSWInterfaceKit/SwiftUI/Views/AsyncView.swift | 5 +++++ 2 files changed, 9 insertions(+) diff --git a/Sources/BSWInterfaceKit/SwiftUI/Views/AsyncButton.swift b/Sources/BSWInterfaceKit/SwiftUI/Views/AsyncButton.swift index 437eb951..d08886af 100644 --- a/Sources/BSWInterfaceKit/SwiftUI/Views/AsyncButton.swift +++ b/Sources/BSWInterfaceKit/SwiftUI/Views/AsyncButton.swift @@ -41,6 +41,7 @@ import SwiftUI /// Use this button when the action requires asynchronous work, which will be shown using a `ProgressView`. /// /// In order to customize it's appereance, use the `.asyncButtonLoadingConfiguration` method + public struct AsyncButton: View { public typealias Action = () async throws -> Void @@ -50,6 +51,7 @@ public struct AsyncButton: View { private let progressViewProvider: (_ style: AsyncButtonLoadingConfiguration.Style) -> Progress + // SKIP @nobridge internal init( action: @escaping Action, label: Label, @@ -225,6 +227,7 @@ public extension AsyncButton { } // MARK: - Convenience inits +// SKIP @nobridge public extension AsyncButton where Label == Text, Progress == DefaultAsyncButtonProgressView { init(_ label: String, action: @escaping Action) { self.init(action: action) { Text(label) } @@ -239,6 +242,7 @@ public extension AsyncButton where Label == Text, Progress == DefaultAsyncButton } } +// SKIP @nobridge extension AsyncButton where Label == Image, Progress == DefaultAsyncButtonProgressView { init(systemImageName: String, action: @escaping Action) { self.init(action: action) { Image(systemName: systemImageName) } diff --git a/Sources/BSWInterfaceKit/SwiftUI/Views/AsyncView.swift b/Sources/BSWInterfaceKit/SwiftUI/Views/AsyncView.swift index 56c28d37..39786e22 100644 --- a/Sources/BSWInterfaceKit/SwiftUI/Views/AsyncView.swift +++ b/Sources/BSWInterfaceKit/SwiftUI/Views/AsyncView.swift @@ -97,6 +97,7 @@ public struct AsyncView, dataGenerator: @escaping DataGenerator, @ViewBuilder hostedViewGenerator: @escaping HostedViewGenerator, @@ -110,6 +111,7 @@ public struct AsyncView, dataGenerator: @escaping DataGenerator, @@ -230,6 +233,7 @@ public extension AsyncView where ErrorView == AsyncStatePlainErrorView { } } +// SKIP @nobridge public extension AsyncView where HostedView: PlaceholderDataProvider, LoadingView == AsyncStatePlainLoadingView, HostedView.PlaceholderData == Data { init(id: Binding, dataGenerator: @escaping DataGenerator, @@ -262,6 +266,7 @@ public extension AsyncView where HostedView: PlaceholderDataProvider, LoadingVie } } +// SKIP @nobridge public extension AsyncView where HostedView: PlaceholderDataProvider, LoadingView == AsyncStatePlainLoadingView, HostedView.PlaceholderData == Data, ErrorView == AsyncStatePlainErrorView { init(id: Binding, dataGenerator: @escaping DataGenerator, From d3325250cc603ca127869200dc2e98287c193674 Mon Sep 17 00:00:00 2001 From: marchidalgo Date: Tue, 24 Mar 2026 18:26:42 +0100 Subject: [PATCH 10/19] Revert nobridge --- Sources/BSWInterfaceKit/SwiftUI/Views/AsyncButton.swift | 3 --- Sources/BSWInterfaceKit/SwiftUI/Views/AsyncView.swift | 6 ------ 2 files changed, 9 deletions(-) diff --git a/Sources/BSWInterfaceKit/SwiftUI/Views/AsyncButton.swift b/Sources/BSWInterfaceKit/SwiftUI/Views/AsyncButton.swift index d08886af..e81e19ab 100644 --- a/Sources/BSWInterfaceKit/SwiftUI/Views/AsyncButton.swift +++ b/Sources/BSWInterfaceKit/SwiftUI/Views/AsyncButton.swift @@ -51,7 +51,6 @@ public struct AsyncButton: View { private let progressViewProvider: (_ style: AsyncButtonLoadingConfiguration.Style) -> Progress - // SKIP @nobridge internal init( action: @escaping Action, label: Label, @@ -227,7 +226,6 @@ public extension AsyncButton { } // MARK: - Convenience inits -// SKIP @nobridge public extension AsyncButton where Label == Text, Progress == DefaultAsyncButtonProgressView { init(_ label: String, action: @escaping Action) { self.init(action: action) { Text(label) } @@ -242,7 +240,6 @@ public extension AsyncButton where Label == Text, Progress == DefaultAsyncButton } } -// SKIP @nobridge extension AsyncButton where Label == Image, Progress == DefaultAsyncButtonProgressView { init(systemImageName: String, action: @escaping Action) { self.init(action: action) { Image(systemName: systemImageName) } diff --git a/Sources/BSWInterfaceKit/SwiftUI/Views/AsyncView.swift b/Sources/BSWInterfaceKit/SwiftUI/Views/AsyncView.swift index cdc059a3..56c28d37 100644 --- a/Sources/BSWInterfaceKit/SwiftUI/Views/AsyncView.swift +++ b/Sources/BSWInterfaceKit/SwiftUI/Views/AsyncView.swift @@ -60,7 +60,6 @@ struct RecipeListView: View, PlaceholderDataProvider { /// `AsyncView` also makes use of SwiftUI's `redacted` modifier to show a placeholder view for the data. /// To do so, implement `generatePlaceholderData()` from `PlaceholderDataProvider` protocol /// -// SKIP @nobridge @MainActor public struct AsyncView: View { @@ -98,7 +97,6 @@ public struct AsyncView, dataGenerator: @escaping DataGenerator, @ViewBuilder hostedViewGenerator: @escaping HostedViewGenerator, @@ -112,7 +110,6 @@ public struct AsyncView, dataGenerator: @escaping DataGenerator, @@ -234,7 +230,6 @@ public extension AsyncView where ErrorView == AsyncStatePlainErrorView { } } -// SKIP @nobridge public extension AsyncView where HostedView: PlaceholderDataProvider, LoadingView == AsyncStatePlainLoadingView, HostedView.PlaceholderData == Data { init(id: Binding, dataGenerator: @escaping DataGenerator, @@ -267,7 +262,6 @@ public extension AsyncView where HostedView: PlaceholderDataProvider, LoadingVie } } -// SKIP @nobridge public extension AsyncView where HostedView: PlaceholderDataProvider, LoadingView == AsyncStatePlainLoadingView, HostedView.PlaceholderData == Data, ErrorView == AsyncStatePlainErrorView { init(id: Binding, dataGenerator: @escaping DataGenerator, From fd83bca91411bc070ae8417f7d567b271ea415fa Mon Sep 17 00:00:00 2001 From: marchidalgo Date: Tue, 24 Mar 2026 18:31:57 +0100 Subject: [PATCH 11/19] Better naming --- Sources/BSWInterfaceKit/Skip/BSWSwiftViewModelHolder.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/BSWInterfaceKit/Skip/BSWSwiftViewModelHolder.kt b/Sources/BSWInterfaceKit/Skip/BSWSwiftViewModelHolder.kt index bb558d72..90890079 100644 --- a/Sources/BSWInterfaceKit/Skip/BSWSwiftViewModelHolder.kt +++ b/Sources/BSWInterfaceKit/Skip/BSWSwiftViewModelHolder.kt @@ -33,7 +33,7 @@ internal val LocalSwiftViewModelRetention = staticCompositionLocalOf { SwiftViewModelRetention.Owner } @Composable -fun BSWWithSwiftViewModelOwnerRetention(content: @Composable () -> Unit) { +fun BSWSwiftViewModelOwnerRetention(content: @Composable () -> Unit) { CompositionLocalProvider( LocalSwiftViewModelRetention provides SwiftViewModelRetention.Owner, ) { From a6710c411070a4ea2e7145ee754eca9b30797b43 Mon Sep 17 00:00:00 2001 From: marchidalgo Date: Tue, 24 Mar 2026 19:36:31 +0100 Subject: [PATCH 12/19] Add NavDisplay and Sheet --- Sources/BSWInterfaceKit/Skip/BSWNavDisplay.kt | 123 +++++++++++++++ Sources/BSWInterfaceKit/Skip/BSWSheet.kt | 144 ++++++++++++++++++ .../Skip/BSWSwiftViewModelHolder.kt | 14 ++ 3 files changed, 281 insertions(+) create mode 100644 Sources/BSWInterfaceKit/Skip/BSWNavDisplay.kt create mode 100644 Sources/BSWInterfaceKit/Skip/BSWSheet.kt diff --git a/Sources/BSWInterfaceKit/Skip/BSWNavDisplay.kt b/Sources/BSWInterfaceKit/Skip/BSWNavDisplay.kt new file mode 100644 index 00000000..12b87932 --- /dev/null +++ b/Sources/BSWInterfaceKit/Skip/BSWNavDisplay.kt @@ -0,0 +1,123 @@ +package bswinterface.kit + +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.ContentTransform +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.lifecycle.ViewModelStore +import androidx.lifecycle.ViewModelStoreOwner +import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.runtime.NavEntryDecorator +import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator +import androidx.navigation3.ui.NavDisplay +import androidx.navigationevent.NavigationEvent + +private const val BSW_BACK_ANIMATION_DURATION_MS = 450 + +private fun AnimatedContentTransitionScope.bswBackTransform( + towards: AnimatedContentTransitionScope.SlideDirection, +): ContentTransform = + slideIntoContainer( + towards = towards, + animationSpec = tween(durationMillis = BSW_BACK_ANIMATION_DURATION_MS), + initialOffset = { it / 3 }, + ) + fadeIn(animationSpec = tween(durationMillis = BSW_BACK_ANIMATION_DURATION_MS)) togetherWith + slideOutOfContainer( + towards = towards, + animationSpec = tween(durationMillis = BSW_BACK_ANIMATION_DURATION_MS), + ) + fadeOut(animationSpec = tween(durationMillis = BSW_BACK_ANIMATION_DURATION_MS)) + +@Composable +fun rememberBSWNavEntryDecorators(): List> = + listOf( + rememberSaveableStateHolderNavEntryDecorator(), + rememberBSWScopedViewModelStoreNavEntryDecorator(), + ) + +@Composable +private fun rememberBSWScopedViewModelStoreNavEntryDecorator(): NavEntryDecorator { + val viewModelStores = remember { mutableStateMapOf() } + + DisposableEffect(Unit) { + onDispose { + viewModelStores.values.forEach(ViewModelStore::clear) + viewModelStores.clear() + } + } + + return remember { + NavEntryDecorator( + onPop = { contentKey -> + viewModelStores.remove(contentKey)?.clear() + }, + ) { entry -> + val store = viewModelStores.getOrPut(entry.contentKey) { ViewModelStore() } + val owner = + object : ViewModelStoreOwner { + override val viewModelStore: ViewModelStore = store + } + + CompositionLocalProvider(LocalViewModelStoreOwner provides owner) { + BSWSwiftViewModelOwnerRetention { + entry.Content() + } + } + } + } +} + +@Composable +fun BSWNavDisplay( + backStack: List, + modifier: Modifier = Modifier, + contentAlignment: Alignment = Alignment.TopStart, + onBack: () -> Unit = { + if (backStack is MutableList) { + backStack.removeLastOrNull() + } + }, + entryDecorators: List>? = null, + entryProvider: (key: T) -> NavEntry, +) { + val resolvedEntryDecorators = entryDecorators ?: rememberBSWNavEntryDecorators() + + NavDisplay( + backStack = backStack, + modifier = modifier, + contentAlignment = contentAlignment, + onBack = onBack, + entryDecorators = resolvedEntryDecorators, + transitionSpec = { + slideIntoContainer( + towards = AnimatedContentTransitionScope.SlideDirection.Left, + animationSpec = spring(), + ) togetherWith ExitTransition.KeepUntilTransitionsFinished + }, + popTransitionSpec = { + bswBackTransform(AnimatedContentTransitionScope.SlideDirection.Right) + }, + predictivePopTransitionSpec = { swipeEdge -> + val towards = + if (swipeEdge == NavigationEvent.EDGE_RIGHT) { + AnimatedContentTransitionScope.SlideDirection.Left + } else { + AnimatedContentTransitionScope.SlideDirection.Right + } + + bswBackTransform(towards) + }, + entryProvider = entryProvider, + ) +} diff --git a/Sources/BSWInterfaceKit/Skip/BSWSheet.kt b/Sources/BSWInterfaceKit/Skip/BSWSheet.kt new file mode 100644 index 00000000..32116f7f --- /dev/null +++ b/Sources/BSWInterfaceKit/Skip/BSWSheet.kt @@ -0,0 +1,144 @@ +package bswinterface.kit + +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.material3.BottomSheetDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.ModalBottomSheetProperties +import androidx.compose.material3.SheetState +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp + +@OptIn(ExperimentalMaterial3Api::class) +object BSWSheet { + @Composable + fun Default( + visible: Boolean, + interactiveDismissDisabled: Boolean = false, + sheetState: SheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + sheetGesturesEnabled: Boolean = true, + sheetMaxWidth: Dp = BottomSheetDefaults.SheetMaxWidth, + containerColor: Color = Color.White, + contentWindowInsets: @Composable () -> WindowInsets = { BottomSheetDefaults.windowInsets }, + dragHandle: @Composable (() -> Unit)? = { BottomSheetDefaults.DragHandle() }, + onDismiss: () -> Unit, + content: @Composable () -> Unit, + ) { + BaseSheet( + initialItem = Unit.takeIf { visible }, + sheetState = sheetState, + interactiveDismissDisabled = interactiveDismissDisabled, + sheetGesturesEnabled = sheetGesturesEnabled, + sheetMaxWidth = sheetMaxWidth, + containerColor = containerColor, + contentWindowInsets = contentWindowInsets, + dragHandle = dragHandle, + onDismiss = onDismiss, + content = { content() }, + ) + } + + @Composable + fun Default( + item: Item?, + interactiveDismissDisabled: Boolean = false, + sheetState: SheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + sheetGesturesEnabled: Boolean = true, + sheetMaxWidth: Dp = BottomSheetDefaults.SheetMaxWidth, + containerColor: Color = Color.White, + contentWindowInsets: @Composable () -> WindowInsets = { BottomSheetDefaults.windowInsets }, + dragHandle: @Composable (() -> Unit)? = { BottomSheetDefaults.DragHandle() }, + contentScopeKey: (Item) -> Any = { it }, + onDismiss: () -> Unit, + content: @Composable (Item) -> Unit, + ) { + BaseSheet( + initialItem = item, + contentIdentity = item?.let(contentScopeKey), + sheetState = sheetState, + interactiveDismissDisabled = interactiveDismissDisabled, + sheetGesturesEnabled = sheetGesturesEnabled, + sheetMaxWidth = sheetMaxWidth, + containerColor = containerColor, + contentWindowInsets = contentWindowInsets, + dragHandle = dragHandle, + onDismiss = onDismiss, + content = content, + ) + } + + @Composable + private fun BaseSheet( + initialItem: Item?, + contentIdentity: Any? = initialItem, + sheetState: SheetState, + interactiveDismissDisabled: Boolean, + sheetGesturesEnabled: Boolean, + sheetMaxWidth: Dp, + containerColor: Color, + contentWindowInsets: @Composable () -> WindowInsets, + dragHandle: @Composable (() -> Unit)?, + onDismiss: () -> Unit, + content: @Composable (Item) -> Unit, + ) { + var isSheetOpen by remember { mutableStateOf(initialItem != null) } + var presentationId by remember { mutableLongStateOf(if (initialItem != null) 1L else 0L) } + var activeItem by remember { mutableStateOf(initialItem) } + var activeIdentity by remember { mutableStateOf(contentIdentity) } + + LaunchedEffect(initialItem, contentIdentity) { + if (initialItem != null) { + if (!isSheetOpen || activeIdentity != contentIdentity) { + presentationId += 1 + } + activeItem = initialItem + activeIdentity = contentIdentity + isSheetOpen = true + } else if (isSheetOpen) { + try { + sheetState.hide() + } catch (_: Exception) { + } finally { + isSheetOpen = false + activeItem = null + activeIdentity = null + } + } + } + + val item = activeItem + if (isSheetOpen && item != null) { + ModalBottomSheet( + properties = ModalBottomSheetProperties( + shouldDismissOnBackPress = !interactiveDismissDisabled, + ), + onDismissRequest = { + if (!interactiveDismissDisabled) { + onDismiss() + } + }, + sheetState = sheetState, + sheetGesturesEnabled = sheetGesturesEnabled && !interactiveDismissDisabled, + sheetMaxWidth = sheetMaxWidth, + containerColor = containerColor, + contentWindowInsets = contentWindowInsets, + dragHandle = dragHandle, + ) { + key(presentationId) { + BSWWithScopedSwiftViewModelOwner(scopeKey = presentationId) { + content(item) + } + } + } + } + } +} diff --git a/Sources/BSWInterfaceKit/Skip/BSWSwiftViewModelHolder.kt b/Sources/BSWInterfaceKit/Skip/BSWSwiftViewModelHolder.kt index 90890079..8a649939 100644 --- a/Sources/BSWInterfaceKit/Skip/BSWSwiftViewModelHolder.kt +++ b/Sources/BSWInterfaceKit/Skip/BSWSwiftViewModelHolder.kt @@ -41,6 +41,20 @@ fun BSWSwiftViewModelOwnerRetention(content: @Composable () -> Unit) { } } +@Composable +fun BSWWithScopedSwiftViewModelOwner( + scopeKey: Any?, + content: @Composable () -> Unit, +) { + val owner = rememberBSWScopedViewModelStoreOwner(scopeKey) + + CompositionLocalProvider(LocalViewModelStoreOwner provides owner) { + BSWSwiftViewModelOwnerRetention { + content() + } + } +} + @Composable fun rememberBSWScopedViewModelStoreOwner(scopeKey: Any?): ViewModelStoreOwner { val viewModelStore = remember(scopeKey) { ViewModelStore() } From fd52cf5cb043e66b4a6756f43f31ebff9b8fd2ca Mon Sep 17 00:00:00 2001 From: marchidalgo Date: Tue, 24 Mar 2026 21:01:12 +0100 Subject: [PATCH 13/19] Fix CI --- Sources/BSWInterfaceKit/Skip/BSWNavDisplay.kt | 28 +++++++++++-------- Sources/BSWInterfaceKit/Skip/skip.yml | 23 +++++++++++++++ 2 files changed, 40 insertions(+), 11 deletions(-) diff --git a/Sources/BSWInterfaceKit/Skip/BSWNavDisplay.kt b/Sources/BSWInterfaceKit/Skip/BSWNavDisplay.kt index 12b87932..9a5400b5 100644 --- a/Sources/BSWInterfaceKit/Skip/BSWNavDisplay.kt +++ b/Sources/BSWInterfaceKit/Skip/BSWNavDisplay.kt @@ -26,18 +26,21 @@ import androidx.navigationevent.NavigationEvent private const val BSW_BACK_ANIMATION_DURATION_MS = 450 -private fun AnimatedContentTransitionScope.bswBackTransform( +private fun bswBackTransform( + scope: AnimatedContentTransitionScope<*>, towards: AnimatedContentTransitionScope.SlideDirection, ): ContentTransform = - slideIntoContainer( - towards = towards, - animationSpec = tween(durationMillis = BSW_BACK_ANIMATION_DURATION_MS), - initialOffset = { it / 3 }, - ) + fadeIn(animationSpec = tween(durationMillis = BSW_BACK_ANIMATION_DURATION_MS)) togetherWith - slideOutOfContainer( + with(scope) { + slideIntoContainer( towards = towards, animationSpec = tween(durationMillis = BSW_BACK_ANIMATION_DURATION_MS), - ) + fadeOut(animationSpec = tween(durationMillis = BSW_BACK_ANIMATION_DURATION_MS)) + initialOffset = { it / 3 }, + ) + fadeIn(animationSpec = tween(durationMillis = BSW_BACK_ANIMATION_DURATION_MS)) togetherWith + slideOutOfContainer( + towards = towards, + animationSpec = tween(durationMillis = BSW_BACK_ANIMATION_DURATION_MS), + ) + fadeOut(animationSpec = tween(durationMillis = BSW_BACK_ANIMATION_DURATION_MS)) + } @Composable fun rememberBSWNavEntryDecorators(): List> = @@ -103,10 +106,13 @@ fun BSWNavDisplay( slideIntoContainer( towards = AnimatedContentTransitionScope.SlideDirection.Left, animationSpec = spring(), - ) togetherWith ExitTransition.KeepUntilTransitionsFinished + ) togetherWith ExitTransition.None }, popTransitionSpec = { - bswBackTransform(AnimatedContentTransitionScope.SlideDirection.Right) + bswBackTransform( + scope = this, + towards = AnimatedContentTransitionScope.SlideDirection.Right, + ) }, predictivePopTransitionSpec = { swipeEdge -> val towards = @@ -116,7 +122,7 @@ fun BSWNavDisplay( AnimatedContentTransitionScope.SlideDirection.Right } - bswBackTransform(towards) + bswBackTransform(scope = this, towards = towards) }, entryProvider = entryProvider, ) diff --git a/Sources/BSWInterfaceKit/Skip/skip.yml b/Sources/BSWInterfaceKit/Skip/skip.yml index b00cedf5..d6dc4b8a 100644 --- a/Sources/BSWInterfaceKit/Skip/skip.yml +++ b/Sources/BSWInterfaceKit/Skip/skip.yml @@ -1,2 +1,25 @@ skip: mode: 'native' + +settings: + contents: + - block: 'dependencyResolutionManagement' + contents: + - block: 'versionCatalogs' + contents: + - block: 'create("libs")' + contents: + - 'version("androidx-navigation3", "1.0.0")' + - 'version("androidx-navigationevent", "1.0.0")' + - 'library("androidx-navigation3-runtime", "androidx.navigation3", "navigation3-runtime").versionRef("androidx-navigation3")' + - 'library("androidx-navigation3-ui", "androidx.navigation3", "navigation3-ui").versionRef("androidx-navigation3")' + - 'library("androidx-navigationevent", "androidx.navigationevent", "navigationevent").versionRef("androidx-navigationevent")' + +build: + contents: + - block: 'dependencies' + export: false + contents: + - 'api(libs.androidx.navigation3.runtime)' + - 'api(libs.androidx.navigation3.ui)' + - 'api(libs.androidx.navigationevent)' From d507c1ddb183813a1004982185c0964365bd6abc Mon Sep 17 00:00:00 2001 From: marchidalgo Date: Tue, 24 Mar 2026 22:48:14 +0100 Subject: [PATCH 14/19] update yml --- Sources/BSWInterfaceKit/Skip/skip.yml | 20 +++----------------- 1 file changed, 3 insertions(+), 17 deletions(-) diff --git a/Sources/BSWInterfaceKit/Skip/skip.yml b/Sources/BSWInterfaceKit/Skip/skip.yml index d6dc4b8a..2fcd01ec 100644 --- a/Sources/BSWInterfaceKit/Skip/skip.yml +++ b/Sources/BSWInterfaceKit/Skip/skip.yml @@ -1,25 +1,11 @@ skip: mode: 'native' -settings: - contents: - - block: 'dependencyResolutionManagement' - contents: - - block: 'versionCatalogs' - contents: - - block: 'create("libs")' - contents: - - 'version("androidx-navigation3", "1.0.0")' - - 'version("androidx-navigationevent", "1.0.0")' - - 'library("androidx-navigation3-runtime", "androidx.navigation3", "navigation3-runtime").versionRef("androidx-navigation3")' - - 'library("androidx-navigation3-ui", "androidx.navigation3", "navigation3-ui").versionRef("androidx-navigation3")' - - 'library("androidx-navigationevent", "androidx.navigationevent", "navigationevent").versionRef("androidx-navigationevent")' - build: contents: - block: 'dependencies' export: false contents: - - 'api(libs.androidx.navigation3.runtime)' - - 'api(libs.androidx.navigation3.ui)' - - 'api(libs.androidx.navigationevent)' + - 'api("androidx.navigation3:navigation3-runtime:1.0.0")' + - 'api("androidx.navigation3:navigation3-ui:1.0.0")' + - 'api("androidx.navigationevent:navigationevent:1.0.0")' From e55e786e3c7ccdc8669ea62934a138d1ade23ff5 Mon Sep 17 00:00:00 2001 From: marchidalgo Date: Wed, 25 Mar 2026 08:11:50 +0100 Subject: [PATCH 15/19] Add KotlinBridgeSerializationSupport --- .../BSWKotlinBridgeSerializationSupport.swift | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 Sources/BSWInterfaceKit/Extensions/BSWKotlinBridgeSerializationSupport.swift diff --git a/Sources/BSWInterfaceKit/Extensions/BSWKotlinBridgeSerializationSupport.swift b/Sources/BSWInterfaceKit/Extensions/BSWKotlinBridgeSerializationSupport.swift new file mode 100644 index 00000000..93f06f68 --- /dev/null +++ b/Sources/BSWInterfaceKit/Extensions/BSWKotlinBridgeSerializationSupport.swift @@ -0,0 +1,56 @@ +// +// BSWKotlinBridgeSerializationSupport.swift +// + +/* SKIP INSERT: +internal abstract class ByteArrayDelegatingBridgeSerializer( + serialName: String +) : kotlinx.serialization.KSerializer { + private val delegateSerializer = kotlinx.serialization.builtins.ByteArraySerializer() + final override val descriptor: kotlinx.serialization.descriptors.SerialDescriptor = + kotlinx.serialization.descriptors.SerialDescriptor(serialName, delegateSerializer.descriptor) + + protected abstract fun toByteArray(value: T): kotlin.ByteArray + protected abstract fun fromByteArray(bytes: kotlin.ByteArray): T + + final override fun serialize(encoder: kotlinx.serialization.encoding.Encoder, value: T) { + encoder.encodeSerializableValue(delegateSerializer, toByteArray(value)) + } + + final override fun deserialize(decoder: kotlinx.serialization.encoding.Decoder): T { + val bytes: kotlin.ByteArray = decoder.decodeSerializableValue(delegateSerializer) + return fromByteArray(bytes) + } +} + +internal fun encodeTagged(tag: Int, payload: kotlin.ByteArray = kotlin.byteArrayOf()): kotlin.ByteArray { + require(tag in 0..255) { "Tag must fit in one byte: $tag" } + return kotlin.byteArrayOf(tag.toByte()) + payload +} + +internal inline fun decodeTagged( + bytes: kotlin.ByteArray, + block: (tag: Int, payload: kotlin.ByteArray) -> T +): T { + require(bytes.isNotEmpty()) { "Expected tagged payload but got empty byte array" } + val tag = bytes[0].toInt() and 0xFF + val payload = bytes.copyOfRange(1, bytes.size) + return block(tag, payload) +} + +internal fun intToByteArray(value: Int): kotlin.ByteArray = + kotlin.byteArrayOf( + ((value shr 24) and 0xFF).toByte(), + ((value shr 16) and 0xFF).toByte(), + ((value shr 8) and 0xFF).toByte(), + (value and 0xFF).toByte() + ) + +internal fun byteArrayToInt(bytes: kotlin.ByteArray): Int { + require(bytes.size == 4) { "Expected exactly 4 bytes for Int, got ${'$'}{bytes.size}" } + return ((bytes[0].toInt() and 0xFF) shl 24) or + ((bytes[1].toInt() and 0xFF) shl 16) or + ((bytes[2].toInt() and 0xFF) shl 8) or + (bytes[3].toInt() and 0xFF) +} +*/ From dc016a43151f7d98e7987d9202a2d1b6aae4d4b4 Mon Sep 17 00:00:00 2001 From: marchidalgo Date: Wed, 25 Mar 2026 10:10:29 +0100 Subject: [PATCH 16/19] Revert "Add KotlinBridgeSerializationSupport" This reverts commit e55e786e3c7ccdc8669ea62934a138d1ade23ff5. --- .../BSWKotlinBridgeSerializationSupport.swift | 56 ------------------- 1 file changed, 56 deletions(-) delete mode 100644 Sources/BSWInterfaceKit/Extensions/BSWKotlinBridgeSerializationSupport.swift diff --git a/Sources/BSWInterfaceKit/Extensions/BSWKotlinBridgeSerializationSupport.swift b/Sources/BSWInterfaceKit/Extensions/BSWKotlinBridgeSerializationSupport.swift deleted file mode 100644 index 93f06f68..00000000 --- a/Sources/BSWInterfaceKit/Extensions/BSWKotlinBridgeSerializationSupport.swift +++ /dev/null @@ -1,56 +0,0 @@ -// -// BSWKotlinBridgeSerializationSupport.swift -// - -/* SKIP INSERT: -internal abstract class ByteArrayDelegatingBridgeSerializer( - serialName: String -) : kotlinx.serialization.KSerializer { - private val delegateSerializer = kotlinx.serialization.builtins.ByteArraySerializer() - final override val descriptor: kotlinx.serialization.descriptors.SerialDescriptor = - kotlinx.serialization.descriptors.SerialDescriptor(serialName, delegateSerializer.descriptor) - - protected abstract fun toByteArray(value: T): kotlin.ByteArray - protected abstract fun fromByteArray(bytes: kotlin.ByteArray): T - - final override fun serialize(encoder: kotlinx.serialization.encoding.Encoder, value: T) { - encoder.encodeSerializableValue(delegateSerializer, toByteArray(value)) - } - - final override fun deserialize(decoder: kotlinx.serialization.encoding.Decoder): T { - val bytes: kotlin.ByteArray = decoder.decodeSerializableValue(delegateSerializer) - return fromByteArray(bytes) - } -} - -internal fun encodeTagged(tag: Int, payload: kotlin.ByteArray = kotlin.byteArrayOf()): kotlin.ByteArray { - require(tag in 0..255) { "Tag must fit in one byte: $tag" } - return kotlin.byteArrayOf(tag.toByte()) + payload -} - -internal inline fun decodeTagged( - bytes: kotlin.ByteArray, - block: (tag: Int, payload: kotlin.ByteArray) -> T -): T { - require(bytes.isNotEmpty()) { "Expected tagged payload but got empty byte array" } - val tag = bytes[0].toInt() and 0xFF - val payload = bytes.copyOfRange(1, bytes.size) - return block(tag, payload) -} - -internal fun intToByteArray(value: Int): kotlin.ByteArray = - kotlin.byteArrayOf( - ((value shr 24) and 0xFF).toByte(), - ((value shr 16) and 0xFF).toByte(), - ((value shr 8) and 0xFF).toByte(), - (value and 0xFF).toByte() - ) - -internal fun byteArrayToInt(bytes: kotlin.ByteArray): Int { - require(bytes.size == 4) { "Expected exactly 4 bytes for Int, got ${'$'}{bytes.size}" } - return ((bytes[0].toInt() and 0xFF) shl 24) or - ((bytes[1].toInt() and 0xFF) shl 16) or - ((bytes[2].toInt() and 0xFF) shl 8) or - (bytes[3].toInt() and 0xFF) -} -*/ From 2a000cdb9ed4f83cab5045eae43e4098da0cb90d Mon Sep 17 00:00:00 2001 From: marchidalgo Date: Wed, 25 Mar 2026 11:08:17 +0100 Subject: [PATCH 17/19] Add docu --- README.md | 28 +++++++++++++++++++ .../BSWInterfaceKit/Skip/BSWAsyncButton.kt | 28 +++++++++++++++++++ Sources/BSWInterfaceKit/Skip/BSWAsyncView.kt | 15 ++++++++++ Sources/BSWInterfaceKit/Skip/BSWBackButton.kt | 3 ++ Sources/BSWInterfaceKit/Skip/BSWNavDisplay.kt | 12 ++++++++ Sources/BSWInterfaceKit/Skip/BSWSheet.kt | 6 ++++ .../Skip/BSWSwiftViewModelHolder.kt | 21 ++++++++++++++ 7 files changed, 113 insertions(+) diff --git a/README.md b/README.md index 456c1b3a..49e5917e 100644 --- a/README.md +++ b/README.md @@ -9,3 +9,31 @@ This framework will allow you to build *better* iOS apps *faster* since it lever ## Documentation Please check out [the documentation](https://swiftpackageindex.com/theleftbit/BSWInterfaceKit/documentation/) generated with DocC and hosted generously by Swift Package Index + +## Android Support + +`BSWInterfaceKit` also ships Android-only Compose primitives under `Sources/BSWInterfaceKit/Skip`. + +These types are intentionally kept as plain infrastructure components so product apps can wrap them with their own branding, strings and visual defaults: + +- `BSWAsyncView`: async loading/error/content container with built-in Swift `ViewModel` retention. +- `BSWAsyncButton`: async action core for Compose buttons, including inline and blocking loading styles. +- `BSWSheet`: modal bottom sheet wrapper that scopes Swift-backed view models correctly for sheet presentations. +- `BSWNavDisplay`: shared Navigation 3 setup with MediQuo-style push/pop transitions and entry decorators. +- `BSWBackButton`: system Material back button used by the default Android views in this package. +- `BSWSwiftViewModelOwnerRetention` and `swiftViewModel(...)`: bridge helpers used to retain Swift-backed state correctly on Android. + +### Naming convention + +SwiftUI components keep their native names on Apple platforms, such as `AsyncView` and `AsyncButton`. + +Android-only Compose APIs use the `BSW` prefix, such as `BSWAsyncView` and `BSWAsyncButton`, to avoid collisions with bridged SwiftUI symbols generated by Skip. + +### Integration guidance + +Apps are expected to wrap the plain Android components above when they need: + +- localized strings +- product-specific loading and error views +- custom button and sheet styling +- feature-specific navigation entry providers diff --git a/Sources/BSWInterfaceKit/Skip/BSWAsyncButton.kt b/Sources/BSWInterfaceKit/Skip/BSWAsyncButton.kt index c2845a43..34fc93bf 100644 --- a/Sources/BSWInterfaceKit/Skip/BSWAsyncButton.kt +++ b/Sources/BSWInterfaceKit/Skip/BSWAsyncButton.kt @@ -51,6 +51,12 @@ enum class AsyncButtonHudKind { Loading, Success, Error } enum class AsyncButtonState { Idle, Loading } +/** + * Describes how [BSWAsyncButton] exposes loading state on Android. + * + * This is the plain shared configuration that app-level wrappers can reuse while + * still drawing their own branded button UI. + */ @Stable data class BSWAsyncButtonLoadingConfiguration( val message: String? = null, @@ -81,6 +87,10 @@ val LocalAsyncButtonLoadingConfiguration = val LocalAsyncButtonOperationKey = staticCompositionLocalOf { null } +/** + * Provides the loading configuration consumed by [BSWAsyncButton] and + * [rememberAsyncButtonController]. + */ @Composable fun ProvideAsyncButtonLoadingConfiguration( message: String? = null, @@ -93,6 +103,9 @@ fun ProvideAsyncButtonLoadingConfiguration( ) } +/** + * Adds an identifier used only for debug tracing of async button operations. + */ @Composable fun ProvideAsyncButtonOperationIdentifierKey( key: String?, @@ -119,6 +132,12 @@ fun normalizeAsyncButtonErrorMessage(raw: String?): String { return trimmed } +/** + * Shared async state holder used by Android button wrappers. + * + * The idea is that product-specific buttons can reuse the async behavior from BSW + * without having to duplicate loading, error and blocking HUD orchestration. + */ @Stable class AsyncButtonController internal constructor( loadingConfiguration: BSWAsyncButtonLoadingConfiguration, @@ -147,6 +166,9 @@ class AsyncButtonController internal constructor( } } +/** + * Creates the controller used by [BSWAsyncButton] and by custom app-level wrappers. + */ @Composable fun rememberAsyncButtonController( action: suspend () -> Unit, @@ -245,6 +267,12 @@ fun DefaultAsyncButtonProgressView( ) } +/** + * Plain Compose async button. + * + * Consumers can use it directly or build custom buttons on top of + * [rememberAsyncButtonController] when they need a branded layout. + */ @Composable fun BSWAsyncButton( modifier: Modifier = Modifier, diff --git a/Sources/BSWInterfaceKit/Skip/BSWAsyncView.kt b/Sources/BSWInterfaceKit/Skip/BSWAsyncView.kt index 11881e01..2af07b80 100644 --- a/Sources/BSWInterfaceKit/Skip/BSWAsyncView.kt +++ b/Sources/BSWInterfaceKit/Skip/BSWAsyncView.kt @@ -60,6 +60,13 @@ data class AsyncOperation( } } +/** + * Plain Android async container for Compose screens. + * + * The defaults in this file are intentionally minimal so apps can wrap this API + * with their own localized loading and error views without reimplementing the + * Swift view-model retention and async state handling. + */ @Composable fun BSWAsyncView( id: ID, @@ -138,6 +145,10 @@ fun BSWAsyncView( } } +/** + * Fallback error UI used by [BSWAsyncView] when the consumer does not inject a + * custom error view. Product apps are expected to override this with localized UI. + */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun BSWDefaultAsyncErrorView( @@ -208,6 +219,10 @@ private fun Throwable.toDisplayMessage(): String { return fallback } +/** + * Fallback loading UI used by [BSWAsyncView] when the consumer does not inject a + * custom loading view. + */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun BSWDefaultAsyncLoadingView( diff --git a/Sources/BSWInterfaceKit/Skip/BSWBackButton.kt b/Sources/BSWInterfaceKit/Skip/BSWBackButton.kt index b095819d..26f994aa 100644 --- a/Sources/BSWInterfaceKit/Skip/BSWBackButton.kt +++ b/Sources/BSWInterfaceKit/Skip/BSWBackButton.kt @@ -8,6 +8,9 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +/** + * Shared Android back button that always uses the system Material icon. + */ @Composable fun BSWBackButton( onClick: () -> Unit, diff --git a/Sources/BSWInterfaceKit/Skip/BSWNavDisplay.kt b/Sources/BSWInterfaceKit/Skip/BSWNavDisplay.kt index 9a5400b5..0832814d 100644 --- a/Sources/BSWInterfaceKit/Skip/BSWNavDisplay.kt +++ b/Sources/BSWInterfaceKit/Skip/BSWNavDisplay.kt @@ -42,6 +42,12 @@ private fun bswBackTransform( ) + fadeOut(animationSpec = tween(durationMillis = BSW_BACK_ANIMATION_DURATION_MS)) } +/** + * Default entry decorators used by [BSWNavDisplay]. + * + * This combines saveable state with a per-entry [ViewModelStoreOwner] so screens + * using `swiftViewModel(...)` keep the expected lifecycle on Android. + */ @Composable fun rememberBSWNavEntryDecorators(): List> = listOf( @@ -81,6 +87,12 @@ private fun rememberBSWScopedViewModelStoreNavEntryDecorator(): NavEnt } } +/** + * Shared Navigation 3 display with the default BSW push/pop behavior for Android. + * + * Apps are expected to supply only their back stack and entry provider unless they + * need a custom list of entry decorators. + */ @Composable fun BSWNavDisplay( backStack: List, diff --git a/Sources/BSWInterfaceKit/Skip/BSWSheet.kt b/Sources/BSWInterfaceKit/Skip/BSWSheet.kt index 32116f7f..43ec062b 100644 --- a/Sources/BSWInterfaceKit/Skip/BSWSheet.kt +++ b/Sources/BSWInterfaceKit/Skip/BSWSheet.kt @@ -18,6 +18,12 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.Dp +/** + * Shared modal bottom sheet helpers for Android. + * + * The sheet content is wrapped in a scoped Swift view-model owner so Swift-backed + * state behaves the same way in sheet presentations as it does in pushed screens. + */ @OptIn(ExperimentalMaterial3Api::class) object BSWSheet { @Composable diff --git a/Sources/BSWInterfaceKit/Skip/BSWSwiftViewModelHolder.kt b/Sources/BSWInterfaceKit/Skip/BSWSwiftViewModelHolder.kt index 8a649939..f5e1ff90 100644 --- a/Sources/BSWInterfaceKit/Skip/BSWSwiftViewModelHolder.kt +++ b/Sources/BSWInterfaceKit/Skip/BSWSwiftViewModelHolder.kt @@ -13,6 +13,10 @@ import androidx.lifecycle.ViewModelStoreOwner import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner import androidx.lifecycle.viewmodel.compose.viewModel +/** + * Android `ViewModel` wrapper used to retain a Swift-backed object inside a + * Compose-managed [ViewModelStoreOwner]. + */ class BSWSwiftViewModelHolder(val swiftViewModel: SW) : ViewModel() class BSWSwiftViewModelFactory( @@ -32,6 +36,10 @@ internal enum class SwiftViewModelRetention { internal val LocalSwiftViewModelRetention = staticCompositionLocalOf { SwiftViewModelRetention.Owner } +/** + * Forces `swiftViewModel(...)` to bind to the nearest provided owner instead of + * creating a composition-scoped store. + */ @Composable fun BSWSwiftViewModelOwnerRetention(content: @Composable () -> Unit) { CompositionLocalProvider( @@ -41,6 +49,9 @@ fun BSWSwiftViewModelOwnerRetention(content: @Composable () -> Unit) { } } +/** + * Creates and provides a dedicated [ViewModelStoreOwner] for a navigation or sheet scope. + */ @Composable fun BSWWithScopedSwiftViewModelOwner( scopeKey: Any?, @@ -55,6 +66,9 @@ fun BSWWithScopedSwiftViewModelOwner( } } +/** + * Returns a [ViewModelStoreOwner] that is recreated whenever [scopeKey] changes. + */ @Composable fun rememberBSWScopedViewModelStoreOwner(scopeKey: Any?): ViewModelStoreOwner { val viewModelStore = remember(scopeKey) { ViewModelStore() } @@ -72,6 +86,13 @@ fun rememberBSWScopedViewModelStoreOwner(scopeKey: Any?): ViewModelStoreOwner { } } +/** + * Android entry point used by shared code to retain Swift-backed state. + * + * When the current context is marked with [BSWSwiftViewModelOwnerRetention], the + * object is stored in the nearest owner. Otherwise it falls back to a + * composition-scoped owner. + */ @Composable inline fun swiftViewModel( key: String = SW::class.java.name, From d2a4046b1793d45a381e171ac08c123315889d5f Mon Sep 17 00:00:00 2001 From: marchidalgo Date: Wed, 25 Mar 2026 11:20:22 +0100 Subject: [PATCH 18/19] Bump iOS version --- .github/workflows/swift.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index 9c2f28e1..1b221fe2 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -11,7 +11,7 @@ jobs: with: lfs: true - name: Test BSWInterfaceKit iOS - run: set -o pipefail && xcodebuild -scheme BSWInterfaceKit -destination "platform=iOS Simulator,name=iPhone 17,OS=26.2" test | xcbeautify --renderer github-actions + run: set -o pipefail && xcodebuild -scheme BSWInterfaceKit -destination "platform=iOS Simulator,name=iPhone 17,OS=26.4" test | xcbeautify --renderer github-actions macos-build: runs-on: mobile From ef5a61f231e43caf910caa9fbee75ae35fbd24dc Mon Sep 17 00:00:00 2001 From: michele-theleftbit Date: Wed, 25 Mar 2026 12:31:38 +0100 Subject: [PATCH 19/19] Change copy --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 49e5917e..e1a7250a 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ These types are intentionally kept as plain infrastructure components so product - `BSWAsyncView`: async loading/error/content container with built-in Swift `ViewModel` retention. - `BSWAsyncButton`: async action core for Compose buttons, including inline and blocking loading styles. - `BSWSheet`: modal bottom sheet wrapper that scopes Swift-backed view models correctly for sheet presentations. -- `BSWNavDisplay`: shared Navigation 3 setup with MediQuo-style push/pop transitions and entry decorators. +- `BSWNavDisplay`: shared Navigation 3 setup with UIKit-style push/pop transitions and entry decorators. - `BSWBackButton`: system Material back button used by the default Android views in this package. - `BSWSwiftViewModelOwnerRetention` and `swiftViewModel(...)`: bridge helpers used to retain Swift-backed state correctly on Android.