diff --git a/datalayer/phone-ui/api/current.api b/datalayer/phone-ui/api/current.api index d5c63ec4c0..885570af4c 100644 --- a/datalayer/phone-ui/api/current.api +++ b/datalayer/phone-ui/api/current.api @@ -3,8 +3,10 @@ package com.google.android.horologist.datalayer.phone.ui { @com.google.android.horologist.annotations.ExperimentalHorologistApi public final class PhoneUiDataLayerHelper { ctor public PhoneUiDataLayerHelper(); - method public android.content.Intent getInstallPromptIntent(android.content.Context context, String appPackageName, @DrawableRes int image, String topMessage, String bottomMessage); + method public android.content.Intent getInstallAppPromptIntent(android.content.Context context, String appPackageName, @DrawableRes int image, String topMessage, String bottomMessage); + method public android.content.Intent getReEngagePromptIntent(android.content.Context context, String nodeId, @DrawableRes int image, String topMessage, String bottomMessage, optional String? positiveButtonLabel, optional String? negativeButtonLabel); method public void showInstallAppPrompt(android.app.Activity activity, String appPackageName, @DrawableRes int image, String topMessage, String bottomMessage, optional int requestCode); + method public void showReEngagePrompt(android.app.Activity activity, String nodeId, @DrawableRes int image, String topMessage, String bottomMessage, optional int requestCode); } } @@ -25,3 +27,11 @@ package com.google.android.horologist.datalayer.phone.ui.prompt.installapp { } +package com.google.android.horologist.datalayer.phone.ui.prompt.reengage { + + public final class ReEngageBottomSheetKt { + method @androidx.compose.runtime.Composable public static void ReEngageBottomSheet(kotlin.jvm.functions.Function0? image, String topMessage, String bottomMessage, kotlin.jvm.functions.Function0 onDismissRequest, kotlin.jvm.functions.Function0 onConfirmation, optional androidx.compose.ui.Modifier modifier, optional String? positiveButtonLabel, optional String? negativeButtonLabel, optional androidx.compose.material3.SheetState sheetState); + } + +} + diff --git a/datalayer/phone-ui/build.gradle.kts b/datalayer/phone-ui/build.gradle.kts index 7eabef33e1..21831b6156 100644 --- a/datalayer/phone-ui/build.gradle.kts +++ b/datalayer/phone-ui/build.gradle.kts @@ -26,7 +26,7 @@ android { compileSdk = 34 defaultConfig { - minSdk = 21 + minSdk = 23 testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } @@ -102,6 +102,7 @@ dependencies { api(libs.compose.ui) api(projects.annotations) + implementation(projects.datalayer.phone) implementation(libs.androidx.activity.compose) implementation(libs.androidx.appcompat) implementation(libs.androidx.corektx) diff --git a/datalayer/phone-ui/src/main/AndroidManifest.xml b/datalayer/phone-ui/src/main/AndroidManifest.xml index b933028151..247db46b45 100644 --- a/datalayer/phone-ui/src/main/AndroidManifest.xml +++ b/datalayer/phone-ui/src/main/AndroidManifest.xml @@ -13,5 +13,11 @@ android:excludeFromRecents="true" android:exported="false" android:theme="@style/HorologistTheme.Transparent" /> + + diff --git a/datalayer/phone-ui/src/main/java/com/google/android/horologist/datalayer/phone/ui/PhoneUiDataLayerHelper.kt b/datalayer/phone-ui/src/main/java/com/google/android/horologist/datalayer/phone/ui/PhoneUiDataLayerHelper.kt index 79e15c1a92..d36b52df53 100644 --- a/datalayer/phone-ui/src/main/java/com/google/android/horologist/datalayer/phone/ui/PhoneUiDataLayerHelper.kt +++ b/datalayer/phone-ui/src/main/java/com/google/android/horologist/datalayer/phone/ui/PhoneUiDataLayerHelper.kt @@ -25,6 +25,7 @@ import androidx.activity.result.ActivityResultLauncher import androidx.annotation.DrawableRes import com.google.android.horologist.annotations.ExperimentalHorologistApi import com.google.android.horologist.datalayer.phone.ui.prompt.installapp.InstallAppBottomSheetActivity +import com.google.android.horologist.datalayer.phone.ui.prompt.reengage.ReEngageBottomSheetActivity private const val NO_RESULT_REQUESTED_REQUEST_CODE = -1 @@ -48,7 +49,7 @@ public class PhoneUiDataLayerHelper { bottomMessage: String, requestCode: Int = NO_RESULT_REQUESTED_REQUEST_CODE, ) { - val intent = getInstallPromptIntent( + val intent = getInstallAppPromptIntent( context = activity, appPackageName = appPackageName, image = image, @@ -62,7 +63,7 @@ public class PhoneUiDataLayerHelper { } /** - * Returns the [Intent] to display an install prompt to the user. + * Returns the [Intent] to display an install app prompt to the user. * * This can be used in Compose with [rememberLauncherForActivityResult] and * [ActivityResultLauncher.launch]: @@ -76,7 +77,7 @@ public class PhoneUiDataLayerHelper { * } * } * - * launcher.launch(getInstallPromptIntent(/*params*/)) + * launcher.launch(getInstallAppPromptIntent(/*params*/)) * ``` * * It can also be used directly in an [ComponentActivity] with @@ -90,10 +91,10 @@ public class PhoneUiDataLayerHelper { * } * } * - * launcher.launch(getInstallPromptIntent(/*params*/)) + * launcher.launch(getInstallAppPromptIntent(/*params*/)) * ``` */ - public fun getInstallPromptIntent( + public fun getInstallAppPromptIntent( context: Context, appPackageName: String, @DrawableRes image: Int, @@ -106,4 +107,81 @@ public class PhoneUiDataLayerHelper { topMessage = topMessage, bottomMessage = bottomMessage, ) + + /** + * Display a re-engage prompt to the user. + * + * Use [requestCode] as an option to check in [Activity.onActivityResult] if the prompt was + * dismissed ([Activity.RESULT_CANCELED]). + */ + public fun showReEngagePrompt( + activity: Activity, + nodeId: String, + @DrawableRes image: Int, + topMessage: String, + bottomMessage: String, + requestCode: Int = NO_RESULT_REQUESTED_REQUEST_CODE, + ) { + val intent = getReEngagePromptIntent( + context = activity, + nodeId = nodeId, + image = image, + topMessage = topMessage, + bottomMessage = bottomMessage, + ) + activity.startActivityForResult( + intent, + requestCode, + ) + } + + /** + * Returns the [Intent] to display a re-engage prompt to the user. + * + * This can be used in Compose with [rememberLauncherForActivityResult] and + * [ActivityResultLauncher.launch]: + * + * ``` + * val launcher = rememberLauncherForActivityResult( + * ActivityResultContracts.StartActivityForResult() + * ) { result -> + * if (result.resultCode == RESULT_OK) { + * // user pushed try! + * } + * } + * + * launcher.launch(getReEngagePromptIntent(/*params*/)) + * ``` + * + * It can also be used directly in an [ComponentActivity] with + * [ComponentActivity.registerForActivityResult]: + * ``` + * val launcher = registerForActivityResult( + * ActivityResultContracts.StartActivityForResult() + * ) { result -> + * if (result.resultCode == RESULT_OK) { + * // user pushed try! + * } + * } + * + * launcher.launch(getReEngagePromptIntent(/*params*/)) + * ``` + */ + public fun getReEngagePromptIntent( + context: Context, + nodeId: String, + @DrawableRes image: Int, + topMessage: String, + bottomMessage: String, + positiveButtonLabel: String? = null, + negativeButtonLabel: String? = null, + ): Intent = ReEngageBottomSheetActivity.getIntent( + context = context, + nodeId = nodeId, + image = image, + topMessage = topMessage, + bottomMessage = bottomMessage, + positiveButtonLabel = positiveButtonLabel, + negativeButtonLabel = negativeButtonLabel, + ) } diff --git a/datalayer/phone-ui/src/main/java/com/google/android/horologist/datalayer/phone/ui/di/CoroutineAppScope.kt b/datalayer/phone-ui/src/main/java/com/google/android/horologist/datalayer/phone/ui/di/CoroutineAppScope.kt new file mode 100644 index 0000000000..13561ecc30 --- /dev/null +++ b/datalayer/phone-ui/src/main/java/com/google/android/horologist/datalayer/phone/ui/di/CoroutineAppScope.kt @@ -0,0 +1,36 @@ +/* + * Copyright 2024 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.datalayer.phone.ui.di + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob + +internal class CoroutineAppScope private constructor() { + companion object { + + @Volatile + private var instance: CoroutineScope? = null + + fun getInstance() = + instance ?: synchronized(this) { + instance ?: CoroutineScope(SupervisorJob() + Dispatchers.Default).also { + instance = it + } + } + } +} diff --git a/datalayer/phone-ui/src/main/java/com/google/android/horologist/datalayer/phone/ui/prompt/installapp/InstallAppBottomSheetActivity.kt b/datalayer/phone-ui/src/main/java/com/google/android/horologist/datalayer/phone/ui/prompt/installapp/InstallAppBottomSheetActivity.kt index 8772f43502..a31c012b8d 100644 --- a/datalayer/phone-ui/src/main/java/com/google/android/horologist/datalayer/phone/ui/prompt/installapp/InstallAppBottomSheetActivity.kt +++ b/datalayer/phone-ui/src/main/java/com/google/android/horologist/datalayer/phone/ui/prompt/installapp/InstallAppBottomSheetActivity.kt @@ -53,7 +53,7 @@ internal class InstallAppBottomSheetActivity : ComponentActivity() { setContent { Surface { - val installAppBottomSheetState = rememberModalBottomSheetState() + val bottomSheetState = rememberModalBottomSheetState() val coroutineScope = rememberCoroutineScope() val image: (@Composable () -> Unit)? = imageResId.takeIf { it != NO_IMAGE }?.let { @@ -73,7 +73,7 @@ internal class InstallAppBottomSheetActivity : ComponentActivity() { setResult(RESULT_CANCELED) coroutineScope.launch { try { - installAppBottomSheetState.hide() + bottomSheetState.hide() } finally { finishWithoutAnimation() } @@ -85,7 +85,7 @@ internal class InstallAppBottomSheetActivity : ComponentActivity() { setResult(RESULT_OK) finishWithoutAnimation() }, - sheetState = installAppBottomSheetState, + sheetState = bottomSheetState, ) } } diff --git a/datalayer/phone-ui/src/main/java/com/google/android/horologist/datalayer/phone/ui/prompt/reengage/ReEngageBottomSheet.kt b/datalayer/phone-ui/src/main/java/com/google/android/horologist/datalayer/phone/ui/prompt/reengage/ReEngageBottomSheet.kt new file mode 100644 index 0000000000..d9e2d9734d --- /dev/null +++ b/datalayer/phone-ui/src/main/java/com/google/android/horologist/datalayer/phone/ui/prompt/reengage/ReEngageBottomSheet.kt @@ -0,0 +1,280 @@ +/* + * Copyright 2024 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.datalayer.phone.ui.prompt.reengage + +import android.content.res.Configuration +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Email +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.SheetState +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.google.android.horologist.datalayer.phone.ui.R + +// Constants from the redlines. +private val PADDING_GREEN = 12.dp +private val PADDING_PINK = 16.dp +private val PADDING_PURPLE = 24.dp +private val PADDING_BLUE = 32.dp + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +public fun ReEngageBottomSheet( + image: @Composable (() -> Unit)?, + topMessage: String, + bottomMessage: String, + onDismissRequest: () -> Unit, + onConfirmation: () -> Unit, + modifier: Modifier = Modifier, + positiveButtonLabel: String? = null, + negativeButtonLabel: String? = null, + sheetState: SheetState = rememberModalBottomSheetState(), +) { + ModalBottomSheet( + onDismissRequest = onDismissRequest, + modifier = modifier, + sheetState = sheetState, + dragHandle = null, + ) { + val configuration = LocalConfiguration.current + when (configuration.orientation) { + Configuration.ORIENTATION_PORTRAIT -> { + ReEngageBottomSheetPortraitContent( + image = image, + topMessage = topMessage, + bottomMessage = bottomMessage, + positiveButtonLabel = positiveButtonLabel ?: stringResource(id = R.string.horologist_reengage_prompt_ok_btn_label), + negativeButtonLabel = negativeButtonLabel ?: stringResource(id = R.string.horologist_reengage_prompt_cancel_btn_label), + onDismissRequest = onDismissRequest, + onConfirmation = onConfirmation, + ) + } + + else -> { + ReEngageBottomSheetLandscapeContent( + image = image, + topMessage = topMessage, + bottomMessage = bottomMessage, + onDismissRequest = onDismissRequest, + onConfirmation = onConfirmation, + ) + } + } + } +} + +@Composable +internal fun ReEngageBottomSheetPortraitContent( + image: @Composable (() -> Unit)?, + topMessage: String, + bottomMessage: String, + positiveButtonLabel: String, + negativeButtonLabel: String, + onDismissRequest: () -> Unit, + onConfirmation: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .padding(PADDING_PINK) + .fillMaxWidth() + .verticalScroll(rememberScrollState()), + ) { + image?.let { + Box( + modifier = Modifier + .padding(top = PADDING_PURPLE) + .align(Alignment.CenterHorizontally), + ) { + image() + } + } + + if (topMessage.isNotBlank()) { + Text( + text = topMessage, + modifier = Modifier + .padding(top = PADDING_PURPLE) + .fillMaxWidth(), + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + maxLines = 3, + style = MaterialTheme.typography.titleLarge, + ) + } + + if (bottomMessage.isNotBlank()) { + Text( + text = bottomMessage, + modifier = Modifier + .padding(top = PADDING_PINK) + .fillMaxWidth(), + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + maxLines = 3, + style = MaterialTheme.typography.bodyLarge, + ) + } + + Spacer(modifier = Modifier.height(PADDING_PURPLE)) + + Row( + modifier = Modifier + .padding(horizontal = PADDING_PINK) + .fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + TextButton( + onClick = onDismissRequest, + modifier = Modifier + .padding(end = PADDING_GREEN), + ) { + Text(text = negativeButtonLabel) + } + + Button( + onClick = onConfirmation, + ) { + Text(text = positiveButtonLabel) + } + } + } +} + +@Composable +internal fun ReEngageBottomSheetLandscapeContent( + image: @Composable (() -> Unit)?, + topMessage: String, + bottomMessage: String, + onDismissRequest: () -> Unit, + onConfirmation: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .padding(horizontal = PADDING_PURPLE) + .padding(top = PADDING_BLUE) + .verticalScroll(rememberScrollState()), + ) { + Row { + image?.let { + Box(modifier = Modifier.padding(end = PADDING_PURPLE)) { + image() + } + } + + Column { + if (topMessage.isNotBlank()) { + Text( + text = topMessage, + modifier = Modifier + .fillMaxWidth(), + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Start, + maxLines = 3, + style = MaterialTheme.typography.titleLarge, + ) + } + + if (bottomMessage.isNotBlank()) { + Text( + text = bottomMessage, + modifier = Modifier + .padding(top = PADDING_PINK) + .fillMaxWidth(), + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Start, + maxLines = 3, + style = MaterialTheme.typography.bodyLarge, + ) + } + } + } + + Row( + modifier = Modifier + .padding(top = PADDING_BLUE, bottom = PADDING_PINK) + .fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + TextButton( + onClick = onDismissRequest, + modifier = Modifier + .padding(end = PADDING_GREEN), + ) { + Text(stringResource(id = R.string.horologist_install_app_prompt_cancel_btn_label)) + } + + Button( + onClick = onConfirmation, + ) { + Text(stringResource(id = R.string.horologist_install_app_prompt_ok_btn_label)) + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun ReEngageBottomSheetContentPreview() { + ReEngageBottomSheetPortraitContent( + image = { Icon(Icons.Default.Email, contentDescription = null) }, + topMessage = "Stay productive and manage emails right from your wrist.", + bottomMessage = "Add the Gmail app to your Wear OS watch for easy access wherever you are.", + positiveButtonLabel = "Try on watch", + negativeButtonLabel = "Now now", + onDismissRequest = { }, + onConfirmation = { }, + ) +} + +@Preview(showBackground = true) +@Composable +private fun ReEngageBottomSheetContentPreviewNoIcon() { + ReEngageBottomSheetPortraitContent( + image = null, + topMessage = "Stay productive and manage emails right from your wrist.", + bottomMessage = "Add the Gmail app to your Wear OS watch for easy access wherever you are.", + positiveButtonLabel = "Try on watch", + negativeButtonLabel = "Now now", + onDismissRequest = { }, + onConfirmation = { }, + ) +} diff --git a/datalayer/phone-ui/src/main/java/com/google/android/horologist/datalayer/phone/ui/prompt/reengage/ReEngageBottomSheetActivity.kt b/datalayer/phone-ui/src/main/java/com/google/android/horologist/datalayer/phone/ui/prompt/reengage/ReEngageBottomSheetActivity.kt new file mode 100644 index 0000000000..23ccf761e9 --- /dev/null +++ b/datalayer/phone-ui/src/main/java/com/google/android/horologist/datalayer/phone/ui/prompt/reengage/ReEngageBottomSheetActivity.kt @@ -0,0 +1,159 @@ +/* + * 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.datalayer.phone.ui.prompt.reengage + +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.annotation.DrawableRes +import androidx.compose.foundation.Image +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Surface +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.res.painterResource +import com.google.android.horologist.data.WearDataLayerRegistry +import com.google.android.horologist.datalayer.phone.PhoneDataLayerAppHelper +import com.google.android.horologist.datalayer.phone.ui.di.CoroutineAppScope +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +internal const val RE_ENGAGE_KEY_NODE_ID = "HOROLOGIST_RE_ENGAGE_KEY_NODE_ID" +internal const val RE_ENGAGE_KEY_IMAGE_RES_ID = "HOROLOGIST_RE_ENGAGE_KEY_IMAGE_RES_ID" +internal const val RE_ENGAGE_KEY_TOP_MESSAGE = "HOROLOGIST_RE_ENGAGE_KEY_TOP_MESSAGE" +internal const val RE_ENGAGE_KEY_BOTTOM_MESSAGE = "HOROLOGIST_RE_ENGAGE_KEY_BOTTOM_MESSAGE" +internal const val RE_ENGAGE_KEY_POSITIVE_BUTTON_LABEL = + "HOROLOGIST_RE_ENGAGE_KEY_POSITIVE_BUTTON_LABEL" +internal const val RE_ENGAGE_KEY_NEGATIVE_BUTTON_LABEL = + "HOROLOGIST_RE_ENGAGE_KEY_NEGATIVE_BUTTON_LABEL" + +private const val NO_IMAGE = 0 + +internal class ReEngageBottomSheetActivity : ComponentActivity() { + + private lateinit var phoneDataLayerAppHelper: PhoneDataLayerAppHelper + private lateinit var coroutineAppScope: CoroutineScope + private lateinit var nodeId: String + + @OptIn(ExperimentalMaterial3Api::class) + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + coroutineAppScope = CoroutineAppScope.getInstance() + + phoneDataLayerAppHelper = PhoneDataLayerAppHelper( + this, + WearDataLayerRegistry.fromContext( + application = application, + coroutineScope = coroutineAppScope, + ), + ) + + nodeId = intent.extras?.getString(RE_ENGAGE_KEY_NODE_ID) ?: "" + val imageResId = intent.extras?.getInt(RE_ENGAGE_KEY_IMAGE_RES_ID) ?: NO_IMAGE + val topMessage = intent.extras?.getString(RE_ENGAGE_KEY_TOP_MESSAGE) ?: "" + val bottomMessage = intent.extras?.getString(RE_ENGAGE_KEY_BOTTOM_MESSAGE) ?: "" + val positiveButtonLabel = intent.extras?.getString(RE_ENGAGE_KEY_POSITIVE_BUTTON_LABEL) + val negativeButtonLabel = intent.extras?.getString(RE_ENGAGE_KEY_NEGATIVE_BUTTON_LABEL) + + setContent { + Surface { + val bottomSheetState = rememberModalBottomSheetState() + val coroutineScope = rememberCoroutineScope() + + val image: (@Composable () -> Unit)? = imageResId.takeIf { it != NO_IMAGE }?.let { + { + Image( + painter = painterResource(id = imageResId), + contentDescription = null, + ) + } + } + + ReEngageBottomSheet( + image = image, + topMessage = topMessage, + bottomMessage = bottomMessage, + onDismissRequest = { + setResult(RESULT_CANCELED) + coroutineScope.launch { + try { + bottomSheetState.hide() + } finally { + finishWithoutAnimation() + } + } + }, + onConfirmation = { + launchAppOnWatch() + }, + positiveButtonLabel = positiveButtonLabel, + negativeButtonLabel = negativeButtonLabel, + sheetState = bottomSheetState, + ) + } + } + } + + private fun launchAppOnWatch() { + // Can't use the Activity's lifecycleScope as it is going to finish the activity immediately + // after this call + coroutineAppScope.launch { + phoneDataLayerAppHelper.startRemoteOwnApp(nodeId = nodeId) + } + + // It returns OK to indicate that the user tapped on the positive button. + // The call above might fail, but the result is not reflected in the activity's result. + // In order to do that, it would require to make the bottom sheet display a spinner while + // waiting for the result of the call. + setResult(RESULT_OK) + finishWithoutAnimation() + } + + private fun finishWithoutAnimation() { + finish() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + overrideActivityTransition(OVERRIDE_TRANSITION_CLOSE, 0, 0) + } else { + @Suppress("DEPRECATION") + overridePendingTransition(0, 0) + } + } + + internal companion object { + fun getIntent( + context: Context, + nodeId: String, + @DrawableRes image: Int, + topMessage: String, + bottomMessage: String, + positiveButtonLabel: String? = null, + negativeButtonLabel: String? = null, + ) = Intent(context, ReEngageBottomSheetActivity::class.java).apply { + putExtra(RE_ENGAGE_KEY_NODE_ID, nodeId) + putExtra(RE_ENGAGE_KEY_IMAGE_RES_ID, image) + putExtra(RE_ENGAGE_KEY_TOP_MESSAGE, topMessage) + putExtra(RE_ENGAGE_KEY_BOTTOM_MESSAGE, bottomMessage) + putExtra(RE_ENGAGE_KEY_POSITIVE_BUTTON_LABEL, positiveButtonLabel) + putExtra(RE_ENGAGE_KEY_NEGATIVE_BUTTON_LABEL, negativeButtonLabel) + } + } +} diff --git a/datalayer/phone-ui/src/main/res/values/strings.xml b/datalayer/phone-ui/src/main/res/values/strings.xml index 9cf0d74208..6b06f2d728 100644 --- a/datalayer/phone-ui/src/main/res/values/strings.xml +++ b/datalayer/phone-ui/src/main/res/values/strings.xml @@ -18,4 +18,6 @@ Install Not now + Try on watch + Not now diff --git a/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/Screen.kt b/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/Screen.kt index 8b80806394..072452d2a9 100644 --- a/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/Screen.kt +++ b/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/Screen.kt @@ -24,5 +24,6 @@ sealed class Screen( data object AppHelperNodesListenerScreen : Screen("appHelperNodesListenerScreen") data object InstallAppPromptDemoScreen : Screen("installAppPromptDemoScreen") data object InstallAppPromptDemo2Screen : Screen("installAppPromptDemo2Screen") + data object ReEngagePromptDemoScreen : Screen("reEngagePromptDemoScreen") data object CounterScreen : Screen("counterScreen") } diff --git a/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/inappprompts/InstallAppPromptDemo2Screen.kt b/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/inappprompts/installapp/InstallAppPromptDemo2Screen.kt similarity index 98% rename from datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/inappprompts/InstallAppPromptDemo2Screen.kt rename to datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/inappprompts/installapp/InstallAppPromptDemo2Screen.kt index 78e6effd71..11f5905838 100644 --- a/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/inappprompts/InstallAppPromptDemo2Screen.kt +++ b/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/inappprompts/installapp/InstallAppPromptDemo2Screen.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.google.android.horologist.datalayer.sample.screens.inappprompts +package com.google.android.horologist.datalayer.sample.screens.inappprompts.installapp import android.app.Activity.RESULT_OK import android.content.Intent @@ -57,7 +57,7 @@ fun InstallAppPromptDemo2Screen( state = state, onRunDemoClick = viewModel::onRunDemoClick, getInstallPromptIntent = { - viewModel.phoneUiDataLayerHelper.getInstallPromptIntent( + viewModel.phoneUiDataLayerHelper.getInstallAppPromptIntent( context = context, appPackageName = context.packageName, image = R.drawable.watch_app_screenshot, diff --git a/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/inappprompts/InstallAppPromptDemo2ViewModel.kt b/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/inappprompts/installapp/InstallAppPromptDemo2ViewModel.kt similarity index 99% rename from datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/inappprompts/InstallAppPromptDemo2ViewModel.kt rename to datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/inappprompts/installapp/InstallAppPromptDemo2ViewModel.kt index 00e1034b34..310c23a4b7 100644 --- a/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/inappprompts/InstallAppPromptDemo2ViewModel.kt +++ b/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/inappprompts/installapp/InstallAppPromptDemo2ViewModel.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.google.android.horologist.datalayer.sample.screens.inappprompts +package com.google.android.horologist.datalayer.sample.screens.inappprompts.installapp import androidx.annotation.MainThread import androidx.lifecycle.ViewModel diff --git a/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/inappprompts/InstallAppPromptDemoScreen.kt b/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/inappprompts/installapp/InstallAppPromptDemoScreen.kt similarity index 98% rename from datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/inappprompts/InstallAppPromptDemoScreen.kt rename to datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/inappprompts/installapp/InstallAppPromptDemoScreen.kt index 5454794000..af89b3a7a4 100644 --- a/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/inappprompts/InstallAppPromptDemoScreen.kt +++ b/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/inappprompts/installapp/InstallAppPromptDemoScreen.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.google.android.horologist.datalayer.sample.screens.inappprompts +package com.google.android.horologist.datalayer.sample.screens.inappprompts.installapp import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding diff --git a/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/inappprompts/reengage/ReEngagePromptDemoScreen.kt b/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/inappprompts/reengage/ReEngagePromptDemoScreen.kt new file mode 100644 index 0000000000..67e81efdb5 --- /dev/null +++ b/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/inappprompts/reengage/ReEngagePromptDemoScreen.kt @@ -0,0 +1,172 @@ +/* + * Copyright 2024 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.datalayer.sample.screens.inappprompts.reengage + +import android.app.Activity.RESULT_OK +import android.content.Intent +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.google.android.horologist.datalayer.sample.R + +@Composable +fun ReEngagePromptDemoScreen( + modifier: Modifier = Modifier, + viewModel: ReEngagePromptDemoViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + + if (state == ReEngagePromptDemoScreenState.Idle) { + SideEffect { + viewModel.initialize() + } + } + + val context = LocalContext.current + + ReEngagePromptDemoScreen( + state = state, + onRunDemoClick = viewModel::onRunDemoClick, + getReEngagePromptIntent = { nodeId -> + viewModel.phoneUiDataLayerHelper.getReEngagePromptIntent( + context = context, + nodeId = nodeId, + image = R.drawable.watch_app_screenshot, + topMessage = context.getString(R.string.reengage_prompt_demo_prompt_top_message), + bottomMessage = context.getString(R.string.reengage_prompt_demo_prompt_bottom_message), + ) + }, + onPromptLaunched = viewModel::onPromptLaunched, + onPromptPositiveButtonClick = viewModel::onPromptPositiveButtonClick, + onPromptDismiss = viewModel::onPromptDismiss, + modifier = modifier, + ) +} + +@Composable +fun ReEngagePromptDemoScreen( + state: ReEngagePromptDemoScreenState, + onRunDemoClick: () -> Unit, + getReEngagePromptIntent: (nodeId: String) -> Intent, + onPromptLaunched: () -> Unit, + onPromptPositiveButtonClick: () -> Unit, + onPromptDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + val launcher = rememberLauncherForActivityResult( + ActivityResultContracts.StartActivityForResult(), + ) { result -> + if (result.resultCode == RESULT_OK) { + onPromptPositiveButtonClick() + } else { + onPromptDismiss() + } + } + + Column( + modifier = modifier.padding(all = 10.dp), + ) { + Text(text = stringResource(id = R.string.reengage_prompt_api_call_demo_message)) + + Button( + onClick = onRunDemoClick, + modifier = Modifier + .padding(top = 10.dp) + .align(Alignment.CenterHorizontally), + enabled = state != ReEngagePromptDemoScreenState.ApiNotAvailable, + ) { + Text(text = stringResource(id = R.string.reengage_prompt_run_demo_button_label)) + } + + when (state) { + ReEngagePromptDemoScreenState.Idle, + ReEngagePromptDemoScreenState.Loaded, + -> { + /* do nothing */ + } + + ReEngagePromptDemoScreenState.Loading -> { + CircularProgressIndicator() + } + + is ReEngagePromptDemoScreenState.WatchFound -> { + SideEffect { launcher.launch(getReEngagePromptIntent(state.nodeId)) } + + onPromptLaunched() + } + + ReEngagePromptDemoScreenState.WatchNotFound -> { + Text( + stringResource( + id = R.string.reengage_prompt_demo_result_label, + stringResource(id = R.string.reengage_prompt_demo_no_watches_found_label), + ), + ) + } + + ReEngagePromptDemoScreenState.PromptPositiveButtonClicked -> { + Text( + stringResource( + id = R.string.reengage_prompt_demo_result_label, + stringResource(id = R.string.reengage_prompt_demo_prompt_positive_result_label), + ), + ) + } + + ReEngagePromptDemoScreenState.PromptDismissed -> { + Text( + stringResource( + id = R.string.reengage_prompt_demo_result_label, + stringResource(id = R.string.reengage_prompt_demo_prompt_dismiss_result_label), + ), + ) + } + + ReEngagePromptDemoScreenState.ApiNotAvailable -> { + Text(stringResource(id = R.string.wearable_message_api_unavailable)) + } + } + } +} + +@Preview(showBackground = true) +@Composable +fun ReEngagePromptDemoScreenPreview() { + ReEngagePromptDemoScreen( + state = ReEngagePromptDemoScreenState.Idle, + onRunDemoClick = { }, + getReEngagePromptIntent = { Intent() }, + onPromptLaunched = { }, + onPromptPositiveButtonClick = { }, + onPromptDismiss = { }, + ) +} diff --git a/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/inappprompts/reengage/ReEngagePromptDemoViewModel.kt b/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/inappprompts/reengage/ReEngagePromptDemoViewModel.kt new file mode 100644 index 0000000000..fc2dc4c479 --- /dev/null +++ b/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/inappprompts/reengage/ReEngagePromptDemoViewModel.kt @@ -0,0 +1,97 @@ +/* + * Copyright 2024 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.datalayer.sample.screens.inappprompts.reengage + +import androidx.annotation.MainThread +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.google.android.horologist.data.apphelper.appInstalled +import com.google.android.horologist.datalayer.phone.PhoneDataLayerAppHelper +import com.google.android.horologist.datalayer.phone.ui.PhoneUiDataLayerHelper +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class ReEngagePromptDemoViewModel + @Inject + constructor( + private val phoneDataLayerAppHelper: PhoneDataLayerAppHelper, + val phoneUiDataLayerHelper: PhoneUiDataLayerHelper, + ) : ViewModel() { + + private var initializeCalled = false + + private val _uiState = + MutableStateFlow(ReEngagePromptDemoScreenState.Idle) + public val uiState: StateFlow = _uiState + + @MainThread + fun initialize() { + if (initializeCalled) return + initializeCalled = true + + _uiState.value = ReEngagePromptDemoScreenState.Loading + + viewModelScope.launch { + if (!phoneDataLayerAppHelper.isAvailable()) { + _uiState.value = ReEngagePromptDemoScreenState.ApiNotAvailable + } else { + _uiState.value = ReEngagePromptDemoScreenState.Loaded + } + } + } + + fun onRunDemoClick() { + _uiState.value = ReEngagePromptDemoScreenState.Loading + + viewModelScope.launch { + val node = phoneDataLayerAppHelper.connectedNodes().firstOrNull { it.appInstalled } + + _uiState.value = if (node != null) { + ReEngagePromptDemoScreenState.WatchFound(node.id) + } else { + ReEngagePromptDemoScreenState.WatchNotFound + } + } + } + + fun onPromptLaunched() { + _uiState.value = ReEngagePromptDemoScreenState.Idle + } + + fun onPromptPositiveButtonClick() { + _uiState.value = ReEngagePromptDemoScreenState.PromptPositiveButtonClicked + } + + fun onPromptDismiss() { + _uiState.value = ReEngagePromptDemoScreenState.PromptDismissed + } + } + +sealed class ReEngagePromptDemoScreenState { + data object Idle : ReEngagePromptDemoScreenState() + data object Loading : ReEngagePromptDemoScreenState() + data object Loaded : ReEngagePromptDemoScreenState() + data class WatchFound(val nodeId: String) : ReEngagePromptDemoScreenState() + data object WatchNotFound : ReEngagePromptDemoScreenState() + data object PromptPositiveButtonClicked : ReEngagePromptDemoScreenState() + data object PromptDismissed : ReEngagePromptDemoScreenState() + data object ApiNotAvailable : ReEngagePromptDemoScreenState() +} diff --git a/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/main/MainScreen.kt b/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/main/MainScreen.kt index 170eac6cf3..705d50212e 100644 --- a/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/main/MainScreen.kt +++ b/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/main/MainScreen.kt @@ -30,8 +30,9 @@ import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import com.google.android.horologist.datalayer.sample.screens.Screen import com.google.android.horologist.datalayer.sample.screens.counter.CounterScreen -import com.google.android.horologist.datalayer.sample.screens.inappprompts.InstallAppPromptDemo2Screen -import com.google.android.horologist.datalayer.sample.screens.inappprompts.InstallAppPromptDemoScreen +import com.google.android.horologist.datalayer.sample.screens.inappprompts.installapp.InstallAppPromptDemo2Screen +import com.google.android.horologist.datalayer.sample.screens.inappprompts.installapp.InstallAppPromptDemoScreen +import com.google.android.horologist.datalayer.sample.screens.inappprompts.reengage.ReEngagePromptDemoScreen import com.google.android.horologist.datalayer.sample.screens.menu.MenuScreen import com.google.android.horologist.datalayer.sample.screens.nodes.NodesScreen import com.google.android.horologist.datalayer.sample.screens.nodeslistener.NodesListenerScreen @@ -72,6 +73,9 @@ fun MainScreen( composable(route = Screen.InstallAppPromptDemo2Screen.route) { InstallAppPromptDemo2Screen() } + composable(route = Screen.ReEngagePromptDemoScreen.route) { + ReEngagePromptDemoScreen() + } composable(route = Screen.CounterScreen.route) { CounterScreen() } diff --git a/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/menu/MenuScreen.kt b/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/menu/MenuScreen.kt index c13ccf41b6..f9cfd39c31 100644 --- a/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/menu/MenuScreen.kt +++ b/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/menu/MenuScreen.kt @@ -61,6 +61,10 @@ fun MenuScreen( Text(text = stringResource(id = R.string.menu_screen_install_app_demo2_item)) } + Button(onClick = { navController.navigate(Screen.ReEngagePromptDemoScreen.route) }) { + Text(text = stringResource(id = R.string.menu_screen_reengage_demo_item)) + } + Text( text = stringResource(id = R.string.menu_screen_datalayer_header), modifier = Modifier.padding(top = 10.dp), diff --git a/datalayer/sample/phone/src/main/res/values/strings.xml b/datalayer/sample/phone/src/main/res/values/strings.xml index 21942cdbde..0ca14834d5 100644 --- a/datalayer/sample/phone/src/main/res/values/strings.xml +++ b/datalayer/sample/phone/src/main/res/values/strings.xml @@ -26,6 +26,7 @@ In-App prompts Install app - demo 1 Install app - demo 2 + Re-Engage Data Layer Counter sample @@ -68,7 +69,7 @@ Is nearby: %1$s - This demo calls the Horologist API to show the install app prompt and the invocation of the deeplink to the Google Play by using the Gmail app as an example. The developer has to gather all the information to display on the prompt, such as app name and watch name to use the prompt in their app. + This demo calls the Horologist API to show the install app prompt and the launch of Google Play, using the Gmail app as an example. Run demo @@ -81,6 +82,16 @@ User tapped install on the prompt. User dismissed the prompt. + + This demo calls the Horologist API to show the re-engage prompt for the watch demo app. The API will only display the prompt if there is a watch connected and the watch has the app installed already. + Run demo + Test the interactions between the phone and the watch with the demo app. + Open the demo app on your Wear OS watch. + Result: %1$s + No watches with the app installed were found. + User tapped on the positive button on the prompt. + User dismissed the prompt. + Counter: %1$s Increase counter