Skip to content

Commit

Permalink
Refactor: AmbientAware API change (#2472)
Browse files Browse the repository at this point in the history
This change refactors the `AmbientAware` API to make it more intuitive and easier to use.

The following changes were made:

- The `AmbientAware` composable moves to the screen level
- The `AmbientStateUpdate` class was merged into `AmbientState`.
- The `AmbientState` sealed interface now has a data object `Interactive` and a data class `Ambient` to store AmbientDetails, as well as Inactive.

---------

Co-authored-by: yschimke <[email protected]>
  • Loading branch information
yschimke and yschimke authored Dec 6, 2024
1 parent c4adbba commit f8ccc68
Show file tree
Hide file tree
Showing 8 changed files with 442 additions and 137 deletions.
51 changes: 32 additions & 19 deletions compose-layout/api/current.api
Original file line number Diff line number Diff line change
Expand Up @@ -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<? super com.google.android.horologist.compose.ambient.AmbientStateUpdate,kotlin.Unit> block);
method @androidx.compose.runtime.Composable public static void AmbientAware(kotlin.jvm.functions.Function1<? super com.google.android.horologist.compose.ambient.AmbientState,kotlin.Unit> content);
method public static androidx.compose.runtime.ProvidableCompositionLocal<com.google.android.horologist.compose.ambient.AmbientState> getLocalAmbientState();
property public static final androidx.compose.runtime.ProvidableCompositionLocal<com.google.android.horologist.compose.ambient.AmbientState> 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<? super java.time.ZonedDateTime,? super java.lang.Boolean,kotlin.Unit> 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<? super java.time.ZonedDateTime,? super java.lang.Boolean,kotlin.Unit> 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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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>(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> { AmbientState.Inactive }

private fun Context.findActivityOrNull(): Activity? {
var context = this
while (context is ContextWrapper) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
) {
Expand All @@ -75,7 +72,7 @@ fun AmbientAwareTime(
}

LaunchedEffect(stateUpdate) {
if (stateUpdate.ambientState == AmbientState.Interactive) {
if (stateUpdate.isInteractive) {
while (isActive) {
isAmbient = false
currentTime = ZonedDateTime.now()
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}

This file was deleted.

Loading

0 comments on commit f8ccc68

Please sign in to comment.