diff --git a/compose-layout/api/current.api b/compose-layout/api/current.api index cd882e6759..a2e5b199b0 100644 --- a/compose-layout/api/current.api +++ b/compose-layout/api/current.api @@ -2,39 +2,52 @@ package com.google.android.horologist.compose.ambient { public final class AmbientAwareKt { - method @androidx.compose.runtime.Composable public static void AmbientAware(optional boolean isAlwaysOnScreen, kotlin.jvm.functions.Function1 block); + method @androidx.compose.runtime.Composable public static void AmbientAware(kotlin.jvm.functions.Function1 content); + method public static androidx.compose.runtime.ProvidableCompositionLocal getLocalAmbientState(); + property public static final androidx.compose.runtime.ProvidableCompositionLocal LocalAmbientState; } public final class AmbientAwareTimeKt { - method @RequiresApi(android.os.Build.VERSION_CODES.O) @androidx.compose.runtime.Composable public static void AmbientAwareTime(com.google.android.horologist.compose.ambient.AmbientStateUpdate stateUpdate, optional long updatePeriodMillis, kotlin.jvm.functions.Function2 block); + method @androidx.compose.runtime.Composable public static void AmbientAwareTime(com.google.android.horologist.compose.ambient.AmbientState stateUpdate, optional long updatePeriodMillis, kotlin.jvm.functions.Function2 block); } - public sealed interface AmbientState { + @androidx.compose.runtime.Immutable public sealed interface AmbientState { + method public String getDisplayName(); + method public default boolean isAmbient(); + method public default boolean isInteractive(); + property public abstract String displayName; + property public default boolean isAmbient; + property public default boolean isInteractive; } public static final class AmbientState.Ambient implements com.google.android.horologist.compose.ambient.AmbientState { - ctor public AmbientState.Ambient(optional androidx.wear.ambient.AmbientLifecycleObserver.AmbientDetails? ambientDetails); - method public androidx.wear.ambient.AmbientLifecycleObserver.AmbientDetails? component1(); - method public com.google.android.horologist.compose.ambient.AmbientState.Ambient copy(androidx.wear.ambient.AmbientLifecycleObserver.AmbientDetails? ambientDetails); - method public androidx.wear.ambient.AmbientLifecycleObserver.AmbientDetails? getAmbientDetails(); - property public final androidx.wear.ambient.AmbientLifecycleObserver.AmbientDetails? ambientDetails; + ctor public AmbientState.Ambient(optional boolean burnInProtectionRequired, optional boolean deviceHasLowBitAmbient, optional long updateTimeMillis); + method public boolean component1(); + method public boolean component2(); + method public long component3(); + method public com.google.android.horologist.compose.ambient.AmbientState.Ambient copy(boolean burnInProtectionRequired, boolean deviceHasLowBitAmbient, long updateTimeMillis); + method public boolean getBurnInProtectionRequired(); + method public boolean getDeviceHasLowBitAmbient(); + method public String getDisplayName(); + method public long getUpdateTimeMillis(); + property public final boolean burnInProtectionRequired; + property public final boolean deviceHasLowBitAmbient; + property public String displayName; + property public final long updateTimeMillis; + } + + public static final class AmbientState.Inactive implements com.google.android.horologist.compose.ambient.AmbientState { + method public String getDisplayName(); + property public String displayName; + field public static final com.google.android.horologist.compose.ambient.AmbientState.Inactive INSTANCE; } public static final class AmbientState.Interactive implements com.google.android.horologist.compose.ambient.AmbientState { + method public String getDisplayName(); + property public String displayName; field public static final com.google.android.horologist.compose.ambient.AmbientState.Interactive INSTANCE; } - public final class AmbientStateUpdate { - ctor public AmbientStateUpdate(com.google.android.horologist.compose.ambient.AmbientState ambientState, optional long changeTimeMillis); - method public com.google.android.horologist.compose.ambient.AmbientState component1(); - method public long component2(); - method public com.google.android.horologist.compose.ambient.AmbientStateUpdate copy(com.google.android.horologist.compose.ambient.AmbientState ambientState, long changeTimeMillis); - method public com.google.android.horologist.compose.ambient.AmbientState getAmbientState(); - method public long getChangeTimeMillis(); - property public final com.google.android.horologist.compose.ambient.AmbientState ambientState; - property public final long changeTimeMillis; - } - } package com.google.android.horologist.compose.layout { diff --git a/compose-layout/src/main/java/com/google/android/horologist/compose/ambient/AmbientAware.kt b/compose-layout/src/main/java/com/google/android/horologist/compose/ambient/AmbientAware.kt index 8f34882289..e196b45b8c 100644 --- a/compose-layout/src/main/java/com/google/android/horologist/compose/ambient/AmbientAware.kt +++ b/compose-layout/src/main/java/com/google/android/horologist/compose/ambient/AmbientAware.kt @@ -20,87 +20,97 @@ import android.app.Activity import android.content.Context import android.content.ContextWrapper import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.getValue +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.wear.ambient.AmbientLifecycleObserver /** * Composable for general handling of changes and updates to ambient status. A new - * [AmbientStateUpdate] is generated with any change of ambient state, as well as with any periodic + * [AmbientState] is generated with any change of ambient state, as well as with any periodic * update generated whilst the screen is in ambient mode. * * This composable changes the behavior of the activity, enabling Always-On. See: * * https://developer.android.com/training/wearables/views/always-on). * - * It should therefore be used high up in the tree of composables. + * It should be used within each individual screen inside nav routes. * - * @param isAlwaysOnScreen If supplied, this indicates whether always-on should be enabled. This can - * be used to ensure that some screens display an ambient-mode version, whereas others do not, for - * example, a workout screen vs a end-of-workout summary screen. - * @param block Lambda that will be used for building the UI, which is passed the current ambient + * @param content Lambda that will be used for building the UI, which is passed the current ambient * state. */ @Composable fun AmbientAware( - isAlwaysOnScreen: Boolean = true, - block: @Composable (AmbientStateUpdate) -> Unit, + content: @Composable (AmbientState) -> Unit, ) { - var ambientUpdate by remember(isAlwaysOnScreen) { - mutableStateOf(if (isAlwaysOnScreen) null else AmbientStateUpdate(AmbientState.Interactive)) - } - - val activity = LocalContext.current.findActivityOrNull() // Using AmbientAware correctly relies on there being an Activity context. If there isn't, then // gracefully allow the composition of [block], but no ambient-mode functionality is enabled. - if (activity != null && isAlwaysOnScreen) { - val lifecycle = LocalLifecycleOwner.current.lifecycle - val observer = remember { - val callback = object : AmbientLifecycleObserver.AmbientLifecycleCallback { - override fun onEnterAmbient(ambientDetails: AmbientLifecycleObserver.AmbientDetails) { - ambientUpdate = AmbientStateUpdate(AmbientState.Ambient(ambientDetails)) - } + val activity = LocalContext.current.findActivityOrNull() + val lifecycle = LocalLifecycleOwner.current.lifecycle - override fun onExitAmbient() { - ambientUpdate = AmbientStateUpdate(AmbientState.Interactive) - } + var ambientState = remember { + mutableStateOf(AmbientState.Inactive) + } - override fun onUpdateAmbient() { - val lastAmbientDetails = - (ambientUpdate?.ambientState as? AmbientState.Ambient)?.ambientDetails - ambientUpdate = AmbientStateUpdate(AmbientState.Ambient(lastAmbientDetails)) - } - } - AmbientLifecycleObserver(activity, callback).also { - // Necessary to populate the initial value - val initialAmbientState = if (it.isAmbient) { - AmbientState.Ambient(null) - } else { - AmbientState.Interactive - } - ambientUpdate = AmbientStateUpdate(initialAmbientState) - } - } + val observer = remember { + if (activity != null) { + AmbientLifecycleObserver( + activity, + object : AmbientLifecycleObserver.AmbientLifecycleCallback { + override fun onEnterAmbient(ambientDetails: AmbientLifecycleObserver.AmbientDetails) { + ambientState.value = AmbientState.Ambient( + burnInProtectionRequired = ambientDetails.burnInProtectionRequired, + deviceHasLowBitAmbient = ambientDetails.deviceHasLowBitAmbient, + ) + } + + override fun onExitAmbient() { + ambientState.value = AmbientState.Interactive + } - DisposableEffect(Unit) { - lifecycle.addObserver(observer) + override fun onUpdateAmbient() { + val lastAmbientDetails = + (ambientState.value as? AmbientState.Ambient) + ambientState.value = AmbientState.Ambient( + burnInProtectionRequired = lastAmbientDetails?.burnInProtectionRequired == true, + deviceHasLowBitAmbient = lastAmbientDetails?.deviceHasLowBitAmbient == true, + ) + } + }, + ).also { observer -> + ambientState.value = + if (observer.isAmbient) AmbientState.Ambient() else AmbientState.Interactive - onDispose { - lifecycle.removeObserver(observer) + lifecycle.addObserver(observer) } + } else { + null } } - ambientUpdate?.let { - block(it) + val value = ambientState.value + CompositionLocalProvider(LocalAmbientState provides value) { + content(value) } } +/** + * AmbientState represents the current state of an ambient effect. + * It defaults to [AmbientState.Inactive] if no state is provided. + * + * @sample + * ```kotlin + * val state = LocalAmbientState.current + * if (state is AmbientState.Active) { + * // Perform actions based on the active state + * } + * ``` + */ +val LocalAmbientState = compositionLocalOf { AmbientState.Inactive } + private fun Context.findActivityOrNull(): Activity? { var context = this while (context is ContextWrapper) { diff --git a/compose-layout/src/main/java/com/google/android/horologist/compose/ambient/AmbientAwareTime.kt b/compose-layout/src/main/java/com/google/android/horologist/compose/ambient/AmbientAwareTime.kt index 6c09916c57..f1aab23118 100644 --- a/compose-layout/src/main/java/com/google/android/horologist/compose/ambient/AmbientAwareTime.kt +++ b/compose-layout/src/main/java/com/google/android/horologist/compose/ambient/AmbientAwareTime.kt @@ -16,8 +16,6 @@ package com.google.android.horologist.compose.ambient -import android.os.Build -import androidx.annotation.RequiresApi import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -58,10 +56,9 @@ import java.time.ZonedDateTime * @param updatePeriodMillis The update period, whilst in interactive mode * @param block The developer-supplied composable for rendering the date and time. */ -@RequiresApi(Build.VERSION_CODES.O) @Composable fun AmbientAwareTime( - stateUpdate: AmbientStateUpdate, + stateUpdate: AmbientState, updatePeriodMillis: Long = 1000, block: @Composable (dateTime: ZonedDateTime, isAmbient: Boolean) -> Unit, ) { @@ -75,7 +72,7 @@ fun AmbientAwareTime( } LaunchedEffect(stateUpdate) { - if (stateUpdate.ambientState == AmbientState.Interactive) { + if (stateUpdate.isInteractive) { while (isActive) { isAmbient = false currentTime = ZonedDateTime.now() diff --git a/compose-layout/src/main/java/com/google/android/horologist/compose/ambient/AmbientState.kt b/compose-layout/src/main/java/com/google/android/horologist/compose/ambient/AmbientState.kt new file mode 100644 index 0000000000..f2ae4367e3 --- /dev/null +++ b/compose-layout/src/main/java/com/google/android/horologist/compose/ambient/AmbientState.kt @@ -0,0 +1,83 @@ +/* + * Copyright 2023 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.google.android.horologist.compose.ambient + +import androidx.compose.runtime.Immutable +import androidx.wear.ambient.AmbientLifecycleObserver + +/** + * Represent Ambient as updates, with the state and time of change. This is necessary to ensure that + * when the system provides a (typically) 1min-frequency callback to onUpdateAmbient, the developer + * may wish to update composables, but the state hasn't changed. + */ +@Immutable +sealed interface AmbientState { + val displayName: String + + /** + * Represents that the state of the device is is interactive, and the app is open and being used. + * + * This object is used to track whether the application is currently + * being interacted with by the user. + */ + data object Interactive : AmbientState { + override val displayName: String + get() = "Interactive" + } + + /** + * Represents the state of a device, that the app is in ambient mode and not actively updating + * the display. + * + * This class holds information about the ambient display properties, such as + * whether burn-in protection is required, if the device has low bit ambient display, + * and the last time the ambient state was updated. + * + * @see [AmbientLifecycleObserver.AmbientDetails] + * @property burnInProtectionRequired Indicates if burn-in protection is necessary for the device. + * Defaults to false. + * @property deviceHasLowBitAmbient Specifies if the device has a low bit ambient display. + * Defaults to false. + * @property updateTimeMillis The timestamp in milliseconds when the ambient state was last updated. + * Defaults to the current system time. + */ + data class Ambient( + val burnInProtectionRequired: Boolean = false, + val deviceHasLowBitAmbient: Boolean = false, + val updateTimeMillis: Long = System.currentTimeMillis(), + ) : + AmbientState { + override val displayName: String + get() = "Ambient" + } + + /** + * Represents the state of a device, that the app isn't currently monitoring the ambient state. + * + * @property displayName A user-friendly name for this state, displayed as "Inactive". + */ + data object Inactive : AmbientState { + override val displayName: String + get() = "Inactive" + } + + val isInteractive: Boolean + get() = !isAmbient + + val isAmbient: Boolean + get() = this is Ambient +} diff --git a/compose-layout/src/main/java/com/google/android/horologist/compose/ambient/AmbientStateUpdate.kt b/compose-layout/src/main/java/com/google/android/horologist/compose/ambient/AmbientStateUpdate.kt deleted file mode 100644 index b69c8c006e..0000000000 --- a/compose-layout/src/main/java/com/google/android/horologist/compose/ambient/AmbientStateUpdate.kt +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright 2023 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.google.android.horologist.compose.ambient - -import androidx.wear.ambient.AmbientLifecycleObserver - -/** - * Represent Ambient as updates, with the state and time of change. This is necessary to ensure that - * when the system provides a (typically) 1min-frequency callback to onUpdateAmbient, the developer - * may wish to update composables, but the state hasn't changed. - */ -data class AmbientStateUpdate( - val ambientState: AmbientState, - val changeTimeMillis: Long = System.currentTimeMillis(), -) - -sealed interface AmbientState { - data class Ambient(val ambientDetails: AmbientLifecycleObserver.AmbientDetails? = null) : - AmbientState - - object Interactive : AmbientState -} diff --git a/docs/compose-layout.md b/docs/compose-layout.md index f1c5e6a459..d742fc9be8 100644 --- a/docs/compose-layout.md +++ b/docs/compose-layout.md @@ -151,47 +151,29 @@ Box( `AmbientAware` allows your UI to react to ambient mode changes. For more information on how Ambient mode and Always-on work on Wear OS, see the [developer guidance][always-on]. -You should place this composable high up in your design, as it alters the behavior of the Activity. +You should place this composable high up in your screen, but within navigation routes so that +different screens can handle ambient mode differently. ```kotlin @Composable -fun WearApp() { - AmbientAware { ambientStateUpdate -> - when (val state = ambientStateUpdate.ambientState) { - is AmbientState.Ambient -> { - val ambientDetails = state.ambientDetails - val burnInProtectionRequired = ambientDetails?.burnInProtectionRequired - val deviceHasLowBitAmbient = ambientDetails?.deviceHasLowBitAmbient - // Device is in ambient (low power) mode - } - is AmbientState.Interactive -> { - // Device is in interactive (high power) mode - } +fun MyScreen() { + AmbientAware { ambientState -> + if (ambientState.isAmbient) { + val ambientDetails = state.ambientDetails + val burnInProtectionRequired = ambientDetails?.burnInProtectionRequired + val deviceHasLowBitAmbient = ambientDetails?.deviceHasLowBitAmbient + // Device is in ambient (low power) mode + } else { + // Device is in interactive (high power) mode } } } ``` -If you need some screens to use always-on, and others not to, then you can use -the additional argument supplied to `AmbientAware` to indicate whether a -recomposition should be triggered when the system goes into ambient mode (i.e. -whether the composable wants to handle ambient mode or not). - For example, in a workout app, it is desirable that the main workout screen uses always-on, but the workout summary at the end does not. See the [`ExerciseClient`][exercise-client] guide and [samples][health-samples] for more information on building a workout app. -```kotlin -@Composable -fun WearApp() { - // Hoist state here for your current screen logic - - AmbientAware(isAlwaysOnScreen = currentScreen.useAlwaysOn) { ambientStateUpdate -> - // App Content here - } -} -``` - ## Download ```groovy diff --git a/sample/src/main/AndroidManifest.xml b/sample/src/main/AndroidManifest.xml index d482fdb998..1f759efa85 100644 --- a/sample/src/main/AndroidManifest.xml +++ b/sample/src/main/AndroidManifest.xml @@ -106,6 +106,17 @@ + + + + + + + { + HomeScreen(onRun = { navController.navigate(Preparing) }, onSettings = { navController.navigate(Settings) }) + } + composable { + PreparingScreen(onStart = { navController.navigate(Exercise) }, onSettings = { navController.navigate(Settings) }) + } + composable { + ExerciseScreen(onStop = { navController.navigate(Home) }, onSettings = { navController.navigate(Settings) }) + } + composable { + SettingsScreen() + } + } + } + } +} + +@Composable +fun HomeScreen(modifier: Modifier = Modifier, onRun: () -> Unit, onSettings: () -> Unit) { + val columnState = rememberResponsiveColumnState() + ScreenScaffold(modifier = modifier, scrollState = columnState) { + ScalingLazyColumn(columnState = columnState) { + item { + Title("Home") + } + item { + Chip( + label = "Run", + onClick = onRun, + ) + } + item { + Chip( + label = "Settings", + onClick = onSettings, + ) + } + } + } +} + +@Composable +fun ExerciseScreen(modifier: Modifier = Modifier, onStop: () -> Unit, onSettings: () -> Unit) { + AmbientAware { ambientState -> + if (ambientState.isInteractive) { + ScreenScaffold(modifier = modifier, timeText = { + if (ambientState.isInteractive) { + AmbientAwareTimeText(ambientState) + } + }) { + Box(modifier = modifier.fillMaxSize()) { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + "Exercise", + color = Color.Green, + ) + Button(onClick = onStop, imageVector = Icons.Rounded.Cancel, contentDescription = "Cancel") + Button(onClick = onSettings, imageVector = Icons.Rounded.Settings, contentDescription = "Settings") + } + } + } + } + } +} + +@Composable +fun PreparingScreen(modifier: Modifier = Modifier, onStart: () -> Unit, onSettings: () -> Unit) { + AmbientAware { ambientState -> + ScreenScaffold(modifier = modifier.ambientGray(ambientState), timeText = { + AmbientAwareTimeText(ambientState) + }) { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + "Preparing", + color = Color.Blue, + ) + Text( + ambientState.displayName, + color = Color.Blue, + ) + Button(onClick = onStart, imageVector = Icons.Rounded.PlayArrow, contentDescription = "Start") + Button(onClick = onSettings, imageVector = Icons.Rounded.Settings, contentDescription = "Settings") + } + } + } +} + +@Composable +fun SettingsScreen(modifier: Modifier = Modifier) { + val columnState = rememberResponsiveColumnState() + ScreenScaffold(modifier = modifier, scrollState = columnState) { + ScalingLazyColumn(columnState = columnState) { + item { + Title("Settings") + } + items(5) { + val toggled = remember { mutableStateOf(false) } + ToggleChip( + checked = false, + label = "Item $it", + onCheckedChanged = { toggled.value = !toggled.value }, + toggleControl = ToggleChipToggleControl.Switch, + ) + } + } + } +} + +private val grayscale = Paint().apply { + colorFilter = ColorFilter.colorMatrix( + ColorMatrix().apply { + setToSaturation(0f) + }, + ) + isAntiAlias = false +} + +internal fun Modifier.ambientGray(ambientState: AmbientState): Modifier = + if (ambientState.isAmbient) { + graphicsLayer { + scaleX = 0.9f + scaleY = 0.9f + }.drawWithContent { + drawIntoCanvas { + it.withSaveLayer(size.toRect(), grayscale) { + drawContent() + } + } + } + } else { + this + } + +@Composable +fun AmbientAwareTimeText(ambientState: AmbientState) { + TimeText(endCurvedContent = { + curvedText(ambientState.displayName, color = Color.LightGray) + }) +} + +@Serializable +object Home + +@Serializable +object Preparing + +@Serializable +object Exercise + +@Serializable +object Settings + +@WearPreviewLargeRound +@Composable +fun WearAppPreview() { + AmbientAwareWearApp() +}