From 575ab3ae6bcd13aa432ea3cd073680a869e57400 Mon Sep 17 00:00:00 2001 From: Dereck Bridie Date: Tue, 16 Sep 2025 15:08:31 +0200 Subject: [PATCH 01/11] Refactor the shared toolbars to the same component --- .../theme/components/HorizontalToolbar.kt | 74 +++++++++++++++++++ .../androidify/creation/PromptTypePager.kt | 40 ++-------- .../androidify/results/ResultOption.kt | 53 +++---------- 3 files changed, 90 insertions(+), 77 deletions(-) create mode 100644 core/theme/src/main/java/com/android/developers/androidify/theme/components/HorizontalToolbar.kt diff --git a/core/theme/src/main/java/com/android/developers/androidify/theme/components/HorizontalToolbar.kt b/core/theme/src/main/java/com/android/developers/androidify/theme/components/HorizontalToolbar.kt new file mode 100644 index 00000000..54790522 --- /dev/null +++ b/core/theme/src/main/java/com/android/developers/androidify/theme/components/HorizontalToolbar.kt @@ -0,0 +1,74 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * 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 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.developers.androidify.theme.components + +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.width +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.FloatingToolbarColors +import androidx.compose.material3.HorizontalFloatingToolbar +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.ToggleButton +import androidx.compose.material3.ToggleButtonDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import kotlin.enums.enumEntries + +@Composable +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +inline fun > HorizontalToolbar( + selectedOption: T, + crossinline label: @Composable (T) -> String, + crossinline onOptionSelected: (T) -> Unit, + modifier: Modifier = Modifier, +) { + val options = enumEntries() + HorizontalFloatingToolbar( + modifier = modifier.border( + 2.dp, + color = MaterialTheme.colorScheme.outline, + shape = MaterialTheme.shapes.large, + ), + colors = FloatingToolbarColors( + toolbarContainerColor = MaterialTheme.colorScheme.surface, + toolbarContentColor = MaterialTheme.colorScheme.onSurfaceVariant, + fabContainerColor = MaterialTheme.colorScheme.tertiary, + fabContentColor = MaterialTheme.colorScheme.onTertiary, + ), + expanded = true, + ) { + options.forEachIndexed { index, item -> + ToggleButton( + modifier = Modifier, + checked = selectedOption == item, + onCheckedChange = { onOptionSelected(item) }, + shapes = ToggleButtonDefaults.shapes(checkedShape = MaterialTheme.shapes.large), + colors = ToggleButtonDefaults.toggleButtonColors( + checkedContainerColor = MaterialTheme.colorScheme.onSurface, + containerColor = MaterialTheme.colorScheme.surface, + ), + ) { + Text(label(options[index]), maxLines = 1) + } + if (index != options.size - 1) { + Spacer(Modifier.width(8.dp)) + } + } + } +} diff --git a/feature/creation/src/main/java/com/android/developers/androidify/creation/PromptTypePager.kt b/feature/creation/src/main/java/com/android/developers/androidify/creation/PromptTypePager.kt index 2f9920e2..eeae9afb 100644 --- a/feature/creation/src/main/java/com/android/developers/androidify/creation/PromptTypePager.kt +++ b/feature/creation/src/main/java/com/android/developers/androidify/creation/PromptTypePager.kt @@ -57,6 +57,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.android.developers.androidify.data.DropBehaviourFactory import com.android.developers.androidify.theme.AndroidifyTheme +import com.android.developers.androidify.theme.components.HorizontalToolbar import kotlinx.coroutines.launch @Composable @@ -167,39 +168,12 @@ fun PromptTypeToolbar( modifier: Modifier = Modifier, onOptionSelected: (PromptType) -> Unit, ) { - val options = PromptType.entries - HorizontalFloatingToolbar( - modifier = modifier.border( - 2.dp, - color = MaterialTheme.colorScheme.outline, - shape = MaterialTheme.shapes.large, - ), - colors = FloatingToolbarColors( - toolbarContainerColor = MaterialTheme.colorScheme.surface, - toolbarContentColor = MaterialTheme.colorScheme.onSurfaceVariant, - fabContainerColor = MaterialTheme.colorScheme.tertiary, - fabContentColor = MaterialTheme.colorScheme.onTertiary, - ), - expanded = true, - ) { - options.forEachIndexed { index, label -> - ToggleButton( - modifier = Modifier, - checked = selectedOption == label, - onCheckedChange = { onOptionSelected(label) }, - shapes = ToggleButtonDefaults.shapes(checkedShape = MaterialTheme.shapes.large), - colors = ToggleButtonDefaults.toggleButtonColors( - checkedContainerColor = MaterialTheme.colorScheme.onSurface, - containerColor = MaterialTheme.colorScheme.surface, - ), - ) { - Text(label.displayName, maxLines = 1) - } - if (index != options.size - 1) { - Spacer(Modifier.width(8.dp)) - } - } - } + HorizontalToolbar( + selectedOption = selectedOption, + modifier = modifier, + label = { item -> item.displayName }, + onOptionSelected = onOptionSelected, + ) } @Preview diff --git a/feature/results/src/main/java/com/android/developers/androidify/results/ResultOption.kt b/feature/results/src/main/java/com/android/developers/androidify/results/ResultOption.kt index 589a2862..d4cb9eb3 100644 --- a/feature/results/src/main/java/com/android/developers/androidify/results/ResultOption.kt +++ b/feature/results/src/main/java/com/android/developers/androidify/results/ResultOption.kt @@ -17,24 +17,16 @@ package com.android.developers.androidify.results -import androidx.compose.foundation.border import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.FloatingToolbarColors -import androidx.compose.material3.HorizontalFloatingToolbar -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.ToggleButton -import androidx.compose.material3.ToggleButtonDefaults import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.android.developers.androidify.theme.AndroidifyTheme +import com.android.developers.androidify.theme.components.HorizontalToolbar @Preview @Composable @@ -54,41 +46,14 @@ fun ResultToolbarOption( wasPromptUsed: Boolean = false, onResultOptionSelected: (ResultOption) -> Unit, ) { - val options = ResultOption.entries - HorizontalFloatingToolbar( - modifier = modifier - .padding(start = 16.dp, end = 16.dp, top = 8.dp) - .border( - 2.dp, - color = MaterialTheme.colorScheme.outline, - shape = MaterialTheme.shapes.large, - ), - colors = FloatingToolbarColors( - toolbarContainerColor = MaterialTheme.colorScheme.surface, - toolbarContentColor = MaterialTheme.colorScheme.onSurfaceVariant, - fabContainerColor = MaterialTheme.colorScheme.tertiary, - fabContentColor = MaterialTheme.colorScheme.onTertiary, - ), - expanded = true, - ) { - options.forEachIndexed { index, label -> - ToggleButton( - modifier = Modifier, - checked = selectedOption == label, - onCheckedChange = { onResultOptionSelected(label) }, - shapes = ToggleButtonDefaults.shapes(checkedShape = MaterialTheme.shapes.large), - colors = ToggleButtonDefaults.toggleButtonColors( - checkedContainerColor = MaterialTheme.colorScheme.onSurface, - containerColor = MaterialTheme.colorScheme.surface, - ), - ) { - Text(stringResource(label.displayText(wasPromptUsed)), maxLines = 1) - } - if (index != options.size - 1) { - Spacer(Modifier.width(8.dp)) - } - } - } + HorizontalToolbar( + selectedOption, + modifier = modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp), + label = { + stringResource(it.displayText(wasPromptUsed)) + }, + onOptionSelected = onResultOptionSelected, + ) } enum class ResultOption(val displayName: Int) { From 8392b777a1fcfc5e7b3d03fa67250ee0d004a10a Mon Sep 17 00:00:00 2001 From: Dereck Bridie Date: Tue, 16 Sep 2025 15:19:11 +0200 Subject: [PATCH 02/11] Pull out ResultsScreen components to their own files --- .../androidify/results/BackgroundQuotes.kt | 95 ++++++++++ .../androidify/results/BotActionsButtonRow.kt | 54 ++++++ .../androidify/results/ResultsScreen.kt | 163 ++++-------------- 3 files changed, 184 insertions(+), 128 deletions(-) create mode 100644 feature/results/src/main/java/com/android/developers/androidify/results/BackgroundQuotes.kt create mode 100644 feature/results/src/main/java/com/android/developers/androidify/results/BotActionsButtonRow.kt diff --git a/feature/results/src/main/java/com/android/developers/androidify/results/BackgroundQuotes.kt b/feature/results/src/main/java/com/android/developers/androidify/results/BackgroundQuotes.kt new file mode 100644 index 00000000..e2e98f44 --- /dev/null +++ b/feature/results/src/main/java/com/android/developers/androidify/results/BackgroundQuotes.kt @@ -0,0 +1,95 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * 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 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.developers.androidify.results + +import androidx.compose.foundation.basicMarquee +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.res.stringArrayResource +import androidx.compose.ui.text.font.FontWeight.Companion.Bold +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +@Composable +fun BackgroundQuotes(quote1: String, quote2: String?, verboseLayout: Boolean = true) { + // Disable animation in tests + val iterations = if (LocalInspectionMode.current) 0 else 100 + + Box(modifier = Modifier.fillMaxSize()) { + Text( + quote1, + style = MaterialTheme.typography.titleLarge.copy(fontWeight = Bold), + fontSize = 120.sp, + modifier = Modifier + .align(if (verboseLayout) Alignment.TopCenter else Alignment.Center) + .basicMarquee( + iterations = iterations, + repeatDelayMillis = 0, + velocity = 80.dp, + initialDelayMillis = 500, + ), + ) + if (quote2 != null) { + Text( + quote2, + style = MaterialTheme.typography.titleLarge.copy(fontWeight = Bold), + fontSize = 110.sp, + modifier = Modifier + .align(Alignment.BottomCenter) + .basicMarquee( + iterations = iterations, + repeatDelayMillis = 0, + velocity = 60.dp, + initialDelayMillis = 500, + ), + ) + } + } +} + +@Composable +fun BackgroundRandomQuotes(verboseLayout: Boolean = true) { + val localInspectionMode = LocalInspectionMode.current + val listResultCompliments = stringArrayResource(R.array.list_compliments) + + val quote1 = remember { + if (localInspectionMode) { + listResultCompliments.first() + } else { + listResultCompliments.random() + } + } + val quote2 = remember { + if (verboseLayout) { + val listMinusOther = listResultCompliments.asList().minus(quote1) + if (localInspectionMode) { + listMinusOther.first() + } else { + listMinusOther.random() + } + } else { + null + } + } + BackgroundQuotes(quote1, quote2, verboseLayout) +} diff --git a/feature/results/src/main/java/com/android/developers/androidify/results/BotActionsButtonRow.kt b/feature/results/src/main/java/com/android/developers/androidify/results/BotActionsButtonRow.kt new file mode 100644 index 00000000..a24d96c4 --- /dev/null +++ b/feature/results/src/main/java/com/android/developers/androidify/results/BotActionsButtonRow.kt @@ -0,0 +1,54 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * 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 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.developers.androidify.results + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.dp +import com.android.developers.androidify.theme.components.PrimaryButton + +@Composable +fun BotActionsButtonRow( + onCustomizeShareClicked: () -> Unit, + modifier: Modifier = Modifier, + verboseLayout: Boolean = false, +) { + Row(modifier) { + PrimaryButton( + onClick = { + onCustomizeShareClicked() + }, + trailingIcon = { + Row { + Spacer(modifier = Modifier.width(8.dp)) + Icon( + ImageVector + .vectorResource(com.android.developers.androidify.theme.R.drawable.rounded_arrow_forward_24), + contentDescription = null, // decorative element + ) + } + }, + buttonText = if (verboseLayout) stringResource(R.string.customize_and_share) else null, + ) + } +} diff --git a/feature/results/src/main/java/com/android/developers/androidify/results/ResultsScreen.kt b/feature/results/src/main/java/com/android/developers/androidify/results/ResultsScreen.kt index 5d377f9c..261a96bc 100644 --- a/feature/results/src/main/java/com/android/developers/androidify/results/ResultsScreen.kt +++ b/feature/results/src/main/java/com/android/developers/androidify/results/ResultsScreen.kt @@ -25,23 +25,17 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideInVertically import androidx.compose.foundation.background -import androidx.compose.foundation.basicMarquee import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Snackbar import androidx.compose.material3.SnackbarDefaults import androidx.compose.material3.SnackbarHost -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.State @@ -51,21 +45,13 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.platform.LocalInspectionMode -import androidx.compose.ui.res.stringArrayResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.res.vectorResource -import androidx.compose.ui.text.font.FontWeight.Companion.Bold import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.android.developers.androidify.customize.getPlaceholderBotUri import com.android.developers.androidify.theme.AndroidifyTheme import com.android.developers.androidify.theme.components.AboutButton import com.android.developers.androidify.theme.components.AndroidifyTopAppBar -import com.android.developers.androidify.theme.components.PrimaryButton import com.android.developers.androidify.theme.components.ResultsBackground import com.android.developers.androidify.util.AdaptivePreview import com.android.developers.androidify.util.SmallPhonePreview @@ -126,53 +112,6 @@ fun ResultsScreen( } } -@AdaptivePreview -@SmallPhonePreview -@Preview -@Composable -private fun ResultsScreenPreview() { - AndroidifyTheme { - val imageUri = getPlaceholderBotUri() - val state = remember { - mutableStateOf( - ResultState( - resultImageUri = imageUri, - promptText = "wearing a hat with straw hair", - ), - ) - } - - ResultsScreenContents( - contentPadding = PaddingValues(0.dp), - state = state, - onCustomizeShareClicked = {}, - ) - } -} - -@SmallPhonePreview -@Composable -private fun ResultsScreenPreviewSmall() { - AndroidifyTheme { - val imageUri = getPlaceholderBotUri() - val state = remember { - mutableStateOf( - ResultState( - resultImageUri = imageUri, - promptText = "wearing a hat with straw hair", - ), - ) - } - - ResultsScreenContents( - contentPadding = PaddingValues(0.dp), - state = state, - verboseLayout = false, - onCustomizeShareClicked = {}, - ) - } -} - @Composable fun ResultsScreenContents( contentPadding: PaddingValues, @@ -287,81 +226,49 @@ fun ResultsScreenContents( } } +@AdaptivePreview +@SmallPhonePreview +@Preview @Composable -private fun BackgroundRandomQuotes(verboseLayout: Boolean = true) { - val locaInspectionMode = LocalInspectionMode.current - Box(modifier = Modifier.fillMaxSize()) { - val listResultCompliments = stringArrayResource(R.array.list_compliments) - val randomQuote = remember { - if (locaInspectionMode) { - listResultCompliments.first() - } else { - listResultCompliments.random() - } - } - // Disable animation in tests - val iterations = if (LocalInspectionMode.current) 0 else 100 - Text( - randomQuote, - style = MaterialTheme.typography.titleLarge.copy(fontWeight = Bold), - fontSize = 120.sp, - modifier = Modifier - .align(if (verboseLayout) Alignment.TopCenter else Alignment.Center) - .basicMarquee( - iterations = iterations, - repeatDelayMillis = 0, - velocity = 80.dp, - initialDelayMillis = 500, +private fun ResultsScreenPreview() { + AndroidifyTheme { + val imageUri = getPlaceholderBotUri() + val state = remember { + mutableStateOf( + ResultState( + resultImageUri = imageUri, + promptText = "wearing a hat with straw hair", ), - ) - if (verboseLayout) { - val listMinusOther = listResultCompliments.asList().minus(randomQuote) - val randomQuote2 = remember { - if (locaInspectionMode) { - listMinusOther.first() - } else { - listMinusOther.random() - } - } - Text( - randomQuote2, - style = MaterialTheme.typography.titleLarge.copy(fontWeight = Bold), - fontSize = 110.sp, - modifier = Modifier - .align(Alignment.BottomCenter) - .basicMarquee( - iterations = iterations, - repeatDelayMillis = 0, - velocity = 60.dp, - initialDelayMillis = 500, - ), ) } + + ResultsScreenContents( + contentPadding = PaddingValues(0.dp), + state = state, + onCustomizeShareClicked = {}, + ) } } +@SmallPhonePreview @Composable -private fun BotActionsButtonRow( - onCustomizeShareClicked: () -> Unit, - modifier: Modifier = Modifier, - verboseLayout: Boolean = false, -) { - Row(modifier) { - PrimaryButton( - onClick = { - onCustomizeShareClicked() - }, - trailingIcon = { - Row { - Spacer(modifier = Modifier.width(8.dp)) - Icon( - ImageVector - .vectorResource(com.android.developers.androidify.theme.R.drawable.rounded_arrow_forward_24), - contentDescription = null, // decorative element - ) - } - }, - buttonText = if (verboseLayout) stringResource(R.string.customize_and_share) else null, +private fun ResultsScreenPreviewSmall() { + AndroidifyTheme { + val imageUri = getPlaceholderBotUri() + val state = remember { + mutableStateOf( + ResultState( + resultImageUri = imageUri, + promptText = "wearing a hat with straw hair", + ), + ) + } + + ResultsScreenContents( + contentPadding = PaddingValues(0.dp), + state = state, + verboseLayout = false, + onCustomizeShareClicked = {}, ) } } From d89aaab8196293fe8424c07c4e389f95a48ec9c6 Mon Sep 17 00:00:00 2001 From: Dereck Bridie Date: Tue, 16 Sep 2025 15:02:41 +0200 Subject: [PATCH 03/11] Add XR dependencies to :feature:results --- feature/results/build.gradle.kts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/feature/results/build.gradle.kts b/feature/results/build.gradle.kts index 49b14939..9d24bba0 100644 --- a/feature/results/build.gradle.kts +++ b/feature/results/build.gradle.kts @@ -66,6 +66,7 @@ dependencies { implementation(libs.androidx.lifecycle.process) implementation(libs.mlkit.segmentation) implementation(libs.timber) + implementation(libs.androidx.xr.compose) ksp(libs.hilt.compiler) implementation(libs.androidx.ui.tooling) @@ -78,6 +79,7 @@ dependencies { implementation(projects.core.theme) implementation(projects.core.util) + implementation(projects.core.xr) implementation(projects.data) implementation(projects.wear.common) implementation(projects.watchface) From 23f17c09893f07f1c092bdbc5d494d01938446f0 Mon Sep 17 00:00:00 2001 From: Dereck Bridie Date: Tue, 16 Sep 2025 15:27:47 +0200 Subject: [PATCH 04/11] Make the behaviors of the Mode buttons and functions more consistent --- .../androidify/xr/SpatialUiModes.kt | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/core/xr/src/main/java/com/android/developers/androidify/xr/SpatialUiModes.kt b/core/xr/src/main/java/com/android/developers/androidify/xr/SpatialUiModes.kt index 4b51981b..f9266bd4 100644 --- a/core/xr/src/main/java/com/android/developers/androidify/xr/SpatialUiModes.kt +++ b/core/xr/src/main/java/com/android/developers/androidify/xr/SpatialUiModes.kt @@ -27,7 +27,6 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource import androidx.compose.ui.unit.dp -import androidx.xr.compose.platform.SpatialCapabilities import androidx.xr.scenecore.scene /** Check if the device is XR-enabled, but is not yet rendering spatial UI. */ @@ -38,9 +37,8 @@ fun couldRequestFullSpace(): Boolean { /** Check if the device is XR-enabled and is rendering spatial UI. */ @Composable -fun SpatialCapabilities.couldRequestHomeSpace(): Boolean { - if (!LocalSpatialConfiguration.current.hasXrSpatialFeature) return false - return isSpatialUiEnabled +fun couldRequestHomeSpace(): Boolean { + return LocalSpatialConfiguration.current.hasXrSpatialFeature && LocalSpatialCapabilities.current.isSpatialUiEnabled } /** Default styling for an IconButton with a home space button and behavior. */ @@ -68,19 +66,26 @@ fun RequestHomeSpaceIconButton( } } -/** Default styling for an TopAppBar Button with a full space button and behavior. */ +/** Default styling for an IconButton with a full space button and behavior. */ @Composable -fun RequestFullSpaceIconButton(modifier: Modifier = Modifier) { +fun RequestFullSpaceIconButton( + modifier: Modifier = Modifier, + colors: IconButtonColors = IconButtonDefaults.iconButtonColors(), +) { val session = LocalSession.current ?: return IconButton( modifier = modifier, + colors = colors, onClick = { session.scene.requestFullSpaceMode() }, ) { Icon( - ImageVector.vectorResource(R.drawable.expand_content_24px), + modifier = Modifier + .fillMaxSize() + .padding(8.dp), + imageVector = ImageVector.vectorResource(R.drawable.expand_content_24px), contentDescription = stringResource(R.string.xr_to_full_space_mode), ) } From 5a9b1c78eeff6b058a9375917ffa936362702da6 Mon Sep 17 00:00:00 2001 From: Dereck Bridie Date: Tue, 16 Sep 2025 15:29:44 +0200 Subject: [PATCH 05/11] Add an onMove listener to move adjacent components along with the background --- .../android/developers/androidify/xr/SpatialComponents.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/core/xr/src/main/java/com/android/developers/androidify/xr/SpatialComponents.kt b/core/xr/src/main/java/com/android/developers/androidify/xr/SpatialComponents.kt index fd2e8493..5bfd7202 100644 --- a/core/xr/src/main/java/com/android/developers/androidify/xr/SpatialComponents.kt +++ b/core/xr/src/main/java/com/android/developers/androidify/xr/SpatialComponents.kt @@ -34,6 +34,7 @@ import androidx.xr.compose.subspace.SpatialBox import androidx.xr.compose.subspace.SpatialBoxScope import androidx.xr.compose.subspace.SpatialPanel import androidx.xr.compose.subspace.SubspaceComposable +import androidx.xr.compose.subspace.layout.MoveEvent import androidx.xr.compose.subspace.layout.SubspaceModifier import androidx.xr.compose.subspace.layout.aspectRatio import androidx.xr.compose.subspace.layout.fillMaxSize @@ -52,6 +53,7 @@ import com.android.developers.androidify.theme.AndroidifyTheme @Composable fun SquiggleBackgroundSubspace( minimumHeight: Dp, + onMove: ((MoveEvent) -> Boolean)? = null, content: @SubspaceComposable @Composable SpatialBoxScope.() -> Unit, @@ -59,6 +61,7 @@ fun SquiggleBackgroundSubspace( BackgroundSubspace( aspectRatio = 1.7f, drawable = R.drawable.squiggle_full, + onMove = onMove, minimumHeight = minimumHeight, content = content, ) @@ -69,6 +72,7 @@ fun BackgroundSubspace( aspectRatio: Float, @DrawableRes drawable: Int, minimumHeight: Dp, + onMove: ((MoveEvent) -> Boolean)? = null, content: @SubspaceComposable @Composable SpatialBoxScope.() -> Unit, @@ -76,7 +80,7 @@ fun BackgroundSubspace( Subspace { SpatialPanel( SubspaceModifier - .movable() + .movable(onMove = onMove) .resizable( minimumSize = DpVolumeSize(0.dp, minimumHeight, 0.dp), maintainAspectRatio = true, From 91960098e371fd6bc74868ca4b8c8382fc348f68 Mon Sep 17 00:00:00 2001 From: Dereck Bridie Date: Tue, 16 Sep 2025 15:20:27 +0200 Subject: [PATCH 06/11] Add XR enabled flag to ResultsViewModel --- .../developers/androidify/results/ResultsViewModel.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/feature/results/src/main/java/com/android/developers/androidify/results/ResultsViewModel.kt b/feature/results/src/main/java/com/android/developers/androidify/results/ResultsViewModel.kt index 5005ecc4..333d30a0 100644 --- a/feature/results/src/main/java/com/android/developers/androidify/results/ResultsViewModel.kt +++ b/feature/results/src/main/java/com/android/developers/androidify/results/ResultsViewModel.kt @@ -18,6 +18,7 @@ package com.android.developers.androidify.results import android.net.Uri import androidx.compose.material3.SnackbarHostState import androidx.lifecycle.ViewModel +import com.android.developers.androidify.data.ConfigProvider import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -32,6 +33,7 @@ class ResultsViewModel @AssistedInject constructor( @Assisted("resultImageUrl") val resultImageUrl: Uri?, @Assisted("originalImageUrl") val originalImageUrl: Uri?, @Assisted("promptText") val promptText: String?, + configProvider: ConfigProvider, ) : ViewModel() { @AssistedFactory @@ -52,7 +54,7 @@ class ResultsViewModel @AssistedInject constructor( init { _state.update { - ResultState(resultImageUrl, originalImageUrl, promptText = promptText) + ResultState(resultImageUrl, originalImageUrl, promptText = promptText, xrEnabled = configProvider.isXrEnabled()) } } } @@ -61,4 +63,5 @@ data class ResultState( val resultImageUri: Uri? = null, val originalImageUrl: Uri? = null, val promptText: String? = null, + val xrEnabled: Boolean = false, ) From c6f7f826e8d9b8d69b2a0b2b8edefba196665f5b Mon Sep 17 00:00:00 2001 From: Dereck Bridie Date: Tue, 16 Sep 2025 15:32:11 +0200 Subject: [PATCH 07/11] Add Spatial layout for ResultsScreen --- .../androidify/results/BotActionsButtonRow.kt | 7 +- .../androidify/results/BotResultCard.kt | 23 +- .../androidify/results/ResultOption.kt | 34 +- .../androidify/results/ResultsLayoutType.kt | 35 ++ .../androidify/results/ResultsScreen.kt | 380 +++++++++++------- .../androidify/results/xr/FlippablePanel.kt | 126 ++++++ .../results/xr/ResultsScreenSpatial.kt | 131 ++++++ .../main/res/drawable/background_results.xml | 31 ++ 8 files changed, 595 insertions(+), 172 deletions(-) create mode 100644 feature/results/src/main/java/com/android/developers/androidify/results/ResultsLayoutType.kt create mode 100644 feature/results/src/main/java/com/android/developers/androidify/results/xr/FlippablePanel.kt create mode 100644 feature/results/src/main/java/com/android/developers/androidify/results/xr/ResultsScreenSpatial.kt create mode 100644 feature/results/src/main/res/drawable/background_results.xml diff --git a/feature/results/src/main/java/com/android/developers/androidify/results/BotActionsButtonRow.kt b/feature/results/src/main/java/com/android/developers/androidify/results/BotActionsButtonRow.kt index a24d96c4..fdb84df6 100644 --- a/feature/results/src/main/java/com/android/developers/androidify/results/BotActionsButtonRow.kt +++ b/feature/results/src/main/java/com/android/developers/androidify/results/BotActionsButtonRow.kt @@ -30,8 +30,8 @@ import com.android.developers.androidify.theme.components.PrimaryButton @Composable fun BotActionsButtonRow( onCustomizeShareClicked: () -> Unit, + layoutType: ResultsLayoutType, modifier: Modifier = Modifier, - verboseLayout: Boolean = false, ) { Row(modifier) { PrimaryButton( @@ -48,7 +48,10 @@ fun BotActionsButtonRow( ) } }, - buttonText = if (verboseLayout) stringResource(R.string.customize_and_share) else null, + buttonText = when (layoutType) { + ResultsLayoutType.Spatial, ResultsLayoutType.Verbose -> stringResource(R.string.customize_and_share) + ResultsLayoutType.Constrained -> null + }, ) } } diff --git a/feature/results/src/main/java/com/android/developers/androidify/results/BotResultCard.kt b/feature/results/src/main/java/com/android/developers/androidify/results/BotResultCard.kt index 3f4139be..c17c94a9 100644 --- a/feature/results/src/main/java/com/android/developers/androidify/results/BotResultCard.kt +++ b/feature/results/src/main/java/com/android/developers/androidify/results/BotResultCard.kt @@ -49,10 +49,9 @@ import coil3.compose.AsyncImage @Composable fun BotResultCard( - resultImageUri: Uri, - originalImageUrl: Uri?, - promptText: String?, flippableState: FlippableState, + front: @Composable () -> Unit, + back: @Composable () -> Unit, modifier: Modifier = Modifier, onFlipStateChanged: ((FlippableState) -> Unit)? = null, ) { @@ -64,21 +63,13 @@ fun BotResultCard( .safeContentPadding(), flippableState = flippableState, onFlipStateChanged = onFlipStateChanged, - front = { - FrontCard(resultImageUri) - }, - back = { - if (originalImageUrl != null) { - BackCard(originalImageUrl) - } else { - BackCardPrompt(promptText!!) - } - }, + front = front, + back = back, ) } @Composable -private fun FrontCard(resultImageUri: Uri) { +fun FrontCardImage(resultImageUri: Uri) { AsyncImage( model = resultImageUri, contentDescription = stringResource(R.string.resultant_android_bot), @@ -92,7 +83,7 @@ private fun FrontCard(resultImageUri: Uri) { } @Composable -private fun BackCard(originalImageUrl: Uri) { +fun BackCard(originalImageUrl: Uri) { AsyncImage( model = originalImageUrl, contentDescription = stringResource(R.string.original_image), @@ -106,7 +97,7 @@ private fun BackCard(originalImageUrl: Uri) { } @Composable -private fun BackCardPrompt(promptText: String) { +fun BackCardPrompt(promptText: String) { val annotatedString = buildAnnotatedString { pushStyle(SpanStyle(fontWeight = Bold)) append(stringResource(R.string.my_bot_is_wearing)) diff --git a/feature/results/src/main/java/com/android/developers/androidify/results/ResultOption.kt b/feature/results/src/main/java/com/android/developers/androidify/results/ResultOption.kt index d4cb9eb3..7e3bc1fa 100644 --- a/feature/results/src/main/java/com/android/developers/androidify/results/ResultOption.kt +++ b/feature/results/src/main/java/com/android/developers/androidify/results/ResultOption.kt @@ -18,27 +18,14 @@ package com.android.developers.androidify.results import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.padding import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp import com.android.developers.androidify.theme.AndroidifyTheme import com.android.developers.androidify.theme.components.HorizontalToolbar -@Preview -@Composable -private fun ResultToolBarOptionPreview() { - AndroidifyTheme { - Column { - ResultToolbarOption { - } - } - } -} - @Composable fun ResultToolbarOption( modifier: Modifier = Modifier, @@ -46,12 +33,10 @@ fun ResultToolbarOption( wasPromptUsed: Boolean = false, onResultOptionSelected: (ResultOption) -> Unit, ) { - HorizontalToolbar( - selectedOption, - modifier = modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp), - label = { - stringResource(it.displayText(wasPromptUsed)) - }, + HorizontalToolbar( + selectedOption = selectedOption, + modifier = modifier, + label = { stringResource(it.displayText(wasPromptUsed)) }, onOptionSelected = onResultOptionSelected, ) } @@ -76,3 +61,14 @@ enum class ResultOption(val displayName: Int) { } } } + +@Preview +@Composable +private fun ResultToolBarOptionPreview() { + AndroidifyTheme { + Column { + ResultToolbarOption { + } + } + } +} diff --git a/feature/results/src/main/java/com/android/developers/androidify/results/ResultsLayoutType.kt b/feature/results/src/main/java/com/android/developers/androidify/results/ResultsLayoutType.kt new file mode 100644 index 00000000..3573e7c6 --- /dev/null +++ b/feature/results/src/main/java/com/android/developers/androidify/results/ResultsLayoutType.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * 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 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.developers.androidify.results + +import androidx.compose.runtime.Composable +import com.android.developers.androidify.util.allowsFullContent +import com.android.developers.androidify.xr.LocalSpatialCapabilities + +enum class ResultsLayoutType { + Verbose, + Constrained, + Spatial, +} + +@Composable +internal fun getLayoutType(enableXr: Boolean): ResultsLayoutType { + return when { + LocalSpatialCapabilities.current.isSpatialUiEnabled && enableXr -> ResultsLayoutType.Spatial + allowsFullContent() -> ResultsLayoutType.Verbose + else -> ResultsLayoutType.Constrained + } +} diff --git a/feature/results/src/main/java/com/android/developers/androidify/results/ResultsScreen.kt b/feature/results/src/main/java/com/android/developers/androidify/results/ResultsScreen.kt index 261a96bc..27aa790a 100644 --- a/feature/results/src/main/java/com/android/developers/androidify/results/ResultsScreen.kt +++ b/feature/results/src/main/java/com/android/developers/androidify/results/ResultsScreen.kt @@ -24,7 +24,6 @@ import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideInVertically -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -36,106 +35,115 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Snackbar import androidx.compose.material3.SnackbarDefaults import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.State import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.android.developers.androidify.customize.getPlaceholderBotUri +import com.android.developers.androidify.results.xr.FlippablePanel +import com.android.developers.androidify.results.xr.ResultsScreenSpatial import com.android.developers.androidify.theme.AndroidifyTheme import com.android.developers.androidify.theme.components.AboutButton import com.android.developers.androidify.theme.components.AndroidifyTopAppBar import com.android.developers.androidify.theme.components.ResultsBackground import com.android.developers.androidify.util.AdaptivePreview import com.android.developers.androidify.util.SmallPhonePreview -import com.android.developers.androidify.util.allowsFullContent import com.android.developers.androidify.util.isAtLeastMedium +import com.android.developers.androidify.xr.RequestFullSpaceIconButton +import com.android.developers.androidify.xr.RequestHomeSpaceIconButton +import com.android.developers.androidify.xr.couldRequestFullSpace +import com.android.developers.androidify.xr.couldRequestHomeSpace import com.google.accompanist.permissions.ExperimentalPermissionsApi @Composable fun ResultsScreen( - modifier: Modifier = Modifier, - verboseLayout: Boolean = allowsFullContent(), onBackPress: () -> Unit, onAboutPress: () -> Unit, onNextPress: (resultImageUri: Uri, originalImageUri: Uri?) -> Unit, viewModel: ResultsViewModel, ) { - val state = viewModel.state.collectAsStateWithLifecycle() + val state by viewModel.state.collectAsStateWithLifecycle() val snackbarHostState by viewModel.snackbarHostState.collectAsStateWithLifecycle() - Scaffold( - snackbarHost = { - SnackbarHost( - hostState = snackbarHostState, - snackbar = { snackbarData -> - Snackbar(snackbarData, shape = SnackbarDefaults.shape) - }, - ) - }, - topBar = { - AndroidifyTopAppBar( - backEnabled = true, - isMediumWindowSize = isAtLeastMedium(), - onBackPressed = { - onBackPress() - }, - actions = { - AboutButton { onAboutPress() } - }, - ) - }, - modifier = modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.primary), - ) { contentPadding -> + val layoutType = getLayoutType(enableXr = state.xrEnabled) - ResultsScreenContents( - contentPadding, - state, - verboseLayout = verboseLayout, - onCustomizeShareClicked = { - viewModel.state.value.resultImageUri?.let { - onNextPress( - it, - viewModel.state.value.originalImageUrl, - ) - } - }, - ) + var selectedResultOption by remember { + mutableStateOf(ResultOption.ResultImage) + } + val wasPromptUsed = state.originalImageUrl == null + val onCustomizeShareClicked = state.resultImageUri?.let { resultUri -> + { + onNextPress( + resultUri, + state.originalImageUrl, + ) + } } + + ResultsScreenContents( + selectedResultOption = selectedResultOption, + onResultOptionSelected = { selectedResultOption = it }, + wasPromptUsed = wasPromptUsed, + onBackPress = onBackPress, + layoutType = layoutType, + onAboutPress = onAboutPress, + state = state, + onCustomizeShareClicked = onCustomizeShareClicked, + snackbarHostState = snackbarHostState, + ) } @Composable -fun ResultsScreenContents( - contentPadding: PaddingValues, - state: State, - verboseLayout: Boolean = allowsFullContent(), - onCustomizeShareClicked: () -> Unit, - defaultSelectedResult: ResultOption = ResultOption.ResultImage, +private fun ResultsScreenContents( + selectedResultOption: ResultOption, + onResultOptionSelected: (ResultOption) -> Unit, + wasPromptUsed: Boolean, + onBackPress: () -> Unit, + layoutType: ResultsLayoutType, + onAboutPress: () -> Unit, + state: ResultState, + onCustomizeShareClicked: (() -> Unit)?, + snackbarHostState: SnackbarHostState, ) { - ResultsBackground() var showResult by remember { mutableStateOf(false) } - LaunchedEffect(state.value.resultImageUri) { - showResult = state.value.resultImageUri != null - } - var selectedResultOption by remember { - mutableStateOf(defaultSelectedResult) + LaunchedEffect(state.resultImageUri) { + showResult = state.resultImageUri != null } - val wasPromptUsed = state.value.originalImageUrl == null + val promptToolbar = @Composable { modifier: Modifier -> ResultToolbarOption( modifier = modifier, - selectedResultOption, - wasPromptUsed, - onResultOptionSelected = { option -> - selectedResultOption = option + selectedOption = selectedResultOption, + wasPromptUsed = wasPromptUsed, + onResultOptionSelected = onResultOptionSelected, + ) + } + val topBar = @Composable { + AndroidifyTopAppBar( + backEnabled = true, + isMediumWindowSize = isAtLeastMedium(), + onBackPressed = { + onBackPress() + }, + expandedCenterButtons = { + if (layoutType == ResultsLayoutType.Spatial) promptToolbar(Modifier) + }, + actions = { + AboutButton { onAboutPress() } + if (couldRequestFullSpace()) { + RequestFullSpaceIconButton() + } + if (couldRequestHomeSpace()) { + RequestHomeSpaceIconButton() + } }, ) } @@ -147,33 +155,58 @@ fun ResultsScreenContents( initialOffsetY = { fullHeight -> fullHeight }, ), ) { - Box( - modifier = Modifier - .fillMaxSize(), - ) { - BotResultCard( - state.value.resultImageUri!!, - state.value.originalImageUrl, - state.value.promptText, - modifier = Modifier.align(Alignment.Center), - flippableState = selectedResultOption.toFlippableState(), - onFlipStateChanged = { flipOption -> - selectedResultOption = when (flipOption) { - FlippableState.Front -> ResultOption.ResultImage - FlippableState.Back -> ResultOption.OriginalInput - } - }, - ) + Box(Modifier.fillMaxSize()) { + val front = @Composable { + FrontCardImage(state.resultImageUri!!) + } + val back = @Composable { + val originalImageUrl = state.originalImageUrl + if (originalImageUrl != null) { + BackCard(originalImageUrl) + } else { + BackCardPrompt(state.promptText!!) + } + } + when (layoutType) { + ResultsLayoutType.Spatial -> + FlippablePanel( + front = front, + back = back, + flippableState = selectedResultOption.toFlippableState(), + onFlipStateChanged = { flipOption -> + val option = when (flipOption) { + FlippableState.Front -> ResultOption.ResultImage + FlippableState.Back -> ResultOption.OriginalInput + } + onResultOptionSelected(option) + }, + ) + + else -> + BotResultCard( + modifier = Modifier.align(Alignment.Center), + front = front, + back = back, + flippableState = selectedResultOption.toFlippableState(), + onFlipStateChanged = { flipOption -> + val option = when (flipOption) { + FlippableState.Front -> ResultOption.ResultImage + FlippableState.Back -> ResultOption.OriginalInput + } + onResultOptionSelected(option) + }, + ) + } } } } val buttonRow = @Composable { modifier: Modifier -> BotActionsButtonRow( onCustomizeShareClicked = { - onCustomizeShareClicked() + onCustomizeShareClicked?.invoke() }, modifier = modifier, - verboseLayout = verboseLayout, + layoutType = layoutType, ) } val backgroundQuotes = @Composable { modifier: Modifier -> @@ -182,47 +215,121 @@ fun ResultsScreenContents( enter = slideInHorizontally(animationSpec = tween(1000)) { fullWidth -> fullWidth }, modifier = Modifier.fillMaxSize(), ) { - BackgroundRandomQuotes(verboseLayout) + BackgroundRandomQuotes(layoutType != ResultsLayoutType.Constrained) } } - // Draw the actual content - if (verboseLayout) { - Column( - Modifier - .fillMaxSize() - .padding(contentPadding), - ) { - promptToolbar(Modifier.align(Alignment.CenterHorizontally)) - Box( - modifier = Modifier - .weight(1f) - .fillMaxSize(), - ) { - backgroundQuotes(Modifier) - botResultCard(Modifier) + when (layoutType) { + ResultsLayoutType.Verbose -> + ResultsScreenScaffold(snackbarHostState, topBar) { contentPadding -> + ResultsBackground() + ResultsScreenVerbose( + backgroundQuotes = backgroundQuotes, + botResultCard = botResultCard, + buttonRow = buttonRow, + promptToolbar = promptToolbar, + contentPadding = contentPadding, + ) } - buttonRow( - Modifier - .padding(bottom = 16.dp, top = 16.dp) - .align(Alignment.CenterHorizontally), - ) - } - } else { - Box { - backgroundQuotes(Modifier.fillMaxSize()) - botResultCard(Modifier) - promptToolbar( - Modifier - .align(Alignment.BottomStart) - .padding(bottom = 16.dp), + + ResultsLayoutType.Constrained -> + ResultsScreenScaffold(snackbarHostState, topBar) { contentPadding -> + ResultsBackground() + ResultsScreenConstrained( + backgroundQuotes = backgroundQuotes, + botResultCard = botResultCard, + buttonRow = buttonRow, + promptToolbar = promptToolbar, + contentPadding = contentPadding, + ) + } + + ResultsLayoutType.Spatial -> + ResultsScreenSpatial( + backgroundQuotes = backgroundQuotes, + botResultCard = botResultCard, + buttonRow = buttonRow, + topBar = { _ -> topBar() }, + snackbarHostState = snackbarHostState, ) - buttonRow( - Modifier - .padding(bottom = 16.dp, end = 16.dp) - .align(Alignment.BottomEnd), + } +} + +@Composable +fun ResultsScreenScaffold( + snackbarHostState: SnackbarHostState, + topBar: @Composable () -> Unit, + modifier: Modifier = Modifier, + containerColor: Color = MaterialTheme.colorScheme.background, + content: @Composable (PaddingValues) -> Unit, +) { + Scaffold( + snackbarHost = { + SnackbarHost( + hostState = snackbarHostState, + snackbar = { snackbarData -> + Snackbar(snackbarData, shape = SnackbarDefaults.shape) + }, ) + }, + topBar = topBar, + containerColor = containerColor, + modifier = modifier.fillMaxSize(), + content = content, + ) +} + +@Composable +private fun ResultsScreenVerbose( + backgroundQuotes: @Composable (Modifier) -> Unit, + botResultCard: @Composable (Modifier) -> Unit, + buttonRow: @Composable (Modifier) -> Unit, + promptToolbar: @Composable (Modifier) -> Unit, + contentPadding: PaddingValues, +) { + Column( + Modifier + .fillMaxSize() + .padding(contentPadding), + ) { + promptToolbar(Modifier.align(Alignment.CenterHorizontally)) + Box( + modifier = Modifier + .weight(1f) + .fillMaxSize(), + ) { + backgroundQuotes(Modifier) + botResultCard(Modifier) } + buttonRow( + Modifier + .padding(bottom = 16.dp, top = 16.dp) + .align(Alignment.CenterHorizontally), + ) + } +} + +@Composable +fun ResultsScreenConstrained( + backgroundQuotes: @Composable (Modifier) -> Unit, + botResultCard: @Composable (Modifier) -> Unit, + buttonRow: @Composable (Modifier) -> Unit, + promptToolbar: @Composable (Modifier) -> Unit, + contentPadding: PaddingValues, +) { + Box(Modifier.padding(contentPadding)) { + backgroundQuotes(Modifier.fillMaxSize()) + botResultCard(Modifier) + promptToolbar( + Modifier + .align(Alignment.BottomStart) + .padding(bottom = 16.dp), + ) + buttonRow( + Modifier + .padding(bottom = 16.dp, end = 16.dp) + .align(Alignment.BottomEnd), + ) } } @@ -233,19 +340,21 @@ fun ResultsScreenContents( private fun ResultsScreenPreview() { AndroidifyTheme { val imageUri = getPlaceholderBotUri() - val state = remember { - mutableStateOf( - ResultState( - resultImageUri = imageUri, - promptText = "wearing a hat with straw hair", - ), - ) - } + val state = ResultState( + resultImageUri = imageUri, + promptText = "wearing a hat with straw hair", + ) ResultsScreenContents( - contentPadding = PaddingValues(0.dp), + selectedResultOption = ResultOption.OriginalInput, + onResultOptionSelected = { }, + wasPromptUsed = true, + onBackPress = { }, + layoutType = ResultsLayoutType.Verbose, + onAboutPress = { }, state = state, - onCustomizeShareClicked = {}, + onCustomizeShareClicked = { }, + snackbarHostState = SnackbarHostState(), ) } } @@ -255,20 +364,21 @@ private fun ResultsScreenPreview() { private fun ResultsScreenPreviewSmall() { AndroidifyTheme { val imageUri = getPlaceholderBotUri() - val state = remember { - mutableStateOf( - ResultState( - resultImageUri = imageUri, - promptText = "wearing a hat with straw hair", - ), - ) - } + val state = ResultState( + resultImageUri = imageUri, + promptText = "wearing a hat with straw hair", + ) ResultsScreenContents( - contentPadding = PaddingValues(0.dp), + selectedResultOption = ResultOption.OriginalInput, + onResultOptionSelected = { }, + wasPromptUsed = true, + onBackPress = { }, + layoutType = ResultsLayoutType.Constrained, + onAboutPress = { }, state = state, - verboseLayout = false, - onCustomizeShareClicked = {}, + onCustomizeShareClicked = { }, + snackbarHostState = SnackbarHostState(), ) } } diff --git a/feature/results/src/main/java/com/android/developers/androidify/results/xr/FlippablePanel.kt b/feature/results/src/main/java/com/android/developers/androidify/results/xr/FlippablePanel.kt new file mode 100644 index 00000000..05fa00ea --- /dev/null +++ b/feature/results/src/main/java/com/android/developers/androidify/results/xr/FlippablePanel.kt @@ -0,0 +1,126 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * 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 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.developers.androidify.results.xr + +import androidx.compose.animation.core.Transition +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.keyframes +import androidx.compose.animation.core.snap +import androidx.compose.animation.core.updateTransition +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.xr.compose.spatial.Subspace +import androidx.xr.compose.subspace.SpatialBox +import androidx.xr.compose.subspace.SpatialPanel +import androidx.xr.compose.subspace.layout.SubspaceModifier +import androidx.xr.compose.subspace.layout.offset +import androidx.xr.compose.subspace.layout.rotate +import androidx.xr.runtime.math.Vector3 +import com.android.developers.androidify.results.FlippableState + +@Composable +fun FlippablePanel( + front: @Composable () -> Unit, + back: @Composable () -> Unit, + modifier: SubspaceModifier = SubspaceModifier, + flipDurationMillis: Int = 1000, + flippableState: FlippableState = FlippableState.Front, + onFlipStateChanged: ((FlippableState) -> Unit)? = null, +) { + val transition = updateTransition(flippableState) + val frontRotation by getRotation(transition, flipDurationMillis) + + val onClickFlip = { + onFlipStateChanged?.invoke(flippableState.toggle()) ?: Unit + } + val interactionSource = remember { MutableInteractionSource() } + + Subspace { + SpatialBox(modifier.rotate(Vector3.Up, frontRotation)) { + SpatialPanel( + SubspaceModifier + .offset(z = 100.dp), + ) { + Box( + modifier = Modifier + .clickable( + interactionSource = interactionSource, + indication = null, + onClick = onClickFlip, + ), + ) { + front() + } + } + SpatialPanel( + SubspaceModifier + .offset(z = (-100).dp) + .rotate(Vector3.Up, 180f), + ) { + Box( + modifier = Modifier + .clickable( + interactionSource = interactionSource, + indication = null, + onClick = onClickFlip, + ), + ) { + back() + } + } + } + } +} + +@Composable +private fun getRotation( + transition: Transition, + flipMs: Int, +) = transition.animateFloat( + transitionSpec = { + when { + FlippableState.Front isTransitioningTo FlippableState.Back -> { + keyframes { + durationMillis = flipMs + 0f at 0 + 180f at flipMs + } + } + + FlippableState.Back isTransitioningTo FlippableState.Front -> { + keyframes { + durationMillis = flipMs + 180f at 0 + 0f at flipMs + } + } + + else -> snap() + } + }, + label = "Panel Rotation", +) { state -> + when (state) { + FlippableState.Front -> 0f + FlippableState.Back -> 180f + } +} diff --git a/feature/results/src/main/java/com/android/developers/androidify/results/xr/ResultsScreenSpatial.kt b/feature/results/src/main/java/com/android/developers/androidify/results/xr/ResultsScreenSpatial.kt new file mode 100644 index 00000000..012f913f --- /dev/null +++ b/feature/results/src/main/java/com/android/developers/androidify/results/xr/ResultsScreenSpatial.kt @@ -0,0 +1,131 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * 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 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.developers.androidify.results.xr + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp +import androidx.xr.compose.spatial.ContentEdge +import androidx.xr.compose.spatial.Orbiter +import androidx.xr.compose.spatial.OrbiterOffsetType +import androidx.xr.compose.spatial.Subspace +import androidx.xr.compose.subspace.SpatialBox +import androidx.xr.compose.subspace.SpatialPanel +import androidx.xr.compose.subspace.layout.SubspaceModifier +import androidx.xr.compose.subspace.layout.fillMaxHeight +import androidx.xr.compose.subspace.layout.fillMaxWidth +import androidx.xr.compose.subspace.layout.offset +import androidx.xr.compose.subspace.layout.rotate +import androidx.xr.runtime.math.Pose +import androidx.xr.runtime.math.Vector3 +import com.android.developers.androidify.results.ResultsScreenScaffold +import com.android.developers.androidify.xr.BackgroundSubspace +import com.android.developers.androidify.xr.DisableSharedTransition +import com.android.developers.androidify.xr.MainPanelWorkaround + +@Composable +fun ResultsScreenSpatial( + backgroundQuotes: @Composable (Modifier) -> Unit, + botResultCard: @Composable (Modifier) -> Unit, + buttonRow: @Composable (Modifier) -> Unit, + topBar: @Composable (Modifier) -> Unit, + snackbarHostState: SnackbarHostState, +) { + var offsetPose by remember { mutableStateOf(Pose()) } + DisableSharedTransition { + Subspace { + MainPanelWorkaround() + + SpatialPanel( + SubspaceModifier + .offset(z = 5.dp) + .transform(offsetPose) + .fillMaxWidth() + .fillMaxHeight(0.35f), + ) { + backgroundQuotes(Modifier) + } + SpatialBox(SubspaceModifier.fillMaxWidth(0.6f)) { + BackgroundSubspace( + aspectRatio = 1.1f, + drawable = com.android.developers.androidify.results.R.drawable.background_results, + minimumHeight = 500.dp, + onMove = { moveEvent -> + offsetPose = moveEvent.pose + false + }, + ) { + Orbiter(ContentEdge.Top, offsetType = OrbiterOffsetType.InnerEdge) { + Box( + Modifier + .background( + color = MaterialTheme.colorScheme.surfaceContainerLowest, + shape = MaterialTheme.shapes.large, + ) + .padding(bottom = 16.dp), + ) { + topBar(Modifier) + } + } + + SpatialPanel( + SubspaceModifier + .offset(z = 10.dp) + .rotate(Vector3.Forward, 5f) + .fillMaxHeight(0.7f) + .fillMaxWidth(0.5f), + ) { + ResultsScreenScaffold(snackbarHostState, topBar = {}, containerColor = Color.Transparent) { + botResultCard(Modifier) + } + } + + Orbiter( + position = ContentEdge.Bottom, + offsetType = OrbiterOffsetType.InnerEdge, + alignment = Alignment.End, + ) { + buttonRow(Modifier) + } + } + } + } + } +} + +@Composable +private fun SubspaceModifier.transform(pose: Pose): SubspaceModifier { + val density = LocalDensity.current + fun floatToDp(float: Float) = with(density) { float.toDp() } + + return this.offset( + floatToDp(pose.translation.x), + floatToDp(pose.translation.y), + floatToDp(pose.translation.z), + ).rotate(pose.rotation) +} diff --git a/feature/results/src/main/res/drawable/background_results.xml b/feature/results/src/main/res/drawable/background_results.xml new file mode 100644 index 00000000..596c003c --- /dev/null +++ b/feature/results/src/main/res/drawable/background_results.xml @@ -0,0 +1,31 @@ + + + + + + + From e99cdf591b5b8dbc067a4c8bcecf8654ec9372c4 Mon Sep 17 00:00:00 2001 From: Dereck Bridie Date: Wed, 17 Sep 2025 13:09:07 +0200 Subject: [PATCH 08/11] Fix ResultsScreen test and clarify up confusing test setups --- .../androidify/results/ResultsScreenTest.kt | 152 ++++++++++-------- .../androidify/results/ResultOption.kt | 13 +- .../androidify/results/ResultsScreen.kt | 2 +- .../results/ResultsScreenScreenshotTest.kt | 37 +++-- .../results/ResultsViewModelTest.kt | 11 +- 5 files changed, 126 insertions(+), 89 deletions(-) diff --git a/feature/results/src/androidTest/java/com/android/developers/androidify/results/ResultsScreenTest.kt b/feature/results/src/androidTest/java/com/android/developers/androidify/results/ResultsScreenTest.kt index f478f44b..79307dad 100644 --- a/feature/results/src/androidTest/java/com/android/developers/androidify/results/ResultsScreenTest.kt +++ b/feature/results/src/androidTest/java/com/android/developers/androidify/results/ResultsScreenTest.kt @@ -15,10 +15,9 @@ */ package com.android.developers.androidify.results +import android.net.Uri import androidx.activity.ComponentActivity -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsOff @@ -27,8 +26,10 @@ import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick -import androidx.compose.ui.unit.dp import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.android.developers.androidify.data.ConfigProvider +import com.android.developers.androidify.theme.SharedElementContextPreview +import com.android.developers.testing.network.TestRemoteConfigDataSource import junit.framework.TestCase.assertTrue import org.junit.Rule import org.junit.Test @@ -41,24 +42,27 @@ class ResultsScreenTest { val composeTestRule = createAndroidComposeRule() // Create a test bitmap for testing - val testUri = android.net.Uri.parse("placeholder://image") + val testUri = Uri.parse("placeholder://image") @Test fun resultsScreenContents_displaysActionButtons() { val shareButtonText = composeTestRule.activity.getString(R.string.customize_and_share) // Note: Download button is identified by icon, harder to test reliably without tags/desc - val initialState = ResultState(resultImageUri = testUri, promptText = "test") - val state = mutableStateOf(initialState) + val configProvider = ConfigProvider(TestRemoteConfigDataSource(false)) + val viewModel = ResultsViewModel(testUri, null, promptText = "test", configProvider) composeTestRule.setContent { - // Disable animation - CompositionLocalProvider(LocalInspectionMode provides true) { - ResultsScreenContents( - contentPadding = PaddingValues(0.dp), - state = state, - onCustomizeShareClicked = {}, - ) + SharedElementContextPreview { + // Disable animation + CompositionLocalProvider(LocalInspectionMode provides true) { + ResultsScreen( + onBackPress = {}, + onAboutPress = {}, + onNextPress = { _, _ -> }, + viewModel = viewModel, + ) + } } } @@ -77,17 +81,20 @@ class ResultsScreenTest { val frontCardDesc = composeTestRule.activity.getString(R.string.resultant_android_bot) // Ensure promptText is non-null when bitmap is present - val initialState = ResultState(resultImageUri = testUri, promptText = "test") - val state = mutableStateOf(initialState) + val configProvider = ConfigProvider(TestRemoteConfigDataSource(false)) + val viewModel = ResultsViewModel(testUri, null, promptText = "test", configProvider) composeTestRule.setContent { - // Disable animation - CompositionLocalProvider(LocalInspectionMode provides true) { - ResultsScreenContents( - contentPadding = PaddingValues(0.dp), - state = state, - onCustomizeShareClicked = {}, - ) + SharedElementContextPreview { + // Disable animation + CompositionLocalProvider(LocalInspectionMode provides true) { + ResultsScreen( + onBackPress = {}, + onAboutPress = {}, + onNextPress = { _, _ -> }, + viewModel = viewModel, + ) + } } } @@ -105,19 +112,22 @@ class ResultsScreenTest { val photoOptionText = composeTestRule.activity.getString(R.string.photo) val frontCardDesc = composeTestRule.activity.getString(R.string.resultant_android_bot) val backCardDesc = composeTestRule.activity.getString(R.string.original_image) - val testUri = android.net.Uri.parse("placeholder://image") + val testUri = Uri.parse("placeholder://image") - val initialState = ResultState(resultImageUri = testUri, originalImageUrl = testUri) - val state = mutableStateOf(initialState) + val configProvider = ConfigProvider(TestRemoteConfigDataSource(false)) + val viewModel = ResultsViewModel(testUri, testUri, null, configProvider) composeTestRule.setContent { - // Disable animation - CompositionLocalProvider(LocalInspectionMode provides true) { - ResultsScreenContents( - contentPadding = PaddingValues(0.dp), - state = state, - onCustomizeShareClicked = {}, - ) + SharedElementContextPreview { + // Disable animation + CompositionLocalProvider(LocalInspectionMode provides true) { + ResultsScreen( + onBackPress = {}, + onAboutPress = {}, + onNextPress = { _, _ -> }, + viewModel = viewModel, + ) + } } } @@ -137,34 +147,37 @@ class ResultsScreenTest { @Test fun toolbarOption_ClickPhoto_selectsPhoto_andShowsBackCard_Prompt() { val botOptionText = composeTestRule.activity.getString(R.string.bot) - val photoOptionText = composeTestRule.activity.getString(R.string.prompt) - val frontCardDesc = composeTestRule.activity.getString(R.string.resultant_android_bot) + val promptOptionText = composeTestRule.activity.getString(R.string.prompt) val promptText = "test prompt" val promptPrefix = composeTestRule.activity.getString(R.string.my_bot_is_wearing) - val initialState = ResultState(resultImageUri = testUri, promptText = promptText) // No original image URI - val state = mutableStateOf(initialState) + val configProvider = ConfigProvider(TestRemoteConfigDataSource(false)) + val viewModel = ResultsViewModel(testUri, null, promptText, configProvider) composeTestRule.setContent { - // Disable animation - CompositionLocalProvider(LocalInspectionMode provides true) { - ResultsScreenContents( - contentPadding = PaddingValues(0.dp), - state = state, - onCustomizeShareClicked = {}, - ) + SharedElementContextPreview { + // Disable animation + CompositionLocalProvider(LocalInspectionMode provides true) { + ResultsScreen( + onBackPress = {}, + onAboutPress = {}, + onNextPress = { _, _ -> }, + viewModel = viewModel, + ) + } } } // Click Photo option - composeTestRule.onNodeWithText(photoOptionText).performClick() + composeTestRule.onNodeWithText(promptOptionText).performClick() // Check toolbar state composeTestRule.onNodeWithText(botOptionText).assertIsOff() - composeTestRule.onNodeWithText(photoOptionText).assertIsOn() + composeTestRule.onNodeWithText(promptOptionText).assertIsOn() // Check back card (prompt) is visible by finding its text - composeTestRule.onNodeWithText(promptPrefix + " " + promptText, substring = true).assertIsDisplayed() + composeTestRule.onNodeWithText(promptPrefix + " " + promptText, substring = true) + .assertIsDisplayed() } @Test @@ -172,19 +185,23 @@ class ResultsScreenTest { val botOptionText = composeTestRule.activity.getString(R.string.bot) val photoOptionText = composeTestRule.activity.getString(R.string.photo) val frontCardDesc = composeTestRule.activity.getString(R.string.resultant_android_bot) - val testUri = android.net.Uri.parse("placeholder://image") + val testUri = Uri.parse("placeholder://image") + + val configProvider = ConfigProvider(TestRemoteConfigDataSource(false)) + val viewModel = ResultsViewModel(testUri, originalImageUrl = testUri, null, configProvider) - val initialState = ResultState(resultImageUri = testUri, originalImageUrl = testUri) - val state = mutableStateOf(initialState) composeTestRule.setContent { // Disable animation - CompositionLocalProvider(LocalInspectionMode provides true) { - ResultsScreenContents( - contentPadding = PaddingValues(0.dp), - state = state, - onCustomizeShareClicked = {}, - ) + SharedElementContextPreview { + CompositionLocalProvider(LocalInspectionMode provides true) { + ResultsScreen( + onBackPress = {}, + onAboutPress = {}, + onNextPress = { _, _ -> }, + viewModel = viewModel, + ) + } } } @@ -209,19 +226,22 @@ class ResultsScreenTest { var shareClicked = false // Ensure promptText is non-null when bitmap is present - val initialState = ResultState(resultImageUri = testUri, promptText = "test") - val state = mutableStateOf(initialState) + val configProvider = ConfigProvider(TestRemoteConfigDataSource(false)) + val viewModel = ResultsViewModel(testUri, originalImageUrl = null, "test", configProvider) composeTestRule.setContent { - // Disable animation - CompositionLocalProvider(LocalInspectionMode provides true) { - ResultsScreenContents( - contentPadding = PaddingValues(0.dp), - state = state, - onCustomizeShareClicked = { - shareClicked = true // Callback to test - }, - ) + SharedElementContextPreview { + // Disable animation + CompositionLocalProvider(LocalInspectionMode provides true) { + ResultsScreen( + onBackPress = {}, + onAboutPress = {}, + onNextPress = { _, _ -> + shareClicked = true + }, + viewModel = viewModel, + ) + } } } diff --git a/feature/results/src/main/java/com/android/developers/androidify/results/ResultOption.kt b/feature/results/src/main/java/com/android/developers/androidify/results/ResultOption.kt index 7e3bc1fa..be7d4ab9 100644 --- a/feature/results/src/main/java/com/android/developers/androidify/results/ResultOption.kt +++ b/feature/results/src/main/java/com/android/developers/androidify/results/ResultOption.kt @@ -41,9 +41,9 @@ fun ResultToolbarOption( ) } -enum class ResultOption(val displayName: Int) { - OriginalInput(R.string.photo), - ResultImage(R.string.bot), +enum class ResultOption { + OriginalInput, + ResultImage, ; fun toFlippableState(): FlippableState { @@ -54,10 +54,9 @@ enum class ResultOption(val displayName: Int) { } fun displayText(wasPromptUsed: Boolean): Int { - return if (this == OriginalInput) { - if (wasPromptUsed) return R.string.prompt else R.string.photo - } else { - this.displayName + return when (this) { + OriginalInput -> if (wasPromptUsed) R.string.prompt else R.string.photo + ResultImage -> R.string.bot } } } diff --git a/feature/results/src/main/java/com/android/developers/androidify/results/ResultsScreen.kt b/feature/results/src/main/java/com/android/developers/androidify/results/ResultsScreen.kt index 27aa790a..663105c1 100644 --- a/feature/results/src/main/java/com/android/developers/androidify/results/ResultsScreen.kt +++ b/feature/results/src/main/java/com/android/developers/androidify/results/ResultsScreen.kt @@ -102,7 +102,7 @@ fun ResultsScreen( } @Composable -private fun ResultsScreenContents( +fun ResultsScreenContents( selectedResultOption: ResultOption, onResultOptionSelected: (ResultOption) -> Unit, wasPromptUsed: Boolean, diff --git a/feature/results/src/screenshotTest/java/com/android/developers/androidify/results/ResultsScreenScreenshotTest.kt b/feature/results/src/screenshotTest/java/com/android/developers/androidify/results/ResultsScreenScreenshotTest.kt index 56e6b586..e264173a 100644 --- a/feature/results/src/screenshotTest/java/com/android/developers/androidify/results/ResultsScreenScreenshotTest.kt +++ b/feature/results/src/screenshotTest/java/com/android/developers/androidify/results/ResultsScreenScreenshotTest.kt @@ -21,14 +21,13 @@ import android.graphics.Color import android.graphics.LinearGradient import android.graphics.Paint import android.graphics.Shader -import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp import androidx.core.net.toUri import com.android.developers.androidify.theme.AndroidifyTheme import com.android.developers.androidify.util.AdaptivePreview @@ -52,10 +51,15 @@ class ResultsScreenScreenshotTest { CompositionLocalProvider(value = LocalInspectionMode provides true) { AndroidifyTheme { ResultsScreenContents( - contentPadding = PaddingValues(0.dp), - state = state, - verboseLayout = true, // Replicates ResultsScreenPreview + state = state.value, + snackbarHostState = SnackbarHostState(), onCustomizeShareClicked = {}, + selectedResultOption = ResultOption.ResultImage, + onResultOptionSelected = {}, + wasPromptUsed = false, + onBackPress = {}, + layoutType = ResultsLayoutType.Verbose, // Replicates ResultsScreenPreview + onAboutPress = {}, ) } } @@ -77,10 +81,15 @@ class ResultsScreenScreenshotTest { CompositionLocalProvider(value = LocalInspectionMode provides true) { AndroidifyTheme { ResultsScreenContents( - contentPadding = PaddingValues(0.dp), - state = state, - verboseLayout = false, // Replicates ResultsScreenPreviewSmall + state = state.value, + snackbarHostState = SnackbarHostState(), onCustomizeShareClicked = {}, + selectedResultOption = ResultOption.ResultImage, + onResultOptionSelected = {}, + wasPromptUsed = false, + onBackPress = {}, + layoutType = ResultsLayoutType.Constrained, // Replicates ResultsScreenPreviewSmall + onAboutPress = {}, ) } } @@ -101,11 +110,15 @@ class ResultsScreenScreenshotTest { CompositionLocalProvider(value = LocalInspectionMode provides true) { AndroidifyTheme { ResultsScreenContents( - contentPadding = PaddingValues(0.dp), - state = state, - verboseLayout = true, + state = state.value, + snackbarHostState = SnackbarHostState(), onCustomizeShareClicked = {}, - defaultSelectedResult = ResultOption.OriginalInput, // Set the non-default option + selectedResultOption = ResultOption.OriginalInput, // Set the non-default option + onResultOptionSelected = {}, + wasPromptUsed = false, + onBackPress = {}, + layoutType = ResultsLayoutType.Verbose, + onAboutPress = {}, ) } } diff --git a/feature/results/src/test/kotlin/com/android/developers/androidify/results/ResultsViewModelTest.kt b/feature/results/src/test/kotlin/com/android/developers/androidify/results/ResultsViewModelTest.kt index 43bf653c..2558cf76 100644 --- a/feature/results/src/test/kotlin/com/android/developers/androidify/results/ResultsViewModelTest.kt +++ b/feature/results/src/test/kotlin/com/android/developers/androidify/results/ResultsViewModelTest.kt @@ -18,6 +18,8 @@ package com.android.developers.androidify.results import android.net.Uri +import com.android.developers.androidify.data.ConfigProvider +import com.android.developers.testing.network.TestRemoteConfigDataSource import com.android.developers.testing.util.MainDispatcherRule import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest @@ -39,7 +41,8 @@ class ResultsViewModelTest { @Test fun stateInitialEmpty() = runTest { - val viewModel = ResultsViewModel(null, null, null) + val configProvider = ConfigProvider(TestRemoteConfigDataSource(false)) + val viewModel = ResultsViewModel(null, null, null, configProvider) assertEquals( ResultState(), viewModel.state.value, @@ -48,7 +51,8 @@ class ResultsViewModelTest { @Test fun setArgumentsWithOriginalImage_isCorrect() = runTest { - val viewModel = ResultsViewModel(fakeUri, originalFakeUri, null) + val configProvider = ConfigProvider(TestRemoteConfigDataSource(false)) + val viewModel = ResultsViewModel(fakeUri, originalFakeUri, null, configProvider) assertEquals( ResultState( resultImageUri = fakeUri, @@ -60,7 +64,8 @@ class ResultsViewModelTest { @Test fun initialState_withPrompt_isCorrect() = runTest { - val viewModel = ResultsViewModel(fakeUri, null, fakePromptText) + val configProvider = ConfigProvider(TestRemoteConfigDataSource(false)) + val viewModel = ResultsViewModel(fakeUri, null, fakePromptText, configProvider) assertEquals( ResultState( resultImageUri = fakeUri, From a896c9d9705935df86111a8c51dc557d60233001 Mon Sep 17 00:00:00 2001 From: Dereck Bridie Date: Wed, 17 Sep 2025 13:27:01 +0200 Subject: [PATCH 09/11] Simplify botResultCard slot --- .../androidify/results/ResultsScreen.kt | 23 ++++++++----------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/feature/results/src/main/java/com/android/developers/androidify/results/ResultsScreen.kt b/feature/results/src/main/java/com/android/developers/androidify/results/ResultsScreen.kt index 663105c1..0c112597 100644 --- a/feature/results/src/main/java/com/android/developers/androidify/results/ResultsScreen.kt +++ b/feature/results/src/main/java/com/android/developers/androidify/results/ResultsScreen.kt @@ -167,19 +167,20 @@ fun ResultsScreenContents( BackCardPrompt(state.promptText!!) } } + val onFlipStateChanged = { flipOption: FlippableState -> + val option = when (flipOption) { + FlippableState.Front -> ResultOption.ResultImage + FlippableState.Back -> ResultOption.OriginalInput + } + onResultOptionSelected(option) + } when (layoutType) { ResultsLayoutType.Spatial -> FlippablePanel( front = front, back = back, flippableState = selectedResultOption.toFlippableState(), - onFlipStateChanged = { flipOption -> - val option = when (flipOption) { - FlippableState.Front -> ResultOption.ResultImage - FlippableState.Back -> ResultOption.OriginalInput - } - onResultOptionSelected(option) - }, + onFlipStateChanged = onFlipStateChanged, ) else -> @@ -188,13 +189,7 @@ fun ResultsScreenContents( front = front, back = back, flippableState = selectedResultOption.toFlippableState(), - onFlipStateChanged = { flipOption -> - val option = when (flipOption) { - FlippableState.Front -> ResultOption.ResultImage - FlippableState.Back -> ResultOption.OriginalInput - } - onResultOptionSelected(option) - }, + onFlipStateChanged = onFlipStateChanged, ) } } From 5865aabb377e517ed6436a6181c5fe734a53e441 Mon Sep 17 00:00:00 2001 From: Dereck Bridie Date: Thu, 18 Sep 2025 13:35:59 +0200 Subject: [PATCH 10/11] Make BackgroundQuotes always marquee, even when the quote is small --- .../androidify/results/BackgroundQuotes.kt | 146 +++++++++++++++--- .../androidify/results/ResultsScreen.kt | 2 +- 2 files changed, 122 insertions(+), 26 deletions(-) diff --git a/feature/results/src/main/java/com/android/developers/androidify/results/BackgroundQuotes.kt b/feature/results/src/main/java/com/android/developers/androidify/results/BackgroundQuotes.kt index e2e98f44..cdfb1ebc 100644 --- a/feature/results/src/main/java/com/android/developers/androidify/results/BackgroundQuotes.kt +++ b/feature/results/src/main/java/com/android/developers/androidify/results/BackgroundQuotes.kt @@ -17,7 +17,12 @@ package com.android.developers.androidify.results import androidx.compose.foundation.basicMarquee import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.requiredWidthIn +import androidx.compose.foundation.layout.size import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -27,42 +32,56 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.res.stringArrayResource import androidx.compose.ui.text.font.FontWeight.Companion.Bold +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.android.developers.androidify.theme.AndroidifyTheme +import com.android.developers.androidify.util.LargeScreensPreview @Composable -fun BackgroundQuotes(quote1: String, quote2: String?, verboseLayout: Boolean = true) { +fun BackgroundQuotes( + quote1: String, + quote2: String?, + verboseLayout: Boolean = true, + enableAnimations: Boolean = !LocalInspectionMode.current, +) { // Disable animation in tests - val iterations = if (LocalInspectionMode.current) 0 else 100 + val iterations = if (enableAnimations) 100 else 0 Box(modifier = Modifier.fillMaxSize()) { - Text( - quote1, - style = MaterialTheme.typography.titleLarge.copy(fontWeight = Bold), - fontSize = 120.sp, - modifier = Modifier - .align(if (verboseLayout) Alignment.TopCenter else Alignment.Center) - .basicMarquee( + AlwaysMarquee( + size = 1.2f, + modifier = Modifier.align(if (verboseLayout) Alignment.TopCenter else Alignment.Center), + marqueeModifier = Modifier.basicMarquee( + iterations = iterations, + repeatDelayMillis = 0, + velocity = 80.dp, + initialDelayMillis = 500, + ), + ) { + Text( + quote1, + style = MaterialTheme.typography.titleLarge.copy(fontWeight = Bold), + fontSize = 120.sp, + ) + } + if (quote2 != null) { + AlwaysMarquee( + size = 1.2f, + modifier = Modifier.align(Alignment.BottomCenter), + marqueeModifier = Modifier.basicMarquee( iterations = iterations, repeatDelayMillis = 0, - velocity = 80.dp, + velocity = 60.dp, initialDelayMillis = 500, ), - ) - if (quote2 != null) { - Text( - quote2, - style = MaterialTheme.typography.titleLarge.copy(fontWeight = Bold), - fontSize = 110.sp, - modifier = Modifier - .align(Alignment.BottomCenter) - .basicMarquee( - iterations = iterations, - repeatDelayMillis = 0, - velocity = 60.dp, - initialDelayMillis = 500, - ), - ) + ) { + Text( + quote2, + style = MaterialTheme.typography.titleLarge.copy(fontWeight = Bold), + fontSize = 110.sp, + ) + } } } } @@ -93,3 +112,80 @@ fun BackgroundRandomQuotes(verboseLayout: Boolean = true) { } BackgroundQuotes(quote1, quote2, verboseLayout) } + +/** + * A composable that will always scroll its contents. [Modifier.basicMarquee] does nothing when the + * contents fit in the max constraints. This composable creates a box that is always larger than the + * max constraints and applies the marquee to that box. + */ +@Composable +private fun AlwaysMarquee( + size: Float, + marqueeModifier: Modifier, + modifier: Modifier = Modifier, + content: @Composable BoxScope.() -> Unit, +) { + BoxWithConstraints(modifier = modifier.fillMaxWidth()) { + Box(marqueeModifier) { + Box(Modifier.requiredWidthIn(min = this@BoxWithConstraints.maxWidth * size)) { + content() + } + } + } +} + +@Preview +@Composable +private fun ShortTextMarqueePreview() { + Box(Modifier.size(600.dp)) { + AlwaysMarquee( + 1.2f, + marqueeModifier = Modifier.basicMarquee( + iterations = Int.MAX_VALUE, + repeatDelayMillis = 0, + velocity = 80.dp, + initialDelayMillis = 500, + ), + ) { + Text( + modifier = Modifier.align(Alignment.Center), + text = "I'm small but I want to marquee!", + style = MaterialTheme.typography.titleLarge.copy(fontWeight = Bold), + fontSize = 20.sp, + ) + } + } +} + +@Preview +@Composable +private fun LongTextMarqueePreview() { + Box(Modifier.size(600.dp)) { + AlwaysMarquee( + 1.2f, + marqueeModifier = Modifier.basicMarquee( + iterations = Int.MAX_VALUE, + repeatDelayMillis = 0, + velocity = 80.dp, + initialDelayMillis = 500, + ), + ) { + Text( + modifier = Modifier.align(Alignment.Center), + + text = "I'm big and moving!", + style = MaterialTheme.typography.titleLarge.copy(fontWeight = Bold), + fontSize = 70.sp, + ) + } + } +} + +@Preview +@Composable +@LargeScreensPreview +private fun BackgroundQuotesPreview() { + AndroidifyTheme { + BackgroundRandomQuotes() + } +} diff --git a/feature/results/src/main/java/com/android/developers/androidify/results/ResultsScreen.kt b/feature/results/src/main/java/com/android/developers/androidify/results/ResultsScreen.kt index 0c112597..aca06a24 100644 --- a/feature/results/src/main/java/com/android/developers/androidify/results/ResultsScreen.kt +++ b/feature/results/src/main/java/com/android/developers/androidify/results/ResultsScreen.kt @@ -208,7 +208,7 @@ fun ResultsScreenContents( AnimatedVisibility( showResult, enter = slideInHorizontally(animationSpec = tween(1000)) { fullWidth -> fullWidth }, - modifier = Modifier.fillMaxSize(), + modifier = modifier.fillMaxSize(), ) { BackgroundRandomQuotes(layoutType != ResultsLayoutType.Constrained) } From 74458e9055369f59ae0d6bb4d0812a701cd018e2 Mon Sep 17 00:00:00 2001 From: Dereck Bridie Date: Thu, 18 Sep 2025 13:36:58 +0200 Subject: [PATCH 11/11] Add feathered edges to the quotes in the Spatial layout --- .../androidify/results/xr/FeatheredEdges.kt | 148 ++++++++++++++++++ .../results/xr/ResultsScreenSpatial.kt | 3 +- 2 files changed, 150 insertions(+), 1 deletion(-) create mode 100644 feature/results/src/main/java/com/android/developers/androidify/results/xr/FeatheredEdges.kt diff --git a/feature/results/src/main/java/com/android/developers/androidify/results/xr/FeatheredEdges.kt b/feature/results/src/main/java/com/android/developers/androidify/results/xr/FeatheredEdges.kt new file mode 100644 index 00000000..f633983e --- /dev/null +++ b/feature/results/src/main/java/com/android/developers/androidify/results/xr/FeatheredEdges.kt @@ -0,0 +1,148 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * 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 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.developers.androidify.results.xr + +import androidx.compose.foundation.background +import androidx.compose.foundation.basicMarquee +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.drawscope.ContentDrawScope +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import com.android.developers.androidify.theme.Primary + +/** + * Applies an edge feathering effect to the [ContentDrawScope]. + * + * This behavior is a little incorrect around the corners, which have alpha transparency applied + * twice over the area that is affected by the horizontal and vertical rectangles. + */ +fun ContentDrawScope.featheredEdges(edgeSize: Size) { + drawContent() + + drawRect( + topLeft = Offset(0f, 0f), + size = Size(edgeSize.width, size.height), + brush = + Brush.horizontalGradient( + colors = listOf(Color.Transparent, Color.Black), + startX = 0f, + endX = edgeSize.width, + ), + blendMode = BlendMode.DstIn, + ) + drawRect( + topLeft = Offset(size.width - edgeSize.width, 0f), + size = Size(edgeSize.width, size.height), + brush = + Brush.horizontalGradient( + colors = listOf(Color.Transparent, Color.Black), + startX = size.width, + endX = size.width - edgeSize.width, + ), + blendMode = BlendMode.DstIn, + ) + + drawRect( + topLeft = Offset(0f, 0f), + size = Size(size.width, edgeSize.height), + brush = + Brush.verticalGradient( + colors = listOf(Color.Transparent, Color.Black), + startY = 0f, + endY = edgeSize.height, + ), + blendMode = BlendMode.DstIn, + ) + + drawRect( + topLeft = Offset(0f, size.height - edgeSize.height), + size = Size(size.width, edgeSize.height), + brush = + Brush.verticalGradient( + colors = listOf(Color.Transparent, Color.Black), + startY = size.width, + endY = size.width - edgeSize.height, + ), + blendMode = BlendMode.DstIn, + ) +} + +fun Modifier.featheredEdges(edgeSize: DpSize) = graphicsLayer { + compositingStrategy = CompositingStrategy.Offscreen +}.drawWithContent { featheredEdges(edgeSize.toSize()) } + +/** + * Feathers the edges. + * + * The size is expressed between [0, 1], where 0 represents 0 feathering, + * and 1 represents a full feathering from edge to edge. + */ +fun Modifier.featheredEdges(edgeSizeFraction: Size) = graphicsLayer { + compositingStrategy = CompositingStrategy.Offscreen +}.drawWithContent { + featheredEdges(Size(size.width * edgeSizeFraction.width, size.height * edgeSizeFraction.height)) +} + +@Preview +@Composable +private fun FeatheredEdgePreview() { + Box( + Modifier + .size(200.dp) + .background(Primary) + .featheredEdges(DpSize(10.dp, 10.dp)), + ) { + } +} + +@Preview +@Composable +private fun FeatheredEdgeAnimatedPreview() { + Box( + Modifier + .size(200.dp) + .background(Primary), + ) { + Box( + Modifier + .featheredEdges(DpSize(100.dp, 0.dp)) + .fillMaxSize(), + ) { + Text( + modifier = Modifier + .align(Alignment.Center) + .basicMarquee(Int.MAX_VALUE), + text = "Test marquee with feathered edges!", + maxLines = 1, + ) + } + } +} diff --git a/feature/results/src/main/java/com/android/developers/androidify/results/xr/ResultsScreenSpatial.kt b/feature/results/src/main/java/com/android/developers/androidify/results/xr/ResultsScreenSpatial.kt index 012f913f..cf3f9cf5 100644 --- a/feature/results/src/main/java/com/android/developers/androidify/results/xr/ResultsScreenSpatial.kt +++ b/feature/results/src/main/java/com/android/developers/androidify/results/xr/ResultsScreenSpatial.kt @@ -27,6 +27,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp @@ -68,7 +69,7 @@ fun ResultsScreenSpatial( .fillMaxWidth() .fillMaxHeight(0.35f), ) { - backgroundQuotes(Modifier) + backgroundQuotes(Modifier.featheredEdges(Size(0.1f, 0f))) } SpatialBox(SubspaceModifier.fillMaxWidth(0.6f)) { BackgroundSubspace(