diff --git a/datalayer/phone-ui/README.md b/datalayer/phone-ui/README.md new file mode 100644 index 0000000000..9a4cb6dd9d --- /dev/null +++ b/datalayer/phone-ui/README.md @@ -0,0 +1,17 @@ +# DataLayer Phone UI library + +[![Maven Central](https://img.shields.io/maven-central/v/com.google.android.horologist/horologist-datalayer-phone-ui)](https://search.maven.org/search?q=g:com.google.android.horologist) + +For more information, visit the documentation: https://google.github.io/horologist/datalayer + +## Download + +```groovy +repositories { + mavenCentral() +} + +dependencies { + implementation "com.google.android.horologist:horologist-datalayer-phone-ui:" +} +``` diff --git a/datalayer/phone-ui/api/current.api b/datalayer/phone-ui/api/current.api new file mode 100644 index 0000000000..350e5b807d --- /dev/null +++ b/datalayer/phone-ui/api/current.api @@ -0,0 +1,32 @@ +// Signature format: 4.0 +package com.google.android.horologist.datalayer.phone.ui { + + @com.google.android.horologist.annotations.ExperimentalHorologistApi public final class PhoneUiDataLayerHelper { + ctor public PhoneUiDataLayerHelper(); + method public void showInstallAppPrompt(android.app.Activity activity, String appName, String watchName, String message, optional int requestCode); + } + + public final class PhoneUiDataLayerHelperKt { + } + +} + +package com.google.android.horologist.datalayer.phone.ui.play { + + public final class PlayLauncherKt { + method public static void launchPlay(android.content.Context, String packageName); + } + +} + +package com.google.android.horologist.datalayer.phone.ui.prompt { + + public final class InstallAppDialogActivityKt { + } + + public final class InstallAppDialogKt { + method @androidx.compose.runtime.Composable public static void InstallAppDialog(String appName, String watchName, String message, kotlin.jvm.functions.Function0 icon, kotlin.jvm.functions.Function0 onDismissRequest, kotlin.jvm.functions.Function0 onConfirmation); + } + +} + diff --git a/datalayer/phone-ui/build.gradle.kts b/datalayer/phone-ui/build.gradle.kts new file mode 100644 index 0000000000..57d927c227 --- /dev/null +++ b/datalayer/phone-ui/build.gradle.kts @@ -0,0 +1,135 @@ +/* + * Copyright 2022 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 { + id("com.android.library") + id("org.jetbrains.dokka") + id("me.tylerbwong.gradle.metalava") + alias(libs.plugins.dependencyAnalysis) + kotlin("android") +} + +android { + compileSdk = 34 + + defaultConfig { + minSdk = 21 + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + buildFeatures { + buildConfig = false + compose = true + } + + kotlinOptions { + jvmTarget = "11" + + freeCompilerArgs = freeCompilerArgs + + listOf( + "-opt-in=kotlin.RequiresOptIn", + "-opt-in=com.google.android.horologist.annotations.ExperimentalHorologistApi", + ) + } + + composeOptions { + kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get() + } + + packaging { + resources { + excludes += + listOf( + "/META-INF/AL2.0", + "/META-INF/LGPL2.1", + ) + } + } + + testOptions { + unitTests { + isIncludeAndroidResources = true + } + animationsDisabled = true + } + + lint { + checkReleaseBuilds = false + textReport = true + } + + resourcePrefix = "horologist_" + + namespace = "com.google.android.horologist.datalayer.phone.ui" +} + +project.tasks.withType().configureEach { + // Workaround for https://youtrack.jetbrains.com/issue/KT-37652 + if (!this.name.endsWith("TestKotlin") && !this.name.startsWith("compileDebug")) { + this.kotlinOptions { + freeCompilerArgs = freeCompilerArgs + "-Xexplicit-api=strict" + } + } +} + +metalava { + sourcePaths.setFrom("src/main") + filename.set("api/current.api") + reportLintsAsErrors.set(true) +} + +dependencies { + api(libs.compose.runtime) + api(libs.compose.ui) + api(projects.annotations) + + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.appcompat) + implementation(libs.androidx.corektx) + implementation(libs.compose.ui.toolingpreview) + implementation(libs.compose.material3) + implementation(libs.material) + implementation(platform(libs.compose.bom)) + + testImplementation(libs.junit) + + androidTestImplementation(libs.androidx.test.ext) + androidTestImplementation(libs.androidx.test.espressocore) +} + +dependencyAnalysis { + issues { + onAny { + severity("fail") + } + } +} + +tasks.withType().configureEach { + dokkaSourceSets { + configureEach { + moduleName.set("datalayer-phone-ui") + } + } +} + +apply(plugin = "com.vanniktech.maven.publish") diff --git a/datalayer/phone-ui/gradle.properties b/datalayer/phone-ui/gradle.properties new file mode 100644 index 0000000000..20e8bb8e2c --- /dev/null +++ b/datalayer/phone-ui/gradle.properties @@ -0,0 +1,3 @@ +POM_ARTIFACT_ID=horologist-datalayer-phone-ui +POM_NAME=Horologist DataLayer Phone UI library +POM_PACKAGING=aar diff --git a/datalayer/phone-ui/src/main/AndroidManifest.xml b/datalayer/phone-ui/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..e6b96523f3 --- /dev/null +++ b/datalayer/phone-ui/src/main/AndroidManifest.xml @@ -0,0 +1,17 @@ + + + + + + + + + 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 new file mode 100644 index 0000000000..510ec90735 --- /dev/null +++ b/datalayer/phone-ui/src/main/java/com/google/android/horologist/datalayer/phone/ui/PhoneUiDataLayerHelper.kt @@ -0,0 +1,51 @@ +/* + * 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 + +import android.app.Activity +import androidx.annotation.DrawableRes +import com.google.android.horologist.annotations.ExperimentalHorologistApi +import com.google.android.horologist.datalayer.phone.ui.prompt.InstallAppDialogActivity + +private const val NO_RESULT_REQUESTED_REQUEST_CODE = -1 + +/** + * Data layer related helper features, for use on phones. + */ +@ExperimentalHorologistApi +public class PhoneUiDataLayerHelper { + + public fun showInstallAppPrompt( + activity: Activity, + appName: String, + watchName: String, + message: String, + @DrawableRes image: Int, + requestCode: Int = NO_RESULT_REQUESTED_REQUEST_CODE, + ) { + activity.startActivityForResult( + InstallAppDialogActivity.getIntent( + context = activity, + appName = appName, + watchName = watchName, + message = message, + image = image, + ), + requestCode, + ) + } +} diff --git a/datalayer/phone-ui/src/main/java/com/google/android/horologist/datalayer/phone/ui/play/PlayLauncher.kt b/datalayer/phone-ui/src/main/java/com/google/android/horologist/datalayer/phone/ui/play/PlayLauncher.kt new file mode 100644 index 0000000000..a53e7279d4 --- /dev/null +++ b/datalayer/phone-ui/src/main/java/com/google/android/horologist/datalayer/phone/ui/play/PlayLauncher.kt @@ -0,0 +1,53 @@ +/* + * 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.play + +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import androidx.core.content.ContextCompat + +private const val PLAY_STORE_APP_URI_PREFIX = "market://details?id=" +private const val PLAY_STORE_WEB_URL_PREFIX = "https://play.google.com/store/apps/details?id=" + +/** + * Launch Google Play app, requesting to display app with specified [package name][packageName]. + */ +public fun Context.launchPlay(packageName: String) { + try { + ContextCompat.startActivity( + this, + Intent( + Intent.ACTION_VIEW, + Uri.parse(PLAY_STORE_APP_URI_PREFIX + packageName), + ), + Bundle(), + ) + } catch (anfe: ActivityNotFoundException) { + // Handle scenario where Google Play app is not installed + ContextCompat.startActivity( + this, + Intent( + Intent.ACTION_VIEW, + Uri.parse(PLAY_STORE_WEB_URL_PREFIX + packageName), + ), + Bundle(), + ) + } +} diff --git a/datalayer/phone-ui/src/main/java/com/google/android/horologist/datalayer/phone/ui/prompt/InstallAppDialog.kt b/datalayer/phone-ui/src/main/java/com/google/android/horologist/datalayer/phone/ui/prompt/InstallAppDialog.kt new file mode 100644 index 0000000000..66e84a9d06 --- /dev/null +++ b/datalayer/phone-ui/src/main/java/com/google/android/horologist/datalayer/phone/ui/prompt/InstallAppDialog.kt @@ -0,0 +1,188 @@ +/* + * 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 + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.foundation.layout.width +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.AlertDialogDefaults +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.ProvideTextStyle +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +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 androidx.compose.ui.window.Dialog +import com.google.android.horologist.datalayer.phone.ui.R + +// Definitions from androidx.compose.material3.AlertDialog +private val DialogMinWidth = 280.dp +private val DialogMaxWidth = 560.dp +private val DialogPadding = PaddingValues(all = 24.dp) +private val IconPadding = PaddingValues(bottom = 16.dp) +private val TitlePadding = PaddingValues(bottom = 16.dp) +private val TextPadding = PaddingValues(bottom = 24.dp) + +@Composable +public fun InstallAppDialog( + appName: String, + watchName: String, + message: String, + icon: @Composable (() -> Unit)?, + onDismissRequest: () -> Unit, + onConfirmation: () -> Unit, + modifier: Modifier = Modifier, +) { + // Content adapted from androidx.compose.material3.AlertDialog + Dialog( + onDismissRequest = onDismissRequest, + ) { + Box( + modifier = modifier + .sizeIn(minWidth = DialogMinWidth, maxWidth = DialogMaxWidth), + propagateMinConstraints = true, + ) { + Surface( + modifier = modifier, + shape = AlertDialogDefaults.shape, + color = AlertDialogDefaults.containerColor, + tonalElevation = AlertDialogDefaults.TonalElevation, + ) { + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .padding(DialogPadding), + ) { + icon?.let { + CompositionLocalProvider(LocalContentColor provides AlertDialogDefaults.iconContentColor) { + Box( + Modifier + .padding(IconPadding) + .align(Alignment.CenterHorizontally), + ) { + icon() + } + } + } + + CompositionLocalProvider(LocalContentColor provides AlertDialogDefaults.titleContentColor) { + // Original: DialogTokens.HeadlineFont + val textStyle = MaterialTheme.typography.headlineMedium + ProvideTextStyle(textStyle) { + Box( + // Align the title to the center when an icon is present. + Modifier + .padding(TitlePadding) + .align( + if (icon == null) { + Alignment.Start + } else { + Alignment.CenterHorizontally + }, + ), + ) { + Text( + text = stringResource( + id = R.string.horologist_install_app_prompt_dialog_title, + appName, + watchName, + ), + textAlign = TextAlign.Center, + ) + } + } + } + + CompositionLocalProvider(LocalContentColor provides AlertDialogDefaults.textContentColor) { + // Original: DialogTokens.SupportingTextFont + val textStyle = MaterialTheme.typography.bodyMedium + ProvideTextStyle(textStyle) { + Box( + Modifier + // .weight(weight = 1f, fill = false) + .padding(TextPadding) + .align(Alignment.Start), + ) { + Text( + text = message, + textAlign = TextAlign.Center, + ) + } + } + } + + Box(modifier = Modifier.align(Alignment.CenterHorizontally)) { + CompositionLocalProvider(LocalContentColor provides AlertDialogDefaults.titleContentColor) { + // Original: DialogTokens.SupportingTextFont + val textStyle = MaterialTheme.typography.labelMedium + ProvideTextStyle( + value = textStyle, + content = { + Row { + OutlinedButton( + onClick = { onDismissRequest() }, + ) { + Text(stringResource(id = R.string.horologist_install_app_prompt_dialog_cancel_btn_label)) + } + Spacer(modifier = Modifier.width(20.dp)) + Button( + onClick = { onConfirmation() }, + ) { + Text(stringResource(id = R.string.horologist_install_app_prompt_dialog_ok_btn_label)) + } + } + }, + ) + } + } + } + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun InstallAppDialogPreview() { + InstallAppDialog( + appName = "Gmail", + watchName = "Pixel Watch", + message = "Stay productive and manage emails right from your wrist.", + icon = { Icon(Icons.Default.Email, contentDescription = null) }, + onDismissRequest = { }, + onConfirmation = { }, + ) +} diff --git a/datalayer/phone-ui/src/main/java/com/google/android/horologist/datalayer/phone/ui/prompt/InstallAppDialogActivity.kt b/datalayer/phone-ui/src/main/java/com/google/android/horologist/datalayer/phone/ui/prompt/InstallAppDialogActivity.kt new file mode 100644 index 0000000000..c2adafe5ac --- /dev/null +++ b/datalayer/phone-ui/src/main/java/com/google/android/horologist/datalayer/phone/ui/prompt/InstallAppDialogActivity.kt @@ -0,0 +1,92 @@ +/* + * 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 + +import android.content.Context +import android.content.Intent +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.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.painterResource +import com.google.android.horologist.datalayer.phone.ui.play.launchPlay + +internal const val INSTALL_APP_KEY_APP_NAME = "HOROLOGIST_INSTALL_APP_KEY_APP_NAME" +internal const val INSTALL_APP_KEY_WATCH_NAME = "HOROLOGIST_INSTALL_APP_KEY_WATCH_NAME" +internal const val INSTALL_APP_KEY_MESSAGE = "HOROLOGIST_INSTALL_APP_KEY_MESSAGE" +internal const val INSTALL_APP_KEY_IMAGE_RES_ID = "HOROLOGIST_INSTALL_APP_KEY_IMAGE_RES_ID" + +private const val NO_IMAGE = 0 + +internal class InstallAppDialogActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val appName = intent.extras?.getString(INSTALL_APP_KEY_APP_NAME) ?: "" + val watchName = intent.extras?.getString(INSTALL_APP_KEY_WATCH_NAME) ?: "" + val message = intent.extras?.getString(INSTALL_APP_KEY_MESSAGE) ?: "" + val imageResId = intent.extras?.getInt(INSTALL_APP_KEY_IMAGE_RES_ID) ?: NO_IMAGE + + setContent { + Surface { + val icon: (@Composable () -> Unit)? = imageResId.takeIf { it != NO_IMAGE }?.let { + { + Image( + painter = painterResource(id = imageResId), + contentDescription = null, + ) + } + } + + InstallAppDialog( + appName = appName, + watchName = watchName, + message = message, + icon = icon, + onDismissRequest = { + setResult(RESULT_CANCELED) + finish() + }, + onConfirmation = { + this.launchPlay(this.applicationContext.packageName) + + setResult(RESULT_OK) + finish() + }, + ) + } + } + } + + internal companion object { + fun getIntent( + context: Context, + appName: String, + watchName: String, + message: String, + @DrawableRes image: Int, + ) = Intent(context, InstallAppDialogActivity::class.java).apply { + putExtra(INSTALL_APP_KEY_APP_NAME, appName) + putExtra(INSTALL_APP_KEY_WATCH_NAME, watchName) + putExtra(INSTALL_APP_KEY_MESSAGE, message) + putExtra(INSTALL_APP_KEY_IMAGE_RES_ID, image) + } + } +} diff --git a/datalayer/phone-ui/src/main/res/values/strings.xml b/datalayer/phone-ui/src/main/res/values/strings.xml new file mode 100644 index 0000000000..ab10c4673e --- /dev/null +++ b/datalayer/phone-ui/src/main/res/values/strings.xml @@ -0,0 +1,22 @@ + + + + + Install %1$s on %2$s + Install + Not now + diff --git a/datalayer/phone-ui/src/main/res/values/themes.xml b/datalayer/phone-ui/src/main/res/values/themes.xml new file mode 100644 index 0000000000..7c5d9401be --- /dev/null +++ b/datalayer/phone-ui/src/main/res/values/themes.xml @@ -0,0 +1,27 @@ + + + + + + diff --git a/datalayer/sample/phone/build.gradle.kts b/datalayer/sample/phone/build.gradle.kts index 2edf53e02d..e99e7eb7b9 100644 --- a/datalayer/sample/phone/build.gradle.kts +++ b/datalayer/sample/phone/build.gradle.kts @@ -96,6 +96,7 @@ dependencies { implementation(projects.datalayer.core) implementation(projects.datalayer.grpc) implementation(projects.datalayer.phone) + implementation(projects.datalayer.phoneUi) implementation(projects.datalayer.sample.shared) implementation(libs.androidx.corektx) diff --git a/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/AppHelperNodeStatusCard.kt b/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/AppHelperNodeStatusCard.kt index 4181f6aef8..b602c770d1 100644 --- a/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/AppHelperNodeStatusCard.kt +++ b/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/AppHelperNodeStatusCard.kt @@ -51,6 +51,7 @@ fun AppHelperNodeStatusCard( onInstallClick: (String) -> Unit, onLaunchClick: (String) -> Unit, onCompanionClick: (String) -> Unit, + onInstallAppPromptClick: (watchName: String) -> Unit, ) { Box( modifier = Modifier @@ -136,6 +137,21 @@ fun AppHelperNodeStatusCard( Text(stringResource(id = R.string.app_helper_companion_button_label)) } } + /* + This button should only be displayed when AppHelperNodeStatus.isAppInstalled returns + false. However, given our sample app is not published on Play, we are using Gmail as + a "sample" in order to display the full flow. + + Once the "install" button on the dialog is pushed, Play will be launched with the + package name of the current app. Given this sample app is not published on Play, an + error will be displayed. + */ + Button( + modifier = Modifier.wrapContentHeight(), + onClick = { onInstallAppPromptClick(nodeStatus.displayName) }, + ) { + Text(stringResource(id = R.string.app_helper_install_app_prompt_button_label)) + } } } } @@ -174,9 +190,10 @@ fun NodeCardPreview() { HorologistTheme { AppHelperNodeStatusCard( nodeStatus = nodeStatus, - onCompanionClick = {}, - onInstallClick = {}, - onLaunchClick = {}, + onCompanionClick = { }, + onInstallClick = { }, + onLaunchClick = { }, + onInstallAppPromptClick = { }, ) } } diff --git a/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/MainActivity.kt b/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/MainActivity.kt index f3dd8950b6..8e6ed0f2bc 100644 --- a/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/MainActivity.kt +++ b/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/MainActivity.kt @@ -24,6 +24,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add @@ -46,6 +47,7 @@ import androidx.compose.ui.graphics.Color 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 androidx.lifecycle.lifecycleScope import com.google.android.horologist.data.ProtoDataStoreHelper.protoDataStore import com.google.android.horologist.data.WearDataLayerRegistry @@ -57,6 +59,7 @@ import com.google.android.horologist.data.complicationInfo import com.google.android.horologist.data.surfacesInfo import com.google.android.horologist.data.tileInfo import com.google.android.horologist.datalayer.phone.PhoneDataLayerAppHelper +import com.google.android.horologist.datalayer.phone.ui.PhoneUiDataLayerHelper import com.google.android.horologist.datalayer.sample.shared.CounterValueSerializer import com.google.android.horologist.datalayer.sample.shared.grpc.GrpcDemoProto.CounterValue import com.google.android.horologist.datalayer.sample.shared.grpc.copy @@ -66,6 +69,7 @@ import kotlinx.coroutines.launch class MainActivity : ComponentActivity() { private lateinit var phoneDataLayerAppHelper: PhoneDataLayerAppHelper + private lateinit var phoneUiDataLayerHelper: PhoneUiDataLayerHelper override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -81,6 +85,7 @@ class MainActivity : ComponentActivity() { context = this, registry = registry, ) + phoneUiDataLayerHelper = PhoneUiDataLayerHelper() val counterDataStore = registry.protoDataStore(lifecycleScope) @@ -104,15 +109,24 @@ class MainActivity : ComponentActivity() { val counterState by counterDataStore.data.collectAsState(initial = CounterValue.getDefaultInstance()) + val sampleAppName = + stringResource(id = R.string.app_helper_install_app_prompt_sample_app_name) + val sampleAppMessage = + stringResource(id = R.string.app_helper_install_app_prompt_sample_message) + MainScreen( apiAvailable = apiAvailable, nodeList = nodeList, - counterState = counterState, onListNodes = { coroutineScope.launch { nodeList = phoneDataLayerAppHelper.connectedNodes() } }, + onInstallClick = { nodeId -> + coroutineScope.launch { + phoneDataLayerAppHelper.installOnNode(nodeId) + } + }, onLaunchClick = { nodeId -> coroutineScope.launch { phoneDataLayerAppHelper.startRemoteOwnApp(nodeId) @@ -123,11 +137,7 @@ class MainActivity : ComponentActivity() { phoneDataLayerAppHelper.startCompanion(nodeId) } }, - onInstallClick = { nodeId -> - coroutineScope.launch { - phoneDataLayerAppHelper.installOnNode(nodeId) - } - }, + counterState = counterState, onCounterIncrement = { coroutineScope.launch { counterDataStore.updateData { @@ -138,6 +148,15 @@ class MainActivity : ComponentActivity() { } } }, + onInstallAppPromptClick = { watchName -> + phoneUiDataLayerHelper.showInstallAppPrompt( + activity = this@MainActivity, + appName = sampleAppName, + watchName = watchName, + message = sampleAppMessage, + image = R.drawable.sample_app_wearos_screenshot, + ) + }, ) } } @@ -153,9 +172,10 @@ fun MainScreen( onInstallClick: (String) -> Unit, onLaunchClick: (String) -> Unit, onCompanionClick: (String) -> Unit, - modifier: Modifier = Modifier, counterState: CounterValue, onCounterIncrement: () -> Unit, + onInstallAppPromptClick: (watchName: String) -> Unit, + modifier: Modifier = Modifier, ) { Column( modifier = modifier.fillMaxSize(), @@ -176,6 +196,7 @@ fun MainScreen( onInstallClick = onInstallClick, onLaunchClick = onLaunchClick, onCompanionClick = onCompanionClick, + onInstallAppPromptClick = onInstallAppPromptClick, ) } @@ -189,9 +210,15 @@ fun MainScreen( } Row(verticalAlignment = Alignment.CenterVertically) { - Text(text = "Counter: " + counterState.value) - Button(onClick = onCounterIncrement) { - Icon(imageVector = Icons.Default.Add, contentDescription = "Plus 1") + Text(text = stringResource(R.string.app_helper_counter_label, counterState.value)) + Button( + onClick = onCounterIncrement, + modifier = Modifier.padding(start = 10.dp), + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = stringResource(id = R.string.app_helper_counter_increase_btn_content_description), + ) } } } @@ -234,7 +261,8 @@ fun MainPreview() { onLaunchClick = { }, onCompanionClick = { }, counterState = CounterValue.getDefaultInstance(), - onCounterIncrement = {}, + onCounterIncrement = { }, + onInstallAppPromptClick = { }, ) } } diff --git a/datalayer/sample/phone/src/main/res/drawable/sample_app_wearos_screenshot.png b/datalayer/sample/phone/src/main/res/drawable/sample_app_wearos_screenshot.png new file mode 100644 index 0000000000..56ef703ded --- /dev/null +++ b/datalayer/sample/phone/src/main/res/drawable/sample_app_wearos_screenshot.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bc5069f43d75ea47c03a4158e6c5461b10cb006c06b8de4884140090fe8d1ca3 +size 87224 diff --git a/datalayer/sample/phone/src/main/res/values/strings.xml b/datalayer/sample/phone/src/main/res/values/strings.xml index 3c44acc461..3e2d99679f 100644 --- a/datalayer/sample/phone/src/main/res/values/strings.xml +++ b/datalayer/sample/phone/src/main/res/values/strings.xml @@ -28,6 +28,11 @@ Install Companion Launch + Install App prompt + Gmail + Stay productive and manage emails right from your wrist. + Counter: %1$s + Increase counter List connected nodes \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 20fd6b8d3d..18bb12238f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -57,6 +57,8 @@ spotless = "6.22.0" truth = "1.1.5" wearcompose = "1.3.0-beta01" wearToolingPreview = "1.0.0-rc01" +appcompat = "1.6.1" +material = "1.10.0" [libraries] accompanist-testharness = { module = "com.google.accompanist:accompanist-testharness", version.ref = "accompanist" } @@ -64,6 +66,7 @@ android-gradlePlugin = { module = "com.android.tools.build:gradle", version.ref android-tools-build-gradle = { module = "com.android.tools.build:gradle", version.ref = "gradlePlugin" } androidx-activity = { module = "androidx.activity:activity", version.ref = "androidxActivity" } androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidxActivity" } +androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } androidx-annotation = "androidx.annotation:annotation:1.7.0" androidx-benchmark-junit4 = { module = "androidx.benchmark:benchmark-junit4", version.ref = "androidx-benchmark" } androidx-benchmark-macro-junit4 = { module = "androidx.benchmark:benchmark-macro-junit4", version.ref = "androidx-benchmark" } @@ -196,11 +199,11 @@ wearcompose-foundation = { module = "androidx.wear.compose:compose-foundation", wearcompose-material = { module = "androidx.wear.compose:compose-material", version.ref = "wearcompose" } wearcompose-navigation = { module = "androidx.wear.compose:compose-navigation", version.ref = "wearcompose" } wearcompose-tooling = { module = "androidx.wear.compose:compose-ui-tooling", version.ref = "wearcompose" } +material = { group = "com.google.android.material", name = "material", version.ref = "material" } [plugins] dependencyAnalysis = { id = "com.autonomousapps.dependency-analysis", version.ref = "dependencyAnalysis" } gradleMavenPublishPlugin = { id = "com.vanniktech.maven.publish", version.ref = "gradlePublishPlugin" } -kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" } kotlinGradle = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } protobuf = "com.google.protobuf:0.9.4" diff --git a/settings.gradle.kts b/settings.gradle.kts index a95c76e5f5..a32e92ece2 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -46,6 +46,7 @@ include(":datalayer:core") include(":datalayer:grpc") include(":datalayer:watch") include(":datalayer:phone") +include(":datalayer:phone-ui") include(":datalayer:sample:phone") include(":datalayer:sample:shared") include(":datalayer:sample:wear")