diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8b44b8a5..100540af 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -155,6 +155,10 @@ dependencies { implementation(projects.core.theme) implementation(projects.core.util) + // library must be compileOnly, see + // https://developer.android.com/develop/xr/jetpack-xr-sdk/getting-started#enable-minification + compileOnly(libs.androidx.xr.extensions) + baselineProfile(projects.benchmark) // Android Instrumented Tests diff --git a/core/network/src/main/java/com/android/developers/androidify/RemoteConfigDataSource.kt b/core/network/src/main/java/com/android/developers/androidify/RemoteConfigDataSource.kt index 8b3a930a..1ffae662 100644 --- a/core/network/src/main/java/com/android/developers/androidify/RemoteConfigDataSource.kt +++ b/core/network/src/main/java/com/android/developers/androidify/RemoteConfigDataSource.kt @@ -45,6 +45,8 @@ interface RemoteConfigDataSource { fun getBotBackgroundInstructionPrompt(): String fun watchfaceFeatureEnabled(): Boolean + + fun isXrEnabled(): Boolean } @Singleton @@ -117,4 +119,8 @@ class RemoteConfigDataSourceImpl @Inject constructor() : RemoteConfigDataSource override fun watchfaceFeatureEnabled(): Boolean { return remoteConfig.getBoolean("watchface_feature_enabled") } + + override fun isXrEnabled(): Boolean { + return remoteConfig.getBoolean("xr_feature_enabled") + } } diff --git a/core/network/src/main/res/xml/remote_config_defaults.xml b/core/network/src/main/res/xml/remote_config_defaults.xml index 6b6f6c63..eb8daa7e 100644 --- a/core/network/src/main/res/xml/remote_config_defaults.xml +++ b/core/network/src/main/res/xml/remote_config_defaults.xml @@ -23,6 +23,10 @@ background_vibes_feature_enabled false + + xr_feature_enabled + false + bot_background_instruction_prompt Add the input image android bot as the main subject to the result, diff --git a/core/testing/src/main/java/com/android/developers/testing/network/TestRemoteConfigDataSource.kt b/core/testing/src/main/java/com/android/developers/testing/network/TestRemoteConfigDataSource.kt index eec9e976..5cfea0b8 100644 --- a/core/testing/src/main/java/com/android/developers/testing/network/TestRemoteConfigDataSource.kt +++ b/core/testing/src/main/java/com/android/developers/testing/network/TestRemoteConfigDataSource.kt @@ -83,4 +83,8 @@ class TestRemoteConfigDataSource(private val useGeminiNano: Boolean) : RemoteCon override fun watchfaceFeatureEnabled(): Boolean { return true } + + override fun isXrEnabled(): Boolean { + return false + } } diff --git a/core/theme/src/main/java/com/android/developers/androidify/theme/components/Backgrounds.kt b/core/theme/src/main/java/com/android/developers/androidify/theme/components/Backgrounds.kt index 976546d2..9a1a2266 100644 --- a/core/theme/src/main/java/com/android/developers/androidify/theme/components/Backgrounds.kt +++ b/core/theme/src/main/java/com/android/developers/androidify/theme/components/Backgrounds.kt @@ -36,6 +36,8 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.tooling.preview.Devices.PIXEL_TABLET +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import com.android.developers.androidify.theme.AndroidifyTheme @@ -78,6 +80,31 @@ fun SquiggleBackground( } } +/** + * Background squiggle that tries to fit in its parent. + */ +@Composable +fun SquiggleBackgroundFull() { + val vectorBackground = + rememberVectorPainter(ImageVector.vectorResource(R.drawable.squiggle_full)) + Box(modifier = Modifier.fillMaxSize()) { + Image( + painter = vectorBackground, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Fit, + ) + } +} + +@Preview(device = PIXEL_TABLET) +@Composable +fun SquiggleFullImagePreview() { + AndroidifyTheme { + SquiggleBackgroundFull() + } +} + @LargeScreensPreview @Composable private fun SquiggleBackgroundLargePreview() { diff --git a/core/theme/src/main/java/com/android/developers/androidify/theme/components/TopAppBar.kt b/core/theme/src/main/java/com/android/developers/androidify/theme/components/TopAppBar.kt index 41d40f7e..2fca24a9 100644 --- a/core/theme/src/main/java/com/android/developers/androidify/theme/components/TopAppBar.kt +++ b/core/theme/src/main/java/com/android/developers/androidify/theme/components/TopAppBar.kt @@ -26,20 +26,17 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.CornerSize import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource @@ -57,10 +54,9 @@ fun AndroidifyTopAppBar( titleText: String = stringResource(R.string.androidify_title), isMediumWindowSize: Boolean = false, backEnabled: Boolean = false, - aboutEnabled: Boolean = true, expandedCenterButtons: @Composable () -> Unit = {}, onBackPressed: () -> Unit = {}, - onAboutClicked: () -> Unit = {}, + actions: (@Composable () -> Unit)? = null, ) { if (isMediumWindowSize) { Box( @@ -95,16 +91,16 @@ fun AndroidifyTopAppBar( expandedCenterButtons() } - if (aboutEnabled) { - AboutButton( - modifier = Modifier - .align(Alignment.CenterEnd) - .background( - color = MaterialTheme.colorScheme.surfaceContainerLowest, - shape = CircleShape, - ), - onAboutClicked = onAboutClicked, - ) + Row( + modifier = Modifier + .align(Alignment.CenterEnd) + .background( + color = MaterialTheme.colorScheme.surfaceContainerLowest, + shape = CircleShape, + ), + + ) { + actions?.invoke() } } } else { @@ -124,9 +120,7 @@ fun AndroidifyTopAppBar( } }, actions = { - if (aboutEnabled) { - AboutButton(onAboutClicked = onAboutClicked) - } + actions?.invoke() }, colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLowest), ) @@ -143,38 +137,6 @@ private fun BackButton(onBackPressed: () -> Unit) { } } -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun AndroidifyTranslucentTopAppBar( - modifier: Modifier = Modifier, - titleText: String = stringResource(R.string.androidify_title), - isMediumSizeLayout: Boolean = false, -) { - if (isMediumSizeLayout) { - TopAppBar( - title = { - Spacer(Modifier.statusBarsPadding()) - AndroidifyTitle(titleText) - }, - modifier = modifier.clip( - MaterialTheme.shapes.large.copy(topStart = CornerSize(0f), topEnd = CornerSize(0f)), - ), - colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent), - ) - } else { - CenterAlignedTopAppBar( - title = { - Spacer(Modifier.statusBarsPadding()) - AndroidifyTitle(titleText) - }, - modifier = modifier.clip( - MaterialTheme.shapes.large.copy(topStart = CornerSize(0f), topEnd = CornerSize(0f)), - ), - colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent), - ) - } -} - @Composable private fun AndroidifyTitle(text: String) { Text(text, fontWeight = FontWeight.Bold) @@ -182,7 +144,7 @@ private fun AndroidifyTitle(text: String) { @OptIn(ExperimentalSharedTransitionApi::class) @Composable -private fun AboutButton(modifier: Modifier = Modifier, onAboutClicked: () -> Unit = {}) { +fun AboutButton(modifier: Modifier = Modifier, onAboutClicked: () -> Unit = {}) { val sharedTransitionScope = LocalSharedTransitionScope.current with(sharedTransitionScope) { IconButton( diff --git a/core/theme/src/main/res/drawable/squiggle_full.xml b/core/theme/src/main/res/drawable/squiggle_full.xml new file mode 100644 index 00000000..4fc308ca --- /dev/null +++ b/core/theme/src/main/res/drawable/squiggle_full.xml @@ -0,0 +1,35 @@ + + + + + + + + + diff --git a/core/xr/.gitignore b/core/xr/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/core/xr/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/core/xr/build.gradle.kts b/core/xr/build.gradle.kts new file mode 100644 index 00000000..58b89183 --- /dev/null +++ b/core/xr/build.gradle.kts @@ -0,0 +1,51 @@ +/* + * 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. + */ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) +} +android { + namespace = "com.android.developers.androidify.xr" + compileSdk = libs.versions.compileSdk.get().toInt() + defaultConfig { + minSdk = libs.versions.minSdk.get().toInt() + } + + buildFeatures { + compose = true + buildConfig = true + } + + compileOptions { + sourceCompatibility = JavaVersion.toVersion(libs.versions.javaVersion.get()) + targetCompatibility = JavaVersion.toVersion(libs.versions.javaVersion.get()) + } + kotlinOptions { + jvmTarget = libs.versions.jvmTarget.get() + } + +} + +dependencies { + implementation(libs.androidx.xr.compose) + + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.ui.tooling.preview) + implementation(libs.androidx.material3) + implementation(projects.core.util) + implementation(projects.core.theme) +} diff --git a/core/xr/proguard-rules.pro b/core/xr/proguard-rules.pro new file mode 100644 index 00000000..481bb434 --- /dev/null +++ b/core/xr/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/core/xr/src/main/AndroidManifest.xml b/core/xr/src/main/AndroidManifest.xml new file mode 100644 index 00000000..b47d651b --- /dev/null +++ b/core/xr/src/main/AndroidManifest.xml @@ -0,0 +1,19 @@ + + + + + \ No newline at end of file diff --git a/core/xr/src/main/java/com/android/developers/androidify/xr/MainPanelWorkaround.kt b/core/xr/src/main/java/com/android/developers/androidify/xr/MainPanelWorkaround.kt new file mode 100644 index 00000000..a587f465 --- /dev/null +++ b/core/xr/src/main/java/com/android/developers/androidify/xr/MainPanelWorkaround.kt @@ -0,0 +1,40 @@ +/* + * 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.xr + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.xr.compose.platform.LocalSession +import androidx.xr.scenecore.scene +import kotlinx.coroutines.delay + +/* + * A composable that attempts to continually hide the mainPanel. + * + * This is a temporary workaround for b/440325404, that causes the mainPanelEntity when an + * ApplicationSubspace transitions out of the composition due to a race condition when transitioning + * the two hierarchies. + */ +@Composable +fun MainPanelWorkaround() { + val session = LocalSession.current + LaunchedEffect(null) { + while (true) { + delay(100L) + session?.scene?.mainPanelEntity?.setEnabled(false) + } + } +} \ No newline at end of file diff --git a/core/xr/src/main/java/com/android/developers/androidify/xr/SharedTransitions.kt b/core/xr/src/main/java/com/android/developers/androidify/xr/SharedTransitions.kt new file mode 100644 index 00000000..119d0b19 --- /dev/null +++ b/core/xr/src/main/java/com/android/developers/androidify/xr/SharedTransitions.kt @@ -0,0 +1,110 @@ +/* + * 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.xr + +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.BoundsTransform +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionLayout +import androidx.compose.animation.SharedTransitionScope +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.Placeable +import com.android.developers.androidify.theme.LocalSharedTransitionScope + +/** + * On Android XR in Full Space Mode, spatial panels inflate into a different Window. + * A layout using a SharedTransitionScope will fail to find shared elements across different + * spatial panels. + * + * This composable replaces the LocalSharedTransitionScope with a no-op SharedTransitionScope, + * effectively disabling the shared transitions. + */ +@Composable +fun DisableSharedTransition(content: @Composable (() -> Unit)) { + @OptIn(ExperimentalSharedTransitionApi::class) + SharedTransitionLayout { + CompositionLocalProvider(LocalSharedTransitionScope provides shimSharedTransitionScope(this)) { + content() + } + } +} + +/** A fake implementation of SharedTransitionScope that disables registering shared transitions. */ +@ExperimentalSharedTransitionApi +private fun shimSharedTransitionScope(original: SharedTransitionScope): SharedTransitionScope { + return object : SharedTransitionScope { + override val isTransitionActive: Boolean + get() = false + + override fun Modifier.skipToLookaheadSize(): Modifier = this + + override fun Modifier.renderInSharedTransitionScopeOverlay( + zIndexInOverlay: Float, + renderInOverlay: (SharedTransitionScope) -> Boolean, + ) = this + + override fun Modifier.sharedElement( + sharedContentState: SharedTransitionScope.SharedContentState, + animatedVisibilityScope: AnimatedVisibilityScope, + boundsTransform: BoundsTransform, + placeHolderSize: SharedTransitionScope.PlaceHolderSize, + renderInOverlayDuringTransition: Boolean, + zIndexInOverlay: Float, + clipInOverlayDuringTransition: SharedTransitionScope.OverlayClip, + ) = this + + override fun Modifier.sharedBounds( + sharedContentState: SharedTransitionScope.SharedContentState, + animatedVisibilityScope: AnimatedVisibilityScope, + enter: EnterTransition, + exit: ExitTransition, + boundsTransform: BoundsTransform, + resizeMode: SharedTransitionScope.ResizeMode, + placeHolderSize: SharedTransitionScope.PlaceHolderSize, + renderInOverlayDuringTransition: Boolean, + zIndexInOverlay: Float, + clipInOverlayDuringTransition: SharedTransitionScope.OverlayClip, + ) = this + + override fun Modifier.sharedElementWithCallerManagedVisibility( + sharedContentState: SharedTransitionScope.SharedContentState, + visible: Boolean, + boundsTransform: BoundsTransform, + placeHolderSize: SharedTransitionScope.PlaceHolderSize, + renderInOverlayDuringTransition: Boolean, + zIndexInOverlay: Float, + clipInOverlayDuringTransition: SharedTransitionScope.OverlayClip, + ) = this + + override fun OverlayClip(clipShape: Shape): SharedTransitionScope.OverlayClip = + original.OverlayClip(clipShape) + + override val Placeable.PlacementScope.lookaheadScopeCoordinates: LayoutCoordinates + get() = with(original) { lookaheadScopeCoordinates } + + override fun LayoutCoordinates.toLookaheadCoordinates(): LayoutCoordinates { + with(original) { + return toLookaheadCoordinates() + } + } + } +} 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 new file mode 100644 index 00000000..1222357e --- /dev/null +++ b/core/xr/src/main/java/com/android/developers/androidify/xr/SpatialUiModes.kt @@ -0,0 +1,86 @@ +/* + * 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.xr + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +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 androidx.xr.compose.platform.SpatialCapabilities +import androidx.xr.scenecore.scene + +/** Check if the device is XR-enabled, but is not yet rendering spatial UI. */ +@Composable +fun couldRequestFullSpace(): Boolean { + return LocalSpatialConfiguration.current.hasXrSpatialFeature && !LocalSpatialCapabilities.current.isSpatialUiEnabled +} + +/** 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 +} + +/** Default styling for an IconButton with a home space button and behavior. */ +@Composable +fun RequestHomeSpaceIconButton(modifier: Modifier = Modifier) { + val session = LocalSession.current ?: return + + IconButton( + modifier = modifier, + colors = IconButtonDefaults.iconButtonColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + ), + onClick = { + session.scene.requestHomeSpaceMode() + }, + ) { + Icon( + modifier = Modifier + .fillMaxSize() + .padding(8.dp), + imageVector = ImageVector.vectorResource(R.drawable.collapse_content_24px), + contentDescription = stringResource(R.string.xr_to_home_space_mode), + ) + } +} + +/** Default styling for an TopAppBar Button with a full space button and behavior. */ +@Composable +fun RequestFullSpaceIconButton(modifier: Modifier = Modifier) { + val session = LocalSession.current ?: return + + IconButton( + modifier = modifier, + onClick = { + session.scene.requestFullSpaceMode() + }, + ) { + Icon( + ImageVector.vectorResource(R.drawable.expand_content_24px), + contentDescription = stringResource(R.string.xr_to_full_space_mode), + ) + } +} diff --git a/core/xr/src/main/java/com/android/developers/androidify/xr/XrPreview.kt b/core/xr/src/main/java/com/android/developers/androidify/xr/XrPreview.kt new file mode 100644 index 00000000..b06d5e65 --- /dev/null +++ b/core/xr/src/main/java/com/android/developers/androidify/xr/XrPreview.kt @@ -0,0 +1,128 @@ +/* + * 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.xr + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.compositionLocalWithComputedDefaultOf +import androidx.compose.ui.tooling.preview.Devices.PIXEL_7_PRO +import androidx.compose.ui.tooling.preview.Devices.PIXEL_TABLET +import androidx.compose.ui.tooling.preview.Preview +import androidx.xr.compose.platform.SpatialCapabilities +import androidx.xr.compose.platform.SpatialConfiguration +import androidx.xr.runtime.Session + +/** + * Preview for a layout that could go into Full Space Mode. + */ +@Composable +fun SupportsFullSpaceModeRequestProvider(contents: @Composable () -> Unit) { + CompositionLocalProvider(LocalSpatialConfiguration provides HasSpatialFeatureSpatialConfiguration) { + CompositionLocalProvider(LocalSpatialCapabilities provides SupportsFullSpaceModeRequestCapabilities) { + CompositionLocalProvider(LocalSession provides null) { + contents() + } + } + } +} + +private object HasSpatialFeatureSpatialConfiguration : SpatialConfiguration { + override val hasXrSpatialFeature: Boolean + get() = true +} + +private object SupportsFullSpaceModeRequestCapabilities : SpatialCapabilities { + override val isSpatialUiEnabled: Boolean + get() = false + override val isContent3dEnabled: Boolean + get() = true + override val isAppEnvironmentEnabled: Boolean + get() = true + override val isPassthroughControlEnabled: Boolean + get() = true + override val isSpatialAudioEnabled: Boolean + get() = true +} + +/** + * Workaround composition locals for b/441901724. + * Any composable referencing XR composition locals will fail to preview instead of gracefully + * degrading due to failing to resolve XR capabilities. + * + * This can be removed when the default for XR capabilities under the preview is no capabilities + * instead of throwing an exception. + * */ +val LocalSpatialCapabilities: ProvidableCompositionLocal = + compositionLocalWithComputedDefaultOf { + runCatching { + androidx.xr.compose.platform.LocalSpatialCapabilities.currentValue + }.getOrDefault(NoSpatialCapabilities) + } + +/** + * Workaround composition locals for b/441901724. + * Any composable referencing XR composition locals will fail to preview instead of gracefully + * degrading due to failing to resolve XR capabilities. + * + * This can be removed when the default for XR capabilities under the preview is no capabilities + * instead of throwing an exception. + * */ +val LocalSpatialConfiguration: ProvidableCompositionLocal = + compositionLocalWithComputedDefaultOf { + runCatching { + androidx.xr.compose.platform.LocalSpatialConfiguration.currentValue + }.getOrDefault(LacksSpatialFeatureSpatialConfiguration) + } + +/** + * Workaround composition locals for b/441901724. + * Any composable referencing XR composition locals will fail to preview instead of gracefully + * degrading due to failing to resolve XR capabilities. + * + * This can be removed when the default for XR capabilities under the preview is no capabilities + * instead of throwing an exception. + * */ +val LocalSession: ProvidableCompositionLocal = + compositionLocalWithComputedDefaultOf { + runCatching { + androidx.xr.compose.platform.LocalSession.currentValue + }.getOrNull() + } + +private object NoSpatialCapabilities : SpatialCapabilities { + override val isSpatialUiEnabled: Boolean + get() = false + override val isContent3dEnabled: Boolean + get() = false + override val isAppEnvironmentEnabled: Boolean + get() = false + override val isPassthroughControlEnabled: Boolean + get() = false + override val isSpatialAudioEnabled: Boolean + get() = false +} + +private object LacksSpatialFeatureSpatialConfiguration : SpatialConfiguration { + override val hasXrSpatialFeature: Boolean + get() = false +} + +@Preview(device = PIXEL_TABLET, name = "Android XR (Home Space Mode)") +annotation class XrHomeSpaceMediumPreview + +@Preview(device = PIXEL_7_PRO, name = "Android XR (Home Space Mode)") +annotation class XrHomeSpaceCompactPreview diff --git a/core/xr/src/main/res/drawable/collapse_content_24px.xml b/core/xr/src/main/res/drawable/collapse_content_24px.xml new file mode 100644 index 00000000..f11b7743 --- /dev/null +++ b/core/xr/src/main/res/drawable/collapse_content_24px.xml @@ -0,0 +1,26 @@ + + + + + diff --git a/core/xr/src/main/res/drawable/expand_content_24px.xml b/core/xr/src/main/res/drawable/expand_content_24px.xml new file mode 100644 index 00000000..e2a6035c --- /dev/null +++ b/core/xr/src/main/res/drawable/expand_content_24px.xml @@ -0,0 +1,26 @@ + + + + + diff --git a/core/xr/src/main/res/values/strings.xml b/core/xr/src/main/res/values/strings.xml new file mode 100644 index 00000000..9f061fa7 --- /dev/null +++ b/core/xr/src/main/res/values/strings.xml @@ -0,0 +1,20 @@ + + + + To Full Space Mode + To Home Space Mode + diff --git a/data/src/main/java/com/android/developers/androidify/data/ConfigProvider.kt b/data/src/main/java/com/android/developers/androidify/data/ConfigProvider.kt index 5c0a36d7..8dda915f 100644 --- a/data/src/main/java/com/android/developers/androidify/data/ConfigProvider.kt +++ b/data/src/main/java/com/android/developers/androidify/data/ConfigProvider.kt @@ -33,4 +33,8 @@ class ConfigProvider @Inject constructor(val remoteConfigDataSource: RemoteConfi fun getDancingDroidLink(): String { return remoteConfigDataSource.getDancingDroidLink() } + + fun isXrEnabled(): Boolean { + return remoteConfigDataSource.isXrEnabled() + } } diff --git a/feature/creation/src/main/java/com/android/developers/androidify/creation/CreationScreen.kt b/feature/creation/src/main/java/com/android/developers/androidify/creation/CreationScreen.kt index c16c1de6..db0043e9 100644 --- a/feature/creation/src/main/java/com/android/developers/androidify/creation/CreationScreen.kt +++ b/feature/creation/src/main/java/com/android/developers/androidify/creation/CreationScreen.kt @@ -139,6 +139,7 @@ import com.android.developers.androidify.theme.Primary90 import com.android.developers.androidify.theme.R import com.android.developers.androidify.theme.Secondary import com.android.developers.androidify.theme.SharedElementKey +import com.android.developers.androidify.theme.components.AboutButton import com.android.developers.androidify.theme.components.AndroidifyTopAppBar import com.android.developers.androidify.theme.components.GradientAssistElevatedChip import com.android.developers.androidify.theme.components.PrimaryButton @@ -290,9 +291,10 @@ fun EditScreen( AndroidifyTopAppBar( backEnabled = true, isMediumWindowSize = isExpanded, - aboutEnabled = true, + actions = { + AboutButton { onAboutPressed() } + }, onBackPressed = onBackPressed, - onAboutClicked = onAboutPressed, expandedCenterButtons = { PromptTypeToolbar( uiState.selectedPromptOption, diff --git a/feature/creation/src/main/java/com/android/developers/androidify/creation/LoadingScreen.kt b/feature/creation/src/main/java/com/android/developers/androidify/creation/LoadingScreen.kt index 0085e8a2..ccf39eef 100644 --- a/feature/creation/src/main/java/com/android/developers/androidify/creation/LoadingScreen.kt +++ b/feature/creation/src/main/java/com/android/developers/androidify/creation/LoadingScreen.kt @@ -87,7 +87,7 @@ fun LoadingScreen( ) { Scaffold( topBar = { - AndroidifyTopAppBar(isMediumWindowSize = isMediumScreen, aboutEnabled = false) + AndroidifyTopAppBar(isMediumWindowSize = isMediumScreen) }, bottomBar = { Box( diff --git a/feature/home/build.gradle.kts b/feature/home/build.gradle.kts index 0e2dc23d..68410264 100644 --- a/feature/home/build.gradle.kts +++ b/feature/home/build.gradle.kts @@ -74,6 +74,9 @@ dependencies { exclude(group = "com.google.guava") } + implementation(libs.androidx.xr.compose) + implementation(projects.core.xr) + implementation(libs.androidx.ui.tooling) debugImplementation(libs.androidx.ui.tooling.preview) diff --git a/feature/home/src/androidTest/java/com/android/developers/androidify/home/HomeScreenTest.kt b/feature/home/src/androidTest/java/com/android/developers/androidify/home/HomeScreenTest.kt index 450f2581..5e0b077e 100644 --- a/feature/home/src/androidTest/java/com/android/developers/androidify/home/HomeScreenTest.kt +++ b/feature/home/src/androidTest/java/com/android/developers/androidify/home/HomeScreenTest.kt @@ -48,7 +48,7 @@ class HomeScreenTest { SharedElementContextPreview { HomeScreenContents( - isMediumWindowSize = false, // Provide a default or mock value + layoutType = HomeScreenLayoutType.Compact, // Provide a default or mock value onClickLetsGo = { offset: IntOffset -> // Match the lambda signature wasClicked = true }, @@ -74,7 +74,7 @@ class HomeScreenTest { composeTestRule.setContent { SharedElementContextPreview { HomeScreenContents( - isMediumWindowSize = false, // Ensure compact mode for pager + layoutType = HomeScreenLayoutType.Compact, // Ensure compact mode for pager onClickLetsGo = { }, onAboutClicked = {}, videoLink = "", diff --git a/feature/home/src/main/java/com/android/developers/androidify/home/HomeScreen.kt b/feature/home/src/main/java/com/android/developers/androidify/home/HomeScreen.kt index b42e157e..370a849f 100644 --- a/feature/home/src/main/java/com/android/developers/androidify/home/HomeScreen.kt +++ b/feature/home/src/main/java/com/android/developers/androidify/home/HomeScreen.kt @@ -16,115 +16,33 @@ package com.android.developers.androidify.home import androidx.annotation.OptIn -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.animateContentSize -import androidx.compose.animation.core.LinearEasing -import androidx.compose.animation.core.RepeatMode -import androidx.compose.animation.core.animateFloat -import androidx.compose.animation.core.infiniteRepeatable -import androidx.compose.animation.core.rememberInfiniteTransition -import androidx.compose.animation.core.tween -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.focusable -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ColumnScope -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawingPadding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.pager.HorizontalPager -import androidx.compose.foundation.pager.rememberPagerState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.BasicText -import androidx.compose.foundation.text.InlineTextContent -import androidx.compose.foundation.text.TextAutoSize -import androidx.compose.foundation.text.appendInlineContent -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonColors -import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButtonDefaults -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedIconButton -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf -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.draw.clip -import androidx.compose.ui.draw.clipToBounds -import androidx.compose.ui.draw.rotate -import androidx.compose.ui.draw.shadow -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.layout.onLayoutRectChanged -import androidx.compose.ui.layout.onVisibilityChanged -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalInspectionMode -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.res.vectorResource -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.Placeholder -import androidx.compose.ui.text.PlaceholderVerticalAlign -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel -import androidx.lifecycle.compose.LifecycleStartEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.media3.common.MediaItem -import androidx.media3.common.Player -import androidx.media3.common.util.UnstableApi -import androidx.media3.exoplayer.ExoPlayer -import androidx.media3.ui.compose.PlayerSurface -import androidx.media3.ui.compose.SURFACE_TYPE_TEXTURE_VIEW -import androidx.media3.ui.compose.state.rememberPlayPauseButtonState -import coil3.compose.AsyncImage -import com.android.developers.androidify.theme.Blue +import com.android.developers.androidify.home.xr.HomeScreenContentsSpatial import com.android.developers.androidify.theme.SharedElementContextPreview -import com.android.developers.androidify.theme.components.AndroidifyTopAppBar -import com.android.developers.androidify.theme.components.AndroidifyTranslucentTopAppBar import com.android.developers.androidify.theme.components.SquiggleBackground import com.android.developers.androidify.util.LargeScreensPreview -import com.android.developers.androidify.util.LocalOcclusion import com.android.developers.androidify.util.PhonePreview -import com.android.developers.androidify.util.isAtLeastMedium -import com.android.developers.androidify.theme.R as ThemeR @ExperimentalMaterial3ExpressiveApi @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable fun HomeScreen( homeScreenViewModel: HomeViewModel = hiltViewModel(), - isMediumWindowSize: Boolean = isAtLeastMedium(), onClickLetsGo: (IntOffset) -> Unit = {}, onAboutClicked: () -> Unit = {}, ) { val state = homeScreenViewModel.state.collectAsStateWithLifecycle() + val xrEnabled = state.value.isXrEnabled + val layoutType = calculateLayoutType(xrEnabled) if (!state.value.isAppActive) { AppInactiveScreen() @@ -132,9 +50,10 @@ fun HomeScreen( HomeScreenContents( state.value.videoLink, state.value.dancingDroidLink, - isMediumWindowSize, + layoutType, onClickLetsGo, onAboutClicked, + xrEnabled, ) } } @@ -143,188 +62,57 @@ fun HomeScreen( fun HomeScreenContents( videoLink: String?, dancingBotLink: String?, - isMediumWindowSize: Boolean, + layoutType: HomeScreenLayoutType, onClickLetsGo: (IntOffset) -> Unit, onAboutClicked: () -> Unit, + xrEnabled: Boolean = false, ) { - Box { - SquiggleBackground() - var positionButtonClick by remember { - mutableStateOf(IntOffset.Zero) - } - Column( - modifier = Modifier - .fillMaxSize() - .safeDrawingPadding(), - ) { - if (isMediumWindowSize) { - AndroidifyTranslucentTopAppBar(isMediumSizeLayout = true) - - Row( - modifier = Modifier - .weight(1f) - .padding(horizontal = 32.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Box( - modifier = Modifier.weight(0.8f), - ) { - VideoPlayerRotatedCard( - videoLink, - modifier = Modifier - .padding(32.dp) - .align(Alignment.Center), - ) - } - Box( - modifier = Modifier - .weight(1.2f) - .align(Alignment.CenterVertically), - ) { - MainHomeContent(dancingBotLink) - HomePageButton( - modifier = Modifier - .onLayoutRectChanged { - positionButtonClick = it.boundsInWindow.center - } - .align(Alignment.BottomCenter) - .padding(bottom = 16.dp) - .height(64.dp) - .width(220.dp), - onClick = { - onClickLetsGo(positionButtonClick) - }, - ) - } - } - } else { - CompactPager( + when (layoutType) { + HomeScreenLayoutType.Compact -> + SquiggleBackgroundBox { + HomeScreenCompactPager( videoLink, dancingBotLink, onClickLetsGo, onAboutClicked, + xrEnabled, ) } - } - } -} - -@Composable -private fun CompactPager( - videoLink: String?, - dancingBotLink: String?, - onClick: (IntOffset) -> Unit, - onAboutClicked: () -> Unit, -) { - val pagerState = rememberPagerState(pageCount = { 2 }) - Column( - modifier = Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - AndroidifyTopAppBar( - aboutEnabled = true, - onAboutClicked = onAboutClicked, - ) - HorizontalPager( - state = pagerState, - modifier = Modifier.weight(.8f), - beyondViewportPageCount = 1, - ) { page -> - if (page == 0) { - MainHomeContent( - dancingBotLink = dancingBotLink, - modifier = Modifier - .padding(horizontal = 16.dp) - .align(Alignment.CenterHorizontally), + HomeScreenLayoutType.Medium -> + SquiggleBackgroundBox { + HomeScreenMediumContents( + Modifier, + videoLink, + dancingBotLink, + onClickLetsGo, + onAboutClicked, + xrEnabled, ) - } else { - Box(modifier = Modifier.fillMaxSize()) { - VideoPlayerRotatedCard( - videoLink = videoLink, - modifier = Modifier - .padding(horizontal = 32.dp) - .align(Alignment.Center), - ) - } } - } - Row( - modifier = Modifier - .weight(.1f) - .fillMaxWidth(), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, - ) { - repeat(pagerState.pageCount) { iteration -> - val isCurrent by remember { derivedStateOf { pagerState.currentPage == iteration } } - val animatedColor by animateColorAsState( - if (isCurrent) MaterialTheme.colorScheme.tertiary else MaterialTheme.colorScheme.onTertiary, - label = "animatedFirstColor", - ) - Box( - modifier = Modifier - .padding(2.dp) - .clip(RoundedCornerShape(size = 16.dp)) - .animateContentSize() - .background( - color = animatedColor, - shape = RoundedCornerShape(size = 16.dp), - ) - .height(16.dp) - .width(if (isCurrent) 40.dp else 16.dp), - ) - } - } - Spacer(modifier = Modifier.size(12.dp)) - var buttonPosition by remember { - mutableStateOf(IntOffset.Zero) - } - HomePageButton( - modifier = Modifier - .onLayoutRectChanged { - buttonPosition = it.boundsInWindow.center - } - .padding(bottom = 16.dp) - .height(64.dp) - .width(220.dp), - colors = ButtonDefaults.buttonColors() - .copy(containerColor = Blue), - onClick = { - onClick(buttonPosition) - }, - ) + HomeScreenLayoutType.Spatial -> + HomeScreenContentsSpatial( + videoLink, + dancingBotLink, + onClickLetsGo, + onAboutClicked, + ) } } @Composable -private fun VideoPlayerRotatedCard( - videoLink: String?, - modifier: Modifier = Modifier, -) { - val aspectRatio = 280f / 380f - val videoInstructionText = stringResource(R.string.instruction_video_transcript) - Box( - modifier = modifier - .focusable() - .semantics { contentDescription = videoInstructionText } - .aspectRatio(aspectRatio) - .rotate(-3f) - .shadow(elevation = 8.dp, shape = MaterialTheme.shapes.large) - .background( - color = Color.White, - shape = MaterialTheme.shapes.large, - ), - ) { - VideoPlayer( - videoLink, +private fun SquiggleBackgroundBox(contents: @Composable () -> Unit) { + Box { + SquiggleBackground() + + Box( modifier = Modifier - .aspectRatio(aspectRatio) - .align(Alignment.Center) - .clip(MaterialTheme.shapes.large) - .clipToBounds(), - ) + .fillMaxSize() + .safeDrawingPadding(), + ) { + contents() + } } } @@ -334,7 +122,7 @@ private fun VideoPlayerRotatedCard( private fun HomeScreenPhonePreview() { SharedElementContextPreview { HomeScreenContents( - isMediumWindowSize = false, + layoutType = HomeScreenLayoutType.Compact, onClickLetsGo = {}, videoLink = "", dancingBotLink = "https://services.google.com/fh/files/misc/android_dancing.gif", @@ -349,7 +137,7 @@ private fun HomeScreenPhonePreview() { private fun HomeScreenLargeScreensPreview() { SharedElementContextPreview { HomeScreenContents( - isMediumWindowSize = true, + layoutType = HomeScreenLayoutType.Medium, onClickLetsGo = { }, videoLink = "", dancingBotLink = "https://services.google.com/fh/files/misc/android_dancing.gif", @@ -357,245 +145,3 @@ private fun HomeScreenLargeScreensPreview() { ) } } - -@Composable -private fun MainHomeContent( - dancingBotLink: String?, - modifier: Modifier = Modifier, -) { - Column( - modifier = modifier, - ) { - DecorativeSquiggleLimeGreen() - DancingBotHeadlineText( - dancingBotLink, - modifier = Modifier.weight(1f), - ) - DecorativeSquiggleLightGreen() - } -} - -@Composable -private fun ColumnScope.DecorativeSquiggleLightGreen() { - val infiniteAnimation = rememberInfiniteTransition() - val rotationAnimation = infiniteAnimation.animateFloat( - 0f, - 720f, - animationSpec = infiniteRepeatable( - tween(12000, easing = LinearEasing), - repeatMode = RepeatMode.Reverse, - ), - ) - Image( - painter = rememberVectorPainter( - ImageVector.vectorResource(ThemeR.drawable.decorative_squiggle_2), - ), - contentDescription = null, // decorative element - modifier = Modifier - .padding(start = 60.dp) - .size(60.dp) - .align(Alignment.Start) - .graphicsLayer { - rotationZ = rotationAnimation.value - }, - ) -} - -@Composable -private fun ColumnScope.DecorativeSquiggleLimeGreen() { - val infiniteAnimation = rememberInfiniteTransition() - val rotationAnimation = infiniteAnimation.animateFloat( - 0f, - -720f, - animationSpec = infiniteRepeatable( - tween(24000, easing = LinearEasing), - repeatMode = RepeatMode.Reverse, - ), - ) - Image( - painter = rememberVectorPainter( - ImageVector.vectorResource(ThemeR.drawable.decorative_squiggle), - ), - contentDescription = null, // decorative element - modifier = Modifier - .padding(end = 80.dp) - .size(60.dp) - .align(Alignment.End) - .graphicsLayer { - rotationZ = rotationAnimation.value - }, - - ) -} - -@Preview -@Composable -private fun HomePageButton( - modifier: Modifier = Modifier, - colors: ButtonColors = ButtonDefaults.buttonColors().copy(containerColor = Blue), - onClick: () -> Unit = {}, -) { - val style = MaterialTheme.typography.titleLarge.copy( - fontWeight = FontWeight(700), - letterSpacing = .15f.sp, - ) - - Button( - onClick = onClick, - modifier = modifier, - colors = colors, - ) { - Text( - stringResource(R.string.home_button_label), - style = style, - ) - } -} - -@Composable -private fun DancingBot( - dancingBotLink: String?, - modifier: Modifier, -) { - if (LocalInspectionMode.current) { - Image( - painter = painterResource(id = R.drawable.dancing_droid_gif_placeholder), - contentDescription = null, - modifier = modifier, - ) - } else { - AsyncImage( - model = dancingBotLink, - modifier = modifier, - contentDescription = null, - ) - } -} - -@Composable -private fun DancingBotHeadlineText( - dancingBotLink: String?, - modifier: Modifier = Modifier, -) { - Box(modifier = modifier) { - val animatedBot = "animatedBot" - val text = buildAnnotatedString { - append(stringResource(R.string.customize_your_own)) - // Attach "animatedBot" annotation on the placeholder - appendInlineContent(animatedBot) - append(stringResource(R.string.into_an_android_bot)) - } - var placeHolderSize by remember { - mutableStateOf(220.sp) - } - val inlineContent = mapOf( - Pair( - animatedBot, - InlineTextContent( - Placeholder( - width = placeHolderSize, - height = placeHolderSize, - placeholderVerticalAlign = PlaceholderVerticalAlign.TextCenter, - ), - ) { - DancingBot( - dancingBotLink, - modifier = Modifier - .padding(top = 32.dp) - .fillMaxSize(), - ) - }, - ), - ) - BasicText( - text, - modifier = Modifier - .align(Alignment.Center) - .padding(bottom = 16.dp, start = 16.dp, end = 16.dp), - style = MaterialTheme.typography.titleLarge, - autoSize = TextAutoSize.StepBased(maxFontSize = 220.sp), - maxLines = 5, - onTextLayout = { result -> - placeHolderSize = result.layoutInput.style.fontSize * 3.5f - }, - inlineContent = inlineContent, - ) - } -} - -@OptIn(UnstableApi::class) // New Media3 Compose artifact is currently experimental -@Composable -private fun VideoPlayer( - videoLink: String?, - modifier: Modifier = Modifier, -) { - if (LocalInspectionMode.current) { - Image( - painter = painterResource(id = R.drawable.promo_video_placeholder), - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = modifier, - ) - return - } else { - val context = LocalContext.current - var player by remember { mutableStateOf(null) } - LifecycleStartEffect(videoLink) { - if (videoLink != null) { - player = ExoPlayer.Builder(context).build().apply { - setMediaItem(MediaItem.fromUri(videoLink)) - repeatMode = Player.REPEAT_MODE_ONE - prepare() - } - } - onStopOrDispose { - player?.release() - player = null - } - } - - var videoFullyOnScreen by remember { mutableStateOf(false) } - val isWindowOccluded = LocalOcclusion.current - Box( - Modifier - .background(MaterialTheme.colorScheme.surfaceContainerLowest) - .onVisibilityChanged( - minDurationMs = 100, - minFractionVisible = 1f, - ) { fullyVisible -> videoFullyOnScreen = fullyVisible } - .then(modifier), - ) { - player?.let { currentPlayer -> - LaunchedEffect(videoFullyOnScreen, LocalOcclusion.current.value) { - if (videoFullyOnScreen && !isWindowOccluded.value) currentPlayer.play() else currentPlayer.pause() - } - - // Render the video - PlayerSurface(currentPlayer, surfaceType = SURFACE_TYPE_TEXTURE_VIEW) - - // Show a play / pause button - val playPauseButtonState = rememberPlayPauseButtonState(currentPlayer) - OutlinedIconButton( - onClick = playPauseButtonState::onClick, - enabled = playPauseButtonState.isEnabled, - modifier = Modifier - .align(Alignment.BottomEnd) - .padding(16.dp), - colors = IconButtonDefaults.outlinedIconButtonColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerLowest, - disabledContainerColor = MaterialTheme.colorScheme.surfaceContainerLowest, - ), - ) { - val icon = - if (playPauseButtonState.showPlay) R.drawable.rounded_play_arrow_24 else R.drawable.rounded_pause_24 - val contentDescription = - if (playPauseButtonState.showPlay) R.string.play else R.string.pause - Icon( - painterResource(icon), - stringResource(contentDescription), - ) - } - } - } - } -} diff --git a/feature/home/src/main/java/com/android/developers/androidify/home/HomeScreenCompact.kt b/feature/home/src/main/java/com/android/developers/androidify/home/HomeScreenCompact.kt new file mode 100644 index 00000000..ec64d73e --- /dev/null +++ b/feature/home/src/main/java/com/android/developers/androidify/home/HomeScreenCompact.kt @@ -0,0 +1,165 @@ +/* + * 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.home + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.background +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.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +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.draw.clip +import androidx.compose.ui.layout.onLayoutRectChanged +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import com.android.developers.androidify.theme.Blue +import com.android.developers.androidify.theme.SharedElementContextPreview +import com.android.developers.androidify.theme.components.AboutButton +import com.android.developers.androidify.theme.components.AndroidifyTopAppBar +import com.android.developers.androidify.util.PhonePreview +import com.android.developers.androidify.xr.RequestFullSpaceIconButton +import com.android.developers.androidify.xr.couldRequestFullSpace + +@Composable +fun HomeScreenCompactPager( + videoLink: String?, + dancingBotLink: String?, + onClick: (IntOffset) -> Unit, + onAboutClicked: () -> Unit, + xrEnabled: Boolean = false, +) { + val pagerState = rememberPagerState(pageCount = { 2 }) + + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + AndroidifyTopAppBar( + actions = { + AboutButton { onAboutClicked() } + if (xrEnabled && couldRequestFullSpace()) { + RequestFullSpaceIconButton() + } + }, + ) + HorizontalPager( + state = pagerState, + modifier = Modifier.weight(.8f), + beyondViewportPageCount = 1, + ) { page -> + if (page == 0) { + MainHomeContent( + dancingBotLink = dancingBotLink, + modifier = Modifier + .padding(horizontal = 16.dp) + .align(Alignment.CenterHorizontally), + ) + } else { + Box(modifier = Modifier.fillMaxSize()) { + VideoPlayerRotatedCard( + videoLink = videoLink, + modifier = Modifier + .padding(horizontal = 32.dp) + .align(Alignment.Center), + ) + } + } + } + Row( + modifier = Modifier + .weight(.1f) + .fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + repeat(pagerState.pageCount) { iteration -> + val isCurrent by remember { derivedStateOf { pagerState.currentPage == iteration } } + val animatedColor by animateColorAsState( + if (isCurrent) MaterialTheme.colorScheme.tertiary else MaterialTheme.colorScheme.onTertiary, + label = "animatedFirstColor", + ) + + Box( + modifier = Modifier + .padding(2.dp) + .clip(RoundedCornerShape(size = 16.dp)) + .animateContentSize() + .background( + color = animatedColor, + shape = RoundedCornerShape(size = 16.dp), + ) + .height(16.dp) + .width(if (isCurrent) 40.dp else 16.dp), + ) + } + } + Spacer(modifier = Modifier.size(12.dp)) + var buttonPosition by remember { + mutableStateOf(IntOffset.Zero) + } + HomePageButton( + modifier = Modifier + .onLayoutRectChanged { + buttonPosition = it.boundsInWindow.center + } + .padding(bottom = 16.dp) + .height(64.dp) + .width(220.dp), + colors = ButtonDefaults.buttonColors() + .copy(containerColor = Blue), + onClick = { + onClick(buttonPosition) + }, + ) + } +} + +@ExperimentalMaterial3ExpressiveApi +@PhonePreview +@Composable +private fun HomeScreenPhonePreview() { + SharedElementContextPreview { + HomeScreenContents( + layoutType = HomeScreenLayoutType.Compact, + onClickLetsGo = {}, + videoLink = "", + dancingBotLink = "https://services.google.com/fh/files/misc/android_dancing.gif", + onAboutClicked = {}, + ) + } +} diff --git a/feature/home/src/main/java/com/android/developers/androidify/home/HomeScreenComponents.kt b/feature/home/src/main/java/com/android/developers/androidify/home/HomeScreenComponents.kt new file mode 100644 index 00000000..678600e5 --- /dev/null +++ b/feature/home/src/main/java/com/android/developers/androidify/home/HomeScreenComponents.kt @@ -0,0 +1,228 @@ +/* + * 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.home + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.BasicText +import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.foundation.text.TextAutoSize +import androidx.compose.foundation.text.appendInlineContent +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonColors +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +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.graphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.Placeholder +import androidx.compose.ui.text.PlaceholderVerticalAlign +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil3.compose.AsyncImage +import com.android.developers.androidify.theme.Blue +import com.android.developers.androidify.theme.R as ThemeR + +@Composable +fun MainHomeContent( + dancingBotLink: String?, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + ) { + DecorativeSquiggleLimeGreen() + DancingBotHeadlineText( + dancingBotLink, + modifier = Modifier.weight(1f), + ) + DecorativeSquiggleLightGreen() + } +} + +@Composable +fun ColumnScope.DecorativeSquiggleLightGreen() { + val infiniteAnimation = rememberInfiniteTransition() + val rotationAnimation = infiniteAnimation.animateFloat( + 0f, + 720f, + animationSpec = infiniteRepeatable( + tween(12000, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + ), + ) + Image( + painter = rememberVectorPainter( + ImageVector.vectorResource(ThemeR.drawable.decorative_squiggle_2), + ), + contentDescription = null, // decorative element + modifier = Modifier + .padding(start = 60.dp) + .size(60.dp) + .align(Alignment.Start) + .graphicsLayer { + rotationZ = rotationAnimation.value + }, + ) +} + +@Composable +fun ColumnScope.DecorativeSquiggleLimeGreen() { + val infiniteAnimation = rememberInfiniteTransition() + val rotationAnimation = infiniteAnimation.animateFloat( + 0f, + -720f, + animationSpec = infiniteRepeatable( + tween(24000, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + ), + ) + Image( + painter = rememberVectorPainter( + ImageVector.vectorResource(ThemeR.drawable.decorative_squiggle), + ), + contentDescription = null, // decorative element + modifier = Modifier + .padding(end = 80.dp) + .size(60.dp) + .align(Alignment.End) + .graphicsLayer { + rotationZ = rotationAnimation.value + }, + + ) +} + +@Preview +@Composable +fun HomePageButton( + modifier: Modifier = Modifier, + colors: ButtonColors = ButtonDefaults.buttonColors().copy(containerColor = Blue), + onClick: () -> Unit = {}, +) { + val style = MaterialTheme.typography.titleLarge.copy( + fontWeight = FontWeight(700), + letterSpacing = .15f.sp, + ) + + Button( + onClick = onClick, + modifier = modifier, + colors = colors, + ) { + Text( + stringResource(R.string.home_button_label), + style = style, + ) + } +} + +@Composable +private fun DancingBot( + dancingBotLink: String?, + modifier: Modifier, +) { + if (LocalInspectionMode.current) { + Image( + painter = painterResource(id = R.drawable.dancing_droid_gif_placeholder), + contentDescription = null, + modifier = modifier, + ) + } else { + AsyncImage( + model = dancingBotLink, + modifier = modifier, + contentDescription = null, + ) + } +} + +@Composable +fun DancingBotHeadlineText( + dancingBotLink: String?, + modifier: Modifier = Modifier, +) { + Box(modifier = modifier) { + val animatedBot = "animatedBot" + val text = buildAnnotatedString { + append(stringResource(R.string.customize_your_own)) + // Attach "animatedBot" annotation on the placeholder + appendInlineContent(animatedBot) + append(stringResource(R.string.into_an_android_bot)) + } + var placeHolderSize by remember { + mutableStateOf(220.sp) + } + val inlineContent = mapOf( + Pair( + animatedBot, + InlineTextContent( + Placeholder( + width = placeHolderSize, + height = placeHolderSize, + placeholderVerticalAlign = PlaceholderVerticalAlign.TextCenter, + ), + ) { + DancingBot( + dancingBotLink, + modifier = Modifier + .padding(top = 32.dp) + .fillMaxSize(), + ) + }, + ), + ) + BasicText( + text, + modifier = Modifier + .align(Alignment.Center) + .padding(bottom = 16.dp, start = 16.dp, end = 16.dp), + style = MaterialTheme.typography.titleLarge, + autoSize = TextAutoSize.StepBased(maxFontSize = 220.sp), + maxLines = 5, + onTextLayout = { result -> + placeHolderSize = result.layoutInput.style.fontSize * 3.5f + }, + inlineContent = inlineContent, + ) + } +} diff --git a/feature/home/src/main/java/com/android/developers/androidify/home/HomeScreenLayoutType.kt b/feature/home/src/main/java/com/android/developers/androidify/home/HomeScreenLayoutType.kt new file mode 100644 index 00000000..2828f9e2 --- /dev/null +++ b/feature/home/src/main/java/com/android/developers/androidify/home/HomeScreenLayoutType.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.home + +import androidx.compose.runtime.Composable +import androidx.xr.compose.platform.LocalSpatialCapabilities +import com.android.developers.androidify.util.isAtLeastMedium + +enum class HomeScreenLayoutType { + Compact, + Medium, + Spatial, +} + +@Composable +fun calculateLayoutType(enableXr: Boolean = false): HomeScreenLayoutType { + return when { + LocalSpatialCapabilities.current.isSpatialUiEnabled && enableXr -> HomeScreenLayoutType.Spatial + isAtLeastMedium() -> HomeScreenLayoutType.Medium + else -> HomeScreenLayoutType.Compact + } +} diff --git a/feature/home/src/main/java/com/android/developers/androidify/home/HomeScreenMedium.kt b/feature/home/src/main/java/com/android/developers/androidify/home/HomeScreenMedium.kt new file mode 100644 index 00000000..6d4cecbb --- /dev/null +++ b/feature/home/src/main/java/com/android/developers/androidify/home/HomeScreenMedium.kt @@ -0,0 +1,134 @@ +/* + * 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.home + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +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.layout.onLayoutRectChanged +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import com.android.developers.androidify.theme.SharedElementContextPreview +import com.android.developers.androidify.theme.components.AboutButton +import com.android.developers.androidify.theme.components.AndroidifyTopAppBar +import com.android.developers.androidify.util.LargeScreensPreview +import com.android.developers.androidify.xr.RequestFullSpaceIconButton +import com.android.developers.androidify.xr.SupportsFullSpaceModeRequestProvider +import com.android.developers.androidify.xr.XrHomeSpaceMediumPreview +import com.android.developers.androidify.xr.couldRequestFullSpace + +@Composable +fun HomeScreenMediumContents( + modifier: Modifier, + videoLink: String?, + dancingBotLink: String?, + onClickLetsGo: (IntOffset) -> Unit, + onAboutClicked: () -> Unit, + xrEnabled: Boolean = false, +) { + var positionButtonClick by remember { + mutableStateOf(IntOffset.Zero) + } + AndroidifyTopAppBar( + isMediumWindowSize = true, + actions = { + AboutButton(onAboutClicked = onAboutClicked) + if (xrEnabled && couldRequestFullSpace()) { + RequestFullSpaceIconButton() + } + }, + ) + + Row( + modifier = modifier + .padding(horizontal = 32.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier.weight(0.8f), + ) { + VideoPlayerRotatedCard( + videoLink, + modifier = Modifier + .padding(32.dp) + .align(Alignment.Center), + ) + } + Box( + modifier = Modifier + .weight(1.2f) + .align(Alignment.CenterVertically), + ) { + MainHomeContent(dancingBotLink) + + HomePageButton( + modifier = Modifier + .onLayoutRectChanged { + positionButtonClick = it.boundsInWindow.center + } + .align(Alignment.BottomCenter) + .padding(bottom = 16.dp) + .height(64.dp) + .width(220.dp), + onClick = { + onClickLetsGo(positionButtonClick) + }, + ) + } + } +} + +@ExperimentalMaterial3ExpressiveApi +@LargeScreensPreview +@Composable +private fun HomeScreenLargeScreensPreview() { + SharedElementContextPreview { + HomeScreenContents( + layoutType = HomeScreenLayoutType.Medium, + onClickLetsGo = { }, + videoLink = "", + dancingBotLink = "https://services.google.com/fh/files/misc/android_dancing.gif", + onAboutClicked = {}, + ) + } +} + +@ExperimentalMaterial3ExpressiveApi +@XrHomeSpaceMediumPreview +@Composable +private fun HomeScreenLargeScreensHomeSpaceModePreview() { + SupportsFullSpaceModeRequestProvider { + SharedElementContextPreview { + HomeScreenContents( + layoutType = HomeScreenLayoutType.Medium, + onClickLetsGo = { }, + videoLink = "", + dancingBotLink = "https://services.google.com/fh/files/misc/android_dancing.gif", + onAboutClicked = {}, + ) + } + } +} diff --git a/feature/home/src/main/java/com/android/developers/androidify/home/HomeScreenVideoPlayer.kt b/feature/home/src/main/java/com/android/developers/androidify/home/HomeScreenVideoPlayer.kt new file mode 100644 index 00000000..eb7c17ac --- /dev/null +++ b/feature/home/src/main/java/com/android/developers/androidify/home/HomeScreenVideoPlayer.kt @@ -0,0 +1,166 @@ +/* + * 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.home + +import androidx.annotation.OptIn +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedIconButton +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.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onVisibilityChanged +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.LifecycleStartEffect +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.ui.compose.PlayerSurface +import androidx.media3.ui.compose.SURFACE_TYPE_TEXTURE_VIEW +import androidx.media3.ui.compose.state.rememberPlayPauseButtonState +import com.android.developers.androidify.util.LocalOcclusion + +@OptIn(UnstableApi::class) // New Media3 Compose artifact is currently experimental +@Composable +fun VideoPlayer( + videoLink: String?, + modifier: Modifier = Modifier, +) { + if (LocalInspectionMode.current) { + Image( + painter = painterResource(id = R.drawable.promo_video_placeholder), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = modifier, + ) + return + } else { + val context = LocalContext.current + var player by remember { mutableStateOf(null) } + LifecycleStartEffect(videoLink) { + if (videoLink != null) { + player = ExoPlayer.Builder(context).build().apply { + setMediaItem(MediaItem.fromUri(videoLink)) + repeatMode = Player.REPEAT_MODE_ONE + prepare() + } + } + onStopOrDispose { + player?.release() + player = null + } + } + + var videoFullyOnScreen by remember { mutableStateOf(false) } + val isWindowOccluded = LocalOcclusion.current + Box( + Modifier + .background(MaterialTheme.colorScheme.surfaceContainerLowest) + .onVisibilityChanged( + minDurationMs = 100, + minFractionVisible = 1f, + ) { fullyVisible -> videoFullyOnScreen = fullyVisible } + .then(modifier), + ) { + player?.let { currentPlayer -> + LaunchedEffect(videoFullyOnScreen, LocalOcclusion.current.value) { + if (videoFullyOnScreen && !isWindowOccluded.value) currentPlayer.play() else currentPlayer.pause() + } + + // Render the video + PlayerSurface(currentPlayer, surfaceType = SURFACE_TYPE_TEXTURE_VIEW) + + // Show a play / pause button + val playPauseButtonState = rememberPlayPauseButtonState(currentPlayer) + OutlinedIconButton( + onClick = playPauseButtonState::onClick, + enabled = playPauseButtonState.isEnabled, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(16.dp), + colors = IconButtonDefaults.outlinedIconButtonColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerLowest, + disabledContainerColor = MaterialTheme.colorScheme.surfaceContainerLowest, + ), + ) { + val icon = + if (playPauseButtonState.showPlay) R.drawable.rounded_play_arrow_24 else R.drawable.rounded_pause_24 + val contentDescription = + if (playPauseButtonState.showPlay) R.string.play else R.string.pause + Icon( + painterResource(icon), + stringResource(contentDescription), + ) + } + } + } + } +} + +@Composable +fun VideoPlayerRotatedCard( + videoLink: String?, + modifier: Modifier = Modifier, +) { + val aspectRatio = 280f / 380f + val videoInstructionText = stringResource(R.string.instruction_video_transcript) + Box( + modifier = modifier + .focusable() + .semantics { contentDescription = videoInstructionText } + .aspectRatio(aspectRatio) + .rotate(-3f) + .shadow(elevation = 8.dp, shape = MaterialTheme.shapes.large) + .background( + color = Color.White, + shape = MaterialTheme.shapes.large, + ), + ) { + VideoPlayer( + videoLink, + modifier = Modifier + .aspectRatio(aspectRatio) + .align(Alignment.Center) + .clip(MaterialTheme.shapes.large) + .clipToBounds(), + ) + } +} diff --git a/feature/home/src/main/java/com/android/developers/androidify/home/HomeViewModel.kt b/feature/home/src/main/java/com/android/developers/androidify/home/HomeViewModel.kt index 40d3ab14..51475ff5 100644 --- a/feature/home/src/main/java/com/android/developers/androidify/home/HomeViewModel.kt +++ b/feature/home/src/main/java/com/android/developers/androidify/home/HomeViewModel.kt @@ -29,6 +29,7 @@ class HomeViewModel @Inject constructor(configProvider: ConfigProvider) : ViewMo isAppActive = !configProvider.isAppInactive(), dancingDroidLink = configProvider.getDancingDroidLink(), videoLink = configProvider.getPromoVideoLink(), + isXrEnabled = configProvider.isXrEnabled(), ), ) val state = _state.asStateFlow() @@ -36,6 +37,7 @@ class HomeViewModel @Inject constructor(configProvider: ConfigProvider) : ViewMo data class HomeState( val isAppActive: Boolean = true, + val isXrEnabled: Boolean = false, val videoLink: String? = null, val dancingDroidLink: String? = null, ) diff --git a/feature/home/src/main/java/com/android/developers/androidify/home/xr/HomeScreenHomeSpaceModePreviews.kt b/feature/home/src/main/java/com/android/developers/androidify/home/xr/HomeScreenHomeSpaceModePreviews.kt new file mode 100644 index 00000000..22df109a --- /dev/null +++ b/feature/home/src/main/java/com/android/developers/androidify/home/xr/HomeScreenHomeSpaceModePreviews.kt @@ -0,0 +1,59 @@ +/* + * 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.home.xr + +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.runtime.Composable +import com.android.developers.androidify.home.HomeScreenContents +import com.android.developers.androidify.home.HomeScreenLayoutType +import com.android.developers.androidify.theme.SharedElementContextPreview +import com.android.developers.androidify.xr.SupportsFullSpaceModeRequestProvider +import com.android.developers.androidify.xr.XrHomeSpaceCompactPreview +import com.android.developers.androidify.xr.XrHomeSpaceMediumPreview + +@XrHomeSpaceMediumPreview +@ExperimentalMaterial3ExpressiveApi +@Composable +private fun HomeScreenMediumXrHomeSpaceModePreview() { + SharedElementContextPreview { + SupportsFullSpaceModeRequestProvider { + HomeScreenContents( + layoutType = HomeScreenLayoutType.Medium, + onClickLetsGo = { }, + videoLink = "", + dancingBotLink = "https://services.google.com/fh/files/misc/android_dancing.gif", + onAboutClicked = {}, + ) + } + } +} + +@ExperimentalMaterial3ExpressiveApi +@XrHomeSpaceCompactPreview +@Composable +private fun HomeScreenCompactXrHomeSpaceModePreview() { + SupportsFullSpaceModeRequestProvider { + SharedElementContextPreview { + HomeScreenContents( + layoutType = HomeScreenLayoutType.Compact, + onClickLetsGo = {}, + videoLink = "", + dancingBotLink = "https://services.google.com/fh/files/misc/android_dancing.gif", + onAboutClicked = {}, + ) + } + } +} diff --git a/feature/home/src/main/java/com/android/developers/androidify/home/xr/HomeScreenSpatial.kt b/feature/home/src/main/java/com/android/developers/androidify/home/xr/HomeScreenSpatial.kt new file mode 100644 index 00000000..38a5c4bd --- /dev/null +++ b/feature/home/src/main/java/com/android/developers/androidify/home/xr/HomeScreenSpatial.kt @@ -0,0 +1,173 @@ +/* + * 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.home.xr + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +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.layout.onLayoutRectChanged +import androidx.compose.ui.unit.IntOffset +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.SpatialPanel +import androidx.xr.compose.subspace.layout.SpatialAlignment +import androidx.xr.compose.subspace.layout.SubspaceModifier +import androidx.xr.compose.subspace.layout.aspectRatio +import androidx.xr.compose.subspace.layout.fillMaxHeight +import androidx.xr.compose.subspace.layout.fillMaxWidth +import androidx.xr.compose.subspace.layout.movable +import androidx.xr.compose.subspace.layout.offset +import androidx.xr.compose.subspace.layout.resizable +import androidx.xr.compose.subspace.layout.rotate +import com.android.developers.androidify.home.HomePageButton +import com.android.developers.androidify.home.MainHomeContent +import com.android.developers.androidify.home.VideoPlayer +import com.android.developers.androidify.theme.SharedElementContextPreview +import com.android.developers.androidify.theme.components.AboutButton +import com.android.developers.androidify.theme.components.AndroidifyTopAppBar +import com.android.developers.androidify.theme.components.SquiggleBackgroundFull +import com.android.developers.androidify.util.TabletPreview +import com.android.developers.androidify.xr.DisableSharedTransition +import com.android.developers.androidify.xr.MainPanelWorkaround +import com.android.developers.androidify.xr.RequestHomeSpaceIconButton + +@Composable +fun HomeScreenContentsSpatial( + videoLink: String?, + dancingBotLink: String?, + onClickLetsGo: (IntOffset) -> Unit, + onAboutClicked: () -> Unit, +) { + DisableSharedTransition { + Subspace { + MainPanelWorkaround() + SpatialPanel( + SubspaceModifier + .movable() + .resizable() + .fillMaxWidth(1f) + .aspectRatio(1.7f), + ) { + Orbiter( + position = ContentEdge.Top, + offsetType = OrbiterOffsetType.OuterEdge, + offset = 32.dp, + alignment = Alignment.End, + ) { + RequestHomeSpaceIconButton( + modifier = Modifier + .size(64.dp, 64.dp) + .padding(8.dp), + ) + } + HomeScreenSpatialMainContent(dancingBotLink, onClickLetsGo, onAboutClicked) + Subspace { + SpatialPanel( + SubspaceModifier + .fillMaxWidth(0.2f) + .fillMaxHeight(0.8f) + .aspectRatio(0.77f) + .resizable(maintainAspectRatio = true) + .movable() + .align(SpatialAlignment.CenterRight) + .offset(z = 10.dp) + .rotate(0f, 0f, 5f), + ) { + VideoPlayer(videoLink) + } + } + } + } + } +} + +@Composable +private fun HomeScreenSpatialMainContent( + dancingBotLink: String?, + onClickLetsGo: (IntOffset) -> Unit, + onAboutClicked: () -> Unit, +) { + var positionButtonClick by remember { + mutableStateOf(IntOffset.Zero) + } + Box { + SquiggleBackgroundFull() + Column( + modifier = Modifier + .fillMaxWidth(0.55f) + .fillMaxHeight(1f) + .align(Alignment.Center), + ) { + AndroidifyTopAppBar( + modifier = Modifier + .align(Alignment.CenterHorizontally) + .width(220.dp) + .padding(bottom = 16.dp, top = 48.dp), + + actions = { + AboutButton { onAboutClicked() } + }, + ) + MainHomeContent( + modifier = Modifier + .align(Alignment.CenterHorizontally) + .fillMaxHeight(0.8f), + dancingBotLink = dancingBotLink, + ) + + HomePageButton( + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(bottom = 48.dp) + .onLayoutRectChanged { + positionButtonClick = it.boundsInWindow.center + } + .height(64.dp) + .width(220.dp), + onClick = { + onClickLetsGo(positionButtonClick) + }, + ) + } + } +} + +@TabletPreview +@Composable +private fun HomeScreenSpatialMainContentPreview() { + SharedElementContextPreview { + HomeScreenSpatialMainContent( + dancingBotLink = "https://services.google.com/fh/files/misc/android_dancing.gif", + onClickLetsGo = {}, + onAboutClicked = {}, + ) + } +} diff --git a/feature/home/src/screenshotTest/java/com/android/developers/androidify/home/HomeScreenScreenshotTest.kt b/feature/home/src/screenshotTest/java/com/android/developers/androidify/home/HomeScreenScreenshotTest.kt index 5cdf0e62..49439c55 100644 --- a/feature/home/src/screenshotTest/java/com/android/developers/androidify/home/HomeScreenScreenshotTest.kt +++ b/feature/home/src/screenshotTest/java/com/android/developers/androidify/home/HomeScreenScreenshotTest.kt @@ -31,7 +31,7 @@ class HomeScreenScreenshotTest { AndroidifyTheme { SharedElementContextPreview { HomeScreenContents( - isMediumWindowSize = isAtLeastMedium(), + layoutType = calculateLayoutType(enableXr = false), onClickLetsGo = { }, onAboutClicked = {}, videoLink = "", diff --git a/feature/results/src/main/java/com/android/developers/androidify/customize/CustomizeExportScreen.kt b/feature/results/src/main/java/com/android/developers/androidify/customize/CustomizeExportScreen.kt index 9b5f8736..3eaffa62 100644 --- a/feature/results/src/main/java/com/android/developers/androidify/customize/CustomizeExportScreen.kt +++ b/feature/results/src/main/java/com/android/developers/androidify/customize/CustomizeExportScreen.kt @@ -81,6 +81,7 @@ import com.android.developers.androidify.results.R import com.android.developers.androidify.results.shareImage import com.android.developers.androidify.theme.AndroidifyTheme import com.android.developers.androidify.theme.LocalAnimateBoundsScope +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.SecondaryOutlinedButton @@ -175,7 +176,9 @@ private fun CustomizeExportContents( titleText = stringResource(R.string.customize_and_export), isMediumWindowSize = isMediumWindowSize, onBackPressed = onBackPress, - onAboutClicked = onInfoPress, + actions = { + AboutButton { onInfoPress() } + }, ) }, containerColor = MaterialTheme.colorScheme.surface, 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 a6c2036c..96a685b6 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 @@ -67,6 +67,7 @@ import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle 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 @@ -109,7 +110,9 @@ fun ResultsScreen( onBackPressed = { onBackPress() }, - onAboutClicked = onAboutPress, + actions = { + AboutButton { onAboutPress() } + }, ) }, modifier = modifier diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9dedc003..5a6d434c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -6,6 +6,7 @@ agp = "8.11.1" bcpkixJdk18on = "1.81" compileSdk = "36" core = "1.5.0" +extensionsXr = "1.0.0" leakcanaryAndroid = "2.14" minSdk = "26" javaVersion = "17" @@ -81,6 +82,7 @@ lifecycleProcess = "2.9.1" mlkitCommon = "18.11.0" mlkitSegmentation = "16.0.0-beta1" playServicesBase = "18.4.0" +xr-compose = "1.0.0-alpha06" [libraries] accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanist" } @@ -176,6 +178,9 @@ validator-push-cli = { module = "com.google.android.wearable.watchface.validator watchface-push = { group = "androidx.wear.watchfacepush", name="watchfacepush", version.ref = "watchFacePush" } play-services-base = { group = "com.google.android.gms", name = "play-services-base", version.ref = "playServicesBase" } google-firebase-appcheck-debug = { group = "com.google.firebase", name = "firebase-appcheck-debug"} +androidx-xr-compose = { group = "androidx.xr.compose", name="compose", version.ref = "xr-compose"} +androidx-xr-extensions = { module = "com.android.extensions.xr:extensions-xr", version.ref = "extensionsXr" } + [plugins] android-application = { id = "com.android.application", version.ref = "agp" } android-library = { id = "com.android.library", version.ref = "agp" } diff --git a/settings.gradle.kts b/settings.gradle.kts index d9ff8b8c..7e3f7b83 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -39,6 +39,7 @@ include(":core:network") include(":core:util") include(":core:theme") include(":core:testing") +include(":core:xr") include(":benchmark") include(":watchface") include(":wear")