diff --git a/.github/workflows/android_test.yml b/.github/workflows/android_test.yml index 3146b3c148..ee681ae2f8 100644 --- a/.github/workflows/android_test.yml +++ b/.github/workflows/android_test.yml @@ -18,7 +18,7 @@ jobs: TERM: dumb steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 with: lfs: 'true' @@ -29,7 +29,7 @@ jobs: run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties - name: set up JDK - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: 'zulu' java-version: 17 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 19fa909c00..883b1c3abc 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -19,7 +19,7 @@ jobs: timeout-minutes: 40 steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 with: lfs: 'true' @@ -27,10 +27,10 @@ jobs: run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties - name: set up JDK - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: 'zulu' - java-version: 17 + java-version: 21 - name: Unit Tests uses: gradle/gradle-build-action@v3 @@ -55,7 +55,7 @@ jobs: timeout-minutes: 40 steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 with: lfs: 'true' @@ -63,10 +63,10 @@ jobs: run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties - name: set up JDK - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: 'zulu' - java-version: 17 + java-version: 21 - name: Unit Tests uses: gradle/gradle-build-action@v3 @@ -93,7 +93,7 @@ jobs: timeout-minutes: 40 steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 with: lfs: 'true' @@ -101,10 +101,10 @@ jobs: run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties - name: set up JDK - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: 'zulu' - java-version: 17 + java-version: 21 - name: Unit Tests 2 uses: gradle/gradle-build-action@v3 @@ -134,7 +134,7 @@ jobs: TERM: dumb steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 with: lfs: 'true' @@ -142,10 +142,10 @@ jobs: run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties - name: set up JDK - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: 'zulu' - java-version: 17 + java-version: 21 - name: Compile tests uses: gradle/gradle-build-action@v3 @@ -160,7 +160,7 @@ jobs: timeout-minutes: 40 steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 with: lfs: 'true' @@ -168,10 +168,10 @@ jobs: run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties - name: set up JDK - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: 'zulu' - java-version: 17 + java-version: 21 - name: Setup Gradle uses: gradle/gradle-build-action@v3 diff --git a/.github/workflows/build_nightly.yml b/.github/workflows/build_nightly.yml index 9bf9a53a7f..b54be58e15 100644 --- a/.github/workflows/build_nightly.yml +++ b/.github/workflows/build_nightly.yml @@ -11,7 +11,7 @@ jobs: timeout-minutes: 40 steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 with: lfs: 'true' @@ -19,7 +19,7 @@ jobs: run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties - name: set up JDK - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: 'zulu' java-version: 17 diff --git a/.github/workflows/fixup.yml b/.github/workflows/fixup.yml index c5e3b8f025..1a329cf54f 100644 --- a/.github/workflows/fixup.yml +++ b/.github/workflows/fixup.yml @@ -9,7 +9,7 @@ jobs: timeout-minutes: 40 steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 with: lfs: 'true' @@ -17,10 +17,10 @@ jobs: run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties - name: set up JDK - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: 'zulu' - java-version: 17 + java-version: 21 - name: Reformat uses: gradle/gradle-build-action@v3 diff --git a/.github/workflows/macrobenchmark.yml b/.github/workflows/macrobenchmark.yml index e2beefd48e..be20d50e00 100644 --- a/.github/workflows/macrobenchmark.yml +++ b/.github/workflows/macrobenchmark.yml @@ -11,7 +11,7 @@ jobs: TERM: dumb steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 with: lfs: 'true' @@ -19,7 +19,7 @@ jobs: run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties - name: set up JDK - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: 'zulu' java-version: 17 diff --git a/.github/workflows/publish-docs.yml b/.github/workflows/publish-docs.yml index 8e680de8d8..68fa4e77fc 100644 --- a/.github/workflows/publish-docs.yml +++ b/.github/workflows/publish-docs.yml @@ -13,7 +13,7 @@ jobs: TERM: dumb steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 with: lfs: 'true' @@ -21,7 +21,7 @@ jobs: run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties - name: set up JDK - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: 'zulu' java-version: 17 diff --git a/ai/sample/core/build.gradle.kts b/ai/sample/core/build.gradle.kts index f43b26f6dd..7433f637c1 100644 --- a/ai/sample/core/build.gradle.kts +++ b/ai/sample/core/build.gradle.kts @@ -26,7 +26,7 @@ android { compileSdk = 36 defaultConfig { - minSdk = 21 + minSdk = 23 testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } @@ -117,6 +117,8 @@ dependencies { implementation(projects.datalayer.grpc) ksp(libs.dagger.hiltandroidcompiler) + implementation(libs.grpc.stub) + implementation(platform(libs.compose.bom)) implementation(libs.kotlin.stdlib) implementation(libs.kotlinx.coroutines.core) diff --git a/ai/sample/core/src/main/java/com/google/android/horologist/ai/core/InferenceService.kt b/ai/sample/core/src/main/java/com/google/android/horologist/ai/core/InferenceService.kt index b9d4c7ca49..d368270b7c 100644 --- a/ai/sample/core/src/main/java/com/google/android/horologist/ai/core/InferenceService.kt +++ b/ai/sample/core/src/main/java/com/google/android/horologist/ai/core/InferenceService.kt @@ -30,6 +30,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.stateIn @@ -77,7 +78,7 @@ class InferenceService } } - suspend fun submit(prompt: Prompt): Response { + suspend fun submit(prompt: Prompt): ResponseBundle { val currentModel = connectedModel.value ?: throw Exception("No model selected") val (_, service) = models.value?.first { it.first.modelsList.find { it.modelId == currentModel } != null } @@ -91,6 +92,20 @@ class InferenceService ) } + suspend fun submitStream(prompt: Prompt): Flow { + val currentModel = connectedModel.value ?: throw Exception("No model selected") + + val (_, service) = models.value?.first { it.first.modelsList.find { it.modelId == currentModel } != null } + ?: throw Exception("Service missing") + + return service.answerPromptWithStream( + promptRequest { + this.prompt = prompt + this.modelId = currentModel + }, + ) + } + fun selectModel(modelId: ModelId) { connectedModel.value = modelId } @@ -98,4 +113,10 @@ class InferenceService fun clearModel() { connectedModel.value = null } + + suspend fun currentKnownModels(): List { + return models.filterNotNull().first().flatMap { + it.first.modelsList.map { it.modelId } + } + } } diff --git a/ai/sample/core/src/main/java/com/google/android/horologist/ai/core/binder/BinderInferenceService.kt b/ai/sample/core/src/main/java/com/google/android/horologist/ai/core/binder/BinderInferenceService.kt index 22aef50973..8ae45e35c2 100644 --- a/ai/sample/core/src/main/java/com/google/android/horologist/ai/core/binder/BinderInferenceService.kt +++ b/ai/sample/core/src/main/java/com/google/android/horologist/ai/core/binder/BinderInferenceService.kt @@ -19,8 +19,13 @@ package com.google.android.horologist.ai.core.binder import com.google.android.horologist.ai.core.InferenceServiceGrpcKt import com.google.android.horologist.ai.core.PromptRequest import com.google.android.horologist.ai.core.Response +import com.google.android.horologist.ai.core.ResponseBundle import com.google.android.horologist.ai.core.ServiceInfo import com.google.protobuf.Empty +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.asFlow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow class BinderInferenceService( val stub: InferenceServiceGrpcKt.InferenceServiceCoroutineStub, @@ -37,7 +42,13 @@ class BinderInferenceService( } } - override suspend fun answerPrompt(request: PromptRequest): Response { + override suspend fun answerPrompt(request: PromptRequest): ResponseBundle { return stub.answerPrompt(request) } + + override fun answerPromptWithStream(request: PromptRequest): Flow { + return flow { + emitAll(answerPrompt(request).responsesList.asFlow()) + } + } } diff --git a/ai/sample/core/src/main/java/com/google/android/horologist/ai/core/binder/BinderInferenceServiceRegistry.kt b/ai/sample/core/src/main/java/com/google/android/horologist/ai/core/binder/BinderInferenceServiceRegistry.kt index 961aca7153..882ff35fdd 100644 --- a/ai/sample/core/src/main/java/com/google/android/horologist/ai/core/binder/BinderInferenceServiceRegistry.kt +++ b/ai/sample/core/src/main/java/com/google/android/horologist/ai/core/binder/BinderInferenceServiceRegistry.kt @@ -46,4 +46,7 @@ class BinderInferenceServiceRegistry( ) } } + + override val priority: Int + get() = 1 } diff --git a/ai/sample/core/src/main/java/com/google/android/horologist/ai/core/datalayer/DataLayerInferenceService.kt b/ai/sample/core/src/main/java/com/google/android/horologist/ai/core/datalayer/DataLayerInferenceService.kt index 010e746867..70eae316c5 100644 --- a/ai/sample/core/src/main/java/com/google/android/horologist/ai/core/datalayer/DataLayerInferenceService.kt +++ b/ai/sample/core/src/main/java/com/google/android/horologist/ai/core/datalayer/DataLayerInferenceService.kt @@ -20,6 +20,7 @@ import com.google.android.gms.wearable.Node import com.google.android.horologist.ai.core.InferenceServiceGrpcKt import com.google.android.horologist.ai.core.PromptRequest import com.google.android.horologist.ai.core.Response +import com.google.android.horologist.ai.core.ResponseBundle import com.google.android.horologist.ai.core.ServiceInfo import com.google.android.horologist.ai.core.copy import com.google.android.horologist.data.TargetNodeId @@ -27,6 +28,10 @@ import com.google.android.horologist.data.WearDataLayerRegistry import com.google.android.horologist.datalayer.grpc.GrpcExtensions.grpcClient import com.google.protobuf.Empty import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.asFlow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow class DataLayerInferenceService( dataLayerRegistry: WearDataLayerRegistry, @@ -46,7 +51,13 @@ class DataLayerInferenceService( } } - override suspend fun answerPrompt(request: PromptRequest): Response { + override suspend fun answerPrompt(request: PromptRequest): ResponseBundle { return proxy.answerPrompt(request) } + + override fun answerPromptWithStream(request: PromptRequest): Flow { + return flow { + emitAll(answerPrompt(request).responsesList.asFlow()) + } + } } diff --git a/ai/sample/core/src/main/java/com/google/android/horologist/ai/core/datalayer/DataLayerInferenceServiceRegistry.kt b/ai/sample/core/src/main/java/com/google/android/horologist/ai/core/datalayer/DataLayerInferenceServiceRegistry.kt index 1d6fc31a49..b1efc45bd2 100644 --- a/ai/sample/core/src/main/java/com/google/android/horologist/ai/core/datalayer/DataLayerInferenceServiceRegistry.kt +++ b/ai/sample/core/src/main/java/com/google/android/horologist/ai/core/datalayer/DataLayerInferenceServiceRegistry.kt @@ -32,14 +32,12 @@ class DataLayerInferenceServiceRegistry( override fun models(): Flow> { return flow { val allCapabilities = dataLayerRegistry.capabilityClient.getAllCapabilities(CapabilityClient.FILTER_ALL).await() - println("Capabilties") allCapabilities.forEach { (key, list) -> println(key) list.nodes.forEach { println(it.id + " " + it.displayName + " " + it.isNearby) } } - println("End") val capabilities = dataLayerRegistry.capabilityClient.getCapability( CAPABILITY_INFERENCE_SERVICE, @@ -54,6 +52,9 @@ class DataLayerInferenceServiceRegistry( } } + override val priority: Int + get() = 2 + companion object { val CAPABILITY_INFERENCE_SERVICE = "InferenceService" } diff --git a/ai/sample/core/src/main/java/com/google/android/horologist/ai/core/dummy/DummyInferenceServiceImpl.kt b/ai/sample/core/src/main/java/com/google/android/horologist/ai/core/dummy/DummyInferenceServiceImpl.kt index d381d607a9..90fdedf91a 100644 --- a/ai/sample/core/src/main/java/com/google/android/horologist/ai/core/dummy/DummyInferenceServiceImpl.kt +++ b/ai/sample/core/src/main/java/com/google/android/horologist/ai/core/dummy/DummyInferenceServiceImpl.kt @@ -19,47 +19,65 @@ package com.google.android.horologist.ai.core.dummy import com.google.android.horologist.ai.core.InferenceServiceGrpcKt import com.google.android.horologist.ai.core.PromptRequest import com.google.android.horologist.ai.core.Response +import com.google.android.horologist.ai.core.ResponseBundle import com.google.android.horologist.ai.core.ServiceInfo import com.google.android.horologist.ai.core.failure import com.google.android.horologist.ai.core.modelId import com.google.android.horologist.ai.core.modelInfo import com.google.android.horologist.ai.core.response +import com.google.android.horologist.ai.core.responseBundle import com.google.android.horologist.ai.core.serviceInfo import com.google.android.horologist.ai.core.textResponse import com.google.protobuf.Empty +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.asFlow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow class DummyInferenceServiceImpl(val thisId: String) : InferenceServiceGrpcKt.InferenceServiceCoroutineImplBase() { - override suspend fun answerPrompt(request: PromptRequest): Response { + override suspend fun answerPrompt(request: PromptRequest): ResponseBundle { if (request.modelId.id != thisId) { - return response { - failure = failure { - message = "Unknown model ${request.modelId.id}" + return responseBundle { + responses += response { + failure = failure { + message = "Unknown model ${request.modelId.id}" + } } } } else if (request.prompt.hasTextPrompt()) { val query = request.prompt.textPrompt.text - return response { - textResponse = textResponse { - text = """ + return responseBundle { + responses += response { + textResponse = textResponse { + text = """ I didn't understand. > $query. Please try again with a different question. From *$thisId* - """.trimIndent() + """.trimIndent() + } } } } else { - return response { - failure = failure { - message = "Unhandled request type $request" + return responseBundle { + responses += response { + failure = failure { + message = "Unhandled request type $request" + } } } } } + override fun answerPromptWithStream(request: PromptRequest): Flow { + return flow { + emitAll(answerPrompt(request).responsesList.asFlow()) + } + } + override suspend fun serviceInfo(request: Empty): ServiceInfo { return serviceInfo { name = "Dummy $thisId" diff --git a/ai/sample/core/src/main/java/com/google/android/horologist/ai/core/registry/CombinedInferenceServiceRegistry.kt b/ai/sample/core/src/main/java/com/google/android/horologist/ai/core/registry/CombinedInferenceServiceRegistry.kt index fe2c7ba9f0..5069bc3fa8 100644 --- a/ai/sample/core/src/main/java/com/google/android/horologist/ai/core/registry/CombinedInferenceServiceRegistry.kt +++ b/ai/sample/core/src/main/java/com/google/android/horologist/ai/core/registry/CombinedInferenceServiceRegistry.kt @@ -24,8 +24,14 @@ class CombinedInferenceServiceRegistry( val registries: List, ) : InferenceServiceRegistry { override fun models(): Flow> { - return combine(registries.map { registry -> registry.models() }) { + return combine( + registries.sortedByDescending { it.priority } + .map { registry -> registry.models() }, + ) { it.toList().flatten() } } + + override val priority: Int + get() = 0 } diff --git a/ai/sample/core/src/main/java/com/google/android/horologist/ai/core/registry/InferenceServiceRegistry.kt b/ai/sample/core/src/main/java/com/google/android/horologist/ai/core/registry/InferenceServiceRegistry.kt index 8be95f476e..f4e43377cb 100644 --- a/ai/sample/core/src/main/java/com/google/android/horologist/ai/core/registry/InferenceServiceRegistry.kt +++ b/ai/sample/core/src/main/java/com/google/android/horologist/ai/core/registry/InferenceServiceRegistry.kt @@ -20,5 +20,7 @@ import com.google.android.horologist.ai.core.InferenceServiceGrpcKt import kotlinx.coroutines.flow.Flow interface InferenceServiceRegistry { + val priority: Int + fun models(): Flow> } diff --git a/ai/sample/core/src/main/java/com/google/android/horologist/ai/core/registry/LocalInferenceServiceRegistry.kt b/ai/sample/core/src/main/java/com/google/android/horologist/ai/core/registry/LocalInferenceServiceRegistry.kt index d44daba33c..428c189929 100644 --- a/ai/sample/core/src/main/java/com/google/android/horologist/ai/core/registry/LocalInferenceServiceRegistry.kt +++ b/ai/sample/core/src/main/java/com/google/android/horologist/ai/core/registry/LocalInferenceServiceRegistry.kt @@ -22,6 +22,7 @@ import kotlinx.coroutines.flow.flowOf class LocalInferenceServiceRegistry( val models: List, + override val priority: Int = 0, ) : InferenceServiceRegistry { override fun models(): Flow> { return flowOf(models) diff --git a/ai/sample/core/src/main/proto/ai.proto b/ai/sample/core/src/main/proto/ai.proto index a0298d9c7a..35751be163 100644 --- a/ai/sample/core/src/main/proto/ai.proto +++ b/ai/sample/core/src/main/proto/ai.proto @@ -23,10 +23,15 @@ message TextPrompt { string text = 1; } +message ResponseBundle { + repeated Response responses = 1; +} + message Response { oneof response_data { TextResponse text_response = 1; Failure failure = 2; + ImageResponse image_response = 3; } } @@ -34,6 +39,11 @@ message TextResponse { string text = 1; } +message ImageResponse { + optional string gcsUrl = 1; + optional bytes encoded = 2; +} + message Failure { string message = 1; } @@ -64,5 +74,6 @@ message ModelInfo { service InferenceService { rpc serviceInfo(.google.protobuf.Empty) returns (ServiceInfo); - rpc answerPrompt(PromptRequest) returns (Response); + rpc answerPrompt(PromptRequest) returns (ResponseBundle); + rpc answerPromptWithStream(PromptRequest) returns (stream Response); } \ No newline at end of file diff --git a/ai/sample/phone/build.gradle.kts b/ai/sample/phone/build.gradle.kts index 78bbb5a0d5..064e6db8b4 100644 --- a/ai/sample/phone/build.gradle.kts +++ b/ai/sample/phone/build.gradle.kts @@ -29,17 +29,13 @@ android { defaultConfig { applicationId = "com.google.android.horologist.ai.sample" - minSdk = 21 - targetSdk = 34 + minSdk = 26 + targetSdk = 36 versionCode = 1 versionName = "1.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - - vectorDrawables { - useSupportLibrary = true - } } buildTypes { @@ -77,7 +73,13 @@ android { packaging { resources { - excludes += "/META-INF/{AL2.0,LGPL2.1}" + excludes += + listOf( + "/META-INF/AL2.0", + "/META-INF/LGPL2.1", + "/META-INF/INDEX.LIST", + "/META-INF/DEPENDENCIES", + ) } } @@ -97,14 +99,13 @@ dependencies { implementation(projects.datalayer.phone) implementation(projects.datalayer.grpc) + implementation(projects.ai.sample.wearGeminiLib) + implementation(libs.dagger.hiltandroid) ksp(libs.dagger.hiltandroidcompiler) implementation(libs.hilt.navigationcompose) implementation(libs.androidx.navigation.compose) - implementation(projects.datalayer.core) - implementation(projects.datalayer.grpc) - implementation(projects.datalayer.phone) implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.androidx.lifecycle.runtime.compose) implementation(libs.kotlinx.coroutines.playservices) diff --git a/ai/sample/phone/src/main/java/com/google/android/horologist/ai/sample/phone/activity/StatusScreen.kt b/ai/sample/phone/src/main/java/com/google/android/horologist/ai/sample/phone/activity/StatusScreen.kt index 334075e0e8..25aefd2587 100644 --- a/ai/sample/phone/src/main/java/com/google/android/horologist/ai/sample/phone/activity/StatusScreen.kt +++ b/ai/sample/phone/src/main/java/com/google/android/horologist/ai/sample/phone/activity/StatusScreen.kt @@ -24,7 +24,7 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.google.android.horologist.ai.sample.R diff --git a/ai/sample/phone/src/main/java/com/google/android/horologist/ai/sample/phone/di/ServiceModule.kt b/ai/sample/phone/src/main/java/com/google/android/horologist/ai/sample/phone/di/ServiceModule.kt new file mode 100644 index 0000000000..67266bbf24 --- /dev/null +++ b/ai/sample/phone/src/main/java/com/google/android/horologist/ai/sample/phone/di/ServiceModule.kt @@ -0,0 +1,59 @@ +/* + * 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.ai.sample.phone.di + +import com.google.android.horologist.ai.sample.wear.gemini.service.GeminiModel +import com.google.android.horologist.ai.sample.wear.gemini.service.GeminiSDKInferenceServiceImpl +import com.google.android.horologist.ai.sample.wear.geminilib.BuildConfig.GEMINI_API_KEY +import com.google.genai.Client +import com.google.genai.types.ClientOptions +import com.google.genai.types.HttpOptions +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ServiceComponent +import dagger.hilt.android.scopes.ServiceScoped + +@Module +@InstallIn(ServiceComponent::class) +object ServiceModule { + @ServiceScoped + @Provides + fun client() = Client.builder() + .apiKey(GEMINI_API_KEY) + .clientOptions( + ClientOptions.builder() + .build(), + ) + .httpOptions( + HttpOptions.builder() + .build(), + ) + .build() + + @ServiceScoped + @Provides + fun geminiSDKService( + client: Client, + ) = GeminiSDKInferenceServiceImpl( + client, + serviceName = "Phone", + configuredModels = listOf( + GeminiModel.Veo2, + ), + ) +} diff --git a/ai/sample/phone/src/main/java/com/google/android/horologist/ai/sample/phone/service/InferenceBinderGrpcServiceImpl.kt b/ai/sample/phone/src/main/java/com/google/android/horologist/ai/sample/phone/service/InferenceBinderGrpcServiceImpl.kt index 90f8bf764a..36cce044a4 100644 --- a/ai/sample/phone/src/main/java/com/google/android/horologist/ai/sample/phone/service/InferenceBinderGrpcServiceImpl.kt +++ b/ai/sample/phone/src/main/java/com/google/android/horologist/ai/sample/phone/service/InferenceBinderGrpcServiceImpl.kt @@ -17,10 +17,12 @@ package com.google.android.horologist.ai.sample.phone.service import com.google.android.horologist.ai.core.BindableAiGrpcService -import com.google.android.horologist.ai.core.dummy.DummyInferenceServiceImpl +import com.google.android.horologist.ai.sample.wear.gemini.service.GeminiSDKInferenceServiceImpl import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject @AndroidEntryPoint class InferenceBinderGrpcServiceImpl : BindableAiGrpcService() { - override val bindableService = DummyInferenceServiceImpl("phone") + @Inject + override lateinit var bindableService: GeminiSDKInferenceServiceImpl } diff --git a/ai/sample/phone/src/main/java/com/google/android/horologist/ai/sample/phone/service/InferenceGrpcServiceImpl.kt b/ai/sample/phone/src/main/java/com/google/android/horologist/ai/sample/phone/service/InferenceGrpcServiceImpl.kt index 8d9f2fa81e..18e13f768a 100644 --- a/ai/sample/phone/src/main/java/com/google/android/horologist/ai/sample/phone/service/InferenceGrpcServiceImpl.kt +++ b/ai/sample/phone/src/main/java/com/google/android/horologist/ai/sample/phone/service/InferenceGrpcServiceImpl.kt @@ -17,7 +17,7 @@ package com.google.android.horologist.ai.sample.phone.service import com.google.android.horologist.ai.core.InferenceServiceGrpcKt -import com.google.android.horologist.ai.core.dummy.DummyInferenceServiceImpl +import com.google.android.horologist.ai.sample.wear.gemini.service.GeminiSDKInferenceServiceImpl import com.google.android.horologist.data.WearDataLayerRegistry import com.google.android.horologist.datalayer.grpc.server.BaseGrpcDataService import dagger.hilt.android.AndroidEntryPoint @@ -28,6 +28,9 @@ class InferenceGrpcServiceImpl : BaseGrpcDataService; } \ No newline at end of file diff --git a/ai/sample/wear-gemini-lib/src/main/AndroidManifest.xml b/ai/sample/wear-gemini-lib/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..5585d7ebe1 --- /dev/null +++ b/ai/sample/wear-gemini-lib/src/main/AndroidManifest.xml @@ -0,0 +1,21 @@ + + + + + + + \ No newline at end of file diff --git a/ai/sample/wear-gemini-lib/src/main/java/com/google/android/horologist/ai/sample/wear/gemini/service/GeminiModel.kt b/ai/sample/wear-gemini-lib/src/main/java/com/google/android/horologist/ai/sample/wear/gemini/service/GeminiModel.kt new file mode 100644 index 0000000000..fd74038df6 --- /dev/null +++ b/ai/sample/wear-gemini-lib/src/main/java/com/google/android/horologist/ai/sample/wear/gemini/service/GeminiModel.kt @@ -0,0 +1,86 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.horologist.ai.sample.wear.gemini.service + +import com.google.genai.types.MediaModality + +data class GeminiModel( + val name: String, + val displayName: String, + val inputs: List, + val outputs: List, +) { + val isImagesOnly: Boolean + get() = outputs.singleOrNull() == Image + + companion object { + val Audio = MediaModality(MediaModality.Known.AUDIO) + val Text = MediaModality(MediaModality.Known.TEXT) + val Document = MediaModality(MediaModality.Known.DOCUMENT) + val Video = MediaModality(MediaModality.Known.VIDEO) + val Image = MediaModality(MediaModality.Known.IMAGE) + + // From https://ai.google.dev/gemini-api/docs/models + val Gemini2dot5Pro = GeminiModel( + "gemini-2.5-pro", + "Gemini 2.5 Pro", + listOf(Audio, Image, Video, Text, Document), + listOf( + Text, + ), + ) + + val Gemini2dot5Flash = GeminiModel( + "gemini-2.5-flash", + "Gemini 2.5 Flash", + listOf(Audio, Image, Video, Text), + listOf( + Text, + ), + ) + val Imagen4 = GeminiModel( + "imagen-4.0-ultra-generate-preview-06-06", + "Imagen 4", + listOf(Text), + listOf(Image), + ) + val Gemini2dot0FlashLive = + GeminiModel( + "gemini-2.0-flash-live-001", + "Gemini 2.0 Flash Live", + listOf(Audio, Video, Text), + listOf(Text, Audio), + ) + val Gemini2dot5FlashLiveFlashLive = + GeminiModel( + "gemini-live-2.5-flash-preview", + "Gemini 2.5 Flash Live", + listOf(Audio, Video, Text), + listOf(Text, Audio), + ) + val Veo2 = GeminiModel("veo-2.0-generate-001", "Veo 2", listOf(Text, Image), listOf(Video)) + + val All = listOf( + Gemini2dot5Flash, + Gemini2dot5Pro, + Imagen4, + Gemini2dot0FlashLive, + Gemini2dot5FlashLiveFlashLive, + Veo2, + ) + } +} diff --git a/ai/sample/wear-gemini-lib/src/main/java/com/google/android/horologist/ai/sample/wear/gemini/service/GeminiSDKInferenceServiceImpl.kt b/ai/sample/wear-gemini-lib/src/main/java/com/google/android/horologist/ai/sample/wear/gemini/service/GeminiSDKInferenceServiceImpl.kt new file mode 100644 index 0000000000..47002ac911 --- /dev/null +++ b/ai/sample/wear-gemini-lib/src/main/java/com/google/android/horologist/ai/sample/wear/gemini/service/GeminiSDKInferenceServiceImpl.kt @@ -0,0 +1,182 @@ +/* + * 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. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package com.google.android.horologist.ai.sample.wear.gemini.service + +import android.util.Log +import com.google.android.horologist.ai.core.InferenceServiceGrpcKt +import com.google.android.horologist.ai.core.PromptRequest +import com.google.android.horologist.ai.core.Response +import com.google.android.horologist.ai.core.ResponseBundle +import com.google.android.horologist.ai.core.ServiceInfo +import com.google.android.horologist.ai.core.failure +import com.google.android.horologist.ai.core.imageResponse +import com.google.android.horologist.ai.core.modelId +import com.google.android.horologist.ai.core.modelInfo +import com.google.android.horologist.ai.core.response +import com.google.android.horologist.ai.core.responseBundle +import com.google.android.horologist.ai.core.serviceInfo +import com.google.android.horologist.ai.core.textResponse +import com.google.genai.Client +import com.google.genai.types.Content +import com.google.genai.types.GenerateContentConfig +import com.google.genai.types.GenerateImagesConfig +import com.google.genai.types.MediaResolution +import com.google.genai.types.Part +import com.google.protobuf.ByteString +import com.google.protobuf.Empty +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.asFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flatMapConcat +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.toList +import kotlin.jvm.optionals.getOrNull + +class GeminiSDKInferenceServiceImpl( + val client: Client, + val serviceName: String = "Gemini API", + val configuredModels: List = GeminiModel.All, + val contentConfig: GenerateContentConfig = GenerateContentConfig.builder() + .mediaResolution(MediaResolution.Known.MEDIA_RESOLUTION_LOW) + .build(), + val imagesConfig: GenerateImagesConfig = GenerateImagesConfig.builder() + // Vertex only +// .outputGcsUri(BuildConfig.GCS_URI) + .build(), +) : + InferenceServiceGrpcKt.InferenceServiceCoroutineImplBase() { + override suspend fun answerPrompt(request: PromptRequest): ResponseBundle { + return responseBundle { + responses += answerPromptWithStream(request).toList() + } + } + + override fun answerPromptWithStream(request: PromptRequest): Flow { + val model = configuredModels.first { it.name == request.modelId.id } + + return if (model.isImagesOnly) { + geminiGenerateImages(request, model.name) + } else { + geminiGenerateContentStream(request, model.name) + }.catch { + emit( + response { + failure = failure { + message = "Gemini query failed: $it" + } + }, + ) + } + } + + private fun geminiGenerateImages(request: PromptRequest, modelId: String): Flow = + flow { + val responses = client.models.generateImages( + modelId, + request.toTextPrompt(), + imagesConfig, + ) + + emitAll( + responses.generatedImages().getOrNull().orEmpty().asFlow().mapNotNull { + val image = it.image().getOrNull() ?: return@mapNotNull null + response { + imageResponse = imageResponse { + if (image.gcsUri().isPresent) { + gcsUrl = image.gcsUri().get() + } else { + encoded = ByteString.copyFrom(image.imageBytes().get()) + } + } + } + }, + ) + } + + private fun geminiGenerateContentStream( + request: PromptRequest, + modelId: String, + ): Flow { + // TODO handle stream and multiple parts + + val responseStream = try { + client.models.generateContentStream( + modelId, + request.toContent(), + contentConfig, + ) + } catch (e: Exception) { + Log.w("Gemini", "Gemini query failed", e) + return flowOf( + response { + failure = failure { + message = "Gemini query failed: $e" + } + }, + ) + } + + return flow { + with(Dispatchers.IO) { + this@flow.emitAll( + responseStream.iterator().asFlow().flatMapConcat { generateContentResponse -> + generateContentResponse.parts().orEmpty().asFlow().map { + response { + Log.i("Gemini", it.toString()) + textResponse = textResponse { + text = it.text().orElse("--") + } + } + } + }, + ) + } + } + } + + override suspend fun serviceInfo(request: Empty): ServiceInfo { + return serviceInfo { + name = serviceName + models += configuredModels.map { + modelInfo { + modelId = modelId { id = it.name } + name = it.displayName + } + } + } + } + + private fun PromptRequest.toTextPrompt(): String { + if (prompt.hasTextPrompt()) { + return prompt.textPrompt.text!! + } else { + throw IllegalArgumentException("Prompt must be a text prompt") + } + } + + private fun PromptRequest.toContent(): Content { + return Content.builder().parts(Part.fromText(toTextPrompt())).build() + } + } diff --git a/ai/sample/wear-gemini-lib/src/main/res/values/strings.xml b/ai/sample/wear-gemini-lib/src/main/res/values/strings.xml new file mode 100644 index 0000000000..3a0cbf0a4c --- /dev/null +++ b/ai/sample/wear-gemini-lib/src/main/res/values/strings.xml @@ -0,0 +1,19 @@ + + + + Horologist Gemini Sample + \ No newline at end of file diff --git a/ai/sample/wear-gemini-lib/src/main/res/values/wear.xml b/ai/sample/wear-gemini-lib/src/main/res/values/wear.xml new file mode 100644 index 0000000000..576c8c1859 --- /dev/null +++ b/ai/sample/wear-gemini-lib/src/main/res/values/wear.xml @@ -0,0 +1,24 @@ + + + + + + data_layer_app_helper_device_watch + + horologist_watch + + diff --git a/ai/sample/wear-gemini/build.gradle.kts b/ai/sample/wear-gemini/build.gradle.kts index 1b96d4b79c..22826459b5 100644 --- a/ai/sample/wear-gemini/build.gradle.kts +++ b/ai/sample/wear-gemini/build.gradle.kts @@ -61,18 +61,6 @@ android { versionName = "1.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - - buildConfigField( - "String", - "GEMINI_API_KEY", - "\"" + localProperties["gemini.apk.key"] + "\"", - ) - - buildConfigField( - "String", - "GEMINI_PROXY", - if (localProperties.containsKey("gemini.apk.proxy")) "\"" + localProperties["gemini.apk.proxy"] + "\"" else "null", - ) } buildTypes { @@ -116,6 +104,18 @@ android { animationsDisabled = true } + packaging { + resources { + excludes += + listOf( + "/META-INF/AL2.0", + "/META-INF/LGPL2.1", + "/META-INF/INDEX.LIST", + "/META-INF/DEPENDENCIES", + ) + } + } + namespace = "com.google.android.horologist.ai.sample.wear.gemini" } @@ -129,6 +129,7 @@ dependencies { implementation(projects.composables) implementation(projects.composeLayout) implementation(projects.composeMaterial) + implementation(projects.ai.sample.wearGeminiLib) implementation(libs.dagger.hiltandroid) implementation(libs.androidx.wear.input) @@ -139,7 +140,7 @@ dependencies { implementation(projects.datalayer.grpc) implementation(projects.datalayer.watch) implementation(libs.kotlinx.coroutines.playservices) - implementation(libs.google.generativeai) + implementation(libs.google.genai) implementation(libs.androidx.activity.compose) implementation(libs.androidx.complications.data) @@ -156,6 +157,8 @@ dependencies { implementation(libs.wearcompose.navigation) implementation(libs.com.squareup.okhttp3.okhttp) + implementation(libs.coil) + implementation(libs.coil.svg) debugImplementation(libs.compose.ui.tooling) implementation(libs.androidx.wear.tooling.preview) diff --git a/ai/sample/wear-gemini/src/main/AndroidManifest.xml b/ai/sample/wear-gemini/src/main/AndroidManifest.xml index ba89c9b7e0..f160c8639d 100644 --- a/ai/sample/wear-gemini/src/main/AndroidManifest.xml +++ b/ai/sample/wear-gemini/src/main/AndroidManifest.xml @@ -37,7 +37,7 @@ android:value="true" /> diff --git a/ai/sample/wear-gemini/src/main/java/com/google/android/horologist/ai/sample/wear/gemini/SampleApplication.kt b/ai/sample/wear-gemini/src/main/java/com/google/android/horologist/ai/sample/wear/gemini/SampleApplication.kt index b2fb460269..9365af5ce4 100644 --- a/ai/sample/wear-gemini/src/main/java/com/google/android/horologist/ai/sample/wear/gemini/SampleApplication.kt +++ b/ai/sample/wear-gemini/src/main/java/com/google/android/horologist/ai/sample/wear/gemini/SampleApplication.kt @@ -17,7 +17,15 @@ package com.google.android.horologist.ai.sample.wear.gemini import android.app.Application +import coil.ImageLoader +import coil.ImageLoaderFactory import dagger.hilt.android.HiltAndroidApp +import javax.inject.Inject @HiltAndroidApp -class SampleApplication : Application() +class SampleApplication : Application(), ImageLoaderFactory { + @Inject + lateinit var imageLoader: ImageLoader + + override fun newImageLoader(): ImageLoader = imageLoader +} diff --git a/ai/sample/wear-gemini/src/main/java/com/google/android/horologist/ai/sample/wear/gemini/activity/DeviceStatusScreen.kt b/ai/sample/wear-gemini/src/main/java/com/google/android/horologist/ai/sample/wear/gemini/activity/DeviceStatusScreen.kt new file mode 100644 index 0000000000..770b3ad164 --- /dev/null +++ b/ai/sample/wear-gemini/src/main/java/com/google/android/horologist/ai/sample/wear/gemini/activity/DeviceStatusScreen.kt @@ -0,0 +1,70 @@ +/* + * 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.ai.sample.wear.gemini.activity + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.wear.compose.material3.MaterialTheme +import androidx.wear.compose.material3.ScreenScaffold +import androidx.wear.compose.material3.Text +import coil.compose.AsyncImage + +@Composable +fun DeviceStatusScreen( + modifier: Modifier = Modifier, + viewModel: DeviceStatusViewModel = hiltViewModel(), +) { + val uiState = viewModel.uiState.collectAsStateWithLifecycle() + ScreenScaffold(modifier = modifier) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + val uiState = uiState.value + if (uiState is Loaded) { + AsyncImage( + model = uiState.image, + contentDescription = null, + modifier = Modifier.width(100.dp), + contentScale = ContentScale.FillWidth, + ) + Text(uiState.description ?: "None", style = MaterialTheme.typography.bodyExtraSmall) + } else { + Text("Loading...") + } + } + } +} + +sealed interface DeviceStatusUiState + +data object Loading : DeviceStatusUiState + +data class Loaded( + val image: ByteArray?, + val description: String?, +) : DeviceStatusUiState diff --git a/ai/sample/wear-gemini/src/main/java/com/google/android/horologist/ai/sample/wear/gemini/activity/DeviceStatusViewModel.kt b/ai/sample/wear-gemini/src/main/java/com/google/android/horologist/ai/sample/wear/gemini/activity/DeviceStatusViewModel.kt new file mode 100644 index 0000000000..99b93be1ca --- /dev/null +++ b/ai/sample/wear-gemini/src/main/java/com/google/android/horologist/ai/sample/wear/gemini/activity/DeviceStatusViewModel.kt @@ -0,0 +1,70 @@ +/* + * 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.ai.sample.wear.gemini.activity + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.google.android.horologist.ai.sample.wear.gemini.service.GeminiModel +import com.google.genai.Client +import com.google.genai.types.GenerateContentConfig +import com.google.genai.types.GenerateImagesConfig +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.withContext +import javax.inject.Inject + +@HiltViewModel +class DeviceStatusViewModel + @Inject + constructor( + client: Client, + val contentConfig: GenerateContentConfig, + ) : ViewModel() { + val uiState = flow { + val model: String = ExposedMethods.deviceModel() + val manufacturer: String = ExposedMethods.deviceManufacturer() + + val imageGen = withContext(Dispatchers.IO) { + async { + val images = client.models.generateImages( + GeminiModel.Imagen4.name, + "Generate an image for this android device $manufacturer $model", + GenerateImagesConfig.builder() + .numberOfImages(1) + .build(), + ) + images.generatedImages().get().first().image().get().imageBytes().get() + } + } + + val descriptionGen = withContext(Dispatchers.IO) { + async { + client.models.generateContent( + GeminiModel.Gemini2dot5Flash.name, + "Make a poem about the device and its manufacturer", + contentConfig, + ).parts()?.get(0)?.text()?.get() + } + } + + emit(Loaded(imageGen.await(), descriptionGen.await())) + }.stateIn(viewModelScope, SharingStarted.Lazily, Loading) + } diff --git a/ai/sample/wear-gemini/src/main/java/com/google/android/horologist/ai/sample/wear/gemini/activity/ExposedMethods.kt b/ai/sample/wear-gemini/src/main/java/com/google/android/horologist/ai/sample/wear/gemini/activity/ExposedMethods.kt new file mode 100644 index 0000000000..2d81753037 --- /dev/null +++ b/ai/sample/wear-gemini/src/main/java/com/google/android/horologist/ai/sample/wear/gemini/activity/ExposedMethods.kt @@ -0,0 +1,31 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.horologist.ai.sample.wear.gemini.activity + +import android.os.Build + +object ExposedMethods { + @JvmStatic + fun deviceModel(): String { + return Build.MODEL + } + + @JvmStatic + fun deviceManufacturer(): String { + return Build.MANUFACTURER + } +} diff --git a/ai/sample/wear-gemini/src/main/java/com/google/android/horologist/ai/sample/wear/gemini/activity/MainActivity.kt b/ai/sample/wear-gemini/src/main/java/com/google/android/horologist/ai/sample/wear/gemini/activity/GeminiStandaloneActivity.kt similarity index 94% rename from ai/sample/wear-gemini/src/main/java/com/google/android/horologist/ai/sample/wear/gemini/activity/MainActivity.kt rename to ai/sample/wear-gemini/src/main/java/com/google/android/horologist/ai/sample/wear/gemini/activity/GeminiStandaloneActivity.kt index 29d91c53d4..de9964ae55 100644 --- a/ai/sample/wear-gemini/src/main/java/com/google/android/horologist/ai/sample/wear/gemini/activity/MainActivity.kt +++ b/ai/sample/wear-gemini/src/main/java/com/google/android/horologist/ai/sample/wear/gemini/activity/GeminiStandaloneActivity.kt @@ -22,7 +22,7 @@ import androidx.activity.compose.setContent import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint -class MainActivity : ComponentActivity() { +class GeminiStandaloneActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) diff --git a/ai/sample/wear-gemini/src/main/java/com/google/android/horologist/ai/sample/wear/gemini/activity/StatusScreen.kt b/ai/sample/wear-gemini/src/main/java/com/google/android/horologist/ai/sample/wear/gemini/activity/StatusScreen.kt deleted file mode 100644 index f334095366..0000000000 --- a/ai/sample/wear-gemini/src/main/java/com/google/android/horologist/ai/sample/wear/gemini/activity/StatusScreen.kt +++ /dev/null @@ -1,49 +0,0 @@ -/* - * 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.ai.sample.wear.gemini.activity - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.hilt.navigation.compose.hiltViewModel -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.wear.compose.material.Text -import com.google.android.horologist.compose.layout.ScreenScaffold - -@Composable -fun StatusScreen( - modifier: Modifier = Modifier, - viewModel: StatusViewModel = hiltViewModel(), -) { - val uiState by viewModel.uiState.collectAsStateWithLifecycle() - ScreenScaffold(modifier = modifier) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - if (uiState.serviceName != null) { - Text("Hosting " + uiState.serviceName) - } else { - Text("Connecting...") - } - } - } -} - -data class StatusUiState( - val serviceName: String? = null, -) diff --git a/ai/sample/wear-gemini/src/main/java/com/google/android/horologist/ai/sample/wear/gemini/activity/StatusViewModel.kt b/ai/sample/wear-gemini/src/main/java/com/google/android/horologist/ai/sample/wear/gemini/activity/StatusViewModel.kt deleted file mode 100644 index 3fdd488cc4..0000000000 --- a/ai/sample/wear-gemini/src/main/java/com/google/android/horologist/ai/sample/wear/gemini/activity/StatusViewModel.kt +++ /dev/null @@ -1,38 +0,0 @@ -/* - * 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.ai.sample.wear.gemini.activity - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.google.android.horologist.ai.core.InferenceServiceGrpcKt -import com.google.protobuf.empty -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.stateIn -import javax.inject.Inject - -@HiltViewModel -class StatusViewModel - @Inject - constructor( - service: InferenceServiceGrpcKt.InferenceServiceCoroutineStub, - ) : ViewModel() { - val uiState = flow { - emit(StatusUiState(serviceName = service.serviceInfo(empty { }).name)) - }.stateIn(viewModelScope, SharingStarted.Lazily, StatusUiState()) - } diff --git a/ai/sample/wear-gemini/src/main/java/com/google/android/horologist/ai/sample/wear/gemini/activity/WearApp.kt b/ai/sample/wear-gemini/src/main/java/com/google/android/horologist/ai/sample/wear/gemini/activity/WearApp.kt index dee2e3494b..b228e82f95 100644 --- a/ai/sample/wear-gemini/src/main/java/com/google/android/horologist/ai/sample/wear/gemini/activity/WearApp.kt +++ b/ai/sample/wear-gemini/src/main/java/com/google/android/horologist/ai/sample/wear/gemini/activity/WearApp.kt @@ -19,11 +19,11 @@ package com.google.android.horologist.ai.sample.wear.gemini.activity import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.navigation.NavHostController +import androidx.wear.compose.material3.AppScaffold import androidx.wear.compose.navigation.SwipeDismissableNavHost import androidx.wear.compose.navigation.composable import androidx.wear.compose.navigation.rememberSwipeDismissableNavController import androidx.wear.compose.ui.tooling.preview.WearPreviewSmallRound -import com.google.android.horologist.compose.layout.AppScaffold @Composable fun WearApp( @@ -38,7 +38,7 @@ fun WearApp( composable( route = "Home", ) { - StatusScreen() + DeviceStatusScreen() } } } diff --git a/ai/sample/wear-gemini/src/main/java/com/google/android/horologist/ai/sample/wear/gemini/di/AiModule.kt b/ai/sample/wear-gemini/src/main/java/com/google/android/horologist/ai/sample/wear/gemini/di/AiModule.kt new file mode 100644 index 0000000000..afdb771606 --- /dev/null +++ b/ai/sample/wear-gemini/src/main/java/com/google/android/horologist/ai/sample/wear/gemini/di/AiModule.kt @@ -0,0 +1,122 @@ +/* + * 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.ai.sample.wear.gemini.di + +import com.google.android.horologist.ai.sample.wear.gemini.activity.ExposedMethods +import com.google.android.horologist.ai.sample.wear.gemini.service.GeminiModel +import com.google.android.horologist.ai.sample.wear.gemini.service.GeminiSDKInferenceServiceImpl +import com.google.android.horologist.ai.sample.wear.geminilib.BuildConfig.GEMINI_API_KEY +import com.google.genai.Client +import com.google.genai.types.ClientOptions +import com.google.genai.types.FunctionCallingConfig +import com.google.genai.types.FunctionCallingConfigMode +import com.google.genai.types.GenerateContentConfig +import com.google.genai.types.HttpOptions +import com.google.genai.types.LatLng +import com.google.genai.types.MediaResolution +import com.google.genai.types.RetrievalConfig +import com.google.genai.types.Tool +import com.google.genai.types.ToolConfig +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object AiModule { + @Singleton + @Provides + fun client() = Client.builder() + .apiKey(GEMINI_API_KEY) + .clientOptions( + ClientOptions.builder() + .build(), + ) + .httpOptions( + HttpOptions.builder() + .build(), + ) + .build() + + @Singleton + @Provides + fun geminiSDKService( + client: Client, + contentConfig: GenerateContentConfig, + ) = GeminiSDKInferenceServiceImpl( + client, + serviceName = "Device Info", + configuredModels = listOf( + GeminiModel.Gemini2dot5Flash, + ), + contentConfig = GenerateContentConfig.builder() + .mediaResolution(MediaResolution.Known.MEDIA_RESOLUTION_LOW) + .build(), + ) + + @Singleton + @Provides + fun generateContentConfig(): GenerateContentConfig = GenerateContentConfig.builder() + .toolConfig( + ToolConfig.builder() + .retrievalConfig( + RetrievalConfig.builder() + .latLng( + LatLng.builder().latitude(51.5332609).longitude(-0.1285781) + .build(), + ) + .languageCode("en_GB") + .build(), + ) + .functionCallingConfig( + FunctionCallingConfig.builder() + .mode(FunctionCallingConfigMode.Known.AUTO) + .build(), + ) + .build(), + ) + .tools( + Tool.builder() +// googleMaps(GoogleMaps.builder() +// .authConfig(AuthConfig.builder() +// .build()) +// .build()) +// .functionDeclarations(FunctionDeclaration.builder() +// .build()) + .functions( + ExposedMethods::class.java.getMethod("deviceModel"), + ExposedMethods::class.java.getMethod("deviceManufacturer"), + ) +// .googleSearchRetrieval( +// GoogleSearchRetrieval.builder() +// .dynamicRetrievalConfig( +// DynamicRetrievalConfig.builder() +// .mode(DynamicRetrievalConfigMode.Known.MODE_DYNAMIC) +// .build() +// ) +// .build() +// ) +// .googleSearch( +// GoogleSearch.builder() +// .build() +// ) + .build(), + ) + .build() +} diff --git a/ai/sample/wear-gemini/src/main/java/com/google/android/horologist/ai/sample/wear/gemini/di/NetworkModule.kt b/ai/sample/wear-gemini/src/main/java/com/google/android/horologist/ai/sample/wear/gemini/di/NetworkModule.kt new file mode 100644 index 0000000000..e06048be7c --- /dev/null +++ b/ai/sample/wear-gemini/src/main/java/com/google/android/horologist/ai/sample/wear/gemini/di/NetworkModule.kt @@ -0,0 +1,44 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.horologist.ai.sample.wear.gemini.di + +import android.content.Context +import coil.ImageLoader +import coil.decode.SvgDecoder +import coil.request.CachePolicy +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object NetworkModule { + + @Singleton + @Provides + fun imageLoader( + @ApplicationContext application: Context, + ): ImageLoader = ImageLoader.Builder(application) + .components { + add(SvgDecoder.Factory()) + } + .memoryCachePolicy(CachePolicy.ENABLED) + .build() +} diff --git a/ai/sample/wear-gemini/src/main/java/com/google/android/horologist/ai/sample/wear/gemini/di/ServiceModule.kt b/ai/sample/wear-gemini/src/main/java/com/google/android/horologist/ai/sample/wear/gemini/di/ServiceModule.kt index 3e0fa9f1ef..99694703cf 100644 --- a/ai/sample/wear-gemini/src/main/java/com/google/android/horologist/ai/sample/wear/gemini/di/ServiceModule.kt +++ b/ai/sample/wear-gemini/src/main/java/com/google/android/horologist/ai/sample/wear/gemini/di/ServiceModule.kt @@ -16,62 +16,10 @@ package com.google.android.horologist.ai.sample.wear.gemini.di -import com.google.ai.client.generativeai.GenerativeModel -import com.google.ai.client.generativeai.type.GenerationConfig -import com.google.ai.client.generativeai.type.generationConfig -import com.google.android.horologist.ai.sample.wear.gemini.BuildConfig -import com.google.android.horologist.ai.sample.wear.gemini.service.GeminiSDKInferenceServiceImpl import dagger.Module -import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.components.ServiceComponent -import dagger.hilt.android.scopes.ServiceScoped -import javax.inject.Qualifier @Module @InstallIn(ServiceComponent::class) -object ServiceModule { - // From https://github.com/google/generative-ai-android/blob/main/generativeai-android-sample/app/src/main/kotlin/com/google/ai/sample/GenerativeAiViewModelFactory.kt - - @ServiceScoped - @Provides - fun modelConfig(): GenerationConfig = generationConfig { - temperature = 0.7f - } - - @ServiceScoped - @Provides - @ChatModel - fun textModel( - config: GenerationConfig, - ) = GenerativeModel( - modelName = "gemini-pro", - apiKey = BuildConfig.GEMINI_API_KEY, - generationConfig = config, - ) - - @ServiceScoped - @Provides - @MultiModelModel - fun multiModelModel( - config: GenerationConfig, - ) = GenerativeModel( - modelName = "gemini-pro-vision", - apiKey = BuildConfig.GEMINI_API_KEY, - generationConfig = config, - ) - - @ServiceScoped - @Provides - fun geminiSDKService( - @ChatModel model: GenerativeModel, - ) = GeminiSDKInferenceServiceImpl(model) -} - -@Qualifier -@Retention(AnnotationRetention.BINARY) -annotation class ChatModel - -@Qualifier -@Retention(AnnotationRetention.BINARY) -annotation class MultiModelModel +object ServiceModule diff --git a/ai/sample/wear-gemini/src/main/java/com/google/android/horologist/ai/sample/wear/gemini/service/GeminiSDKInferenceServiceImpl.kt b/ai/sample/wear-gemini/src/main/java/com/google/android/horologist/ai/sample/wear/gemini/service/GeminiSDKInferenceServiceImpl.kt deleted file mode 100644 index 25567df1da..0000000000 --- a/ai/sample/wear-gemini/src/main/java/com/google/android/horologist/ai/sample/wear/gemini/service/GeminiSDKInferenceServiceImpl.kt +++ /dev/null @@ -1,90 +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.ai.sample.wear.gemini.service - -import com.google.ai.client.generativeai.GenerativeModel -import com.google.android.horologist.ai.core.InferenceServiceGrpcKt -import com.google.android.horologist.ai.core.PromptRequest -import com.google.android.horologist.ai.core.Response -import com.google.android.horologist.ai.core.ServiceInfo -import com.google.android.horologist.ai.core.failure -import com.google.android.horologist.ai.core.modelId -import com.google.android.horologist.ai.core.modelInfo -import com.google.android.horologist.ai.core.response -import com.google.android.horologist.ai.core.serviceInfo -import com.google.android.horologist.ai.core.textResponse -import com.google.protobuf.Empty -import kotlinx.coroutines.flow.first - -class GeminiSDKInferenceServiceImpl(val model: GenerativeModel) : - InferenceServiceGrpcKt.InferenceServiceCoroutineImplBase() { - override suspend fun answerPrompt(request: PromptRequest): Response { - if (request.modelId.id != "gemini") { - return response { - failure = failure { - message = "Unknown model ${request.modelId.id}" - } - } - } else if (request.prompt.hasTextPrompt()) { - val query = request.prompt.textPrompt.text - return geminiQuery(query!!) - } else { - return response { - failure = failure { - message = "Unhandled request type $request" - } - } - } - } - - private suspend fun geminiQuery(query: String): Response { - val textAnswer = try { - model.generateContentStream(query).first().text - } catch (e: Exception) { - e.printStackTrace() - return response { - failure = failure { - message = "Gemini query failed: $e" - } - } - } - - if (textAnswer == null) { - return response { - failure = failure { - message = "Gemini query failed: No text content" - } - } - } - - return response { - textResponse = textResponse { - text = textAnswer - } - } - } - - override suspend fun serviceInfo(request: Empty): ServiceInfo { - return serviceInfo { - name = "Gemini" - models += modelInfo { - modelId = modelId { id = "gemini" } - name = "Gemini" - } - } - } - } diff --git a/ai/sample/wear-prompt-app/build.gradle.kts b/ai/sample/wear-prompt-app/build.gradle.kts index 6da1e3a370..6c23e316ce 100644 --- a/ai/sample/wear-prompt-app/build.gradle.kts +++ b/ai/sample/wear-prompt-app/build.gradle.kts @@ -54,6 +54,8 @@ android { } compileOptions { + isCoreLibraryDesugaringEnabled = true + sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } @@ -72,6 +74,18 @@ android { ) } + packaging { + resources { + excludes += + listOf( + "/META-INF/AL2.0", + "/META-INF/LGPL2.1", + "/META-INF/INDEX.LIST", + "/META-INF/DEPENDENCIES", + ) + } + } + testOptions { unitTests { isIncludeAndroidResources = true @@ -85,15 +99,20 @@ android { dependencies { api(projects.annotations) + coreLibraryDesugaring(libs.desugar.jdk.libs) + implementation(platform(libs.compose.bom)) implementation(projects.ai.sample.wearCore) + implementation(projects.ai.sample.wearGeminiLib) implementation(projects.ai.ui) implementation(projects.ai.sample.core) implementation(projects.composables) implementation(projects.composeLayout) implementation(projects.composeMaterial) + implementation(libs.coil) + implementation(libs.coil.svg) implementation(libs.dagger.hiltandroid) implementation(libs.androidx.wear.input) diff --git a/ai/sample/wear-prompt-app/proguard-rules.pro b/ai/sample/wear-prompt-app/proguard-rules.pro index c164a4f218..f3bb88b985 100644 --- a/ai/sample/wear-prompt-app/proguard-rules.pro +++ b/ai/sample/wear-prompt-app/proguard-rules.pro @@ -1,2 +1,4 @@ # https://issuetracker.google.com/issues/144631039 --keepclassmembers class * extends com.google.protobuf.GeneratedMessageLite { ; } \ No newline at end of file +-keepclassmembers class * extends com.google.protobuf.GeneratedMessageLite { ; } + +-dontwarn javax.lang.model.** \ No newline at end of file diff --git a/ai/sample/wear-prompt-app/src/main/AndroidManifest.xml b/ai/sample/wear-prompt-app/src/main/AndroidManifest.xml index 4954238a45..86b89dfdea 100644 --- a/ai/sample/wear-prompt-app/src/main/AndroidManifest.xml +++ b/ai/sample/wear-prompt-app/src/main/AndroidManifest.xml @@ -44,7 +44,7 @@ android:value="true" /> @@ -56,6 +56,17 @@ + + + + + + + + + \ No newline at end of file diff --git a/ai/sample/wear-prompt-app/src/main/java/com/google/android/horologist/ai/sample/wear/prompt/MainActivity.kt b/ai/sample/wear-prompt-app/src/main/java/com/google/android/horologist/ai/sample/wear/prompt/PromptActivity.kt similarity index 95% rename from ai/sample/wear-prompt-app/src/main/java/com/google/android/horologist/ai/sample/wear/prompt/MainActivity.kt rename to ai/sample/wear-prompt-app/src/main/java/com/google/android/horologist/ai/sample/wear/prompt/PromptActivity.kt index 31e8d1d455..f21f69f260 100644 --- a/ai/sample/wear-prompt-app/src/main/java/com/google/android/horologist/ai/sample/wear/prompt/MainActivity.kt +++ b/ai/sample/wear-prompt-app/src/main/java/com/google/android/horologist/ai/sample/wear/prompt/PromptActivity.kt @@ -22,7 +22,7 @@ import androidx.activity.compose.setContent import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint -class MainActivity : ComponentActivity() { +class PromptActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) diff --git a/ai/sample/wear-prompt-app/src/main/java/com/google/android/horologist/ai/sample/wear/prompt/SampleApplication.kt b/ai/sample/wear-prompt-app/src/main/java/com/google/android/horologist/ai/sample/wear/prompt/SampleApplication.kt index fb79268001..fe56454eec 100644 --- a/ai/sample/wear-prompt-app/src/main/java/com/google/android/horologist/ai/sample/wear/prompt/SampleApplication.kt +++ b/ai/sample/wear-prompt-app/src/main/java/com/google/android/horologist/ai/sample/wear/prompt/SampleApplication.kt @@ -17,7 +17,15 @@ package com.google.android.horologist.ai.sample.wear.prompt import android.app.Application +import coil.ImageLoader +import coil.ImageLoaderFactory import dagger.hilt.android.HiltAndroidApp +import javax.inject.Inject @HiltAndroidApp -class SampleApplication : Application() +class SampleApplication : Application(), ImageLoaderFactory { + @Inject + lateinit var imageLoader: ImageLoader + + override fun newImageLoader(): ImageLoader = imageLoader +} diff --git a/ai/sample/wear-prompt-app/src/main/java/com/google/android/horologist/ai/sample/wear/prompt/WearApp.kt b/ai/sample/wear-prompt-app/src/main/java/com/google/android/horologist/ai/sample/wear/prompt/WearApp.kt index b18dce2a95..1e6bd1a6b9 100644 --- a/ai/sample/wear-prompt-app/src/main/java/com/google/android/horologist/ai/sample/wear/prompt/WearApp.kt +++ b/ai/sample/wear-prompt-app/src/main/java/com/google/android/horologist/ai/sample/wear/prompt/WearApp.kt @@ -19,12 +19,12 @@ package com.google.android.horologist.ai.sample.wear.prompt import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.navigation.NavHostController +import androidx.wear.compose.material3.AppScaffold import androidx.wear.compose.navigation.rememberSwipeDismissableNavController import androidx.wear.compose.ui.tooling.preview.WearPreviewLargeRound import androidx.wear.compose.ui.tooling.preview.WearPreviewSmallRound import com.google.android.horologist.ai.sample.wear.prompt.prompt.SamplePromptScreen import com.google.android.horologist.ai.sample.wear.prompt.settings.SettingsScreen -import com.google.android.horologist.compose.layout.AppScaffold import com.google.android.horologist.compose.nav.SwipeDismissableNavHost import com.google.android.horologist.compose.nav.composable diff --git a/ai/sample/wear-prompt-app/src/main/java/com/google/android/horologist/ai/sample/wear/prompt/di/InferenceServicesModule.kt b/ai/sample/wear-prompt-app/src/main/java/com/google/android/horologist/ai/sample/wear/prompt/di/InferenceServicesModule.kt index cc076dbc5c..7b5970e1b3 100644 --- a/ai/sample/wear-prompt-app/src/main/java/com/google/android/horologist/ai/sample/wear/prompt/di/InferenceServicesModule.kt +++ b/ai/sample/wear-prompt-app/src/main/java/com/google/android/horologist/ai/sample/wear/prompt/di/InferenceServicesModule.kt @@ -56,7 +56,10 @@ object InferenceServicesModule { @Provides @Singleton fun localRegistry(): LocalInferenceServiceRegistry { - return LocalInferenceServiceRegistry(listOf(DummyInferenceServiceImpl("dummy-local"))) + return LocalInferenceServiceRegistry( + listOf(DummyInferenceServiceImpl("dummy-local")), + priority = Int.MIN_VALUE, + ) } @Provides diff --git a/ai/sample/wear-prompt-app/src/main/java/com/google/android/horologist/ai/sample/wear/prompt/di/NetworkModule.kt b/ai/sample/wear-prompt-app/src/main/java/com/google/android/horologist/ai/sample/wear/prompt/di/NetworkModule.kt new file mode 100644 index 0000000000..7e10a88f17 --- /dev/null +++ b/ai/sample/wear-prompt-app/src/main/java/com/google/android/horologist/ai/sample/wear/prompt/di/NetworkModule.kt @@ -0,0 +1,44 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.horologist.ai.sample.wear.prompt.di + +import android.content.Context +import coil.ImageLoader +import coil.decode.SvgDecoder +import coil.request.CachePolicy +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object NetworkModule { + + @Singleton + @Provides + fun imageLoader( + @ApplicationContext application: Context, + ): ImageLoader = ImageLoader.Builder(application) + .components { + add(SvgDecoder.Factory()) + } + .memoryCachePolicy(CachePolicy.ENABLED) + .build() +} diff --git a/ai/sample/wear-prompt-app/src/main/java/com/google/android/horologist/ai/sample/wear/prompt/di/ServiceModule.kt b/ai/sample/wear-prompt-app/src/main/java/com/google/android/horologist/ai/sample/wear/prompt/di/ServiceModule.kt new file mode 100644 index 0000000000..5e36b27508 --- /dev/null +++ b/ai/sample/wear-prompt-app/src/main/java/com/google/android/horologist/ai/sample/wear/prompt/di/ServiceModule.kt @@ -0,0 +1,52 @@ +/* + * 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.ai.sample.wear.prompt.di + +import com.google.android.horologist.ai.sample.wear.gemini.service.GeminiSDKInferenceServiceImpl +import com.google.android.horologist.ai.sample.wear.geminilib.BuildConfig +import com.google.genai.Client +import com.google.genai.types.ClientOptions +import com.google.genai.types.HttpOptions +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ServiceComponent +import dagger.hilt.android.scopes.ServiceScoped + +@Module +@InstallIn(ServiceComponent::class) +object ServiceModule { + @ServiceScoped + @Provides + fun client() = Client.builder() + .apiKey(BuildConfig.GEMINI_API_KEY) + .clientOptions( + ClientOptions.builder() + .build(), + ) + .httpOptions( + HttpOptions.builder() + .build(), + ) + .build() + + @ServiceScoped + @Provides + fun geminiSDKService( + client: Client, + ) = GeminiSDKInferenceServiceImpl(client) +} diff --git a/ai/sample/wear-prompt-app/src/main/java/com/google/android/horologist/ai/sample/wear/prompt/localgemini/InferenceBinderGrpcServiceImpl.kt b/ai/sample/wear-prompt-app/src/main/java/com/google/android/horologist/ai/sample/wear/prompt/localgemini/InferenceBinderGrpcServiceImpl.kt new file mode 100644 index 0000000000..136d560b10 --- /dev/null +++ b/ai/sample/wear-prompt-app/src/main/java/com/google/android/horologist/ai/sample/wear/prompt/localgemini/InferenceBinderGrpcServiceImpl.kt @@ -0,0 +1,28 @@ +/* + * 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.ai.sample.wear.prompt.localgemini + +import com.google.android.horologist.ai.core.BindableAiGrpcService +import com.google.android.horologist.ai.sample.wear.gemini.service.GeminiSDKInferenceServiceImpl +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject + +@AndroidEntryPoint +class InferenceBinderGrpcServiceImpl : BindableAiGrpcService() { + @Inject + override lateinit var bindableService: GeminiSDKInferenceServiceImpl +} diff --git a/ai/sample/wear-prompt-app/src/main/java/com/google/android/horologist/ai/sample/wear/prompt/markdown/SampleTypography.kt b/ai/sample/wear-prompt-app/src/main/java/com/google/android/horologist/ai/sample/wear/prompt/markdown/SampleTypography.kt new file mode 100644 index 0000000000..40038560bd --- /dev/null +++ b/ai/sample/wear-prompt-app/src/main/java/com/google/android/horologist/ai/sample/wear/prompt/markdown/SampleTypography.kt @@ -0,0 +1,71 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.horologist.ai.sample.wear.prompt.markdown + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLinkStyles +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.wear.compose.material3.LocalContentColor +import androidx.wear.compose.material3.MaterialTheme +import com.mikepenz.markdown.model.DefaultMarkdownColors +import com.mikepenz.markdown.model.DefaultMarkdownTypography + +@Composable +fun sampleTypography(): DefaultMarkdownTypography { + val link = MaterialTheme.typography.bodyMedium.copy( + fontWeight = FontWeight.Bold, + textDecoration = TextDecoration.Underline, + ) + val text = MaterialTheme.typography.bodyMedium + return DefaultMarkdownTypography( + h1 = MaterialTheme.typography.titleLarge, + h2 = MaterialTheme.typography.titleMedium, + h3 = MaterialTheme.typography.titleSmall, + h4 = MaterialTheme.typography.displayLarge, + h5 = MaterialTheme.typography.displayMedium, + h6 = MaterialTheme.typography.displaySmall, + text = text, + code = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace), + quote = MaterialTheme.typography.bodyMedium.plus(SpanStyle(fontStyle = FontStyle.Italic)), + paragraph = MaterialTheme.typography.bodyLarge, + ordered = MaterialTheme.typography.bodyLarge, + bullet = MaterialTheme.typography.bodyLarge, + list = MaterialTheme.typography.bodyLarge, + link = link, + inlineCode = MaterialTheme.typography.bodyLarge.copy(fontFamily = FontFamily.Monospace), + textLink = TextLinkStyles(style = link.toSpanStyle()), + table = text, + ) +} + +@Composable +fun sampleColors() = DefaultMarkdownColors( + text = Color.White, + codeText = LocalContentColor.current, + linkText = Color.Blue, + codeBackground = MaterialTheme.colorScheme.background, + inlineCodeBackground = MaterialTheme.colorScheme.background, + dividerColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f), + inlineCodeText = LocalContentColor.current, + tableText = Color.Unspecified, + tableBackground = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.02f), +) diff --git a/ai/sample/wear-prompt-app/src/main/java/com/google/android/horologist/ai/sample/wear/prompt/prompt/SamplePromptScreen.kt b/ai/sample/wear-prompt-app/src/main/java/com/google/android/horologist/ai/sample/wear/prompt/prompt/SamplePromptScreen.kt index 1a32299491..2542fe0f6e 100644 --- a/ai/sample/wear-prompt-app/src/main/java/com/google/android/horologist/ai/sample/wear/prompt/prompt/SamplePromptScreen.kt +++ b/ai/sample/wear-prompt-app/src/main/java/com/google/android/horologist/ai/sample/wear/prompt/prompt/SamplePromptScreen.kt @@ -19,8 +19,6 @@ package com.google.android.horologist.ai.sample.wear.prompt.prompt import android.content.Intent import android.speech.RecognizerIntent import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Mic @@ -29,23 +27,21 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.TextLinkStyles -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontStyle -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextDecoration -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.wear.compose.material.Card -import androidx.wear.compose.material.CardDefaults -import androidx.wear.compose.material.LocalContentColor -import androidx.wear.compose.material.MaterialTheme +import androidx.wear.compose.material3.Card +import androidx.wear.compose.material3.CardDefaults +import androidx.wear.compose.material3.EdgeButton +import androidx.wear.compose.material3.EdgeButtonSize +import androidx.wear.compose.material3.Icon +import androidx.wear.compose.material3.MaterialTheme +import androidx.wear.compose.material3.SurfaceTransformation import androidx.wear.compose.ui.tooling.preview.WearPreviewLargeRound import androidx.wear.compose.ui.tooling.preview.WearPreviewSmallRound import com.google.android.horologist.ai.sample.prompt.R +import com.google.android.horologist.ai.sample.wear.prompt.markdown.sampleColors +import com.google.android.horologist.ai.sample.wear.prompt.markdown.sampleTypography import com.google.android.horologist.ai.ui.components.PromptOrResponseDisplay import com.google.android.horologist.ai.ui.model.ModelInstanceUiModel import com.google.android.horologist.ai.ui.model.PromptOrResponseUiModel @@ -53,12 +49,9 @@ import com.google.android.horologist.ai.ui.model.TextPromptUiModel import com.google.android.horologist.ai.ui.model.TextResponseUiModel import com.google.android.horologist.ai.ui.screens.PromptScreen import com.google.android.horologist.ai.ui.screens.PromptUiState -import com.google.android.horologist.compose.material.Button import com.mikepenz.markdown.compose.LocalMarkdownColors import com.mikepenz.markdown.compose.LocalMarkdownTypography import com.mikepenz.markdown.compose.Markdown -import com.mikepenz.markdown.model.DefaultMarkdownColors -import com.mikepenz.markdown.model.DefaultMarkdownTypography @Composable fun SamplePromptScreen( @@ -98,14 +91,17 @@ fun SamplePromptScreen( uiState = uiState, modifier = modifier, onSettingsClick = onSettingsClick, - ) { - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) { - Button( + ) { pending -> + EdgeButton( + onClick = { + voiceLauncher.launch(voiceIntent) + }, + buttonSize = EdgeButtonSize.ExtraSmall, + enabled = !pending, + ) { + Icon( Icons.Default.Mic, contentDescription = stringResource(R.string.prompt_input), - onClick = { - voiceLauncher.launch(voiceIntent) - }, ) } } @@ -116,73 +112,41 @@ private fun SamplePromptScreen( uiState: PromptUiState, modifier: Modifier = Modifier, onSettingsClick: (() -> Unit)? = null, - promptEntry: @Composable () -> Unit, + promptEntry: @Composable (Boolean) -> Unit, ) { CompositionLocalProvider( - LocalMarkdownColors provides SampleColors(), - LocalMarkdownTypography provides SampleTypography(), + LocalMarkdownColors provides sampleColors(), + LocalMarkdownTypography provides sampleTypography(), ) { PromptScreen( uiState = uiState, modifier = modifier, promptEntry = promptEntry, onSettingsClick = onSettingsClick, - promptDisplay = { - ModelDisplay(it) + promptDisplay = { model, modifier, spec -> + ModelDisplay(model, modifier, spec) }, ) } } @Composable -private fun SampleTypography(): DefaultMarkdownTypography { - val link = MaterialTheme.typography.body1.copy( - fontWeight = FontWeight.Bold, - textDecoration = TextDecoration.Underline, - ) - val text = MaterialTheme.typography.body1 - return DefaultMarkdownTypography( - h1 = MaterialTheme.typography.title1, - h2 = MaterialTheme.typography.title2, - h3 = MaterialTheme.typography.title3, - h4 = MaterialTheme.typography.caption1, - h5 = MaterialTheme.typography.caption2, - h6 = MaterialTheme.typography.caption3, - text = text, - code = MaterialTheme.typography.body2.copy(fontFamily = FontFamily.Monospace), - quote = MaterialTheme.typography.body2.plus(SpanStyle(fontStyle = FontStyle.Italic)), - paragraph = MaterialTheme.typography.body1, - ordered = MaterialTheme.typography.body1, - bullet = MaterialTheme.typography.body1, - list = MaterialTheme.typography.body1, - link = link, - inlineCode = MaterialTheme.typography.body1.copy(fontFamily = FontFamily.Monospace), - textLink = TextLinkStyles(style = link.toSpanStyle()), - table = text, - ) -} - -@Composable -private fun SampleColors() = DefaultMarkdownColors( - text = Color.White, - codeText = LocalContentColor.current, - linkText = Color.Blue, - codeBackground = MaterialTheme.colors.background, - inlineCodeBackground = MaterialTheme.colors.background, - dividerColor = MaterialTheme.colors.onSurface.copy(alpha = 0.12f), - inlineCodeText = LocalContentColor.current, - tableText = Color.Unspecified, - tableBackground = MaterialTheme.colors.onBackground.copy(alpha = 0.02f), -) - -@Composable -private fun ModelDisplay(it: PromptOrResponseUiModel) { - if (it is TextResponseUiModel) { - SampleTextResponseCard(it) +private fun ModelDisplay( + model: PromptOrResponseUiModel, + modifier: Modifier = Modifier, + transformation: SurfaceTransformation? = null, +) { + if (model is TextResponseUiModel) { + SampleTextResponseCard( + model, + modifier = modifier, + transformation = transformation, + ) } else { PromptOrResponseDisplay( - promptResponse = it, - onClick = {}, + promptResponse = model, + modifier = modifier, + transformation = transformation, ) } } @@ -192,19 +156,20 @@ public fun SampleTextResponseCard( textResponseUiModel: TextResponseUiModel, modifier: Modifier = Modifier, onClick: () -> Unit = {}, + transformation: SurfaceTransformation? = null, ) { Card( modifier = modifier.fillMaxWidth(), onClick = onClick, - backgroundPainter = CardDefaults.cardBackgroundPainter( - MaterialTheme.colors.surface, - MaterialTheme.colors.surface, + colors = CardDefaults.cardColors( + MaterialTheme.colorScheme.surfaceContainer, ), + transformation = transformation, ) { Markdown( textResponseUiModel.text, - colors = SampleColors(), - typography = SampleTypography(), + colors = sampleColors(), + typography = sampleTypography(), ) } } @@ -216,11 +181,15 @@ fun SamplePromptScreenPreviewEmpty() { SamplePromptScreen( uiState = PromptUiState(), promptEntry = { - Button( - imageVector = Icons.Default.QuestionAnswer, - contentDescription = "Ask Again", + EdgeButton( onClick = { }, - ) + buttonSize = EdgeButtonSize.ExtraSmall, + ) { + Icon( + imageVector = Icons.Default.QuestionAnswer, + contentDescription = stringResource(R.string.ask_again), + ) + } }, ) } @@ -248,11 +217,15 @@ fun SamplePromptScreenPreviewMany() { ), ), promptEntry = { - Button( - imageVector = Icons.Default.QuestionAnswer, - contentDescription = "Ask Again", + EdgeButton( onClick = { }, - ) + buttonSize = EdgeButtonSize.ExtraSmall, + ) { + Icon( + imageVector = Icons.Default.QuestionAnswer, + contentDescription = stringResource(R.string.ask_again), + ) + } }, ) } @@ -268,14 +241,18 @@ fun SamplePromptScreenPreviewQuestion() { TextPromptUiModel("why did the chicken cross the road?"), TextResponseUiModel("To get to the other side."), ), - TextPromptUiModel("why did the chicken cross the road?"), + true, ), promptEntry = { - Button( - imageVector = Icons.Default.QuestionAnswer, - contentDescription = "Ask Again", + EdgeButton( onClick = { }, - ) + buttonSize = EdgeButtonSize.ExtraSmall, + ) { + Icon( + imageVector = Icons.Default.QuestionAnswer, + contentDescription = stringResource(R.string.ask_again), + ) + } }, ) } @@ -292,11 +269,15 @@ fun SamplePromptScreenPreviewMarkdown() { ), ), promptEntry = { - Button( - imageVector = Icons.Default.QuestionAnswer, - contentDescription = "Ask Again", + EdgeButton( onClick = { }, - ) + buttonSize = EdgeButtonSize.ExtraSmall, + ) { + Icon( + imageVector = Icons.Default.QuestionAnswer, + contentDescription = stringResource(R.string.ask_again), + ) + } }, ) } diff --git a/ai/sample/wear-prompt-app/src/main/java/com/google/android/horologist/ai/sample/wear/prompt/prompt/SamplePromptViewModel.kt b/ai/sample/wear-prompt-app/src/main/java/com/google/android/horologist/ai/sample/wear/prompt/prompt/SamplePromptViewModel.kt index b92c21e456..caac4abb08 100644 --- a/ai/sample/wear-prompt-app/src/main/java/com/google/android/horologist/ai/sample/wear/prompt/prompt/SamplePromptViewModel.kt +++ b/ai/sample/wear-prompt-app/src/main/java/com/google/android/horologist/ai/sample/wear/prompt/prompt/SamplePromptViewModel.kt @@ -22,16 +22,22 @@ import com.google.android.horologist.ai.core.InferenceService import com.google.android.horologist.ai.core.prompt import com.google.android.horologist.ai.core.textPrompt import com.google.android.horologist.ai.ui.model.FailedResponseUiModel +import com.google.android.horologist.ai.ui.model.ImageResponseUiModel import com.google.android.horologist.ai.ui.model.ModelInstanceUiModel import com.google.android.horologist.ai.ui.model.PromptOrResponseUiModel import com.google.android.horologist.ai.ui.model.TextPromptUiModel import com.google.android.horologist.ai.ui.model.TextResponseUiModel import com.google.android.horologist.ai.ui.screens.PromptUiState import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -44,43 +50,74 @@ class SamplePromptViewModel private val inferenceService: InferenceService, ) : ViewModel() { + init { + viewModelScope.launch { + val current = inferenceService.connectedModel.first() + if (current == null) { + val defaultModel = inferenceService.currentKnownModels().firstOrNull() + if (defaultModel != null) { + inferenceService.selectModel(defaultModel) + } + } + } + } + private val previousQuestions: MutableStateFlow> = MutableStateFlow(listOf()) - private val pendingQuestion: MutableStateFlow = - MutableStateFlow(null) + private val pendingQuestion: MutableStateFlow = + MutableStateFlow(false) fun askQuestion(enteredPrompt: String) { val textPromptUiModel = TextPromptUiModel(enteredPrompt) - pendingQuestion.value = textPromptUiModel + pendingQuestion.value = true + previousQuestions.update { + it + textPromptUiModel + } viewModelScope.launch { - val responseUi = queryForPrompt(enteredPrompt) + val responseUis = queryForPrompt(enteredPrompt) - previousQuestions.update { - it + listOf(textPromptUiModel, responseUi) + responseUis.collect { responseUi -> + previousQuestions.update { + it + responseUi + } } - pendingQuestion.value = null + pendingQuestion.value = false } } - private suspend fun queryForPrompt( + private fun queryForPrompt( enteredPrompt: String, - ): PromptOrResponseUiModel { - return try { - val response = inferenceService.submit( - prompt { - textPrompt = textPrompt { text = enteredPrompt } - }, - ) + ): Flow { + return flow { + try { + val responses = inferenceService.submitStream( + prompt { + textPrompt = textPrompt { text = enteredPrompt } + }, + ) + + emitAll( + responses.map { response -> + when { + response.hasTextResponse() -> TextResponseUiModel(response.textResponse.text) + response.hasImageResponse() -> if (response.imageResponse.hasGcsUrl()) { + ImageResponseUiModel( + imageUrl = response.imageResponse.gcsUrl, + ) + } else { + ImageResponseUiModel(image = response.imageResponse.encoded.toByteArray()) + } - when { - response.hasTextResponse() -> TextResponseUiModel(response.textResponse.text) - response.hasFailure() -> FailedResponseUiModel(response.failure.message) - else -> FailedResponseUiModel("Unhandled response type ${response.responseDataCase}") + response.hasFailure() -> FailedResponseUiModel(response.failure.message) + else -> FailedResponseUiModel("Unhandled response type ${response.responseDataCase}") + } + }, + ) + } catch (e: Exception) { + emit(FailedResponseUiModel(e.toString())) } - } catch (e: Exception) { - FailedResponseUiModel(e.toString()) } } @@ -89,9 +126,9 @@ class SamplePromptViewModel previousQuestions, pendingQuestion, inferenceService.currentModelInfo, - ) { prev, curr, info -> + ) { prev, pending, info -> val modelInfo = info?.first?.let { ModelInstanceUiModel(it.modelId.id, it.name) } - PromptUiState(modelInfo, prev, curr) + PromptUiState(modelInfo, prev, pending) }.stateIn( viewModelScope, started = SharingStarted.WhileSubscribed(5000), diff --git a/ai/sample/wear-prompt-app/src/main/java/com/google/android/horologist/ai/sample/wear/prompt/settings/SettingsScreen.kt b/ai/sample/wear-prompt-app/src/main/java/com/google/android/horologist/ai/sample/wear/prompt/settings/SettingsScreen.kt index cc152d47fd..80e561076b 100644 --- a/ai/sample/wear-prompt-app/src/main/java/com/google/android/horologist/ai/sample/wear/prompt/settings/SettingsScreen.kt +++ b/ai/sample/wear-prompt-app/src/main/java/com/google/android/horologist/ai/sample/wear/prompt/settings/SettingsScreen.kt @@ -14,32 +14,34 @@ * limitations under the License. */ -@file:OptIn(ExperimentalWearMaterialApi::class) - package com.google.android.horologist.ai.sample.wear.prompt.settings +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.key import androidx.compose.ui.Modifier -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.wear.compose.foundation.lazy.TransformingLazyColumn import androidx.wear.compose.foundation.lazy.items -import androidx.wear.compose.material.ExperimentalWearMaterialApi -import androidx.wear.compose.material.Text +import androidx.wear.compose.foundation.lazy.rememberTransformingLazyColumnState +import androidx.wear.compose.material3.ListHeader +import androidx.wear.compose.material3.ListSubHeader +import androidx.wear.compose.material3.RadioButton +import androidx.wear.compose.material3.ScreenScaffold +import androidx.wear.compose.material3.SurfaceTransformation +import androidx.wear.compose.material3.Text +import androidx.wear.compose.material3.lazy.rememberTransformationSpec +import androidx.wear.compose.material3.lazy.transformedHeight +import androidx.wear.compose.material3.placeholder +import androidx.wear.compose.material3.placeholderShimmer +import androidx.wear.compose.material3.rememberPlaceholderState import androidx.wear.compose.ui.tooling.preview.WearPreviewLargeRound +import com.google.android.horologist.ai.sample.wear.geminilib.BuildConfig import com.google.android.horologist.ai.ui.model.ModelInstanceUiModel -import com.google.android.horologist.composables.PlaceholderChip -import com.google.android.horologist.compose.layout.ScalingLazyColumn -import com.google.android.horologist.compose.layout.ScalingLazyColumnDefaults.ItemType -import com.google.android.horologist.compose.layout.ScalingLazyColumnDefaults.listTextPadding -import com.google.android.horologist.compose.layout.ScalingLazyColumnDefaults.padding -import com.google.android.horologist.compose.layout.ScreenScaffold -import com.google.android.horologist.compose.layout.rememberActivePlaceholderState -import com.google.android.horologist.compose.layout.rememberResponsiveColumnState -import com.google.android.horologist.compose.material.ResponsiveListHeader -import com.google.android.horologist.compose.material.ToggleChip -import com.google.android.horologist.compose.material.ToggleChipToggleControl +import com.google.android.horologist.compose.layout.ColumnItemType +import com.google.android.horologist.compose.layout.rememberResponsiveColumnPadding @Composable fun SettingsScreen( @@ -61,42 +63,78 @@ private fun SettingsScreen( modifier: Modifier = Modifier, selectModel: (ModelInstanceUiModel) -> Unit, ) { - val columnState = rememberResponsiveColumnState( - contentPadding = padding( - first = ItemType.Text, - last = ItemType.Chip, - ), + val transformationSpec = rememberTransformationSpec() + val columnState = rememberTransformingLazyColumnState() + val contentPadding = rememberResponsiveColumnPadding( + first = ColumnItemType.ListHeader, + last = ColumnItemType.Button, ) - val placeholderState = rememberActivePlaceholderState { uiState.models != null } + val placeholderState = rememberPlaceholderState(uiState.models == null) - ScreenScaffold(scrollState = columnState, modifier = modifier) { - ScalingLazyColumn(columnState = columnState) { + ScreenScaffold( + scrollState = columnState, + modifier = modifier, + contentPadding = contentPadding, + ) { contentPadding -> + TransformingLazyColumn(state = columnState, contentPadding = contentPadding) { + item { + ListHeader( + modifier = Modifier + .fillMaxWidth() + .transformedHeight(this, transformationSpec), + transformation = SurfaceTransformation(transformationSpec), + ) { + Text("Browse") + } + } if (uiState.models == null) { items(3) { - PlaceholderChip( - placeholderState = placeholderState, - icon = false, - secondaryLabel = false, + RadioButton( + modifier = Modifier + .fillMaxWidth() + .transformedHeight(this, transformationSpec) + .placeholderShimmer(placeholderState), + selected = false, + onSelect = {}, + label = { + Text( + " ", + modifier = Modifier.placeholder(placeholderState), + ) + }, + transformation = SurfaceTransformation(transformationSpec), ) } } else { - item { - ResponsiveListHeader(modifier = Modifier.listTextPadding()) { - Text("Browse") - } - } items(uiState.models) { model -> key(model.id) { - ToggleChip( - checked = model == uiState.current, - onCheckedChanged = { selectModel(model) }, - label = model.name, - toggleControl = ToggleChipToggleControl.Radio, + RadioButton( + modifier = Modifier + .fillMaxWidth() + .transformedHeight(this, transformationSpec), + selected = model == uiState.current, + onSelect = { selectModel(model) }, + label = { Text(model.name) }, + secondaryLabel = model.service?.let { { Text(it) } }, + transformation = SurfaceTransformation(transformationSpec), ) } } } + item { + ListSubHeader( + modifier = Modifier + .fillMaxWidth() + .transformedHeight(this, transformationSpec), + transformation = SurfaceTransformation(transformationSpec), + ) { + Text("Gemini Key") + } + } + item { + Text(BuildConfig.GEMINI_API_KEY) + } } } } diff --git a/ai/sample/wear-prompt-app/src/main/java/com/google/android/horologist/ai/sample/wear/prompt/settings/SettingsViewModel.kt b/ai/sample/wear-prompt-app/src/main/java/com/google/android/horologist/ai/sample/wear/prompt/settings/SettingsViewModel.kt index d9fcdffd91..c562b30374 100644 --- a/ai/sample/wear-prompt-app/src/main/java/com/google/android/horologist/ai/sample/wear/prompt/settings/SettingsViewModel.kt +++ b/ai/sample/wear-prompt-app/src/main/java/com/google/android/horologist/ai/sample/wear/prompt/settings/SettingsViewModel.kt @@ -40,7 +40,11 @@ class SettingsViewModel ) { current, models -> val uiModels = models?.flatMap { (serviceInfo, _) -> serviceInfo.modelsList.map { modelInfo -> - ModelInstanceUiModel(modelInfo.modelId.id, modelInfo.name).also { + ModelInstanceUiModel( + modelInfo.modelId.id, + modelInfo.name, + serviceInfo.name, + ).also { if (it.id.isBlank()) { throw Exception("Blank id ${modelInfo.name} ") } diff --git a/ai/sample/wear-prompt-app/src/main/res/values/strings.xml b/ai/sample/wear-prompt-app/src/main/res/values/strings.xml index f93c0d4bd8..bd31506348 100644 --- a/ai/sample/wear-prompt-app/src/main/res/values/strings.xml +++ b/ai/sample/wear-prompt-app/src/main/res/values/strings.xml @@ -17,4 +17,5 @@ Horologist AI Prompt Prompt + Ask Again \ No newline at end of file diff --git a/ai/ui/api/current.api b/ai/ui/api/current.api index 26dd5e8c3a..333114fc02 100644 --- a/ai/ui/api/current.api +++ b/ai/ui/api/current.api @@ -2,17 +2,18 @@ package com.google.android.horologist.ai.ui.components { public final class PromptDisplayKt { - method @androidx.compose.runtime.Composable public static void TextPromptDisplay(com.google.android.horologist.ai.ui.model.TextPromptUiModel prompt, optional androidx.compose.ui.Modifier modifier, optional kotlin.jvm.functions.Function0 onClick); + method @androidx.compose.runtime.Composable public static void TextPromptDisplay(com.google.android.horologist.ai.ui.model.TextPromptUiModel prompt, optional androidx.compose.ui.Modifier modifier, optional kotlin.jvm.functions.Function0? onClick, optional androidx.wear.compose.material3.SurfaceTransformation? transformation); } public final class PromptOrResponseDisplayKt { - method @androidx.compose.runtime.Composable public static void PromptOrResponseDisplay(com.google.android.horologist.ai.ui.model.PromptOrResponseUiModel promptResponse, optional androidx.compose.ui.Modifier modifier, optional kotlin.jvm.functions.Function0 onClick); + method @androidx.compose.runtime.Composable public static void PromptOrResponseDisplay(com.google.android.horologist.ai.ui.model.PromptOrResponseUiModel promptResponse, optional androidx.compose.ui.Modifier modifier, optional kotlin.jvm.functions.Function0? onClick, optional androidx.wear.compose.material3.SurfaceTransformation? transformation); } public final class ResponseDisplayKt { - method @androidx.compose.runtime.Composable public static void FailedResponseChip(com.google.android.horologist.ai.ui.model.FailedResponseUiModel answer, optional androidx.compose.ui.Modifier modifier); - method @androidx.compose.runtime.Composable public static void ResponseInProgressCard(com.google.android.horologist.ai.ui.model.InProgressResponseUiModel inProgress, optional androidx.compose.ui.Modifier modifier); - method @androidx.compose.runtime.Composable public static void TextResponseCard(com.google.android.horologist.ai.ui.model.TextResponseUiModel textResponseUiModel, optional androidx.compose.ui.Modifier modifier, optional kotlin.jvm.functions.Function0 onClick); + method @androidx.compose.runtime.Composable public static void FailedResponseChip(com.google.android.horologist.ai.ui.model.FailedResponseUiModel answer, optional androidx.compose.ui.Modifier modifier, optional kotlin.jvm.functions.Function0? onClick, optional androidx.wear.compose.material3.SurfaceTransformation? transformation); + method @androidx.compose.runtime.Composable public static void ImageResponseCard(com.google.android.horologist.ai.ui.model.ImageResponseUiModel imageResponseUiModel, optional androidx.compose.ui.Modifier modifier, optional kotlin.jvm.functions.Function0? onClick, optional androidx.wear.compose.material3.SurfaceTransformation? transformation); + method @androidx.compose.runtime.Composable public static void ResponseInProgressCard(com.google.android.horologist.ai.ui.model.InProgressResponseUiModel inProgress, optional androidx.compose.ui.Modifier modifier, optional kotlin.jvm.functions.Function0? onClick, optional androidx.wear.compose.material3.SurfaceTransformation? transformation); + method @androidx.compose.runtime.Composable public static void TextResponseCard(com.google.android.horologist.ai.ui.model.TextResponseUiModel textResponseUiModel, optional androidx.compose.ui.Modifier modifier, optional kotlin.jvm.functions.Function0? onClick, optional androidx.wear.compose.material3.SurfaceTransformation? transformation); } } @@ -32,19 +33,33 @@ package com.google.android.horologist.ai.ui.model { method public com.google.android.horologist.ai.ui.model.FailedResponseUiModel NoCompanion(android.content.Context context); } + public final class ImageResponseUiModel implements com.google.android.horologist.ai.ui.model.ResponseUiModel { + ctor public ImageResponseUiModel(optional String? imageUrl, optional byte[]? image); + method public String? component1(); + method public byte[]? component2(); + method public com.google.android.horologist.ai.ui.model.ImageResponseUiModel copy(String? imageUrl, byte[]? image); + method public byte[]? getImage(); + method public String? getImageUrl(); + property public final byte[]? image; + property public final String? imageUrl; + } + public final class InProgressResponseUiModel implements com.google.android.horologist.ai.ui.model.ResponseUiModel { field public static final com.google.android.horologist.ai.ui.model.InProgressResponseUiModel INSTANCE; } public final class ModelInstanceUiModel { - ctor public ModelInstanceUiModel(String id, String name); + ctor public ModelInstanceUiModel(String id, String name, optional String? service); method public String component1(); method public String component2(); - method public com.google.android.horologist.ai.ui.model.ModelInstanceUiModel copy(String id, String name); + method public String? component3(); + method public com.google.android.horologist.ai.ui.model.ModelInstanceUiModel copy(String id, String name, String? service); method public String getId(); method public String getName(); + method public String? getService(); property public final String id; property public final String name; + property public final String? service; } public sealed interface PromptOrResponseUiModel { @@ -80,21 +95,21 @@ package com.google.android.horologist.ai.ui.model { package com.google.android.horologist.ai.ui.screens { public final class PromptScreenKt { - method @androidx.compose.runtime.Composable public static void PromptScreen(com.google.android.horologist.ai.ui.screens.PromptUiState uiState, optional androidx.compose.ui.Modifier modifier, optional kotlin.jvm.functions.Function0? onSettingsClick, optional kotlin.jvm.functions.Function1 promptDisplay, kotlin.jvm.functions.Function0 promptEntry); + method @androidx.compose.runtime.Composable public static void PromptScreen(com.google.android.horologist.ai.ui.screens.PromptUiState uiState, optional androidx.compose.ui.Modifier modifier, optional kotlin.jvm.functions.Function0? onSettingsClick, optional kotlin.jvm.functions.Function3 promptDisplay, kotlin.jvm.functions.Function1 promptEntry); } public final class PromptUiState { - ctor public PromptUiState(optional com.google.android.horologist.ai.ui.model.ModelInstanceUiModel? modelInfo, optional java.util.List messages, optional com.google.android.horologist.ai.ui.model.TextPromptUiModel? inProgress); + ctor public PromptUiState(optional com.google.android.horologist.ai.ui.model.ModelInstanceUiModel? modelInfo, optional java.util.List messages, optional boolean pending); method public com.google.android.horologist.ai.ui.model.ModelInstanceUiModel? component1(); method public java.util.List component2(); - method public com.google.android.horologist.ai.ui.model.TextPromptUiModel? component3(); - method public com.google.android.horologist.ai.ui.screens.PromptUiState copy(com.google.android.horologist.ai.ui.model.ModelInstanceUiModel? modelInfo, java.util.List messages, com.google.android.horologist.ai.ui.model.TextPromptUiModel? inProgress); - method public com.google.android.horologist.ai.ui.model.TextPromptUiModel? getInProgress(); + method public boolean component3(); + method public com.google.android.horologist.ai.ui.screens.PromptUiState copy(com.google.android.horologist.ai.ui.model.ModelInstanceUiModel? modelInfo, java.util.List messages, boolean pending); method public java.util.List getMessages(); method public com.google.android.horologist.ai.ui.model.ModelInstanceUiModel? getModelInfo(); - property public final com.google.android.horologist.ai.ui.model.TextPromptUiModel? inProgress; + method public boolean getPending(); property public final java.util.List messages; property public final com.google.android.horologist.ai.ui.model.ModelInstanceUiModel? modelInfo; + property public final boolean pending; } } diff --git a/ai/ui/build.gradle.kts b/ai/ui/build.gradle.kts index 49f5951e1c..6919ad91b4 100644 --- a/ai/ui/build.gradle.kts +++ b/ai/ui/build.gradle.kts @@ -91,14 +91,17 @@ metalava { dependencies { api(projects.annotations) - api(libs.wearcompose.material) + implementation(platform(libs.compose.bom)) + api(libs.androidx.wear.compose.material3) api(libs.wearcompose.foundation) implementation(libs.compose.material.iconscore) implementation(libs.compose.material.iconsext) api(projects.composeLayout) - api(projects.composeMaterial) implementation(libs.androidx.wear) + implementation(libs.coil) + implementation(libs.coil.base) + implementation(libs.coil.svg) debugImplementation(projects.composeTools) debugImplementation(libs.compose.ui.tooling) diff --git a/ai/ui/src/main/java/com/google/android/horologist/ai/ui/components/PromptDisplay.kt b/ai/ui/src/main/java/com/google/android/horologist/ai/ui/components/PromptDisplay.kt index ec05e5f550..4c7f6158eb 100644 --- a/ai/ui/src/main/java/com/google/android/horologist/ai/ui/components/PromptDisplay.kt +++ b/ai/ui/src/main/java/com/google/android/horologist/ai/ui/components/PromptDisplay.kt @@ -19,10 +19,11 @@ package com.google.android.horologist.ai.ui.components import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.wear.compose.material.Card -import androidx.wear.compose.material.CardDefaults -import androidx.wear.compose.material.MaterialTheme -import androidx.wear.compose.material.Text +import androidx.wear.compose.material3.Card +import androidx.wear.compose.material3.CardDefaults +import androidx.wear.compose.material3.MaterialTheme +import androidx.wear.compose.material3.SurfaceTransformation +import androidx.wear.compose.material3.Text import com.google.android.horologist.ai.ui.model.TextPromptUiModel /** @@ -32,16 +33,18 @@ import com.google.android.horologist.ai.ui.model.TextPromptUiModel public fun TextPromptDisplay( prompt: TextPromptUiModel, modifier: Modifier = Modifier, - onClick: () -> Unit = {}, + onClick: (() -> Unit)? = null, + transformation: SurfaceTransformation? = null, ) { Card( modifier = modifier.fillMaxWidth(), - onClick = onClick, - backgroundPainter = CardDefaults.cardBackgroundPainter( - MaterialTheme.colors.primaryVariant, - MaterialTheme.colors.primaryVariant, + onClick = onClick ?: {}, + colors = CardDefaults.cardColors( + contentColor = MaterialTheme.colorScheme.secondary, + containerColor = MaterialTheme.colorScheme.secondaryContainer, ), + transformation = transformation, ) { - Text(text = prompt.prompt, color = MaterialTheme.colors.surface) + Text(text = prompt.prompt) } } diff --git a/ai/ui/src/main/java/com/google/android/horologist/ai/ui/components/PromptOrResponseDisplay.kt b/ai/ui/src/main/java/com/google/android/horologist/ai/ui/components/PromptOrResponseDisplay.kt index 5ca78f3f05..5e4ce8ee93 100644 --- a/ai/ui/src/main/java/com/google/android/horologist/ai/ui/components/PromptOrResponseDisplay.kt +++ b/ai/ui/src/main/java/com/google/android/horologist/ai/ui/components/PromptOrResponseDisplay.kt @@ -18,7 +18,9 @@ package com.google.android.horologist.ai.ui.components import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.wear.compose.material3.SurfaceTransformation import com.google.android.horologist.ai.ui.model.FailedResponseUiModel +import com.google.android.horologist.ai.ui.model.ImageResponseUiModel import com.google.android.horologist.ai.ui.model.InProgressResponseUiModel import com.google.android.horologist.ai.ui.model.PromptOrResponseUiModel import com.google.android.horologist.ai.ui.model.TextPromptUiModel @@ -31,23 +33,28 @@ import com.google.android.horologist.ai.ui.model.TextResponseUiModel public fun PromptOrResponseDisplay( promptResponse: PromptOrResponseUiModel, modifier: Modifier = Modifier, - onClick: () -> Unit = {}, + onClick: (() -> Unit)? = null, + transformation: SurfaceTransformation? = null, ) { when (promptResponse) { is TextResponseUiModel -> { - TextResponseCard(promptResponse, onClick = onClick, modifier = modifier) + TextResponseCard(promptResponse, onClick = onClick, modifier = modifier, transformation = transformation) + } + + is ImageResponseUiModel -> { + ImageResponseCard(promptResponse, onClick = onClick, modifier = modifier, transformation = transformation) } is FailedResponseUiModel -> { - FailedResponseChip(promptResponse, modifier = modifier) + FailedResponseChip(promptResponse, onClick = onClick, modifier = modifier, transformation = transformation) } is InProgressResponseUiModel -> { - ResponseInProgressCard(promptResponse, modifier = modifier) + ResponseInProgressCard(promptResponse, onClick = onClick, modifier = modifier, transformation = transformation) } is TextPromptUiModel -> { - TextPromptDisplay(prompt = promptResponse, modifier = modifier) + TextPromptDisplay(prompt = promptResponse, onClick = onClick, modifier = modifier, transformation = transformation) } } } diff --git a/ai/ui/src/main/java/com/google/android/horologist/ai/ui/components/ResponseDisplay.kt b/ai/ui/src/main/java/com/google/android/horologist/ai/ui/components/ResponseDisplay.kt index ea8f5a463e..a79fa166a9 100644 --- a/ai/ui/src/main/java/com/google/android/horologist/ai/ui/components/ResponseDisplay.kt +++ b/ai/ui/src/main/java/com/google/android/horologist/ai/ui/components/ResponseDisplay.kt @@ -16,16 +16,20 @@ package com.google.android.horologist.ai.ui.components +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.wear.compose.material.Card -import androidx.wear.compose.material.CardDefaults -import androidx.wear.compose.material.CircularProgressIndicator -import androidx.wear.compose.material.MaterialTheme -import androidx.wear.compose.material.Text +import androidx.wear.compose.material3.Card +import androidx.wear.compose.material3.CardDefaults +import androidx.wear.compose.material3.CircularProgressIndicator +import androidx.wear.compose.material3.MaterialTheme +import androidx.wear.compose.material3.SurfaceTransformation +import androidx.wear.compose.material3.Text +import coil.compose.AsyncImage import com.google.android.horologist.ai.ui.model.FailedResponseUiModel +import com.google.android.horologist.ai.ui.model.ImageResponseUiModel import com.google.android.horologist.ai.ui.model.InProgressResponseUiModel import com.google.android.horologist.ai.ui.model.TextResponseUiModel @@ -33,11 +37,13 @@ import com.google.android.horologist.ai.ui.model.TextResponseUiModel public fun FailedResponseChip( answer: FailedResponseUiModel, modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null, + transformation: SurfaceTransformation? = null, ) { Text( text = answer.message, modifier = modifier, - color = MaterialTheme.colors.error, + color = MaterialTheme.colorScheme.error, ) } @@ -45,24 +51,42 @@ public fun FailedResponseChip( public fun TextResponseCard( textResponseUiModel: TextResponseUiModel, modifier: Modifier = Modifier, - onClick: () -> Unit = {}, + onClick: (() -> Unit)? = null, + transformation: SurfaceTransformation? = null, ) { Card( modifier = modifier.fillMaxWidth(), - onClick = onClick, - backgroundPainter = CardDefaults.cardBackgroundPainter( - MaterialTheme.colors.surface, - MaterialTheme.colors.surface, + onClick = onClick ?: {}, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + contentColor = MaterialTheme.colorScheme.onSurface, ), + transformation = transformation, ) { - Text(text = textResponseUiModel.text, color = MaterialTheme.colors.onSurface, style = MaterialTheme.typography.body2) + Text(text = textResponseUiModel.text, style = MaterialTheme.typography.bodyMedium) } } +@Composable +public fun ImageResponseCard( + imageResponseUiModel: ImageResponseUiModel, + modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null, + transformation: SurfaceTransformation? = null, +) { + AsyncImage( + modifier = modifier.clickable(enabled = onClick != null, onClick = onClick ?: {}), + model = imageResponseUiModel.imageUrl ?: imageResponseUiModel.image, + contentDescription = null, + ) +} + @Composable public fun ResponseInProgressCard( @Suppress("UNUSED_PARAMETER") inProgress: InProgressResponseUiModel, modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null, + transformation: SurfaceTransformation? = null, ) { Box(modifier = modifier) { CircularProgressIndicator() diff --git a/ai/ui/src/main/java/com/google/android/horologist/ai/ui/model/ModelInstanceUiModel.kt b/ai/ui/src/main/java/com/google/android/horologist/ai/ui/model/ModelInstanceUiModel.kt index 1ca019daf1..71ae1a9db6 100644 --- a/ai/ui/src/main/java/com/google/android/horologist/ai/ui/model/ModelInstanceUiModel.kt +++ b/ai/ui/src/main/java/com/google/android/horologist/ai/ui/model/ModelInstanceUiModel.kt @@ -19,4 +19,5 @@ package com.google.android.horologist.ai.ui.model public data class ModelInstanceUiModel( val id: String, val name: String, + val service: String? = null, ) diff --git a/ai/ui/src/main/java/com/google/android/horologist/ai/ui/model/ResponseUiModel.kt b/ai/ui/src/main/java/com/google/android/horologist/ai/ui/model/ResponseUiModel.kt index f744364af1..65d98a9bbc 100644 --- a/ai/ui/src/main/java/com/google/android/horologist/ai/ui/model/ResponseUiModel.kt +++ b/ai/ui/src/main/java/com/google/android/horologist/ai/ui/model/ResponseUiModel.kt @@ -23,3 +23,8 @@ public object InProgressResponseUiModel : ResponseUiModel public data class TextResponseUiModel( val text: String, ) : ResponseUiModel + +public data class ImageResponseUiModel( + val imageUrl: String? = null, + val image: ByteArray? = null, +) : ResponseUiModel diff --git a/ai/ui/src/main/java/com/google/android/horologist/ai/ui/screens/PromptScreen.kt b/ai/ui/src/main/java/com/google/android/horologist/ai/ui/screens/PromptScreen.kt index f3eea0b498..7d4a925b28 100644 --- a/ai/ui/src/main/java/com/google/android/horologist/ai/ui/screens/PromptScreen.kt +++ b/ai/ui/src/main/java/com/google/android/horologist/ai/ui/screens/PromptScreen.kt @@ -23,24 +23,32 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Settings import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import androidx.wear.compose.material.ListHeader -import androidx.wear.compose.material.Text +import androidx.wear.compose.foundation.lazy.TransformingLazyColumn +import androidx.wear.compose.foundation.lazy.rememberTransformingLazyColumnState +import androidx.wear.compose.material3.Icon +import androidx.wear.compose.material3.IconButton +import androidx.wear.compose.material3.IconButtonDefaults +import androidx.wear.compose.material3.ListHeader +import androidx.wear.compose.material3.ScreenScaffold +import androidx.wear.compose.material3.SurfaceTransformation +import androidx.wear.compose.material3.Text +import androidx.wear.compose.material3.lazy.TransformationSpec +import androidx.wear.compose.material3.lazy.rememberTransformationSpec +import androidx.wear.compose.material3.lazy.transformedHeight +import androidx.wear.compose.material3.touchTargetAwareSize import com.google.android.horologist.ai.ui.R import com.google.android.horologist.ai.ui.components.PromptOrResponseDisplay import com.google.android.horologist.ai.ui.components.ResponseInProgressCard -import com.google.android.horologist.ai.ui.components.TextPromptDisplay import com.google.android.horologist.ai.ui.model.InProgressResponseUiModel import com.google.android.horologist.ai.ui.model.PromptOrResponseUiModel import com.google.android.horologist.ai.ui.model.PromptUiModel import com.google.android.horologist.ai.ui.model.ResponseUiModel -import com.google.android.horologist.compose.layout.ScalingLazyColumn -import com.google.android.horologist.compose.layout.ScalingLazyColumnDefaults -import com.google.android.horologist.compose.layout.ScreenScaffold -import com.google.android.horologist.compose.layout.rememberResponsiveColumnState -import com.google.android.horologist.compose.material.Button +import com.google.android.horologist.compose.layout.ColumnItemType +import com.google.android.horologist.compose.layout.rememberResponsiveColumnPadding /** * A screen to display metrics, e.g. workout metrics. @@ -52,29 +60,61 @@ public fun PromptScreen( uiState: PromptUiState, modifier: Modifier = Modifier, onSettingsClick: (() -> Unit)? = null, - promptDisplay: @Composable (PromptOrResponseUiModel) -> Unit = { + promptDisplay: @Composable (PromptOrResponseUiModel, Modifier, SurfaceTransformation) -> Unit = { model, modifier, transformation -> PromptOrResponseDisplay( - promptResponse = it, + promptResponse = model, onClick = {}, + modifier = modifier, + transformation = transformation, ) }, - promptEntry: @Composable () -> Unit, + promptEntry: @Composable (Boolean) -> Unit, ) { - val columnState = rememberResponsiveColumnState( - contentPadding = ScalingLazyColumnDefaults.padding( - first = ScalingLazyColumnDefaults.ItemType.Text, - last = ScalingLazyColumnDefaults.ItemType.Chip, - ), + val transformationSpec: TransformationSpec = rememberTransformationSpec() + val columnState = rememberTransformingLazyColumnState() + val contentPadding = rememberResponsiveColumnPadding( + first = ColumnItemType.ListHeader, + last = ColumnItemType.Button, ) - ScreenScaffold(scrollState = columnState) { - ScalingLazyColumn(columnState = columnState, modifier = modifier) { + ScreenScaffold( + scrollState = columnState, + contentPadding = contentPadding, + edgeButton = { promptEntry(uiState.pending) }, + ) { contentPadding -> + TransformingLazyColumn( + state = columnState, + modifier = modifier, + contentPadding = contentPadding, + ) { item { - ListHeader(modifier = Modifier.fillMaxWidth(0.8f)) { - Text( - text = uiState.modelInfo?.name - ?: stringResource(R.string.horologist_unknown_model), - ) + ListHeader( + modifier = Modifier + .fillMaxWidth() + .transformedHeight(this, transformationSpec), + transformation = SurfaceTransformation(transformationSpec), + ) { + Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + Text( + text = uiState.modelInfo?.name + ?: stringResource(R.string.horologist_unknown_model), + modifier = Modifier.fillMaxWidth(0.6f), + ) + + if (onSettingsClick != null) { + IconButton( + onClick = onSettingsClick, + modifier = Modifier + .touchTargetAwareSize(IconButtonDefaults.ExtraSmallButtonSize) + .align(Alignment.CenterEnd), + ) { + Icon( + imageVector = Icons.Default.Settings, + contentDescription = stringResource(R.string.horologist_settings_content_description), + ) + } + } + } } } uiState.messages.forEach { @@ -84,39 +124,24 @@ public fun PromptScreen( is ResponseUiModel -> PaddingValues(start = 20.dp) else -> PaddingValues() } - Box( - modifier = Modifier + promptDisplay( + it, + Modifier .fillMaxWidth() - .padding(padding), - ) { - promptDisplay(it) - } - } - } - val inProgress = uiState.inProgress - if (inProgress != null) { - item { - TextPromptDisplay( - prompt = inProgress, - onClick = {}, - modifier = Modifier - .fillMaxWidth() - .padding(start = 5.dp, end = 25.dp), + .padding(padding) + .transformedHeight(this, transformationSpec), + SurfaceTransformation(transformationSpec), ) } - item { - ResponseInProgressCard(InProgressResponseUiModel) - } } - item { - promptEntry() - } - if (onSettingsClick != null) { + val pending = uiState.pending + if (pending) { item { - Button( - Icons.Default.Settings, - contentDescription = stringResource(R.string.horologist_settings_content_description), - onClick = onSettingsClick, + ResponseInProgressCard( + InProgressResponseUiModel, + transformation = SurfaceTransformation(transformationSpec), + modifier = Modifier + .transformedHeight(this, transformationSpec), ) } } diff --git a/ai/ui/src/main/java/com/google/android/horologist/ai/ui/screens/PromptUiState.kt b/ai/ui/src/main/java/com/google/android/horologist/ai/ui/screens/PromptUiState.kt index 4a342ad68e..a0b6832747 100644 --- a/ai/ui/src/main/java/com/google/android/horologist/ai/ui/screens/PromptUiState.kt +++ b/ai/ui/src/main/java/com/google/android/horologist/ai/ui/screens/PromptUiState.kt @@ -18,10 +18,9 @@ package com.google.android.horologist.ai.ui.screens import com.google.android.horologist.ai.ui.model.ModelInstanceUiModel import com.google.android.horologist.ai.ui.model.PromptOrResponseUiModel -import com.google.android.horologist.ai.ui.model.TextPromptUiModel public data class PromptUiState( val modelInfo: ModelInstanceUiModel? = null, val messages: List = listOf(), - val inProgress: TextPromptUiModel? = null, + val pending: Boolean = false, ) diff --git a/ai/ui/src/test/java/com/google/android/horologist/ai/composables/components/PromptScreenTest.kt b/ai/ui/src/test/java/com/google/android/horologist/ai/composables/components/PromptScreenTest.kt index 3185a5d5f6..ebe982016d 100644 --- a/ai/ui/src/test/java/com/google/android/horologist/ai/composables/components/PromptScreenTest.kt +++ b/ai/ui/src/test/java/com/google/android/horologist/ai/composables/components/PromptScreenTest.kt @@ -18,12 +18,14 @@ package com.google.android.horologist.ai.composables.components import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.KeyboardVoice +import androidx.wear.compose.material3.EdgeButton +import androidx.wear.compose.material3.EdgeButtonSize +import androidx.wear.compose.material3.Icon import com.google.android.horologist.ai.ui.model.ModelInstanceUiModel import com.google.android.horologist.ai.ui.model.TextPromptUiModel import com.google.android.horologist.ai.ui.model.TextResponseUiModel import com.google.android.horologist.ai.ui.screens.PromptScreen import com.google.android.horologist.ai.ui.screens.PromptUiState -import com.google.android.horologist.compose.material.Button import com.google.android.horologist.screenshots.rng.WearLegacyScreenTest import org.junit.Test @@ -40,11 +42,15 @@ class PromptScreenTest : WearLegacyScreenTest() { ), ), promptEntry = { - Button( - imageVector = Icons.Default.KeyboardVoice, - contentDescription = "Voice Prompt", + EdgeButton( onClick = { /*TODO*/ }, - ) + buttonSize = EdgeButtonSize.ExtraSmall, + ) { + Icon( + imageVector = Icons.Default.KeyboardVoice, + contentDescription = "Voice Prompt", + ) + } }, ) } diff --git a/ai/ui/src/test/snapshots/images/com.google.android.horologist.ai.composables.components_PromptScreenTest_empty.png b/ai/ui/src/test/snapshots/images/com.google.android.horologist.ai.composables.components_PromptScreenTest_empty.png index a2e98e2a41..70f03b99b7 100644 --- a/ai/ui/src/test/snapshots/images/com.google.android.horologist.ai.composables.components_PromptScreenTest_empty.png +++ b/ai/ui/src/test/snapshots/images/com.google.android.horologist.ai.composables.components_PromptScreenTest_empty.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f6e20f405daf9685089c3600f0e4b71f9cb488f53ea520a6fb26be37e1561b6a -size 30811 +oid sha256:c425aaf50f4ee6bec190300687c251b860618b31e8fdf99dec6aad6e8ab91500 +size 31232 diff --git a/auth/composables-material3/README.md b/auth/composables-material3/README.md new file mode 100644 index 0000000000..5d86b8f476 --- /dev/null +++ b/auth/composables-material3/README.md @@ -0,0 +1,17 @@ +# Auth Composables library + +[![Maven Central](https://img.shields.io/maven-central/v/com.google.android.horologist/horologist-auth-composables-material3)](https://search.maven.org/search?q=g:com.google.android.horologist) + +For more information, visit the documentation: https://google.github.io/horologist/auth-composables-material3 + +## Download + +```groovy +repositories { + mavenCentral() +} + +dependencies { + implementation "com.google.android.horologist:horologist-auth-composables-material3:" +} +``` diff --git a/auth/composables-material3/api/current.api b/auth/composables-material3/api/current.api new file mode 100644 index 0000000000..a824a9aae3 --- /dev/null +++ b/auth/composables-material3/api/current.api @@ -0,0 +1,32 @@ +// Signature format: 4.0 +package com.google.android.horologist.auth.composables.material3.models { + + public final class AccountUiModel { + ctor public AccountUiModel(String email, String name, optional com.google.android.horologist.images.base.paintable.Paintable? avatar); + method public String component1(); + method public String component2(); + method public com.google.android.horologist.images.base.paintable.Paintable? component3(); + method public com.google.android.horologist.auth.composables.material3.models.AccountUiModel copy(String email, String name, com.google.android.horologist.images.base.paintable.Paintable? avatar); + method public com.google.android.horologist.images.base.paintable.Paintable? getAvatar(); + method public String getEmail(); + method public String getName(); + property public final com.google.android.horologist.images.base.paintable.Paintable? avatar; + property public final String email; + property public final String name; + } + +} + +package com.google.android.horologist.auth.composables.material3.screens { + + public final class SelectAccountScreenKt { + method @androidx.compose.runtime.Composable public static void SelectAccountScreen(java.util.List accounts, kotlin.jvm.functions.Function2 onAccountClicked, optional androidx.compose.ui.Modifier modifier, optional String title, optional com.google.android.horologist.images.base.paintable.Paintable defaultAvatar, optional androidx.compose.foundation.layout.PaddingValues contentPadding); + } + + public final class SignedInConfirmationScreenKt { + method @androidx.compose.runtime.Composable public static void SignedInConfirmationScreen(kotlin.jvm.functions.Function0 onDismissOrTimeout, optional androidx.compose.ui.Modifier modifier, com.google.android.horologist.auth.composables.material3.models.AccountUiModel accountUiModel); + method @androidx.compose.runtime.Composable public static void SignedInConfirmationScreen(kotlin.jvm.functions.Function0 onDismissOrTimeout, optional androidx.compose.ui.Modifier modifier, optional String? name, optional String? email, optional com.google.android.horologist.images.base.paintable.Paintable? avatar, optional com.google.android.horologist.images.base.paintable.Paintable defaultAvatar); + } + +} + diff --git a/auth/composables-material3/build.gradle.kts b/auth/composables-material3/build.gradle.kts new file mode 100644 index 0000000000..713c586900 --- /dev/null +++ b/auth/composables-material3/build.gradle.kts @@ -0,0 +1,140 @@ +/* + * 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") + alias(libs.plugins.dokka) + alias(libs.plugins.metalavaGradle) + alias(libs.plugins.dependencyAnalysis) + kotlin("android") + alias(libs.plugins.roborazzi) + alias(libs.plugins.compose.compiler) +} + +android { + + compileSdk = 36 + defaultConfig { + minSdk = 26 + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + buildFeatures { + buildConfig = false + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.majorVersion + freeCompilerArgs += listOf( + "-opt-in=com.google.android.horologist.annotations.ExperimentalHorologistApi", + ) + } + + packaging { + resources { + excludes += + listOf( + "/META-INF/AL2.0", + "/META-INF/LGPL2.1", + ) + } + } + + testOptions { + unitTests { + isIncludeAndroidResources = true + } + animationsDisabled = true + } + + lint { + checkReleaseBuilds = false + disable += listOf("MissingTranslation", "ExtraTranslation") + textReport = true + } + + resourcePrefix = "horologist_" + + namespace = "com.google.android.horologist.auth.composables.material3" +} + +project.tasks.withType().configureEach { + // Workaround for https://youtrack.jetbrains.com/issue/KT-37652 + if (!this.name.endsWith("TestKotlin") && !this.name.startsWith("compileDebug")) { + compilerOptions { + freeCompilerArgs.add("-Xexplicit-api=strict") + } + } +} + +metalava { + excludedSourceSets.setFrom("src/debug/java") + filename.set("api/current.api") +} + +dependencies { + api(projects.composeLayout) + + api(libs.compose.runtime) + api(libs.compose.ui) + + implementation(projects.images.coil) + + implementation(libs.compose.foundation.foundation) + implementation(libs.compose.foundation.foundation.layout) + implementation(libs.compose.material.iconscore) + implementation(libs.compose.material.iconsext) + implementation(libs.compose.material3) + implementation(libs.compose.ui.graphics) + implementation(libs.compose.ui.text) + implementation(libs.compose.ui.unit) + implementation(libs.kotlin.stdlib) + implementation(libs.androidx.wear.compose.material3) + implementation(libs.wearcompose.foundation) + + debugApi(libs.wearcompose.tooling) + debugImplementation(libs.compose.ui.toolingpreview) + + testImplementation(projects.composeTools) + testImplementation(projects.roboscreenshots) + testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) + testRuntimeOnly(libs.robolectric) +} + +dependencyAnalysis { + issues { + onAny { + severity("fail") + } + } +} + +tasks.withType().configureEach { + dokkaSourceSets { + configureEach { + moduleName.set("auth-composables-material3") + } + } +} + +apply(plugin = "com.vanniktech.maven.publish") diff --git a/auth/composables-material3/gradle.properties b/auth/composables-material3/gradle.properties new file mode 100644 index 0000000000..297d0d1d98 --- /dev/null +++ b/auth/composables-material3/gradle.properties @@ -0,0 +1,3 @@ +POM_ARTIFACT_ID=horologist-auth-composables-material3 +POM_NAME=Horologist Auth Composables Material 3 library +POM_PACKAGING=aar diff --git a/auth/composables-material3/src/debug/java/com/google/android/horologist/auth/composables/material3/screens/SelectAccountScreenPreview.kt b/auth/composables-material3/src/debug/java/com/google/android/horologist/auth/composables/material3/screens/SelectAccountScreenPreview.kt new file mode 100644 index 0000000000..4cd35c6cd0 --- /dev/null +++ b/auth/composables-material3/src/debug/java/com/google/android/horologist/auth/composables/material3/screens/SelectAccountScreenPreview.kt @@ -0,0 +1,102 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.horologist.auth.composables.material3.screens + +import androidx.compose.runtime.Composable +import androidx.wear.compose.ui.tooling.preview.WearPreviewDevices +import com.google.android.horologist.auth.composables.material3.R +import com.google.android.horologist.auth.composables.material3.models.AccountUiModel +import com.google.android.horologist.auth.composables.material3.theme.HorologistMaterialTheme +import com.google.android.horologist.images.base.paintable.DrawableResPaintable + +@WearPreviewDevices +@Composable +fun SelectAccountScreenPreview() { + HorologistMaterialTheme { + SelectAccountScreen( + accounts = listOf( + AccountUiModel( + email = "john@example.com", + name = "John Doe", + ), + AccountUiModel( + email = "tim@example.com", + name = "Timothy Andrews", + avatar = DrawableResPaintable(R.drawable.horologist_avatar_small_1), + ), + AccountUiModel( + email = "thisisaverylongemailaccountsample@example.com", + name = "Kim Wong", + avatar = DrawableResPaintable(R.drawable.horologist_avatar_small_2), + ), + ), + onAccountClicked = { _, _ -> }, + title = "Select Account", + ) + } +} + +@WearPreviewDevices +@Composable +fun SelectAccountScreenManyAccountsPreview() { + HorologistMaterialTheme { + SelectAccountScreen( + accounts = listOf( + AccountUiModel( + email = "thisisaverylongemailaccountsample@example.com", + name = "Extenta Namuratus Hereditus III", + ), + AccountUiModel( + email = "timandrews123@example.com", + name = "Timothy Andrews", + ), + AccountUiModel( + email = "john@example.com", + name = "John Doe", + ), + AccountUiModel( + email = "john@example.com", + name = "John Doe", + ), + ), + onAccountClicked = { _, _ -> }, + title = "Select Account", + ) + } +} + +@WearPreviewDevices +@Composable +fun SelectAccountScreenOneLineAccountsPreview() { + HorologistMaterialTheme { + SelectAccountScreen( + accounts = listOf( + AccountUiModel( + email = "john@example.com", + name = "John Doe", + ), + AccountUiModel( + email = "timandrews123@example.com", + name = "Tim Andrews", + avatar = DrawableResPaintable(R.drawable.horologist_avatar_small_1), + ), + ), + onAccountClicked = { _, _ -> }, + title = "Select Account", + ) + } +} diff --git a/auth/composables-material3/src/debug/java/com/google/android/horologist/auth/composables/material3/screens/SignedInConfirmationScreenPreview.kt b/auth/composables-material3/src/debug/java/com/google/android/horologist/auth/composables/material3/screens/SignedInConfirmationScreenPreview.kt new file mode 100644 index 0000000000..10a54d3737 --- /dev/null +++ b/auth/composables-material3/src/debug/java/com/google/android/horologist/auth/composables/material3/screens/SignedInConfirmationScreenPreview.kt @@ -0,0 +1,111 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.horologist.auth.composables.material3.screens + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.wear.compose.ui.tooling.preview.WearPreviewDevices +import com.google.android.horologist.auth.composables.material3.R +import com.google.android.horologist.auth.composables.material3.theme.HorologistMaterialTheme +import com.google.android.horologist.images.base.paintable.DrawableResPaintable + +@WearPreviewDevices +@Composable +fun SignedInConfirmationScreenPreview() { + HorologistMaterialTheme { + SignedInConfirmationDialogContent( + modifier = Modifier.fillMaxSize(), + name = "Maggie", + + avatar = DrawableResPaintable(R.drawable.horologist_avatar_small_3), + ) + } +} + +@WearPreviewDevices +@Composable +fun SignedInConfirmationMMMScreenPreview() { + HorologistMaterialTheme { + SignedInConfirmationDialogContent( + modifier = Modifier.fillMaxSize(), + name = "MMMMMMMMM", + email = "MMMMMMMMMMMMMMMMMMMMMMMM", + avatar = DrawableResPaintable(R.drawable.horologist_avatar_small_3), + ) + } +} + +@WearPreviewDevices +@Composable +fun SignedInConfirmationScreenContentPreview() { + HorologistMaterialTheme { + SignedInConfirmationDialogContent( + modifier = Modifier.fillMaxSize(), + name = "Maggie", + email = "maggiesveryveryverylongworkemail@example.com", + avatar = DrawableResPaintable(R.drawable.horologist_avatar_small_3), + ) + } +} + +@WearPreviewDevices +@Composable +fun SignedInConfirmationNoAvatar() { + HorologistMaterialTheme { + SignedInConfirmationDialogContent( + modifier = Modifier.fillMaxSize(), + name = "Timothy", + email = "timandrews123@example.com", + ) + } +} + +@WearPreviewDevices +@Composable +fun SignedInConfirmationScreenPreviewNoName() { + SignedInConfirmationScreen( + onDismissOrTimeout = {}, + email = "timandrews123@example.com", + avatar = DrawableResPaintable(R.drawable.horologist_avatar_small_3), + ) +} + +@WearPreviewDevices +@Composable +fun SignedInConfirmationScreenPreviewNoEmail() { + SignedInConfirmationScreen( + onDismissOrTimeout = {}, + name = "Maggie", + ) +} + +@WearPreviewDevices +@Composable +fun SignedInConfirmationScreenPreviewNoInformation() { + SignedInConfirmationScreen(onDismissOrTimeout = {}) +} + +@WearPreviewDevices +@Composable +fun SignedInConfirmationScreenPreviewTruncation() { + SignedInConfirmationScreen( + onDismissOrTimeout = {}, + name = "Wolfeschlegelsteinhausenbergerdorff", + email = "wolfeschlegelsteinhausenbergerdorff@example.com", + ) +} diff --git a/auth/composables-material3/src/debug/res/drawable/horologist_avatar_small_1.png b/auth/composables-material3/src/debug/res/drawable/horologist_avatar_small_1.png new file mode 100644 index 0000000000..70dfb825f1 Binary files /dev/null and b/auth/composables-material3/src/debug/res/drawable/horologist_avatar_small_1.png differ diff --git a/auth/composables-material3/src/debug/res/drawable/horologist_avatar_small_2.png b/auth/composables-material3/src/debug/res/drawable/horologist_avatar_small_2.png new file mode 100644 index 0000000000..642a5e0dcf Binary files /dev/null and b/auth/composables-material3/src/debug/res/drawable/horologist_avatar_small_2.png differ diff --git a/auth/composables-material3/src/debug/res/drawable/horologist_avatar_small_3.png b/auth/composables-material3/src/debug/res/drawable/horologist_avatar_small_3.png new file mode 100644 index 0000000000..29873afc75 Binary files /dev/null and b/auth/composables-material3/src/debug/res/drawable/horologist_avatar_small_3.png differ diff --git a/auth/composables-material3/src/main/AndroidManifest.xml b/auth/composables-material3/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..2365e6992e --- /dev/null +++ b/auth/composables-material3/src/main/AndroidManifest.xml @@ -0,0 +1,25 @@ + + + + + + + + + \ No newline at end of file diff --git a/auth/composables-material3/src/main/java/com/google/android/horologist/auth/composables/material3/models/AccountUiModel.kt b/auth/composables-material3/src/main/java/com/google/android/horologist/auth/composables/material3/models/AccountUiModel.kt new file mode 100644 index 0000000000..f8d7aa0733 --- /dev/null +++ b/auth/composables-material3/src/main/java/com/google/android/horologist/auth/composables/material3/models/AccountUiModel.kt @@ -0,0 +1,28 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.horologist.auth.composables.material3.models + +import com.google.android.horologist.images.base.paintable.Paintable + +/** + * A UI model to represent an account. + */ +public data class AccountUiModel( + val email: String, + val name: String, + val avatar: Paintable? = null, +) diff --git a/auth/composables-material3/src/main/java/com/google/android/horologist/auth/composables/material3/screens/SelectAccountScreen.kt b/auth/composables-material3/src/main/java/com/google/android/horologist/auth/composables/material3/screens/SelectAccountScreen.kt new file mode 100644 index 0000000000..a88164eafd --- /dev/null +++ b/auth/composables-material3/src/main/java/com/google/android/horologist/auth/composables/material3/screens/SelectAccountScreen.kt @@ -0,0 +1,148 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.horologist.auth.composables.material3.screens + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.AccountCircle +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.LineBreak +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.wear.compose.foundation.lazy.TransformingLazyColumn +import androidx.wear.compose.foundation.lazy.rememberTransformingLazyColumnState +import androidx.wear.compose.material3.Button +import androidx.wear.compose.material3.ButtonDefaults +import androidx.wear.compose.material3.Icon +import androidx.wear.compose.material3.ListHeader +import androidx.wear.compose.material3.MaterialTheme +import androidx.wear.compose.material3.SurfaceTransformation +import androidx.wear.compose.material3.Text +import androidx.wear.compose.material3.lazy.rememberTransformationSpec +import androidx.wear.compose.material3.lazy.transformedHeight +import com.google.android.horologist.auth.composables.material3.R +import com.google.android.horologist.auth.composables.material3.models.AccountUiModel +import com.google.android.horologist.images.base.paintable.ImageVectorPaintable.Companion.asPaintable +import com.google.android.horologist.images.base.paintable.Paintable + +@Composable +public fun SelectAccountScreen( + accounts: List, + onAccountClicked: (index: Int, account: AccountUiModel) -> Unit, + modifier: Modifier = Modifier, + title: String = stringResource(id = R.string.horologist_select_account_title), + defaultAvatar: Paintable = Icons.Outlined.AccountCircle.asPaintable(), + contentPadding: PaddingValues = defaultContentPadding(), +) { + val state = rememberTransformingLazyColumnState() + val transformationSpec = rememberTransformationSpec() + + val emailTextStyle = MaterialTheme.typography.labelSmall.copy( + lineBreak = LineBreak( + strategy = LineBreak.Strategy.Balanced, + strictness = LineBreak.Strictness.Normal, + wordBreak = LineBreak.WordBreak.Default, + ), + ) + + TransformingLazyColumn( + state = state, + contentPadding = contentPadding, + modifier = modifier, + ) { + item { + ListHeader { + Text(text = title, style = MaterialTheme.typography.titleLarge, maxLines = 2) + } + } + accounts.forEachIndexed { index, account -> + item { + val hasAvatar = account.avatar != null + Button( + onClick = { onAccountClicked(index, account) }, + modifier = Modifier + .fillMaxWidth() + .transformedHeight(this@item, transformationSpec), + transformation = SurfaceTransformation(transformationSpec), + icon = { + if (hasAvatar) { + Image( + account.avatar.rememberPainter(), + contentDescription = null, + modifier = Modifier + .size(ButtonDefaults.LargeIconSize), + ) + } else { + Icon( + defaultAvatar.rememberPainter(), + contentDescription = null, + modifier = Modifier + .size(ButtonDefaults.IconSize), + ) + } + }, + contentPadding = if (hasAvatar) { + ButtonDefaults.ButtonWithLargeIconContentPadding + } else { + ButtonDefaults.ContentPadding + }, + colors = ButtonDefaults.filledTonalButtonColors(), + secondaryLabel = { + Text( + account.email, + style = emailTextStyle, + overflow = TextOverflow.Ellipsis, + maxLines = 2, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + ) { + Text( + account.name, + style = MaterialTheme.typography.titleMedium, + overflow = TextOverflow.Ellipsis, + maxLines = 2, + color = MaterialTheme.colorScheme.onBackground, + ) + } + } + } + } +} + +@Composable +private fun defaultContentPadding(): PaddingValues { + val (screenWidthDp, screenHeightDp) = LocalConfiguration.current.run { + screenWidthDp.dp to screenHeightDp.dp + } + + val horizontalPadding = (screenWidthDp * 0.052f) + val topPadding = (screenHeightDp * 0.1f) + val bottomPadding = (screenHeightDp * 0.3646f) + return PaddingValues( + start = horizontalPadding, + top = topPadding, + end = horizontalPadding, + bottom = bottomPadding, + ) +} diff --git a/auth/composables-material3/src/main/java/com/google/android/horologist/auth/composables/material3/screens/SignedInConfirmationScreen.kt b/auth/composables-material3/src/main/java/com/google/android/horologist/auth/composables/material3/screens/SignedInConfirmationScreen.kt new file mode 100644 index 0000000000..4ab2f66df0 --- /dev/null +++ b/auth/composables-material3/src/main/java/com/google/android/horologist/auth/composables/material3/screens/SignedInConfirmationScreen.kt @@ -0,0 +1,222 @@ +/* + * 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. + */ + +package com.google.android.horologist.auth.composables.material3.screens + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccountCircle +import androidx.compose.material.icons.outlined.AccountCircle +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialShapes +import androidx.compose.material3.toShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.LineBreak +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.wear.compose.material3.Dialog +import androidx.wear.compose.material3.MaterialTheme +import androidx.wear.compose.material3.Text +import com.google.android.horologist.auth.composables.material3.R +import com.google.android.horologist.auth.composables.material3.models.AccountUiModel +import com.google.android.horologist.compose.layout.ScreenScaffold +import com.google.android.horologist.images.base.paintable.ImageVectorPaintable.Companion.asPaintable +import com.google.android.horologist.images.base.paintable.Paintable + +private const val HORIZONTAL_PADDING_SCREEN_PERCENTAGE = 0.052f +private const val TOP_PADDING_SCREEN_PERCENTAGE = 0.012f +private const val BOTTOM_PADDING_SCREEN_PERCENTAGE = 0.092f +private const val EMAIL_PADDING_HORIZONTAL_SCREEN_PERCENTAGE = 0.092f + +/** + * A signed in confirmation dialog that can display the name, email and avatar image of the user. + * + * + */ +@Composable +public fun SignedInConfirmationScreen( + onDismissOrTimeout: () -> Unit, + modifier: Modifier = Modifier, + name: String? = null, + email: String? = null, + avatar: Paintable? = null, + defaultAvatar: Paintable = Icons.Default.AccountCircle.asPaintable(), +) { + var showConfirmation by remember { mutableStateOf(true) } + + Dialog( + showConfirmation, + onDismissRequest = { + showConfirmation = false + onDismissOrTimeout() + }, + modifier = modifier, + ) { + SignedInConfirmationDialogContent( + modifier = modifier, + name = name, + email = email, + avatar = avatar, + defaultAvatar = defaultAvatar, + ) + } +} + +/** + * A [SignedInConfirmationScreen] that can display the name, email and avatar image of an + * [AccountUiModel]. + * + * + */ +@Composable +public fun SignedInConfirmationScreen( + onDismissOrTimeout: () -> Unit, + modifier: Modifier = Modifier, + accountUiModel: AccountUiModel, +) { + SignedInConfirmationScreen( + onDismissOrTimeout = onDismissOrTimeout, + modifier = modifier, + name = accountUiModel.name, + email = accountUiModel.email, + avatar = accountUiModel.avatar, + ) +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +internal fun SignedInConfirmationDialogContent( + modifier: Modifier = Modifier, + name: String? = null, + email: String? = null, + avatar: Paintable? = null, + defaultAvatar: Paintable = Icons.Outlined.AccountCircle.asPaintable(), +) { + val configuration = LocalConfiguration.current + val topPadding = (configuration.screenHeightDp * TOP_PADDING_SCREEN_PERCENTAGE).dp + val bottomPadding = (configuration.screenHeightDp * BOTTOM_PADDING_SCREEN_PERCENTAGE).dp + val horizontalPadding = (configuration.screenWidthDp * HORIZONTAL_PADDING_SCREEN_PERCENTAGE).dp + + ScreenScaffold(timeText = {}) { + Column( + modifier = modifier + .fillMaxSize() + .padding( + top = topPadding, + start = horizontalPadding, + end = horizontalPadding, + bottom = bottomPadding, + ), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + val hasName = !name.isNullOrEmpty() + Box( + modifier = Modifier + .padding(4.dp) + .size(96.dp) + .clip(MaterialShapes.Pill.toShape()) + .background(MaterialTheme.colorScheme.surfaceContainer), + contentAlignment = Alignment.Center, + ) { + if (avatar != null) { + Image( + modifier = Modifier.fillMaxSize(), + painter = avatar.rememberPainter(), + contentDescription = null, + contentScale = ContentScale.FillBounds, + ) + } else { + Icon( + painter = defaultAvatar.rememberPainter(), + tint = MaterialTheme.colorScheme.onSurface, + contentDescription = null, + modifier = Modifier + .size(32.dp), + ) + } + } + + // Title text + Text( + text = if (hasName) { + stringResource( + id = R.string.horologist_signedin_confirmation_greeting, + name!!, + ) + } else { + stringResource(id = R.string.horologist_signedin_confirmation_greeting_no_name) + }, + modifier = Modifier + .fillMaxWidth(), + color = MaterialTheme.colorScheme.onBackground, + textAlign = TextAlign.Center, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + style = MaterialTheme.typography.displayMedium, + ) + + email?.let { + val emailHorizontalPadding = + (configuration.screenWidthDp * EMAIL_PADDING_HORIZONTAL_SCREEN_PERCENTAGE).dp + val emailTextStyle = MaterialTheme.typography.bodyMedium.copy( + // linebreak specific to email strings + lineBreak = LineBreak( + strategy = LineBreak.Strategy.Balanced, + strictness = LineBreak.Strictness.Normal, + wordBreak = LineBreak.WordBreak.Default, + ), + textAlign = TextAlign.Center, + ) + Text( + text = email, + modifier = Modifier + .padding( + top = 4.dp, + start = emailHorizontalPadding, + end = emailHorizontalPadding, + ) + .fillMaxWidth(), + color = MaterialTheme.colorScheme.onBackground, + textAlign = TextAlign.Center, + overflow = TextOverflow.Ellipsis, + maxLines = 2, + style = emailTextStyle, + ) + } + } + } +} diff --git a/auth/composables-material3/src/main/java/com/google/android/horologist/auth/composables/material3/theme/Color.kt b/auth/composables-material3/src/main/java/com/google/android/horologist/auth/composables/material3/theme/Color.kt new file mode 100644 index 0000000000..e33bf15975 --- /dev/null +++ b/auth/composables-material3/src/main/java/com/google/android/horologist/auth/composables/material3/theme/Color.kt @@ -0,0 +1,25 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.horologist.auth.composables.material3.theme + +import androidx.compose.ui.graphics.Color + +internal val horologist_primary: Color = Color(0xFFD3E3FD) +internal val horologist_on_primary: Color = Color(0xFF001944) +internal val horologist_primary_container: Color = Color(0xFF04409F) +internal val horologist_on_primary_container: Color = Color(0xFFD3E3FD) +internal val horologist_on_background: Color = Color(0xFFFFFFFF) diff --git a/auth/composables-material3/src/main/java/com/google/android/horologist/auth/composables/material3/theme/Theme.kt b/auth/composables-material3/src/main/java/com/google/android/horologist/auth/composables/material3/theme/Theme.kt new file mode 100644 index 0000000000..0992f9a2a9 --- /dev/null +++ b/auth/composables-material3/src/main/java/com/google/android/horologist/auth/composables/material3/theme/Theme.kt @@ -0,0 +1,34 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.horologist.auth.composables.material3.theme + +import androidx.compose.runtime.Composable +import androidx.wear.compose.material3.MaterialTheme + +@Composable +internal fun HorologistMaterialTheme(content: @Composable () -> Unit) { + MaterialTheme( + colorScheme = MaterialTheme.colorScheme.copy( + primary = horologist_primary, + onPrimary = horologist_on_primary, + primaryContainer = horologist_primary_container, + onPrimaryContainer = horologist_on_primary_container, + onBackground = horologist_on_background, + ), + content = content, + ) +} diff --git a/auth/composables-material3/src/main/res/values-af/strings.xml b/auth/composables-material3/src/main/res/values-af/strings.xml new file mode 100755 index 0000000000..f0714a5ece --- /dev/null +++ b/auth/composables-material3/src/main/res/values-af/strings.xml @@ -0,0 +1,28 @@ + + + + Iets is fout. Probeer weer. + Hallo, %s + Hallo! + Meld aan + Meld aan + Gebruik sonder rekening + Ander opsies + Skep rekening + Kies rekening + Meld tans aan … + diff --git a/auth/composables-material3/src/main/res/values-am/strings.xml b/auth/composables-material3/src/main/res/values-am/strings.xml new file mode 100755 index 0000000000..e1a79079f0 --- /dev/null +++ b/auth/composables-material3/src/main/res/values-am/strings.xml @@ -0,0 +1,28 @@ + + + + የሆነ ስህተት ተፈጥሯል። እንደገና ይሞክሩ። + ሠላም፣ %s + ሠላም! + ይግቡ + ግባ + ያለ መለያ ይጠቀሙ + ሌሎች አማራጮች + መለያ ፍጠር + መለያ ይምረጡ + በመግባት ላይ… + diff --git a/auth/composables-material3/src/main/res/values-ar/strings.xml b/auth/composables-material3/src/main/res/values-ar/strings.xml new file mode 100755 index 0000000000..6c5566d708 --- /dev/null +++ b/auth/composables-material3/src/main/res/values-ar/strings.xml @@ -0,0 +1,28 @@ + + + + حدث خطأ. يُرجى إعادة المحاولة. + مرحبًا %s، + مرحبًا، + تسجيل الدخول + تسجيل الدخول + بدون تسجيل دخول + خيارات أخرى + إنشاء حساب + اختيار حساب + جارٍ تسجيل الدخول… + diff --git a/auth/composables-material3/src/main/res/values-as/strings.xml b/auth/composables-material3/src/main/res/values-as/strings.xml new file mode 100755 index 0000000000..77c9f99644 --- /dev/null +++ b/auth/composables-material3/src/main/res/values-as/strings.xml @@ -0,0 +1,28 @@ + + + + কিবা ভুল হ’ল। পুনৰ চেষ্টা কৰক। + নমস্কাৰ, %s + নমস্কাৰ! + ছাইন ইন কৰক + ছাইন ইন কৰক + একাউণ্টৰ অবিহনে ব্যৱহাৰ কৰক + অন্য বিকল্পসমূহ + একাউণ্ট সৃষ্টি কৰক + একাউণ্ট বাছনি কৰক + ছাইন ইন কৰি থকা হৈছে… + diff --git a/auth/composables-material3/src/main/res/values-az/strings.xml b/auth/composables-material3/src/main/res/values-az/strings.xml new file mode 100755 index 0000000000..9838ea480b --- /dev/null +++ b/auth/composables-material3/src/main/res/values-az/strings.xml @@ -0,0 +1,28 @@ + + + + Xəta oldu. Yenidən cəhd edin. + Salam, %s + Salam! + Daxil olun + Daxil olun + Hesab olmadan istifadə edin + Digər seçimlər + Hesab yaradın + Hesab seçin + Giriş edilir… + diff --git a/auth/composables-material3/src/main/res/values-b+es+419/strings.xml b/auth/composables-material3/src/main/res/values-b+es+419/strings.xml new file mode 100755 index 0000000000..b1dc07d218 --- /dev/null +++ b/auth/composables-material3/src/main/res/values-b+es+419/strings.xml @@ -0,0 +1,28 @@ + + + + Se produjo un error. Vuelve a intentarlo. + Hola, %s + ¡Hola! + Accede + Acceder + Usar sin una cuenta + Otras opciones + Crear cuenta + Seleccionar cuenta + Accediendo… + diff --git a/auth/composables-material3/src/main/res/values-be/strings.xml b/auth/composables-material3/src/main/res/values-be/strings.xml new file mode 100755 index 0000000000..bff30ed0da --- /dev/null +++ b/auth/composables-material3/src/main/res/values-be/strings.xml @@ -0,0 +1,28 @@ + + + + Нешта пайшло не так. Паўтарыце спробу. + Вітаем, %s + Прывітанне! + Увайдзіце + Увайсці + Працягнуць без уваходу + Іншыя варыянты + Новы ўліковы запіс + Выбар уліковага запісу + Уваход… + diff --git a/auth/composables-material3/src/main/res/values-bg/strings.xml b/auth/composables-material3/src/main/res/values-bg/strings.xml new file mode 100755 index 0000000000..20fd296ec3 --- /dev/null +++ b/auth/composables-material3/src/main/res/values-bg/strings.xml @@ -0,0 +1,28 @@ + + + + Нещо се обърка. Опитайте отново. + Здравейте, %s, + Здравейте! + Вход + Вход + Ползване без профил + Други опции + Създаване на профил + Избиране на профил + Влизате в профила си… + diff --git a/auth/composables-material3/src/main/res/values-bn/strings.xml b/auth/composables-material3/src/main/res/values-bn/strings.xml new file mode 100755 index 0000000000..3c25b0347b --- /dev/null +++ b/auth/composables-material3/src/main/res/values-bn/strings.xml @@ -0,0 +1,28 @@ + + + + কোনও সমস্যা হয়েছে। আবার চেষ্টা করুন। + হাই, %s + হাই! + সাইন-ইন করুন + সাইন-ইন করুন + অ্যাকাউন্ট ছাড়া ব্যবহার করুন + অন্যান্য বিকল্প + অ্যাকাউন্ট তৈরি করুন + অ্যাকাউন্ট বেছে নিন + সাইন ইন করা হচ্ছে… + diff --git a/auth/composables-material3/src/main/res/values-bs/strings.xml b/auth/composables-material3/src/main/res/values-bs/strings.xml new file mode 100755 index 0000000000..5b11162abd --- /dev/null +++ b/auth/composables-material3/src/main/res/values-bs/strings.xml @@ -0,0 +1,28 @@ + + + + Nešto nije u redu. Pokušajte ponovo. + Zdravo, %s + Zdravo! + Prijava + Prijava + Koristite bez računa + Druge opcije + Kreirajte račun + Odaberite račun + Prijavljivanje… + diff --git a/auth/composables-material3/src/main/res/values-ca/strings.xml b/auth/composables-material3/src/main/res/values-ca/strings.xml new file mode 100755 index 0000000000..18ac18b908 --- /dev/null +++ b/auth/composables-material3/src/main/res/values-ca/strings.xml @@ -0,0 +1,28 @@ + + + + S\'ha produït un error. Torna-ho a provar. + Hola, %s, + Hola, + Inicia la sessió + Inicia la sessió + Utilitza sense compte + Altres opcions + Crea un compte + Selecciona un compte + S\'està iniciant la sessió… + diff --git a/auth/composables-material3/src/main/res/values-cs/strings.xml b/auth/composables-material3/src/main/res/values-cs/strings.xml new file mode 100755 index 0000000000..ff1ff59663 --- /dev/null +++ b/auth/composables-material3/src/main/res/values-cs/strings.xml @@ -0,0 +1,28 @@ + + + + Něco se pokazilo. Zkuste to znovu. + Dobrý den, %s + Dobrý den! + Přihlásit se + Přihlásit se + Používat bez účtu + Další možnosti + Vytvořit účet + Vyberte účet + Přihlašování… + diff --git a/auth/composables-material3/src/main/res/values-da/strings.xml b/auth/composables-material3/src/main/res/values-da/strings.xml new file mode 100755 index 0000000000..d7932a1f62 --- /dev/null +++ b/auth/composables-material3/src/main/res/values-da/strings.xml @@ -0,0 +1,28 @@ + + + + Der gik noget galt. Prøv igen. + Hej %s + Hej + Log ind + Log ind + Brug uden en konto + Andre valgmuligheder + Opret konto + Vælg konto + Logger ind… + diff --git a/auth/composables-material3/src/main/res/values-de/strings.xml b/auth/composables-material3/src/main/res/values-de/strings.xml new file mode 100755 index 0000000000..c7b069eec5 --- /dev/null +++ b/auth/composables-material3/src/main/res/values-de/strings.xml @@ -0,0 +1,28 @@ + + + + Ein Fehler ist aufgetreten. Versuche es bitte noch einmal. + Hallo %s + Hallo! + Anmelden + Anmelden + Ohne Konto nutzen + Weitere Optionen + Konto erstellen + Konto auswählen + Du wirst angemeldet… + diff --git a/auth/composables-material3/src/main/res/values-el/strings.xml b/auth/composables-material3/src/main/res/values-el/strings.xml new file mode 100755 index 0000000000..866fd64e4f --- /dev/null +++ b/auth/composables-material3/src/main/res/values-el/strings.xml @@ -0,0 +1,28 @@ + + + + Παρουσιάστηκε κάποιο πρόβλημα. Δοκιμάστε ξανά. + Γεια σας %s, + Γεια σας! + Σύνδεση + Σύνδεση + Χρήση χωρίς λογαρ. + Άλλες επιλογές + Δημιουργία λογαριασμού + Επιλογή λογαριασμού + Σύνδεση… + diff --git a/auth/composables-material3/src/main/res/values-en-rGB/strings.xml b/auth/composables-material3/src/main/res/values-en-rGB/strings.xml new file mode 100755 index 0000000000..5a34e6171c --- /dev/null +++ b/auth/composables-material3/src/main/res/values-en-rGB/strings.xml @@ -0,0 +1,28 @@ + + + + Something\'s gone wrong. Try again. + Hi, %s + Hi! + Sign in + Sign in + Use without account + Other options + Create account + Select account + Signing in… + diff --git a/auth/composables-material3/src/main/res/values-en-rIE/strings.xml b/auth/composables-material3/src/main/res/values-en-rIE/strings.xml new file mode 100755 index 0000000000..5a34e6171c --- /dev/null +++ b/auth/composables-material3/src/main/res/values-en-rIE/strings.xml @@ -0,0 +1,28 @@ + + + + Something\'s gone wrong. Try again. + Hi, %s + Hi! + Sign in + Sign in + Use without account + Other options + Create account + Select account + Signing in… + diff --git a/auth/composables-material3/src/main/res/values-es-rUS/strings.xml b/auth/composables-material3/src/main/res/values-es-rUS/strings.xml new file mode 100755 index 0000000000..b1dc07d218 --- /dev/null +++ b/auth/composables-material3/src/main/res/values-es-rUS/strings.xml @@ -0,0 +1,28 @@ + + + + Se produjo un error. Vuelve a intentarlo. + Hola, %s + ¡Hola! + Accede + Acceder + Usar sin una cuenta + Otras opciones + Crear cuenta + Seleccionar cuenta + Accediendo… + diff --git a/auth/composables-material3/src/main/res/values-es/strings.xml b/auth/composables-material3/src/main/res/values-es/strings.xml new file mode 100755 index 0000000000..324a11bfe6 --- /dev/null +++ b/auth/composables-material3/src/main/res/values-es/strings.xml @@ -0,0 +1,28 @@ + + + + Se ha producido un error. Inténtalo de nuevo. + Hola, %s + ¡Hola! + Inicia sesión + Iniciar sesión + Usar sin cuenta + Otras opciones + Crear cuenta + Seleccionar cuenta + Iniciando sesión… + diff --git a/auth/composables-material3/src/main/res/values-et/strings.xml b/auth/composables-material3/src/main/res/values-et/strings.xml new file mode 100755 index 0000000000..43f2ab06bf --- /dev/null +++ b/auth/composables-material3/src/main/res/values-et/strings.xml @@ -0,0 +1,28 @@ + + + + Midagi läks valesti. Proovige uuesti. + Tere, %s! + Tere! + Logi sisse + Logi sisse + Kasuta ilma kontota + Muud valikud + Konto loomine + Valige konto + Sisselogimine … + diff --git a/auth/composables-material3/src/main/res/values-eu/strings.xml b/auth/composables-material3/src/main/res/values-eu/strings.xml new file mode 100755 index 0000000000..8d78676ea3 --- /dev/null +++ b/auth/composables-material3/src/main/res/values-eu/strings.xml @@ -0,0 +1,28 @@ + + + + Arazoren bat izan da. Saiatu berriro. + Kaixo, %s + Kaixo! + Hasi saioa + Hasi saioa + Erabili konturik gabe + Beste aukera batzuk + Sortu kontu bat + Hautatu kontu bat + Saioa hasten… + diff --git a/auth/composables-material3/src/main/res/values-fa/strings.xml b/auth/composables-material3/src/main/res/values-fa/strings.xml new file mode 100755 index 0000000000..ca8e74ff57 --- /dev/null +++ b/auth/composables-material3/src/main/res/values-fa/strings.xml @@ -0,0 +1,28 @@ + + + + مشکلی پیش آمده است. دوباره امتحان کنید. + سلام، %s + سلام! + ورود به سیستم + ورود به سیستم + استفاده بدون حساب + گزینه‌های دیگر + ایجاد حساب + انتخاب حساب + ورود به سیستم… + diff --git a/auth/composables-material3/src/main/res/values-fi/strings.xml b/auth/composables-material3/src/main/res/values-fi/strings.xml new file mode 100755 index 0000000000..1218fdad3c --- /dev/null +++ b/auth/composables-material3/src/main/res/values-fi/strings.xml @@ -0,0 +1,28 @@ + + + + Jotain meni pieleen. Yritä uudelleen. + Hei %s + Hei! + Sisäänkirjautuminen + Kirjaudu sisään + Käytä ilman tiliä + Muut vaihtoehdot + Luo tili + Valitse tili + Kirjaudutaan sisään… + diff --git a/auth/composables-material3/src/main/res/values-fil/strings.xml b/auth/composables-material3/src/main/res/values-fil/strings.xml new file mode 100755 index 0000000000..485bdc6630 --- /dev/null +++ b/auth/composables-material3/src/main/res/values-fil/strings.xml @@ -0,0 +1,28 @@ + + + + Nagkaproblema. Subukan ulit. + Kumusta, %s + Kumusta! + Mag-sign in + Mag-sign in + Walang account + Iba pang opsyon + Gumawa ng account + Pumili ng account + Nagsa-sign in… + diff --git a/auth/composables-material3/src/main/res/values-fr-rCA/strings.xml b/auth/composables-material3/src/main/res/values-fr-rCA/strings.xml new file mode 100755 index 0000000000..b8ba730a44 --- /dev/null +++ b/auth/composables-material3/src/main/res/values-fr-rCA/strings.xml @@ -0,0 +1,28 @@ + + + + Une erreur s\'est produite. Réessayez. + Bonjour %s + Bonjour! + Se connecter + Se connecter + Utiliser sans compte + Autres options + Créer un compte + Sélectionner un compte + Connexion en cours… + diff --git a/auth/composables-material3/src/main/res/values-fr/strings.xml b/auth/composables-material3/src/main/res/values-fr/strings.xml new file mode 100755 index 0000000000..ed7caa986d --- /dev/null +++ b/auth/composables-material3/src/main/res/values-fr/strings.xml @@ -0,0 +1,28 @@ + + + + Un problème est survenu. Réessayez. + Bonjour %s + Bonjour ! + Connectez-vous + Se connecter + Utiliser sans compte + Autres options + Créer un compte + Sélectionner un compte + Connexion… + diff --git a/auth/composables-material3/src/main/res/values-gl/strings.xml b/auth/composables-material3/src/main/res/values-gl/strings.xml new file mode 100755 index 0000000000..e470cdf89e --- /dev/null +++ b/auth/composables-material3/src/main/res/values-gl/strings.xml @@ -0,0 +1,28 @@ + + + + Produciuse un problema. Téntao de novo. + Ola, %s + Ola! + Inicia sesión + Iniciar sesión + Usar sen conta + Outras opcións + Crear conta + Selecciona unha conta + Iniciando sesión… + diff --git a/auth/composables-material3/src/main/res/values-gu/strings.xml b/auth/composables-material3/src/main/res/values-gu/strings.xml new file mode 100755 index 0000000000..1ceb653782 --- /dev/null +++ b/auth/composables-material3/src/main/res/values-gu/strings.xml @@ -0,0 +1,28 @@ + + + + કંઈક ખોટું થયું. ફરી પ્રયાસ કરો. + નમસ્તે, %s + નમસ્તે! + સાઇન ઇન કરો + સાઇન ઇન કરો + એકાઉન્ટ વગર ઉપયોગ કરો + અન્ય વિકલ્પો + એકાઉન્ટ બનાવો + એકાઉન્ટ પસંદ કરો + સાઇન ઇન કરી રહ્યાં છીએ… + diff --git a/auth/composables-material3/src/main/res/values-hi/strings.xml b/auth/composables-material3/src/main/res/values-hi/strings.xml new file mode 100755 index 0000000000..d7f18d21d1 --- /dev/null +++ b/auth/composables-material3/src/main/res/values-hi/strings.xml @@ -0,0 +1,28 @@ + + + + कुछ गड़बड़ी हुई. फिर से कोशिश करें. + नमस्ते, %s + नमस्ते! + साइन इन करें + साइन इन करें + खाते के बिना चलाएं + दूसरे विकल्प + खाता बनाएं + खाता चुनें + साइन इन किया जा रहा है… + diff --git a/auth/composables-material3/src/main/res/values-hr/strings.xml b/auth/composables-material3/src/main/res/values-hr/strings.xml new file mode 100755 index 0000000000..63c5b8ac0f --- /dev/null +++ b/auth/composables-material3/src/main/res/values-hr/strings.xml @@ -0,0 +1,28 @@ + + + + Došlo je do pogreške. Pokušajte ponovo. + Pozdrav, %s + Pozdrav! + Prijava + Prijava + Koristite bez računa + Ostale opcije + Izradite račun + Odaberite račun + Prijava… + diff --git a/auth/composables-material3/src/main/res/values-hu/strings.xml b/auth/composables-material3/src/main/res/values-hu/strings.xml new file mode 100755 index 0000000000..c0d20b39fe --- /dev/null +++ b/auth/composables-material3/src/main/res/values-hu/strings.xml @@ -0,0 +1,28 @@ + + + + Probléma lépett fel. Próbálja újra. + Kedves %s! + Üdv! + Bejelentkezés + Bejelentkezés + Használat fiók nélkül + Egyéb beállítások + Fiók létrehozása + Fiók kiválasztása + Bejelentkezés… + diff --git a/auth/composables-material3/src/main/res/values-hy/strings.xml b/auth/composables-material3/src/main/res/values-hy/strings.xml new file mode 100755 index 0000000000..440dbff2ff --- /dev/null +++ b/auth/composables-material3/src/main/res/values-hy/strings.xml @@ -0,0 +1,28 @@ + + + + Սխալ առաջացավ։ Նորից փորձեք։ + Ողջո՛ւյն, %s + Ողջո՛ւյն + Մուտք գործեք + Մուտք գործել + Օգտվել առանց հաշվի + Այլ տարբերակներ + Ստեղծել հաշիվ + Ընտրեք հաշիվ + Մուտք… + diff --git a/auth/composables-material3/src/main/res/values-in/strings.xml b/auth/composables-material3/src/main/res/values-in/strings.xml new file mode 100755 index 0000000000..08d4003ab6 --- /dev/null +++ b/auth/composables-material3/src/main/res/values-in/strings.xml @@ -0,0 +1,28 @@ + + + + Terjadi error. Coba lagi. + Halo %s, + Hai! + Login + Login + Gunakan tanpa akun + Opsi lain + Buat akun + Pilih akun + Login … + diff --git a/auth/composables-material3/src/main/res/values-is/strings.xml b/auth/composables-material3/src/main/res/values-is/strings.xml new file mode 100755 index 0000000000..35c310f939 --- /dev/null +++ b/auth/composables-material3/src/main/res/values-is/strings.xml @@ -0,0 +1,28 @@ + + + + Eitthvað fór úrskeiðis. Reyndu aftur. + Hæ, %s + Hæ! + Skrá inn + Skrá inn + Nota án reiknings + Aðrir valkostir + Stofna reikning + Velja reikning + Skráir inn… + diff --git a/auth/composables-material3/src/main/res/values-it/strings.xml b/auth/composables-material3/src/main/res/values-it/strings.xml new file mode 100755 index 0000000000..010553ef24 --- /dev/null +++ b/auth/composables-material3/src/main/res/values-it/strings.xml @@ -0,0 +1,28 @@ + + + + Qualcosa non ha funzionato. Riprova. + Ciao %s + Ciao! + Accedi + Accedi + Usa senza l\'account + Altre opzioni + Crea account + Seleziona account + Accesso in corso… + diff --git a/auth/composables-material3/src/main/res/values-iw/strings.xml b/auth/composables-material3/src/main/res/values-iw/strings.xml new file mode 100755 index 0000000000..ffd11ffd05 --- /dev/null +++ b/auth/composables-material3/src/main/res/values-iw/strings.xml @@ -0,0 +1,28 @@ + + + + משהו השתבש. כדאי לנסות שוב. + שלום, %s + היי. + כניסה לחשבון + כניסה לחשבון + שימוש ללא חשבון + אפשרויות אחרות + יצירת חשבון + בחירת חשבון + בתהליך כניסה… + diff --git a/auth/composables-material3/src/main/res/values-ja/strings.xml b/auth/composables-material3/src/main/res/values-ja/strings.xml new file mode 100755 index 0000000000..8ff598f5af --- /dev/null +++ b/auth/composables-material3/src/main/res/values-ja/strings.xml @@ -0,0 +1,28 @@ + + + + 問題が発生しました。もう一度お試しください。 + %s さん + こんにちは。 + ログイン + ログイン + アカウントなしで使う + その他のオプション + アカウントを作成 + アカウントの選択 + ログイン中… + diff --git a/auth/composables-material3/src/main/res/values-ka/strings.xml b/auth/composables-material3/src/main/res/values-ka/strings.xml new file mode 100755 index 0000000000..d267b584c9 --- /dev/null +++ b/auth/composables-material3/src/main/res/values-ka/strings.xml @@ -0,0 +1,28 @@ + + + + რაღაც შეფერხდა. სცადეთ ხელახლა. + გამარჯობა, %s + გამარჯობა! + შესვლა + შესვლა + გამოიყენეთ ანგარიშის გარეშე + სხვა ვარიანტები + ანგარიშის შექმნა + აირჩიეთ ანგარიში + შედის… + diff --git a/auth/composables-material3/src/main/res/values-kk/strings.xml b/auth/composables-material3/src/main/res/values-kk/strings.xml new file mode 100755 index 0000000000..75d23ebc70 --- /dev/null +++ b/auth/composables-material3/src/main/res/values-kk/strings.xml @@ -0,0 +1,28 @@ + + + + Бірдеңе дұрыс болмады. Қайталап көріңіз. + Сәлеметсіз бе, %s! + Сәлеметсіз бе! + Аккаунтқа кіру + Аккаунтқа кіру + Аккаунтсыз пайдалану + Басқа опциялар + Аккаунт жасау + Аккаунт таңдау + Аккаунтқа кіріп жатыр… + diff --git a/auth/composables-material3/src/main/res/values-km/strings.xml b/auth/composables-material3/src/main/res/values-km/strings.xml new file mode 100755 index 0000000000..eaf16a3ad7 --- /dev/null +++ b/auth/composables-material3/src/main/res/values-km/strings.xml @@ -0,0 +1,28 @@ + + + + មានអ្វីមួយខុសប្រក្រតី។ សូមព្យាយាមម្ដងទៀត។ + សួស្ដី %s + សួស្ដី! + ចូលគណនី + ចូលគណនី + ប្រើដោយគ្មានគណនី + ជម្រើស​ផ្សេងទៀត + បង្កើតគណនី + ជ្រើសរើស​គណនី + កំពុង​ចូល… + diff --git a/auth/composables-material3/src/main/res/values-kn/strings.xml b/auth/composables-material3/src/main/res/values-kn/strings.xml new file mode 100755 index 0000000000..84ee4e3d0a --- /dev/null +++ b/auth/composables-material3/src/main/res/values-kn/strings.xml @@ -0,0 +1,28 @@ + + + + ಏನೋ ತಪ್ಪಾಗಿದೆ. ಪುನಃ ಪ್ರಯತ್ನಿಸಿ. + ಹಾಯ್, %s + ಹಾಯ್! + ಸೈನ್ ಇನ್ ಮಾಡಿ + ಸೈನ್ ಇನ್ ಮಾಡಿ + ಖಾತೆ ಇಲ್ಲದೆಯೇ ಬಳಸಿ + ಇತರ ಆಯ್ಕೆಗಳು + ಖಾತೆಯನ್ನು ರಚಿಸಿ + ಖಾತೆಯನ್ನು ಆಯ್ಕೆಮಾಡಿ + ಸೈನ್ ಇನ್ ಮಾಡಲಾಗುತ್ತಿದೆ… + diff --git a/auth/composables-material3/src/main/res/values-ko/strings.xml b/auth/composables-material3/src/main/res/values-ko/strings.xml new file mode 100755 index 0000000000..683ce021ab --- /dev/null +++ b/auth/composables-material3/src/main/res/values-ko/strings.xml @@ -0,0 +1,28 @@ + + + + 문제가 발생했습니다. 다시 시도해 보세요. + %s님, 안녕하세요. + 안녕하세요. + 로그인 + 로그인 + 계정 없이 사용 + 기타 옵션 + 계정 만들기 + 계정 선택 + 로그인 중… + diff --git a/auth/composables-material3/src/main/res/values-ky/strings.xml b/auth/composables-material3/src/main/res/values-ky/strings.xml new file mode 100755 index 0000000000..973cc6421a --- /dev/null +++ b/auth/composables-material3/src/main/res/values-ky/strings.xml @@ -0,0 +1,28 @@ + + + + Бир жерден ката кетти. Кайра аракет кылыңыз. + Салам, %s + Салам! + Кирүү + Кирүү + Аккаунтсуз колдонуу + Башка ыкмалар + Аккаунт түзүү + Аккаунт тандоо + Кирүүдө… + diff --git a/auth/composables-material3/src/main/res/values-lo/strings.xml b/auth/composables-material3/src/main/res/values-lo/strings.xml new file mode 100755 index 0000000000..8f29f6396b --- /dev/null +++ b/auth/composables-material3/src/main/res/values-lo/strings.xml @@ -0,0 +1,28 @@ + + + + ມີບາງຢ່າງຜິດພາດເກີດຂຶ້ນ. ກະລຸນາລອງໃໝ່. + ສະບາຍດີ, %s + ສະບາຍດີ! + ເຂົ້າສູ່ລະບົບ + ເຂົ້າສູ່ລະບົບ + ໃຊ້ແບບບໍ່ມີບັນຊີ + ຕົວເລືອກອື່ນໆ + ສ້າງບັນຊີ + ເລືອກບັນຊີ + ກຳລັງເຂົ້າສູ່ລະບົບ… + diff --git a/auth/composables-material3/src/main/res/values-lt/strings.xml b/auth/composables-material3/src/main/res/values-lt/strings.xml new file mode 100755 index 0000000000..a9f72caece --- /dev/null +++ b/auth/composables-material3/src/main/res/values-lt/strings.xml @@ -0,0 +1,28 @@ + + + + Kažkas nepavyko. Bandykite dar kartą. + Sveiki, %s! + Sveiki! + Prisijungti + Prisijungti + Naudoti be paskyros + Kitos parinktys + Sukurti paskyrą + Paskyros pasirinkimas + Prisijungiama… + diff --git a/auth/composables-material3/src/main/res/values-lv/strings.xml b/auth/composables-material3/src/main/res/values-lv/strings.xml new file mode 100755 index 0000000000..cbd8098aa4 --- /dev/null +++ b/auth/composables-material3/src/main/res/values-lv/strings.xml @@ -0,0 +1,28 @@ + + + + Radās kļūda. Mēģiniet vēlreiz. + Labdien, %s! + Labdien! + Pierakstieties + Pierakstīties + Izmantot bez konta + Citas opcijas + Izveidot kontu + Konta atlasīšana + Notiek pierakstīšanās… + diff --git a/auth/composables-material3/src/main/res/values-mk/strings.xml b/auth/composables-material3/src/main/res/values-mk/strings.xml new file mode 100755 index 0000000000..ddb36a6fc1 --- /dev/null +++ b/auth/composables-material3/src/main/res/values-mk/strings.xml @@ -0,0 +1,28 @@ + + + + Нешто се случи. Обидете се повторно. + Здраво %s + Здраво! + Најавете се + Најавете се + Користи без сметка + Други опции + Создајте сметка + Изберете сметка + Најавување… + diff --git a/auth/composables-material3/src/main/res/values-ml/strings.xml b/auth/composables-material3/src/main/res/values-ml/strings.xml new file mode 100755 index 0000000000..1f2524d730 --- /dev/null +++ b/auth/composables-material3/src/main/res/values-ml/strings.xml @@ -0,0 +1,28 @@ + + + + എന്തോ കുഴപ്പമുണ്ടായി. വീണ്ടും ശ്രമിക്കുക. + ഹായ്, %s + ഹായ്! + സൈൻ ഇൻ ചെയ്യുക + സൈൻ ഇൻ ചെയ്യുക + അക്കൗണ്ടില്ലാതെ ഉപയോഗിക്കൂ + മറ്റ് ഓപ്ഷനുകൾ + അക്കൗണ്ട് സൃഷ്ടിക്കൂ + അക്കൗണ്ട് തിരഞ്ഞെടുക്കുക + സൈൻ ഇൻ ചെയ്യുന്നു… + diff --git a/auth/composables-material3/src/main/res/values-mn/strings.xml b/auth/composables-material3/src/main/res/values-mn/strings.xml new file mode 100755 index 0000000000..ed3fee3762 --- /dev/null +++ b/auth/composables-material3/src/main/res/values-mn/strings.xml @@ -0,0 +1,28 @@ + + + + Алдаа гарлаа. Дахин оролдоно уу. + Сайн уу, %s + Сайн уу! + Нэвтрэх + Нэвтрэх + Бүртгэлгүйгээр ашиглах + Бусад сонголт + Бүртгэл үүсгэх + Бүртгэл сонгох + Нэвтэрч байна… + diff --git a/auth/composables-material3/src/main/res/values-mr/strings.xml b/auth/composables-material3/src/main/res/values-mr/strings.xml new file mode 100755 index 0000000000..3429f330ab --- /dev/null +++ b/auth/composables-material3/src/main/res/values-mr/strings.xml @@ -0,0 +1,28 @@ + + + + काहीतरी चूक झाली. पुन्हा प्रयत्न करा. + हाय, %s + हाय! + साइन इन करा + साइन इन करा + खात्याशिवाय वापरा + इतर पर्याय + खाते तयार करा + खाते निवडा + साइन इन करत आहे… + diff --git a/auth/composables-material3/src/main/res/values-ms/strings.xml b/auth/composables-material3/src/main/res/values-ms/strings.xml new file mode 100755 index 0000000000..c48cfb22df --- /dev/null +++ b/auth/composables-material3/src/main/res/values-ms/strings.xml @@ -0,0 +1,28 @@ + + + + Ralat telah berlaku. Cuba lagi. + Hai %s + Hai! + Log Masuk + Log masuk + Gunakan tanpa akaun + Pilihan lain + Buat akaun + Pilih akaun + Log masuk… + diff --git a/auth/composables-material3/src/main/res/values-my/strings.xml b/auth/composables-material3/src/main/res/values-my/strings.xml new file mode 100755 index 0000000000..a98b3d0aed --- /dev/null +++ b/auth/composables-material3/src/main/res/values-my/strings.xml @@ -0,0 +1,28 @@ + + + + တစ်ခုခုမှားသွားသည်။ ထပ်စမ်းကြည့်ပါ။ + မင်္ဂလာပါ %s + မင်္ဂလာပါ။ + လက်မှတ်ထိုးဝင်ရန် + လက်မှတ်ထိုးဝင်ရန် + အကောင့်မပါဘဲ သုံးရန် + အခြားနည်းလမ်းများ + အကောင့်ဖွင့်ရန် + အကောင့်ရွေးရန် + လက်မှတ်ထိုး ဝင်နေသည်… + diff --git a/auth/composables-material3/src/main/res/values-nb/strings.xml b/auth/composables-material3/src/main/res/values-nb/strings.xml new file mode 100755 index 0000000000..f8fff851d1 --- /dev/null +++ b/auth/composables-material3/src/main/res/values-nb/strings.xml @@ -0,0 +1,28 @@ + + + + Noe gikk galt. Prøv på nytt. + Hei, %s + Hei! + Logg på + Logg på + Bruk uten konto + Andre alternativer + Opprett konto + Velg konto + Logger på … + diff --git a/auth/composables-material3/src/main/res/values-ne/strings.xml b/auth/composables-material3/src/main/res/values-ne/strings.xml new file mode 100755 index 0000000000..ba7316e757 --- /dev/null +++ b/auth/composables-material3/src/main/res/values-ne/strings.xml @@ -0,0 +1,28 @@ + + + + कुनै समस्या आएको छ। फेरि प्रयास गर्नुहोस्। + नमस्ते, %s + नमस्ते! + साइन इन गर्नुहोस् + साइन इन गर्नुहोस् + साइन इन नगरी एप प्रयोग गर्न + अन्य विकल्पहरू + खाता बनाउनुहोस् + खाता चयन गर्नुहोस् + साइन इन गरिँदै छ… + diff --git a/auth/composables-material3/src/main/res/values-nl/strings.xml b/auth/composables-material3/src/main/res/values-nl/strings.xml new file mode 100755 index 0000000000..fc6779d775 --- /dev/null +++ b/auth/composables-material3/src/main/res/values-nl/strings.xml @@ -0,0 +1,28 @@ + + + + Er is iets misgegaan. Probeer het opnieuw. + Hallo %s + Hallo! + Inloggen + Inloggen + Gebruiken zonder account + Andere opties + Account maken + Account selecteren + Inloggen… + diff --git a/auth/composables-material3/src/main/res/values-no/strings.xml b/auth/composables-material3/src/main/res/values-no/strings.xml new file mode 100755 index 0000000000..f8fff851d1 --- /dev/null +++ b/auth/composables-material3/src/main/res/values-no/strings.xml @@ -0,0 +1,28 @@ + + + + Noe gikk galt. Prøv på nytt. + Hei, %s + Hei! + Logg på + Logg på + Bruk uten konto + Andre alternativer + Opprett konto + Velg konto + Logger på … + diff --git a/auth/composables-material3/src/main/res/values-or/strings.xml b/auth/composables-material3/src/main/res/values-or/strings.xml new file mode 100755 index 0000000000..ef8eb78be0 --- /dev/null +++ b/auth/composables-material3/src/main/res/values-or/strings.xml @@ -0,0 +1,28 @@ + + + + କିଛି ତ୍ରୁଟି ହୋଇଛି। ପୁଣି ଚେଷ୍ଟା କରନ୍ତୁ। + ହାଏ, %s + ହାଏ! + ସାଇନ ଇନ କରନ୍ତୁ + ସାଇନ ଇନ କରନ୍ତୁ + ଆକାଉଣ୍ଟ ବିନା ବ୍ୟବହାର + ଅନ୍ୟ ବିକଳ୍ପଗୁଡ଼ିକ + ଆକାଉଣ୍ଟ ତିଆରି କରନ୍ତୁ + ଆକାଉଣ୍ଟ ଚୟନ କରନ୍ତୁ + ସାଇନ ଇନ କରାଯାଉଛି… + diff --git a/auth/composables-material3/src/main/res/values-pa/strings.xml b/auth/composables-material3/src/main/res/values-pa/strings.xml new file mode 100755 index 0000000000..40f9126a13 --- /dev/null +++ b/auth/composables-material3/src/main/res/values-pa/strings.xml @@ -0,0 +1,28 @@ + + + + ਕੁਝ ਗਲਤ ਹੋ ਗਿਆ। ਦੁਬਾਰਾ ਕੋਸ਼ਿਸ਼ ਕਰੋ। + ਸਤਿ ਸ੍ਰੀ ਅਕਾਲ, %s + ਹੈਲੋ! + ਸਾਈਨ-ਇਨ ਕਰੋ + ਸਾਈਨ-ਇਨ ਕਰੋ + ਖਾਤੇ ਦੇ ਬਿਨਾਂ ਵਰਤੋ + ਹੋਰ ਵਿਕਲਪ + ਖਾਤਾ ਬਣਾਓ + ਖਾਤਾ ਚੁਣੋ + ਸਾਈਨ-ਇਨ ਕੀਤਾ ਜਾ ਰਿਹਾ ਹੈ… + diff --git a/auth/composables-material3/src/main/res/values-pl/strings.xml b/auth/composables-material3/src/main/res/values-pl/strings.xml new file mode 100755 index 0000000000..9d2a3c782b --- /dev/null +++ b/auth/composables-material3/src/main/res/values-pl/strings.xml @@ -0,0 +1,28 @@ + + + + Coś poszło nie tak. Spróbuj ponownie. + Cześć, %s + Cześć + Zaloguj się + Zaloguj się + Używaj bez konta + Więcej opcji + Utwórz konto + Wybierz konto + Loguję… + diff --git a/auth/composables-material3/src/main/res/values-pt-rBR/strings.xml b/auth/composables-material3/src/main/res/values-pt-rBR/strings.xml new file mode 100755 index 0000000000..4f1c7d3806 --- /dev/null +++ b/auth/composables-material3/src/main/res/values-pt-rBR/strings.xml @@ -0,0 +1,28 @@ + + + + Algo deu errado. Tente de novo. + Olá, %s + Olá! + Faça login + Fazer login + Usar sem conta + Outras opções + Criar conta + Selecionar conta + Fazendo login… + diff --git a/auth/composables-material3/src/main/res/values-pt-rPT/strings.xml b/auth/composables-material3/src/main/res/values-pt-rPT/strings.xml new file mode 100755 index 0000000000..4a29e785bc --- /dev/null +++ b/auth/composables-material3/src/main/res/values-pt-rPT/strings.xml @@ -0,0 +1,28 @@ + + + + Algo correu mal. Tente novamente. + Olá, %s + Olá! + Inicie sessão + Iniciar sessão + Usar sem conta + Outras opções + Criar conta + Selecione uma conta + A iniciar sessão… + diff --git a/auth/composables-material3/src/main/res/values-ro/strings.xml b/auth/composables-material3/src/main/res/values-ro/strings.xml new file mode 100755 index 0000000000..8ed6d418ca --- /dev/null +++ b/auth/composables-material3/src/main/res/values-ro/strings.xml @@ -0,0 +1,28 @@ + + + + A apărut o problemă. Încearcă din nou. + Bună, %s! + Bună! + Conectează-te + Conectează-te + Folosește fără cont + Alte opțiuni + Creează un cont + Selectează un cont + Se conectează… + diff --git a/auth/composables-material3/src/main/res/values-ru/strings.xml b/auth/composables-material3/src/main/res/values-ru/strings.xml new file mode 100755 index 0000000000..89910d9f22 --- /dev/null +++ b/auth/composables-material3/src/main/res/values-ru/strings.xml @@ -0,0 +1,28 @@ + + + + Произошла ошибка. Повторите попытку. + Привет, %s! + Привет! + Войдите + Войти + Без входа в аккаунт + Другие варианты + Создать аккаунт + Выбор аккаунта + Вход… + diff --git a/auth/composables-material3/src/main/res/values-si/strings.xml b/auth/composables-material3/src/main/res/values-si/strings.xml new file mode 100755 index 0000000000..487a0d063b --- /dev/null +++ b/auth/composables-material3/src/main/res/values-si/strings.xml @@ -0,0 +1,28 @@ + + + + යමක් වැරදී ඇත. නැවත උත්සාහ කරන්න. + ආයුබෝවන්, %s + ආයුබෝවන්! + පුරන්න + පුරන්න + ගිණුම නොමැතිව භාවිතය + වෙනත් විකල්ප + ගිණුමක් තනන්න + ගිණුම තෝරන්න + පුරමින්… + diff --git a/auth/composables-material3/src/main/res/values-sk/strings.xml b/auth/composables-material3/src/main/res/values-sk/strings.xml new file mode 100755 index 0000000000..e71045722c --- /dev/null +++ b/auth/composables-material3/src/main/res/values-sk/strings.xml @@ -0,0 +1,28 @@ + + + + Niečo sa pokazilo. Skúste to znova. + Dobrý deň, %s + Dobrý deň. + Prihláste sa + Prihlásiť sa + Použiť bez účtu + Ďalšie možnosti + Vytvoriť účet + Vyberte účet + Prihlasuje sa… + diff --git a/auth/composables-material3/src/main/res/values-sl/strings.xml b/auth/composables-material3/src/main/res/values-sl/strings.xml new file mode 100755 index 0000000000..1b7bec2c29 --- /dev/null +++ b/auth/composables-material3/src/main/res/values-sl/strings.xml @@ -0,0 +1,28 @@ + + + + Prišlo je do napake. Poskusite znova. + Živijo, %s + Živijo! + Prijava + Prijava + Uporaba brez računa + Druge možnosti + Ustvarite račun + Izbira računa + Prijava … + diff --git a/auth/composables-material3/src/main/res/values-sq/strings.xml b/auth/composables-material3/src/main/res/values-sq/strings.xml new file mode 100755 index 0000000000..e6edc064cd --- /dev/null +++ b/auth/composables-material3/src/main/res/values-sq/strings.xml @@ -0,0 +1,28 @@ + + + + Ka ndodhur një gabim. Provo përsëri. + Ç\'kemi, %s + Përshëndetje! + Identifikohu + Identifikohu + Përdore pa llogarinë + Opsione të tjera + Krijo një llogari + Zgjidh llogarinë + Po identifikohesh… + diff --git a/auth/composables-material3/src/main/res/values-sr/strings.xml b/auth/composables-material3/src/main/res/values-sr/strings.xml new file mode 100755 index 0000000000..8700b3df78 --- /dev/null +++ b/auth/composables-material3/src/main/res/values-sr/strings.xml @@ -0,0 +1,28 @@ + + + + Дошло је до грешке. Пробајте поново. + Здраво %s, + Здраво! + Пријавите се + Пријави ме + Користи без налога + Друге опције + Отвори налог + Изаберите налог + Пријављујете се… + diff --git a/auth/composables-material3/src/main/res/values-sv/strings.xml b/auth/composables-material3/src/main/res/values-sv/strings.xml new file mode 100755 index 0000000000..2fb971e3e5 --- /dev/null +++ b/auth/composables-material3/src/main/res/values-sv/strings.xml @@ -0,0 +1,28 @@ + + + + Något blev fel. Försök igen. + Hej %s! + Hej! + Logga in + Logga in + Använd utan konto + Andra alternativ + Skapa konto + Välj konto + Loggar in … + diff --git a/auth/composables-material3/src/main/res/values-sw/strings.xml b/auth/composables-material3/src/main/res/values-sw/strings.xml new file mode 100755 index 0000000000..406377411f --- /dev/null +++ b/auth/composables-material3/src/main/res/values-sw/strings.xml @@ -0,0 +1,28 @@ + + + + Hitilafu imetokea. Jaribu tena. + Hujambo, %s + Hujambo! + Ingia Katika Akaunti + Ingia katika akaunti + Tumia bila akaunti + Chaguo zingine + Fungua akaunti mpya + Chagua akaunti + Inaingia katika akaunti... + diff --git a/auth/composables-material3/src/main/res/values-ta/strings.xml b/auth/composables-material3/src/main/res/values-ta/strings.xml new file mode 100755 index 0000000000..7c9c553d6e --- /dev/null +++ b/auth/composables-material3/src/main/res/values-ta/strings.xml @@ -0,0 +1,28 @@ + + + + ஏதோ தவறாகிவிட்டது. மீண்டும் முயலவும். + வணக்கம் %s, + வணக்கம்! + உள்நுழையுங்கள் + உள்நுழையுங்கள் + கணக்கின்றி உபயோகம் + பிற விருப்பங்கள் + கணக்கை உருவாக்குக + கணக்கைத் தேர்ந்தெடுங்கள் + உள்நுழைகிறீர்கள்… + diff --git a/auth/composables-material3/src/main/res/values-te/strings.xml b/auth/composables-material3/src/main/res/values-te/strings.xml new file mode 100755 index 0000000000..6cad8f1fc2 --- /dev/null +++ b/auth/composables-material3/src/main/res/values-te/strings.xml @@ -0,0 +1,28 @@ + + + + ఏదో తప్పు జరిగింది. మళ్లీ ట్రై చేయండి. + హాయ్, %s + హాయ్! + సైన్ ఇన్ చేయండి + సైన్ ఇన్ చేయండి + ఖాతా లేకుండా ఉపయోగం + ఇతర ఆప్షన్‌లు + ఖాతాను క్రియేట్ చేయి + ఖాతాను ఎంచుకోండి + సైన్ ఇన్ చేస్తోంది… + diff --git a/auth/composables-material3/src/main/res/values-th/strings.xml b/auth/composables-material3/src/main/res/values-th/strings.xml new file mode 100755 index 0000000000..8ea630a268 --- /dev/null +++ b/auth/composables-material3/src/main/res/values-th/strings.xml @@ -0,0 +1,28 @@ + + + + เกิดข้อผิดพลาด โปรดลองอีกครั้ง + สวัสดี คุณ %s + สวัสดี + ลงชื่อเข้าใช้ + ลงชื่อเข้าใช้ + ใช้โดยไม่มีบัญชี + ตัวเลือกอื่นๆ + สร้างบัญชี + เลือกบัญชี + กำลังลงชื่อเข้าใช้… + diff --git a/auth/composables-material3/src/main/res/values-tr/strings.xml b/auth/composables-material3/src/main/res/values-tr/strings.xml new file mode 100755 index 0000000000..4c6eafa571 --- /dev/null +++ b/auth/composables-material3/src/main/res/values-tr/strings.xml @@ -0,0 +1,28 @@ + + + + Bir sorun oluştu. Tekrar deneyin. + Merhaba %s, + Merhaba! + Oturum Aç + Oturum aç + Hesap olmadan kullan + Diğer seçenekler + Hesap oluştur + Hesap seçin + Oturum açılıyor… + diff --git a/auth/composables-material3/src/main/res/values-uk/strings.xml b/auth/composables-material3/src/main/res/values-uk/strings.xml new file mode 100755 index 0000000000..8c868771ee --- /dev/null +++ b/auth/composables-material3/src/main/res/values-uk/strings.xml @@ -0,0 +1,28 @@ + + + + Сталася помилка. Повторіть спробу. + Вітаємо, %s! + Вітаємо! + Вхід + Увійти + Не входити + Інші варіанти + Створити обл. запис + Вибрати обліковий запис + Вхід… + diff --git a/auth/composables-material3/src/main/res/values-ur/strings.xml b/auth/composables-material3/src/main/res/values-ur/strings.xml new file mode 100755 index 0000000000..c731aca1c6 --- /dev/null +++ b/auth/composables-material3/src/main/res/values-ur/strings.xml @@ -0,0 +1,28 @@ + + + + کچھ غلط ہو گیا۔ دوبارہ کوشش کریں۔ + آداب، %s + آداب! + سائن ان کریں + سائن ان کریں + اکاؤنٹ کے بغیر استعمال کریں + دیگر اختیارات + اکاؤنٹ بنائیں + اکاؤنٹ منتخب کریں + سائن ان کیا جا رہا ہے… + diff --git a/auth/composables-material3/src/main/res/values-uz/strings.xml b/auth/composables-material3/src/main/res/values-uz/strings.xml new file mode 100755 index 0000000000..21c932eb1a --- /dev/null +++ b/auth/composables-material3/src/main/res/values-uz/strings.xml @@ -0,0 +1,28 @@ + + + + Nimadir xato ketdi. Qayta urining. + Salom, %s + Salom! + Kirish + Kirish + Hisobsiz foydalanish + Boshqa variantlar + Hisob yaratish + Hisobni tanlash + Tizimga kirilmoqda… + diff --git a/auth/composables-material3/src/main/res/values-vi/strings.xml b/auth/composables-material3/src/main/res/values-vi/strings.xml new file mode 100755 index 0000000000..c9bda6285b --- /dev/null +++ b/auth/composables-material3/src/main/res/values-vi/strings.xml @@ -0,0 +1,28 @@ + + + + Đã xảy ra lỗi. Hãy thử lại. + Xin chào %s! + Chào bạn! + Đăng nhập + Đăng nhập + Không dùng tài khoản + Tuỳ chọn khác + Tạo tài khoản + Chọn tài khoản + Đang đăng nhập… + diff --git a/auth/composables-material3/src/main/res/values-zh-rCN/strings.xml b/auth/composables-material3/src/main/res/values-zh-rCN/strings.xml new file mode 100755 index 0000000000..38e0ec4009 --- /dev/null +++ b/auth/composables-material3/src/main/res/values-zh-rCN/strings.xml @@ -0,0 +1,28 @@ + + + + 出了点问题。请重试。 + %s,您好 + 您好! + 登录 + 登录 + 在不登录账号的情况下使用 + 其他选项 + 创建账号 + 选择账号 + 正在登录… + diff --git a/auth/composables-material3/src/main/res/values-zh-rHK/strings.xml b/auth/composables-material3/src/main/res/values-zh-rHK/strings.xml new file mode 100755 index 0000000000..afad44ff3e --- /dev/null +++ b/auth/composables-material3/src/main/res/values-zh-rHK/strings.xml @@ -0,0 +1,28 @@ + + + + 發生錯誤,請再試一次。 + %s,你好 + 你好! + 登入 + 登入 + 不登入帳戶使用 + 其他選項 + 建立帳戶 + 選取帳戶 + 正在登入… + diff --git a/auth/composables-material3/src/main/res/values-zh-rTW/strings.xml b/auth/composables-material3/src/main/res/values-zh-rTW/strings.xml new file mode 100755 index 0000000000..bf4a4552e7 --- /dev/null +++ b/auth/composables-material3/src/main/res/values-zh-rTW/strings.xml @@ -0,0 +1,28 @@ + + + + 發生錯誤,請再試一次。 + %s,你好 + 你好! + 登入 + 登入 + 在不登入帳戶的狀態下使用 + 其他選項 + 建立帳戶 + 選取帳戶 + 登入中… + diff --git a/auth/composables-material3/src/main/res/values-zu/strings.xml b/auth/composables-material3/src/main/res/values-zu/strings.xml new file mode 100755 index 0000000000..91bbbef91c --- /dev/null +++ b/auth/composables-material3/src/main/res/values-zu/strings.xml @@ -0,0 +1,28 @@ + + + + Kunokuthile okungahambanga kahle. Zama futhi. + Sawubona, %s + Sawubona! + Ngena ngemvume + Ngena ngemvume + Sebenzisa ngaphandle kwe-akhawunti + Okunye ongakhetha kukho + Sungula i-akhawunti + Khetha i-akhawunti + Ingena ngemvume… + diff --git a/auth/composables-material3/src/main/res/values/strings.xml b/auth/composables-material3/src/main/res/values/strings.xml new file mode 100644 index 0000000000..8b6507e7f6 --- /dev/null +++ b/auth/composables-material3/src/main/res/values/strings.xml @@ -0,0 +1,28 @@ + + + + Something\'s gone wrong. Try again. + Hi, %s + Hi! + Sign In + Sign in + Use without account + Other options + Create account + Select account + Signing in… + \ No newline at end of file diff --git a/auth/composables-material3/src/test/java/com/google/android/horologist/auth/composables/material3/screens/SelectAccountScreenTest.kt b/auth/composables-material3/src/test/java/com/google/android/horologist/auth/composables/material3/screens/SelectAccountScreenTest.kt new file mode 100644 index 0000000000..2c8cd43bf1 --- /dev/null +++ b/auth/composables-material3/src/test/java/com/google/android/horologist/auth/composables/material3/screens/SelectAccountScreenTest.kt @@ -0,0 +1,75 @@ +/* + * 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. + */ + +package com.google.android.horologist.auth.composables.material3.screens + +import com.google.android.horologist.auth.composables.material3.R +import com.google.android.horologist.auth.composables.material3.models.AccountUiModel +import com.google.android.horologist.images.base.paintable.DrawableResPaintable +import com.google.android.horologist.screenshots.rng.WearLegacyScreenTest +import org.junit.Test + +class SelectAccountScreenTest : WearLegacyScreenTest() { + + @Test + fun selectAccountScreen() { + runTest { + SelectAccountScreen( + accounts = listOf( + AccountUiModel( + email = "timandrews123@example.com", + name = "Timothy Andrews", + avatar = DrawableResPaintable(R.drawable.horologist_avatar_small_1), + ), + AccountUiModel( + email = "thisisaverylongemailaccountsample@example.com", + name = "Kim Wong", + avatar = DrawableResPaintable(R.drawable.horologist_avatar_small_2), + ), + ), + onAccountClicked = { _, _ -> }, + title = "Select Account", + ) + } + } + + @Test + fun selectAccountScreenNoAvatar() { + runTest { + SelectAccountScreen( + accounts = listOf( + AccountUiModel( + email = "thisisaverylongemailaccountsample@example.com", + name = "Extenta Namuratus Hereditus III", + avatar = null, + ), + AccountUiModel( + email = "timandrews123@example.com", + name = "Timothy Andrews", + avatar = null, + ), + AccountUiModel( + email = "thisisaverylongemailaccountsample@example.com", + name = "Kim Wong", + avatar = null, + ), + ), + onAccountClicked = { _, _ -> }, + title = "Select Account", + ) + } + } +} diff --git a/auth/composables-material3/src/test/java/com/google/android/horologist/auth/composables/material3/screens/SignedInConfirmationScreenTest.kt b/auth/composables-material3/src/test/java/com/google/android/horologist/auth/composables/material3/screens/SignedInConfirmationScreenTest.kt new file mode 100644 index 0000000000..47226cf098 --- /dev/null +++ b/auth/composables-material3/src/test/java/com/google/android/horologist/auth/composables/material3/screens/SignedInConfirmationScreenTest.kt @@ -0,0 +1,62 @@ +/* + * 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. + */ + +package com.google.android.horologist.auth.composables.material3.screens + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.ui.Modifier +import com.google.android.horologist.auth.composables.material3.R +import com.google.android.horologist.images.base.paintable.DrawableResPaintable +import com.google.android.horologist.screenshots.rng.WearLegacyScreenTest +import org.junit.Test + +class SignedInConfirmationScreenTest : WearLegacyScreenTest() { + + @Test + fun signedInConfirmationScreen() { + runTest { + SignedInConfirmationDialogContent( + modifier = Modifier.fillMaxSize(), + name = "Maggie", + email = "maggie123@example.com", + avatar = DrawableResPaintable(R.drawable.horologist_avatar_small_3), + ) + } + } + + @Test + fun signedInConfirmationScreenLongEmailAndName() { + runTest { + SignedInConfirmationDialogContent( + modifier = Modifier.fillMaxSize(), + name = "Extenta Namuratus Hereditus III", + email = "thisisaverylongemailaccountsample@example.com", + avatar = DrawableResPaintable(R.drawable.horologist_avatar_small_3), + ) + } + } + + @Test + fun signedInConfirmationNoAvatar() { + runTest { + SignedInConfirmationDialogContent( + modifier = Modifier.fillMaxSize(), + name = "Maggie", + email = "maggie123@example.com", + ) + } + } +} diff --git a/auth/composables-material3/src/test/snapshots/images/com.google.android.horologist.auth.composables.material3.screens_SelectAccountScreenTest_selectAccountScreen.png b/auth/composables-material3/src/test/snapshots/images/com.google.android.horologist.auth.composables.material3.screens_SelectAccountScreenTest_selectAccountScreen.png new file mode 100644 index 0000000000..e4da584b3b --- /dev/null +++ b/auth/composables-material3/src/test/snapshots/images/com.google.android.horologist.auth.composables.material3.screens_SelectAccountScreenTest_selectAccountScreen.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:151e434b9dc5f4d1fd3b0273ed7ee67fa25dea771279c376b6d313cd6956fd38 +size 59518 diff --git a/auth/composables-material3/src/test/snapshots/images/com.google.android.horologist.auth.composables.material3.screens_SelectAccountScreenTest_selectAccountScreenNoAvatar.png b/auth/composables-material3/src/test/snapshots/images/com.google.android.horologist.auth.composables.material3.screens_SelectAccountScreenTest_selectAccountScreenNoAvatar.png new file mode 100644 index 0000000000..90122367d9 --- /dev/null +++ b/auth/composables-material3/src/test/snapshots/images/com.google.android.horologist.auth.composables.material3.screens_SelectAccountScreenTest_selectAccountScreenNoAvatar.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9b8c14b1fe76ebc17fb5df1cf7c9d499dd71ea64946e45d721d39d70a55beeb8 +size 42659 diff --git a/auth/composables-material3/src/test/snapshots/images/com.google.android.horologist.auth.composables.material3.screens_SignedInConfirmationScreenTest_signedInConfirmationNoAvatar.png b/auth/composables-material3/src/test/snapshots/images/com.google.android.horologist.auth.composables.material3.screens_SignedInConfirmationScreenTest_signedInConfirmationNoAvatar.png new file mode 100644 index 0000000000..c47d7a7484 --- /dev/null +++ b/auth/composables-material3/src/test/snapshots/images/com.google.android.horologist.auth.composables.material3.screens_SignedInConfirmationScreenTest_signedInConfirmationNoAvatar.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3a10a5c4095e8bca2b2d0cb296220250b9f799bcde43b9e61ae61182d548210c +size 22477 diff --git a/auth/composables-material3/src/test/snapshots/images/com.google.android.horologist.auth.composables.material3.screens_SignedInConfirmationScreenTest_signedInConfirmationScreen.png b/auth/composables-material3/src/test/snapshots/images/com.google.android.horologist.auth.composables.material3.screens_SignedInConfirmationScreenTest_signedInConfirmationScreen.png new file mode 100644 index 0000000000..74d65c7b2a --- /dev/null +++ b/auth/composables-material3/src/test/snapshots/images/com.google.android.horologist.auth.composables.material3.screens_SignedInConfirmationScreenTest_signedInConfirmationScreen.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b670044ec2c818f7850f6dc5fe7ce4581784a2b493b23665d7e6dbf650fdc948 +size 95140 diff --git a/auth/composables-material3/src/test/snapshots/images/com.google.android.horologist.auth.composables.material3.screens_SignedInConfirmationScreenTest_signedInConfirmationScreenLongEmailAndName.png b/auth/composables-material3/src/test/snapshots/images/com.google.android.horologist.auth.composables.material3.screens_SignedInConfirmationScreenTest_signedInConfirmationScreenLongEmailAndName.png new file mode 100644 index 0000000000..5e50b7f6bf --- /dev/null +++ b/auth/composables-material3/src/test/snapshots/images/com.google.android.horologist.auth.composables.material3.screens_SignedInConfirmationScreenTest_signedInConfirmationScreenLongEmailAndName.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5144c20f57b4c1d64f82c649f60b85e9942a45cd920652dbd4e491971f247f06 +size 97998 diff --git a/auth/composables/api/current.api b/auth/composables/api/current.api index cfdc2afa1e..8bc1e2c6af 100644 --- a/auth/composables/api/current.api +++ b/auth/composables/api/current.api @@ -27,7 +27,7 @@ package com.google.android.horologist.auth.composables.chips { package com.google.android.horologist.auth.composables.dialogs { public final class SignedInConfirmationDialogKt { - method @androidx.compose.runtime.Composable public static void SignedInConfirmationDialog(kotlin.jvm.functions.Function0 onDismissOrTimeout, optional androidx.compose.ui.Modifier modifier, com.google.android.horologist.auth.composables.model.AccountUiModel accountUiModel, optional java.time.Duration duration); + method @Deprecated @androidx.compose.runtime.Composable public static void SignedInConfirmationDialog(kotlin.jvm.functions.Function0 onDismissOrTimeout, optional androidx.compose.ui.Modifier modifier, com.google.android.horologist.auth.composables.model.AccountUiModel accountUiModel, optional java.time.Duration duration); method @androidx.compose.runtime.Composable public static void SignedInConfirmationDialog(kotlin.jvm.functions.Function0 onDismissOrTimeout, optional androidx.compose.ui.Modifier modifier, optional String? name, optional String? email, optional com.google.android.horologist.images.base.paintable.Paintable? avatar, optional java.time.Duration duration); } @@ -35,18 +35,18 @@ package com.google.android.horologist.auth.composables.dialogs { package com.google.android.horologist.auth.composables.model { - public final class AccountUiModel { - ctor public AccountUiModel(String email, optional String? name, optional com.google.android.horologist.images.base.paintable.Paintable? avatar); - method public String component1(); - method public String? component2(); - method public com.google.android.horologist.images.base.paintable.Paintable? component3(); - method public com.google.android.horologist.auth.composables.model.AccountUiModel copy(String email, String? name, com.google.android.horologist.images.base.paintable.Paintable? avatar); - method public com.google.android.horologist.images.base.paintable.Paintable? getAvatar(); - method public String getEmail(); - method public String? getName(); - property public final com.google.android.horologist.images.base.paintable.Paintable? avatar; - property public final String email; - property public final String? name; + @Deprecated public final class AccountUiModel { + ctor @Deprecated public AccountUiModel(String email, optional String? name, optional com.google.android.horologist.images.base.paintable.Paintable? avatar); + method @Deprecated public String component1(); + method @Deprecated public String? component2(); + method @Deprecated public com.google.android.horologist.images.base.paintable.Paintable? component3(); + method @Deprecated public com.google.android.horologist.auth.composables.model.AccountUiModel copy(String email, String? name, com.google.android.horologist.images.base.paintable.Paintable? avatar); + method @Deprecated public com.google.android.horologist.images.base.paintable.Paintable? getAvatar(); + method @Deprecated public String getEmail(); + method @Deprecated public String? getName(); + property @Deprecated public final com.google.android.horologist.images.base.paintable.Paintable? avatar; + property @Deprecated public final String email; + property @Deprecated public final String? name; } } @@ -62,7 +62,7 @@ package com.google.android.horologist.auth.composables.screens { } public final class SelectAccountScreenKt { - method @androidx.compose.runtime.Composable public static void SelectAccountScreen(java.util.List accounts, kotlin.jvm.functions.Function2 onAccountClicked, optional androidx.compose.ui.Modifier modifier, optional String title, optional com.google.android.horologist.images.base.paintable.Paintable? defaultAvatar); + method @Deprecated @androidx.compose.runtime.Composable public static void SelectAccountScreen(java.util.List accounts, kotlin.jvm.functions.Function2 onAccountClicked, optional androidx.compose.ui.Modifier modifier, optional String title, optional com.google.android.horologist.images.base.paintable.Paintable? defaultAvatar); } public final class SignInPlaceholderScreenKt { diff --git a/auth/composables/build.gradle.kts b/auth/composables/build.gradle.kts index 4a1746addb..8537e76e71 100644 --- a/auth/composables/build.gradle.kts +++ b/auth/composables/build.gradle.kts @@ -100,6 +100,7 @@ dependencies { implementation(projects.composeMaterial) implementation(projects.images.coil) + implementation(platform(libs.compose.bom)) implementation(libs.compose.foundation.foundation) implementation(libs.compose.foundation.foundation.layout) implementation(libs.compose.material.iconscore) diff --git a/auth/composables/src/main/java/com/google/android/horologist/auth/composables/dialogs/SignedInConfirmationDialog.kt b/auth/composables/src/main/java/com/google/android/horologist/auth/composables/dialogs/SignedInConfirmationDialog.kt index cffd97a173..7537dcc00b 100644 --- a/auth/composables/src/main/java/com/google/android/horologist/auth/composables/dialogs/SignedInConfirmationDialog.kt +++ b/auth/composables/src/main/java/com/google/android/horologist/auth/composables/dialogs/SignedInConfirmationDialog.kt @@ -88,6 +88,7 @@ public fun SignedInConfirmationDialog( * */ @Composable +@Deprecated("Please use SignedInConfirmationDialog from the material3 module") public fun SignedInConfirmationDialog( onDismissOrTimeout: () -> Unit, modifier: Modifier = Modifier, diff --git a/auth/composables/src/main/java/com/google/android/horologist/auth/composables/model/AccountUiModel.kt b/auth/composables/src/main/java/com/google/android/horologist/auth/composables/model/AccountUiModel.kt index 27a6477178..ddf86b0b75 100644 --- a/auth/composables/src/main/java/com/google/android/horologist/auth/composables/model/AccountUiModel.kt +++ b/auth/composables/src/main/java/com/google/android/horologist/auth/composables/model/AccountUiModel.kt @@ -21,6 +21,9 @@ import com.google.android.horologist.images.base.paintable.Paintable /** * A UI model to represent an account. */ +@Deprecated( + message = "Please use AccountUiModel in composables-material3", +) public data class AccountUiModel( val email: String, val name: String? = null, diff --git a/auth/composables/src/main/java/com/google/android/horologist/auth/composables/screens/SelectAccountScreen.kt b/auth/composables/src/main/java/com/google/android/horologist/auth/composables/screens/SelectAccountScreen.kt index f49b220937..500d2d1acb 100644 --- a/auth/composables/src/main/java/com/google/android/horologist/auth/composables/screens/SelectAccountScreen.kt +++ b/auth/composables/src/main/java/com/google/android/horologist/auth/composables/screens/SelectAccountScreen.kt @@ -45,6 +45,9 @@ import com.google.android.horologist.images.base.paintable.Paintable * * */ +@Deprecated( + message = "Please use SelectAccountScreen in the composables-material3 module", +) @Composable public fun SelectAccountScreen( accounts: List, diff --git a/auth/data-phone/build.gradle.kts b/auth/data-phone/build.gradle.kts index 92f421aaf9..4e43c3aeb1 100644 --- a/auth/data-phone/build.gradle.kts +++ b/auth/data-phone/build.gradle.kts @@ -26,7 +26,7 @@ android { compileSdk = 36 defaultConfig { - minSdk = 21 + minSdk = 23 testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/auth/sample/phone/build.gradle.kts b/auth/sample/phone/build.gradle.kts index f363cdb8d2..23fe827e18 100644 --- a/auth/sample/phone/build.gradle.kts +++ b/auth/sample/phone/build.gradle.kts @@ -27,7 +27,7 @@ android { defaultConfig { applicationId = "com.google.android.horologist.auth.sample" - minSdk = 21 + minSdk = 23 targetSdk = 34 versionCode = 1 diff --git a/auth/sample/shared/build.gradle.kts b/auth/sample/shared/build.gradle.kts index 348c6faafd..6dfaa39522 100644 --- a/auth/sample/shared/build.gradle.kts +++ b/auth/sample/shared/build.gradle.kts @@ -25,7 +25,7 @@ android { compileSdk = 36 defaultConfig { - minSdk = 21 + minSdk = 23 testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/auth/ui/build.gradle.kts b/auth/ui/build.gradle.kts index fa523e292b..d881469aba 100644 --- a/auth/ui/build.gradle.kts +++ b/auth/ui/build.gradle.kts @@ -105,6 +105,7 @@ dependencies { api(libs.kotlinx.coroutines.core) api(libs.wearcompose.foundation) + implementation(platform(libs.compose.bom)) implementation(projects.composeMaterial) implementation(projects.images.coil) diff --git a/build.gradle.kts b/build.gradle.kts index 979511998c..8ccd23cc17 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -2,7 +2,6 @@ import com.android.build.gradle.LibraryExtension import com.vanniktech.maven.publish.AndroidSingleVariantLibrary import com.vanniktech.maven.publish.JavaLibrary import com.vanniktech.maven.publish.JavadocJar -import com.vanniktech.maven.publish.SonatypeHost import org.jetbrains.kotlin.gradle.dsl.JvmTarget import java.net.URI import java.util.Properties @@ -115,7 +114,6 @@ allprojects { } else if (project.plugins.hasPlugin("java-library")) { configure(JavaLibrary(javadocJar = JavadocJar.Empty(), sourcesJar = true)) } - publishToMavenCentral(SonatypeHost("https://google.oss.sonatype.org")) } } } @@ -161,16 +159,6 @@ subprojects { } } - // Read in the signing.properties file if it is exists - val signingPropsFile = rootProject.file("release/signing.properties") - if (signingPropsFile.exists()) { - val localProperties = Properties() - signingPropsFile.inputStream().use { istream -> localProperties.load(istream) } - localProperties.forEach { prop -> - project.extra[prop.key as String] = prop.value - } - } - // Must be afterEvaluate or else com.vanniktech.maven.publish will overwrite our // dokka and version configuration. afterEvaluate { @@ -180,6 +168,7 @@ subprojects { } tasks.named("dokkaHtmlPartial") { + failOnWarning.set(false) dokkaSourceSets.configureEach { reportUndocumented.set(true) skipEmptyPackages.set(true) diff --git a/compose-layout/api/current.api b/compose-layout/api/current.api index 12d5742d4d..35b6be41fe 100644 --- a/compose-layout/api/current.api +++ b/compose-layout/api/current.api @@ -215,6 +215,26 @@ package com.google.android.horologist.compose.layout { } +package com.google.android.horologist.compose.layout.m3 { + + public final class FastScrollingTransformingLazyColumnKt { + method @androidx.compose.runtime.Composable public static void FastScrollingTransformingLazyColumn(androidx.wear.compose.foundation.lazy.TransformingLazyColumnState state, androidx.compose.runtime.snapshots.SnapshotStateList headers, optional androidx.compose.ui.Modifier modifier, optional float sectionIndictatorTopPadding, optional androidx.compose.foundation.layout.PaddingValues contentPadding, kotlin.jvm.functions.Function1 content); + } + + public final class HeaderInfo { + ctor public HeaderInfo(int index, String value, optional java.util.Map inlineContent, optional Integer? extraScrollToOffset); + method public Integer? getExtraScrollToOffset(); + method public int getIndex(); + method public java.util.Map getInlineContent(); + method public String getValue(); + property public final Integer? extraScrollToOffset; + property public final int index; + property public final java.util.Map inlineContent; + property public final String value; + } + +} + package com.google.android.horologist.compose.nav { public final class TypeSafeKt { diff --git a/compose-layout/build.gradle.kts b/compose-layout/build.gradle.kts index 5b195fa95c..18634a506b 100644 --- a/compose-layout/build.gradle.kts +++ b/compose-layout/build.gradle.kts @@ -86,6 +86,7 @@ metalava { dependencies { api(projects.annotations) + implementation(platform(libs.compose.bom)) api(libs.wearcompose.material) api(libs.wearcompose.foundation) @@ -100,6 +101,8 @@ dependencies { implementation(libs.compose.ui.tooling) implementation(libs.androidx.wear.tooling.preview) implementation(libs.wearcompose.tooling) + implementation(libs.androidx.wear.compose.material3) + implementation(libs.compose.foundation.foundation) debugImplementation(libs.compose.ui.toolingpreview) debugImplementation(projects.composeTools) diff --git a/compose-layout/src/main/java/com/google/android/horologist/compose/layout/m3/FastScrollingTransformingLazyColumn.kt b/compose-layout/src/main/java/com/google/android/horologist/compose/layout/m3/FastScrollingTransformingLazyColumn.kt new file mode 100644 index 0000000000..3eb9ef752b --- /dev/null +++ b/compose-layout/src/main/java/com/google/android/horologist/compose/layout/m3/FastScrollingTransformingLazyColumn.kt @@ -0,0 +1,440 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.horologist.compose.layout.m3 + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.updateTransition +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.FlingBehavior +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.ScrollScope +import androidx.compose.foundation.gestures.ScrollableDefaults +import androidx.compose.foundation.gestures.animateScrollBy +import androidx.compose.foundation.gestures.scrollBy +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredHeight +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.foundation.text.appendInlineContent +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.platform.LocalWindowInfo +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.wear.compose.foundation.lazy.TransformingLazyColumn +import androidx.wear.compose.foundation.lazy.TransformingLazyColumnScope +import androidx.wear.compose.foundation.lazy.TransformingLazyColumnState +import androidx.wear.compose.foundation.rotary.RotaryScrollableBehavior +import androidx.wear.compose.material3.MaterialTheme +import androidx.wear.compose.material3.MotionScheme.Companion.expressive +import androidx.wear.compose.material3.MotionScheme.Companion.standard +import androidx.wear.compose.material3.Text +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.yield +import kotlin.math.abs + +/** + * Modification of the TransformingLazyColumn that allows for fast scrolling to objects with + * headers. This is done by on navigation via RSB and scrolling to past a specific speed threshold, + * the list will begin scrolling directly to each section header instead of scrolling through each + * + * @param state The scroll state of the list. + * @param headers The headers within the list, which includes the content of the header and the + * index. This is used by the FastScrollingTransformingLazyColumn to display a header over the + * given information and snap to the speicified header. + * @property modifier The modifier(s) to apply to the list. + * @property sectionIndictatorTopPadding The top padding to apply to the section indicator. This + * should only be needed to align with the header scrolled to when the scrollToOffset is NOT 0. + * @property content The content within the list. This can be used the exact same way as the + * TransformingLazyColumn with content, though do note that any items that you do not want to + * scroll to need to be considered when passing in the HeaderInfo's index to the + * FastScrollingTransformingLazyColumn + */ +@OptIn(ExperimentalComposeUiApi::class) +@Composable +public fun FastScrollingTransformingLazyColumn( + state: TransformingLazyColumnState, + headers: SnapshotStateList, + modifier: Modifier = Modifier, + sectionIndictatorTopPadding: Dp = 0.dp, + contentPadding: PaddingValues = PaddingValues(), + content: TransformingLazyColumnScope.() -> Unit, +) { + val haptics = LocalHapticFeedback.current + val screenHeight = LocalWindowInfo.current.containerSize.height + val defaultFlingBehavior = ScrollableDefaults.flingBehavior() + + val coroutineScope = rememberCoroutineScope() + var fadingOutJob: Job? by remember { mutableStateOf(null) } + var animationJob: Job? by remember { mutableStateOf(null) } + + // Total scroll-to offset for the list. This is the sum of the remaining letter height and the + // section indicator top padding, with whatever extra top padding is passed in from the composable. + val scrollToOffset = + with(LocalDensity.current) { + ( + Constants.REMAINING_LETTER_HEIGHT + + Constants.SECTION_INDICATOR_TOP_PADDING + + sectionIndictatorTopPadding + ) + .roundToPx() + } + + var currentSectionIndex by remember { mutableIntStateOf(0) } + + var firstSkimTime by remember { mutableLongStateOf(0L) } + var verticalScrollPixels by remember { mutableFloatStateOf(0f) } + + var isSkimming by remember { mutableStateOf(false) } + var rsbScrollCount by remember { mutableIntStateOf(0) } + var isFirstFastScroll by remember { mutableStateOf(false) } + val currentSectionHeader: HeaderInfo? = + remember(headers, currentSectionIndex) { headers.getOrNull(currentSectionIndex) } + + var indicatorState by remember { mutableStateOf(IndicatorState.START) } + var pixelsScrolledBy by remember { mutableFloatStateOf(0f) } + + val transition = updateTransition(indicatorState) + + val indicatorWidthScale by + transition.animateFloat( + transitionSpec = { + when { + IndicatorState.START isTransitioningTo IndicatorState.SPRING -> + standard().defaultEffectsSpec() + IndicatorState.SPRING isTransitioningTo IndicatorState.END -> + expressive().fastSpatialSpec() + else -> standard().defaultEffectsSpec() + } + }, + label = "width", + ) { + when (it) { + IndicatorState.START -> 1f + IndicatorState.SPRING -> 1.25f + IndicatorState.END -> 1f + } + } + + fun setCurrentSectionIndex(firstItemIndex: Int) { + if (currentSectionIndex != firstItemIndex) { + currentSectionIndex = firstItemIndex + } + } + + fun scrollListToSection() { + val headerOffset = scrollToOffset + (headers[currentSectionIndex].extraScrollToOffset ?: 0) + + val offset = headerOffset + (screenHeight * -.5).toInt() + + coroutineScope.launch { + haptics.performHapticFeedback(HapticFeedbackType.SegmentTick) + // We run animateScrollBy with a movement of 0 just to remove the timeText from the screen and + // show the position indicators, as animateScrollToItem will fling from each section. + state.animateScrollBy(0f) + yield() + state.scrollToItem(headers[currentSectionIndex].index, offset) + } + } + + fun skimSections(target: Int) { + currentSectionIndex = target + + scrollListToSection() + // Start the animation, and cancel the previous animations if any were running + animationJob?.cancel() + animationJob = + coroutineScope.launch { + if (indicatorState != IndicatorState.START) { + indicatorState = IndicatorState.START + } + delay(50) + indicatorState = IndicatorState.SPRING + delay(50) + indicatorState = IndicatorState.END + } + + // After every skim, we will run a job that will fade out the indicator and reset the flags + // once the timeout is reached. This will continuously allow the skim to keep running if + // skimming keeps being performed. + + fadingOutJob?.cancel() + fadingOutJob = + coroutineScope.launch { + delay(Constants.RSB_SKIMMING_TIMEOUT) + // Skim has finally ended, as another skim did not happen to reset the skim flag. + isSkimming = false + } + } + + fun handleSkim(currentTime: Long, isScrollingDown: Boolean, verticalScrollPixels: Float) { + if (!isFirstFastScroll) { + // If we fast scroll in two different directions, we will reset the pixels scrolled + // by to 0 to make sure skims in the opposite direction will be performed as intended. + if ( + (verticalScrollPixels > 0f && pixelsScrolledBy < 0f) || + (verticalScrollPixels < 0f && pixelsScrolledBy > 0f) + ) { + pixelsScrolledBy = 0f + } + + // If it has been more than the timeout since the last skim, we will begin taking in + // the fast scrolling pixels. This is to prevent the case where a user starts + // skimming mode by scrolling rapidly, but only wants to move a single section. + if (currentTime - firstSkimTime > Constants.FIRST_SCROLL_TIMEOUT) { + pixelsScrolledBy += verticalScrollPixels + } + val sectionsToSkimBy = + (abs(pixelsScrolledBy) / Constants.VERTICAL_SCROLL_BY_THRESHOLD).toInt() + pixelsScrolledBy %= Constants.VERTICAL_SCROLL_BY_THRESHOLD + for (i in 0..= 0 && currentSectionIndex < headers.size) + val scrollCount = (abs(verticalScrollPixels) / Constants.RSB_SPEED_THRESHOLD).toInt() + + if (!isSkimming && scrollCount > 0 && canFastScroll) { + rsbScrollCount += scrollCount + if (rsbScrollCount > 5) { + isFirstFastScroll = true + pixelsScrolledBy = 0f + isSkimming = true + rsbScrollCount = 0 + } + } + + if (isSkimming) { + handleSkim(currentTime = currentTime, isScrollingDown = verticalScrollPixels > 0f, verticalScrollPixels = delta) + } else { + haptics.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick) + coroutineScope.launch { + // Here, we animate the scroll by 0f to remove the timeText from the screen and + // show the position indicators. Running animateScrollBy by the verticalScrollPixels + // does not scroll as much as scrollBy for some reason. + state.animateScrollBy(0f) + yield() + state.scrollBy(verticalScrollPixels) + } + } + } + } + + TransformingLazyColumn( + state = state, + flingBehavior = flingBehavior, + rotaryScrollableBehavior = rotaryScrollableBehavior, + modifier = + modifier + .fillMaxWidth(), + contentPadding = remember { contentPadding }, + ) { + content() + } + + AnimatedVisibility(visible = isSkimming, enter = fadeIn(), exit = fadeOut()) { + SectionIndicator( + indicatorWidthScale, + currentSectionHeader, + sectionIndictatorTopPadding, + ) + } + + LaunchedEffect(key1 = Unit) { + snapshotFlow { (state.layoutInfo.visibleItems.firstOrNull()?.index ?: 0) } + .collect { visibleItemIndex -> + if (!isSkimming && headers.isNotEmpty()) { + val searchResult = headers.binarySearchBy(visibleItemIndex) { it.index } + val sectionIndex = + if (searchResult >= 0) { + // Exact match found + searchResult + } else { + // No exact match, visibleItemIndex is between header indices. + // binarySearchBy returns (-insertion point - 1). + // The section index is the item before the insertion point. + val insertionPoint = -searchResult - 1 + (insertionPoint - 1).coerceIn(0, headers.size - 1) + } + setCurrentSectionIndex(sectionIndex) + } + } + } + } +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +private fun SectionIndicator( + indicatorWidthScale: Float, + currentSectionHeader: HeaderInfo?, + sectionIndictatorTopPadding: Dp, +) { + val shape = remember { RoundedCornerShape(24.dp) } + val annotatedText = + remember(currentSectionHeader) { + if (currentSectionHeader != null) { + val inlineContent = currentSectionHeader.inlineContent + if (inlineContent.isNotEmpty()) { + buildAnnotatedString { + appendInlineContent(inlineContent.keys.first()) + append(currentSectionHeader.value) + } + } else { + buildAnnotatedString { append(currentSectionHeader.value) } + } + } else { + buildAnnotatedString { append("") } + } + } + val inlineContentMap = + remember(currentSectionHeader) { currentSectionHeader?.inlineContent ?: mapOf() } + + Box( + contentAlignment = Alignment.TopCenter, + modifier = Modifier.fillMaxWidth().padding(top = sectionIndictatorTopPadding), + ) { + Box( + modifier = + Modifier.graphicsLayer { this.scaleX = indicatorWidthScale } + .clip(shape) + .requiredHeight(Constants.INDICATOR_HEIGHT) + .sizeIn(minWidth = Constants.INDICATOR_WIDTH) + .background(MaterialTheme.colorScheme.secondary), + contentAlignment = Alignment.Center, + ) { + Text( + modifier = Modifier.padding(horizontal = 16.dp), + color = MaterialTheme.colorScheme.onSecondary, + fontWeight = FontWeight.Bold, + text = annotatedText, + inlineContent = inlineContentMap, + ) + } + } +} + +/** + * Class for storing the index and value of a header used for the FastScrollingTransformingLazyColumn. + * This is used to properly display and snap to the specified header during fast scrolling. + * + * @property index The index of the header in the list. + * @property value The value of the header's text in the list. + * @property inlineContent The optional inline content to be used in the header's text. The string + * key for this would be dependant on the user, but will be added within the appendInlineContent + * function when building an annotated string (see + * https://developer.android.com/reference/kotlin/androidx/compose/ui/text/AnnotatedString for + * more information). + * @property extraScrollToOffset The optional extra offset added to the default offset. + */ +public class HeaderInfo( + val index: Int, + val value: String, + val inlineContent: Map = mapOf(), + val extraScrollToOffset: Int? = null, +) + +// Indicatior's animation state used to modify the animation values during the animation. +private enum class IndicatorState { + START, + SPRING, + END, +} + +private object Constants { + val INDICATOR_WIDTH = 52.dp + val INDICATOR_HEIGHT = 32.dp + + // The remaining height of the letter in the header text. When scrolled to just 6.dp, the text + // will be fully visible. + val REMAINING_LETTER_HEIGHT = 6.dp + + // The inside padding of the section indicator. Text inside is 20.dp with the height being 32, so + // the rest would be 6.dp for the top and bottom (though we only care for the bottom if we are + // attempting to align) + val SECTION_INDICATOR_TOP_PADDING = 6.dp + + // Threshold for the number of pixels the list must scroll before we skim to the next section. + const val VERTICAL_SCROLL_BY_THRESHOLD = 65 + const val FIRST_SCROLL_TIMEOUT = 500L + const val RSB_SPEED_THRESHOLD = 40 + const val RSB_THROTTLE = 150 + const val RSB_SKIMMING_TIMEOUT = 1500L +} diff --git a/compose-layout/src/test/java/com/google/android/horologist/compose/layout/FastScrollingTransformingLazyColumnTest.kt b/compose-layout/src/test/java/com/google/android/horologist/compose/layout/FastScrollingTransformingLazyColumnTest.kt new file mode 100644 index 0000000000..b894b8129e --- /dev/null +++ b/compose-layout/src/test/java/com/google/android/horologist/compose/layout/FastScrollingTransformingLazyColumnTest.kt @@ -0,0 +1,225 @@ +/* + * 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. + */ + +package com.google.android.horologist.compose.layout + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.toMutableStateList +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performRotaryScrollInput +import androidx.wear.compose.foundation.lazy.TransformingLazyColumnState +import androidx.wear.compose.foundation.lazy.items +import androidx.wear.compose.foundation.lazy.rememberTransformingLazyColumnState +import androidx.wear.compose.material3.AppScaffold +import androidx.wear.compose.material3.MaterialTheme +import androidx.wear.compose.material3.ScreenScaffold +import androidx.wear.compose.material3.SurfaceTransformation +import androidx.wear.compose.material3.Text +import androidx.wear.compose.material3.TimeText +import androidx.wear.compose.material3.TitleCard +import androidx.wear.compose.material3.lazy.rememberTransformationSpec +import androidx.wear.compose.material3.lazy.transformedHeight +import com.google.android.horologist.compose.layout.m3.FastScrollingTransformingLazyColumn +import com.google.android.horologist.compose.layout.m3.HeaderInfo +import com.google.android.horologist.screenshots.rng.WearDevice +import com.google.android.horologist.screenshots.rng.WearScreenshotTest +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.ParameterizedRobolectricTestRunner + +@RunWith(ParameterizedRobolectricTestRunner::class) +class FastScrollingTransformingLazyColumnTest(override val device: WearDevice) : + WearScreenshotTest() { + + override fun testName(suffix: String): String = + "src/test/screenshots/${this.javaClass.simpleName}_${testInfo.methodName}_" + "${device.id}$suffix.png" + + override val tolerance: Float + get() = if (device == WearDevice.Companion.GooglePixelWatchLargeFont) 0.05f else 0.0f + + @Composable + override fun TestScaffold(content: @Composable (() -> Unit)) { + content() + } + + open class ScrollableContent(var content: String) + + class Header(val title: String) : ScrollableContent(title) + class Person(val name: String) : ScrollableContent(name) + + val peopleString = """ + Olivia Smith, Liam Johnson, Emma Williams, Noah Brown, Ava Jones, Isabella Garcia, + Sophia Miller, James Davis, William Rodriguez, Benjamin Martinez, Lucas Hernandez, + Henry Lopez, Alexander Gonzalez, Mia Wilson, Charlotte Anderson, Amelia Thomas, + Evelyn Taylor, Abigail Moore, Daniel Jackson, Harper Martin, Ella Lee, Grace Perez, + Aiden Thompson, Jackson White, Scarlett Harris, Emily Sanchez, Michael Clark, + Elizabeth Ramirez, David Lewis, Mila Robinson, Joseph Walker, Chloe Hall, + Samuel Allen, Aubrey Young, Julian King, Zoey Wright, Leo Scott, Layla Green, + Gabriel Baker, Nora Adams, Anthony Nelson, Luna Hill, Christopher Rivera, + Victoria Campbell, Ryan Mitchell, Hannah Roberts, Nathan Carter, Natalie Phillips, + Caleb Parker, Leah Evans, Isaac Edwards, Zoe Collins, Joshua Stewart, Stella Morris, + Matthew Rogers, Aurora Reed, Andrew Cook, Dylan Morgan, John Bell, Genesis Murphy, + Luke Bailey, Sarah Cooper, Gabriel Richardson, Eva Cox, Nathan Howard, Penelope Ward, + Jacob Torres, Alexander Peterson, Mason Gray, Ethan Ramirez, Oliver James, + Elijah Watson, Sebastian Brooks, Owen Kelly, Logan Sanders, Caleb Price, + Dylan Bennett, Isaac Wood, Liam Barnes, Noah Ross, Lucas Henderson, Aiden Coleman, + Jack Jenkins, Daniel Perry, Joseph Powell, Samuel Long, Benjamin Patterson, Leo Hughes, + Julian Flores, Chloe Washington, Zoe Butler, Stella Simmons, Layla Foster, + Nora Gonzales, Luna Bryant, Harper Alexander, Mila Russell, Charlotte Griffin, + Amelia Diaz, Evelyn Hayes, Abigail Myers, Michael Ford, Elizabeth Hamilton, + David Graham, Ella Sullivan, Grace Wallace, Jackson Woods, Scarlett Cole, Emily West, + William Jordan, Benjamin Owens, Lucas Reynolds, Henry Kennedy, Alexander Stone, + Mia Shaw, Charlotte Snyder, Amelia Burke, Evelyn Spencer, Abigail Walsh, Daniel Dean, + Harper Fisher, Ella Lane, Grace Boyd, Aiden Fuller, Jackson Fields, Scarlett Black, + Emily Ryan, Michael Olsen, Elizabeth Pierce, David Porter, Mila Freeman, + Joseph Cunningham, Chloe Lawrence, Samuel Newman, Aubrey Hunt, Julian Meyer, + Zoey Marshall, Leo Stevens, Layla Dixon, Gabriel Arnold, Nora Boyd, Anthony Fuller, + Luna Hayes, Christopher Cox, Victoria Ward, Ryan Gray, Hannah Bailey, Nathan Brooks, + Natalie Kelly, Caleb Price, Leah Bennett, Isaac Barnes, Zoe Henderson, Joshua Coleman, + Stella Jenkins, Matthew Perry, Aurora Powell, Andrew Long, Dylan Patterson, John Hughes, + Genesis Flores, Luke Washington, Sarah Butler, Gabriel Simmons, Eva Foster, + Nathan Gonzales, Penelope Bryant, Jacob Alexander, Alexander Russell, Mason Griffin, + Ethan Diaz, Oliver Hayes, Elijah Myers, Sebastian Ford, Owen Hamilton, Logan Graham, + Caleb Sullivan, Dylan Wallace, Isaac Woods, Liam Cole, Noah West, Lucas Jordan, + Aiden Owens, Jack Reynolds, Daniel Kennedy, Joseph Stone, Samuel Shaw, Benjamin Snyder, + Leo Burke, Julian Spencer, Chloe Walsh, Zoe Dean, Stella Fisher, Layla Lane, Nora Boyd, + Luna Fuller, Harper Fields, Mila Black, Charlotte Ryan, Amelia Olsen, Evelyn Pierce, + Abigail Porter, Michael Freeman, Elizabeth Cunningham, David Lawrence, Ella Newman, + Grace Hunt, Jackson Meyer, Scarlett Marshall, Emily Stevens, William Dixon, + Benjamin Arnold, Lucas Boyd, Henry Fuller, Alexander Hayes, Mia Cox, Charlotte Ward, + Amelia Gray, Evelyn Bailey, Abigail Brooks, Daniel Kelly, Harper Price, Ella Bennett, + Grace Barnes, Aiden Henderson, Jackson Coleman, Scarlett Jenkins, Emily Perry, + Michael Powell, Elizabeth Long, David Patterson, Mila Hughes, Joseph Flores, + Chloe Washington, Samuel Butler, Aubrey Simmons, Julian Foster, Zoey Gonzales, + Leo Bryant, Layla Alexander, Nora Russell, Luna Griffin, Christopher Diaz, + Victoria Hayes, Ryan Myers, Hannah Ford, Nathan Hamilton, Natalie Graham, + Caleb Sullivan, Leah Wallace, Isaac Woods, Zoe Cole, Joshua West, Stella Jordan, + Matthew Owens, Aurora Reynolds, Andrew Kennedy, Dylan Stone, John Shaw, Genesis Snyder, + Luke Burke, Sarah Spencer, Gabriel Walsh, Eva Dean, Nathan Fisher, Penelope Lane, + Jacob Boyd, Alexander Fuller, Mason Fields, Ethan Black, Oliver Ryan, Elijah Olsen, + Sebastian Pierce, Owen Porter, Logan Freeman, Caleb Cunningham, Dylan Lawrence, + Isaac Newman, Liam Hunt, Noah Meyer, Lucas Marshall, Aiden Stevens, Jack Dixon, + Daniel Arnold, Joseph Boyd, Samuel Fuller, Benjamin Hayes, Leo Cox, Julian Ward, + Chloe Gray, Zoe Bailey, Stella Brooks, Layla Kelly, Nora Price, Luna Bennett, + Harper Barnes, Mila Henderson, Charlotte Coleman, Amelia Jenkins, Evelyn Perry, + Abigail Powell + """.trimIndent() + + val people = peopleString.split(",").map { + Person(it.trim()) + } + val headers = people.map { + Header( + it.content.take(1), + ) + }.distinctBy { it.content } + + val tlcContent: List = (people + headers).sortedBy { it.content } + + @OptIn(ExperimentalTestApi::class) // required by [#performRotaryScrollInput]. + @Test + fun BasicExample() { + lateinit var columnState: TransformingLazyColumnState + runTest { + AppScaffold( + timeText = { + TimeText(timeSource = FixedTimeSource3) + }, + // Why black needed here + modifier = Modifier.background(MaterialTheme.colorScheme.background), + ) { + columnState = rememberTransformingLazyColumnState() + ScreenScaffold( + scrollState = columnState, + contentPadding = rememberResponsiveColumnPadding( + first = ColumnItemType.Card, + last = ColumnItemType.Card, + ), + ) { contentPadding -> + val transformationSpec = rememberTransformationSpec() + + val headers = remember { + val letterIndexes = tlcContent.mapIndexed { index, item -> + HeaderInfo( + index, + item.content.take(1), + ) + }.distinctBy { it.value } + letterIndexes.toMutableStateList() + } + + FastScrollingTransformingLazyColumn( + state = columnState, + contentPadding = contentPadding, + modifier = Modifier + .fillMaxSize() + .testTag(TLC), + headers = headers, + ) { + items(tlcContent) { item -> + if (item is Header) { + Row(horizontalArrangement = Arrangement.Center) { + Text(item.content) + } + } else if (item is Person) { + TitleCard( + onClick = {}, + modifier = Modifier + .fillMaxWidth() + .transformedHeight(this, transformationSpec), + transformation = SurfaceTransformation(transformationSpec), + title = { Text(item.content) }, + ) { + Text("Visits to the ISS:") + } + } + } + } + } + } + } + + composeRule.waitForIdle() + + composeRule.onNodeWithTag(TLC) + .performRotaryScrollInput { + repeat(15) { + rotateToScrollVertically(5000.0f) + } + } + + composeRule.waitForIdle() + + captureScreenshot("_end") + } + + companion object { + @JvmStatic + @ParameterizedRobolectricTestRunner.Parameters + fun devices() = WearDevice.entries + + const val TLC = "TransformingLazyColumn" + } + } diff --git a/compose-layout/src/test/screenshots/FastScrollingTransformingLazyColumnTest_BasicExample[0]_ticwatch_pro_5.png b/compose-layout/src/test/screenshots/FastScrollingTransformingLazyColumnTest_BasicExample[0]_ticwatch_pro_5.png new file mode 100644 index 0000000000..cf36e12863 --- /dev/null +++ b/compose-layout/src/test/screenshots/FastScrollingTransformingLazyColumnTest_BasicExample[0]_ticwatch_pro_5.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4f21cdb2886e2ddddc2d840616839af824374fb90211ce4610be6491d082d3c3 +size 35033 diff --git a/compose-layout/src/test/screenshots/FastScrollingTransformingLazyColumnTest_BasicExample[0]_ticwatch_pro_5_end.png b/compose-layout/src/test/screenshots/FastScrollingTransformingLazyColumnTest_BasicExample[0]_ticwatch_pro_5_end.png new file mode 100644 index 0000000000..dfc522b5ed --- /dev/null +++ b/compose-layout/src/test/screenshots/FastScrollingTransformingLazyColumnTest_BasicExample[0]_ticwatch_pro_5_end.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1415f44146adec4b28d500c0929a2f2d79c20e6173638e00dcc4ebede78b76a2 +size 43094 diff --git a/compose-layout/src/test/screenshots/FastScrollingTransformingLazyColumnTest_BasicExample[1]_galaxy_watch_5.png b/compose-layout/src/test/screenshots/FastScrollingTransformingLazyColumnTest_BasicExample[1]_galaxy_watch_5.png new file mode 100644 index 0000000000..9512bb1736 --- /dev/null +++ b/compose-layout/src/test/screenshots/FastScrollingTransformingLazyColumnTest_BasicExample[1]_galaxy_watch_5.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8c84f55d0ed34c676fdeaa34b84a9fc6ef4641a8712498b7b5e4d6ff659f432e +size 29553 diff --git a/compose-layout/src/test/screenshots/FastScrollingTransformingLazyColumnTest_BasicExample[1]_galaxy_watch_5_end.png b/compose-layout/src/test/screenshots/FastScrollingTransformingLazyColumnTest_BasicExample[1]_galaxy_watch_5_end.png new file mode 100644 index 0000000000..1e9b8bcf1c --- /dev/null +++ b/compose-layout/src/test/screenshots/FastScrollingTransformingLazyColumnTest_BasicExample[1]_galaxy_watch_5_end.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3c0b1c567a18ab984348567837435cd21356194b3ea082c0ca739006d57a7116 +size 31707 diff --git a/compose-layout/src/test/screenshots/FastScrollingTransformingLazyColumnTest_BasicExample[2]_galaxy_watch_6.png b/compose-layout/src/test/screenshots/FastScrollingTransformingLazyColumnTest_BasicExample[2]_galaxy_watch_6.png new file mode 100644 index 0000000000..0650fbd00c --- /dev/null +++ b/compose-layout/src/test/screenshots/FastScrollingTransformingLazyColumnTest_BasicExample[2]_galaxy_watch_6.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:70c0dc1a95010fc9626855dd4997b545580eee7ea93b677cd184108462330943 +size 33737 diff --git a/compose-layout/src/test/screenshots/FastScrollingTransformingLazyColumnTest_BasicExample[2]_galaxy_watch_6_end.png b/compose-layout/src/test/screenshots/FastScrollingTransformingLazyColumnTest_BasicExample[2]_galaxy_watch_6_end.png new file mode 100644 index 0000000000..d38f8bab46 --- /dev/null +++ b/compose-layout/src/test/screenshots/FastScrollingTransformingLazyColumnTest_BasicExample[2]_galaxy_watch_6_end.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9fb9178af6c996642cb3ef179e4f6bc17e4897ded72f925b0dc49ab58e9dba48 +size 41993 diff --git a/compose-layout/src/test/screenshots/FastScrollingTransformingLazyColumnTest_BasicExample[3]_pixel_watch.png b/compose-layout/src/test/screenshots/FastScrollingTransformingLazyColumnTest_BasicExample[3]_pixel_watch.png new file mode 100644 index 0000000000..6e449bf22a --- /dev/null +++ b/compose-layout/src/test/screenshots/FastScrollingTransformingLazyColumnTest_BasicExample[3]_pixel_watch.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:53c48bce9e7bdf49008f4c058d76ff2d6c45bc2bca054c7ed2ba1bf23a37f9be +size 29211 diff --git a/compose-layout/src/test/screenshots/FastScrollingTransformingLazyColumnTest_BasicExample[3]_pixel_watch_end.png b/compose-layout/src/test/screenshots/FastScrollingTransformingLazyColumnTest_BasicExample[3]_pixel_watch_end.png new file mode 100644 index 0000000000..0326a29728 --- /dev/null +++ b/compose-layout/src/test/screenshots/FastScrollingTransformingLazyColumnTest_BasicExample[3]_pixel_watch_end.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:12102cfb60df0549457d3f2e7b6f6b97f8bf32bad43188789b9a68ee1323eb58 +size 29252 diff --git a/compose-layout/src/test/screenshots/FastScrollingTransformingLazyColumnTest_BasicExample[4]_small_round.png b/compose-layout/src/test/screenshots/FastScrollingTransformingLazyColumnTest_BasicExample[4]_small_round.png new file mode 100644 index 0000000000..6e449bf22a --- /dev/null +++ b/compose-layout/src/test/screenshots/FastScrollingTransformingLazyColumnTest_BasicExample[4]_small_round.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:53c48bce9e7bdf49008f4c058d76ff2d6c45bc2bca054c7ed2ba1bf23a37f9be +size 29211 diff --git a/compose-layout/src/test/screenshots/FastScrollingTransformingLazyColumnTest_BasicExample[4]_small_round_end.png b/compose-layout/src/test/screenshots/FastScrollingTransformingLazyColumnTest_BasicExample[4]_small_round_end.png new file mode 100644 index 0000000000..0326a29728 --- /dev/null +++ b/compose-layout/src/test/screenshots/FastScrollingTransformingLazyColumnTest_BasicExample[4]_small_round_end.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:12102cfb60df0549457d3f2e7b6f6b97f8bf32bad43188789b9a68ee1323eb58 +size 29252 diff --git a/compose-layout/src/test/screenshots/FastScrollingTransformingLazyColumnTest_BasicExample[5]_large_round.png b/compose-layout/src/test/screenshots/FastScrollingTransformingLazyColumnTest_BasicExample[5]_large_round.png new file mode 100644 index 0000000000..327a887284 --- /dev/null +++ b/compose-layout/src/test/screenshots/FastScrollingTransformingLazyColumnTest_BasicExample[5]_large_round.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d7c75b74dfc96dabf65353b232ebbadf3f08b6ece344d7aaf9ef158a711d3dd0 +size 33964 diff --git a/compose-layout/src/test/screenshots/FastScrollingTransformingLazyColumnTest_BasicExample[5]_large_round_end.png b/compose-layout/src/test/screenshots/FastScrollingTransformingLazyColumnTest_BasicExample[5]_large_round_end.png new file mode 100644 index 0000000000..c464c5a21b --- /dev/null +++ b/compose-layout/src/test/screenshots/FastScrollingTransformingLazyColumnTest_BasicExample[5]_large_round_end.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f726115f8d73fd3e274b5f38106a660f334bf53899282640c6b232a024eca0ea +size 42095 diff --git a/compose-layout/src/test/screenshots/FastScrollingTransformingLazyColumnTest_BasicExample[6]_galaxy_watch_6_small_font.png b/compose-layout/src/test/screenshots/FastScrollingTransformingLazyColumnTest_BasicExample[6]_galaxy_watch_6_small_font.png new file mode 100644 index 0000000000..328d83cd50 --- /dev/null +++ b/compose-layout/src/test/screenshots/FastScrollingTransformingLazyColumnTest_BasicExample[6]_galaxy_watch_6_small_font.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fc651a6cbc4e34e442d4c1cc5e22040a2b8e9198bd716a8da93002857bda5604 +size 33123 diff --git a/compose-layout/src/test/screenshots/FastScrollingTransformingLazyColumnTest_BasicExample[6]_galaxy_watch_6_small_font_end.png b/compose-layout/src/test/screenshots/FastScrollingTransformingLazyColumnTest_BasicExample[6]_galaxy_watch_6_small_font_end.png new file mode 100644 index 0000000000..d1e66b98b5 --- /dev/null +++ b/compose-layout/src/test/screenshots/FastScrollingTransformingLazyColumnTest_BasicExample[6]_galaxy_watch_6_small_font_end.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:48eac47d14fbd387f55f3ae0023c6466d3235e6307bdd2979a815e80c298e332 +size 40278 diff --git a/compose-layout/src/test/screenshots/FastScrollingTransformingLazyColumnTest_BasicExample[7]_pixel_watch_large_font.png b/compose-layout/src/test/screenshots/FastScrollingTransformingLazyColumnTest_BasicExample[7]_pixel_watch_large_font.png new file mode 100644 index 0000000000..032915ffba --- /dev/null +++ b/compose-layout/src/test/screenshots/FastScrollingTransformingLazyColumnTest_BasicExample[7]_pixel_watch_large_font.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e537a7c1c64e742b1ce3ff6958feffa708c9322bd80a670919c0db253cfec5b2 +size 23035 diff --git a/compose-layout/src/test/screenshots/FastScrollingTransformingLazyColumnTest_BasicExample[7]_pixel_watch_large_font_end.png b/compose-layout/src/test/screenshots/FastScrollingTransformingLazyColumnTest_BasicExample[7]_pixel_watch_large_font_end.png new file mode 100644 index 0000000000..01553d2196 --- /dev/null +++ b/compose-layout/src/test/screenshots/FastScrollingTransformingLazyColumnTest_BasicExample[7]_pixel_watch_large_font_end.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:802ebd31e04616779daef8370cb71777ff3bf4ccd27caeff8d40c486eb31ff75 +size 23907 diff --git a/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_ButtonAndEdgeButton[0]_ticwatch_pro_5.png b/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_ButtonAndEdgeButton[0]_ticwatch_pro_5.png index 7b4093bf3a..6fe97565c6 100644 --- a/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_ButtonAndEdgeButton[0]_ticwatch_pro_5.png +++ b/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_ButtonAndEdgeButton[0]_ticwatch_pro_5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:59040ac06dc4b1120e3d1582f1783ec6bae9760ffc6ec214d53f3e1f9439d6c4 -size 28888 +oid sha256:06382dfdfb5c436aa1178f38e256fca9d7e98d784839d0e9ffe84d28a0bee186 +size 28797 diff --git a/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_ButtonAndEdgeButton[0]_ticwatch_pro_5_end.png b/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_ButtonAndEdgeButton[0]_ticwatch_pro_5_end.png index 83da6802f5..225ba4cfd7 100644 --- a/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_ButtonAndEdgeButton[0]_ticwatch_pro_5_end.png +++ b/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_ButtonAndEdgeButton[0]_ticwatch_pro_5_end.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0b4a875a0775a74ccafb343f13f4378b22949d1769b387e5161623fd6a5a18f4 -size 25224 +oid sha256:5b001f35b028a4399fd912f772e179058012146f56c8f155c8c8c8a25aca04d6 +size 25162 diff --git a/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_ButtonAndEdgeButton[1]_galaxy_watch_5.png b/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_ButtonAndEdgeButton[1]_galaxy_watch_5.png index 8705275cb4..e47ab49c99 100644 --- a/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_ButtonAndEdgeButton[1]_galaxy_watch_5.png +++ b/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_ButtonAndEdgeButton[1]_galaxy_watch_5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4751dc560255f0564faba2dd1bea00cb9b668828700c9f9edd7778ebe68b1c21 -size 24439 +oid sha256:cae536e3883b84f6a6c3ca56562c569411095af1de3fee8ec4b9a0f89aec3668 +size 24406 diff --git a/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_ButtonAndEdgeButton[1]_galaxy_watch_5_end.png b/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_ButtonAndEdgeButton[1]_galaxy_watch_5_end.png index d85cab3926..3188778510 100644 --- a/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_ButtonAndEdgeButton[1]_galaxy_watch_5_end.png +++ b/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_ButtonAndEdgeButton[1]_galaxy_watch_5_end.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1ea3bbc3689d7329611ddea20cdb8aab54d418c5d9823cd480fd80177adaeec1 -size 18714 +oid sha256:61efa9a62e3c806ca08ad629b64442dfa924337cf08442f9582a72062ec1dc5a +size 18680 diff --git a/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_ButtonAndEdgeButton[2]_galaxy_watch_6.png b/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_ButtonAndEdgeButton[2]_galaxy_watch_6.png index a7e5c203fd..5709942f78 100644 --- a/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_ButtonAndEdgeButton[2]_galaxy_watch_6.png +++ b/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_ButtonAndEdgeButton[2]_galaxy_watch_6.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a616fc2101b9762414013370bf90d021f793cc7e643bf6a735d06cbaed241856 -size 28040 +oid sha256:f2d4c39b3bfbf401d0dd9061ab9a0949d9e01889942e914394142f9b35b9bbfe +size 27957 diff --git a/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_ButtonAndEdgeButton[2]_galaxy_watch_6_end.png b/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_ButtonAndEdgeButton[2]_galaxy_watch_6_end.png index 2694515e49..7ad0865f01 100644 --- a/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_ButtonAndEdgeButton[2]_galaxy_watch_6_end.png +++ b/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_ButtonAndEdgeButton[2]_galaxy_watch_6_end.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9d76231d9d1f58c45a143236ee076173f57a3df65ec11bf3e4e2480d2e166b61 -size 23799 +oid sha256:7a988078c421a6d6560d1432b31d69d9932e72b7b7300fb84a928d9d1c362c49 +size 23748 diff --git a/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_ButtonAndEdgeButton[3]_pixel_watch.png b/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_ButtonAndEdgeButton[3]_pixel_watch.png index 68f9d423a8..44abe63a2e 100644 --- a/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_ButtonAndEdgeButton[3]_pixel_watch.png +++ b/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_ButtonAndEdgeButton[3]_pixel_watch.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:46e91a72d296a0a3ac483dceaf14a335c9fe8ff6206e35fa4f537b37daeb5c1a -size 23446 +oid sha256:1914d4ded575c0e1b1a4ed82d83ce9b82703948b4483379a6b876554fa5a0fab +size 23405 diff --git a/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_ButtonAndEdgeButton[3]_pixel_watch_end.png b/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_ButtonAndEdgeButton[3]_pixel_watch_end.png index 96c691e84c..23ad7989b4 100644 --- a/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_ButtonAndEdgeButton[3]_pixel_watch_end.png +++ b/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_ButtonAndEdgeButton[3]_pixel_watch_end.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:239ab1d9354288d19ac0e0364aeefd68eae3f5d22a194d82e183b3f63765e06e -size 20099 +oid sha256:bcf870954b622274b6b9852fd8a74cab353eea5b2f86a806a92ff43845595239 +size 20089 diff --git a/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_ButtonAndEdgeButton[4]_small_round.png b/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_ButtonAndEdgeButton[4]_small_round.png index 68f9d423a8..44abe63a2e 100644 --- a/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_ButtonAndEdgeButton[4]_small_round.png +++ b/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_ButtonAndEdgeButton[4]_small_round.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:46e91a72d296a0a3ac483dceaf14a335c9fe8ff6206e35fa4f537b37daeb5c1a -size 23446 +oid sha256:1914d4ded575c0e1b1a4ed82d83ce9b82703948b4483379a6b876554fa5a0fab +size 23405 diff --git a/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_ButtonAndEdgeButton[4]_small_round_end.png b/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_ButtonAndEdgeButton[4]_small_round_end.png index 96c691e84c..23ad7989b4 100644 --- a/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_ButtonAndEdgeButton[4]_small_round_end.png +++ b/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_ButtonAndEdgeButton[4]_small_round_end.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:239ab1d9354288d19ac0e0364aeefd68eae3f5d22a194d82e183b3f63765e06e -size 20099 +oid sha256:bcf870954b622274b6b9852fd8a74cab353eea5b2f86a806a92ff43845595239 +size 20089 diff --git a/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_ButtonAndEdgeButton[5]_large_round.png b/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_ButtonAndEdgeButton[5]_large_round.png index 06f168c2fe..74c444a446 100644 --- a/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_ButtonAndEdgeButton[5]_large_round.png +++ b/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_ButtonAndEdgeButton[5]_large_round.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7ae6292a6f215ee5563f43ff0cd70aa4509adb57e5b24ee2c5c6b6641c8bee2d -size 28198 +oid sha256:fcdcc499b1f1e25ab472dd4d8904617629fa8f844f02df6e2ba096c6c0bb5650 +size 28110 diff --git a/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_ButtonAndEdgeButton[5]_large_round_end.png b/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_ButtonAndEdgeButton[5]_large_round_end.png index 9b7621bbfd..bea885de39 100644 --- a/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_ButtonAndEdgeButton[5]_large_round_end.png +++ b/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_ButtonAndEdgeButton[5]_large_round_end.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:81ef37d092f423ef1bf2e6e29ebdd95a2aaf341b02b04abcf55886cd75c8f7e4 -size 24098 +oid sha256:9ce84dcaca73a627f882a1986bf5375a81da54c0fc5f3817657ab6f36348d6e2 +size 24073 diff --git a/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_ButtonAndEdgeButton[6]_galaxy_watch_6_small_font.png b/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_ButtonAndEdgeButton[6]_galaxy_watch_6_small_font.png index da0f2b6d73..cbe8f8b221 100644 --- a/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_ButtonAndEdgeButton[6]_galaxy_watch_6_small_font.png +++ b/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_ButtonAndEdgeButton[6]_galaxy_watch_6_small_font.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:39e27f18047e7b0d5f75a29f7d79cb90f492cb4b106fe26e181c59e69c6caec4 -size 26999 +oid sha256:1d77b9ce0cd0436f503fd088617a886a4aa7c8b09b8dcc0c5eaf699d3a5300c2 +size 26906 diff --git a/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_ButtonAndEdgeButton[6]_galaxy_watch_6_small_font_end.png b/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_ButtonAndEdgeButton[6]_galaxy_watch_6_small_font_end.png index 1bea461066..4fbceb7125 100644 --- a/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_ButtonAndEdgeButton[6]_galaxy_watch_6_small_font_end.png +++ b/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_ButtonAndEdgeButton[6]_galaxy_watch_6_small_font_end.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:df5de42ffe7e04ae035b81d6f2df673d507aa1210076eb598b06fb092df6d88e -size 23186 +oid sha256:5e1b9f2beab6d7749088f26227b3a3e73cd0cda5be9d1b83b00d0c52c995fa5c +size 23136 diff --git a/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_TitleAndCard[0]_ticwatch_pro_5.png b/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_TitleAndCard[0]_ticwatch_pro_5.png index e3529c2e54..d90fef1b20 100644 --- a/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_TitleAndCard[0]_ticwatch_pro_5.png +++ b/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_TitleAndCard[0]_ticwatch_pro_5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:84c748051261f25bae3cee57b2b278facefd8d7dea9981824c6a7dbf012998f5 -size 28464 +oid sha256:d78065813b8dce4028a410c192a90b97ea6ba1b36b39fb5d7a6ab17583f42067 +size 28300 diff --git a/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_TitleAndCard[0]_ticwatch_pro_5_end.png b/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_TitleAndCard[0]_ticwatch_pro_5_end.png index 0882bb59ea..e64a3aae36 100644 --- a/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_TitleAndCard[0]_ticwatch_pro_5_end.png +++ b/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_TitleAndCard[0]_ticwatch_pro_5_end.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:17e25dc861f9056b151d5e5c6acd9533f88d9a032ce702600c2db689ac158d7d -size 29694 +oid sha256:3ae5725873143add528a82b0ba69965363e50ab787c69591e1f523843dec57c3 +size 29586 diff --git a/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_TitleAndCard[1]_galaxy_watch_5.png b/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_TitleAndCard[1]_galaxy_watch_5.png index 517b758f6a..f8c964985d 100644 --- a/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_TitleAndCard[1]_galaxy_watch_5.png +++ b/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_TitleAndCard[1]_galaxy_watch_5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d46697d68d1bade7131ff1b76db667c877541078572649c09b0f4e45b7f981e9 -size 24088 +oid sha256:13b0c28ee15502f0533814d23abf974e15a600df02296a1026063ceee9f23d8d +size 24016 diff --git a/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_TitleAndCard[1]_galaxy_watch_5_end.png b/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_TitleAndCard[1]_galaxy_watch_5_end.png index 344fc3bbb4..66045d16f7 100644 --- a/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_TitleAndCard[1]_galaxy_watch_5_end.png +++ b/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_TitleAndCard[1]_galaxy_watch_5_end.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c465687b78040868144b3c3276d8dcd242e03b916c58e9a5d5a70e420b939389 -size 25529 +oid sha256:177e2b6161d331a40f0d03ed78c17d89cafc92792594539b83a8ec26db49ad8d +size 25460 diff --git a/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_TitleAndCard[2]_galaxy_watch_6.png b/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_TitleAndCard[2]_galaxy_watch_6.png index 81636b9fe8..3a25baad23 100644 --- a/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_TitleAndCard[2]_galaxy_watch_6.png +++ b/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_TitleAndCard[2]_galaxy_watch_6.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2d00237824b287315fb01d725a67e066bd824d18db30c5aef3a1ab8b8f5305ca -size 27632 +oid sha256:e8bbdcd4f844db2c844b5342e693e59bea17530f2aa7a3d42298eed9e9480ead +size 27576 diff --git a/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_TitleAndCard[2]_galaxy_watch_6_end.png b/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_TitleAndCard[2]_galaxy_watch_6_end.png index 45e364292a..92dd469921 100644 --- a/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_TitleAndCard[2]_galaxy_watch_6_end.png +++ b/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_TitleAndCard[2]_galaxy_watch_6_end.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:595d157e13b188b688cbc5da226768c05892c0645bff3523ed645ad93bbad551 -size 28868 +oid sha256:6d5cba2f02b5d22baa74647ec57257663440909b0f80692f588f8a9246a9aded +size 28811 diff --git a/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_TitleAndCard[3]_pixel_watch.png b/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_TitleAndCard[3]_pixel_watch.png index 2bfdc89dfd..2b77840286 100644 --- a/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_TitleAndCard[3]_pixel_watch.png +++ b/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_TitleAndCard[3]_pixel_watch.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:87ba32321970f1cbce736be00ec4d1363f669da1aeb64d7379f19e7ccd5416a2 -size 22959 +oid sha256:61b3fa8a7287b2243a81138ca3db03ea4e1bcb35192dd60894f43eaed38f8e8a +size 22889 diff --git a/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_TitleAndCard[3]_pixel_watch_end.png b/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_TitleAndCard[3]_pixel_watch_end.png index a8c5fb0d1e..f06b531607 100644 --- a/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_TitleAndCard[3]_pixel_watch_end.png +++ b/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_TitleAndCard[3]_pixel_watch_end.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c3d7dd0134dfeb2f8758f9cf011a8cea727c6bb37c5c5e75a530c2b09efd3c4c -size 24949 +oid sha256:737f92491e2ecf93b825820bb7999c2ef2885366991ae168d5e7b3ed7734a98e +size 24924 diff --git a/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_TitleAndCard[4]_small_round.png b/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_TitleAndCard[4]_small_round.png index 2bfdc89dfd..2b77840286 100644 --- a/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_TitleAndCard[4]_small_round.png +++ b/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_TitleAndCard[4]_small_round.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:87ba32321970f1cbce736be00ec4d1363f669da1aeb64d7379f19e7ccd5416a2 -size 22959 +oid sha256:61b3fa8a7287b2243a81138ca3db03ea4e1bcb35192dd60894f43eaed38f8e8a +size 22889 diff --git a/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_TitleAndCard[4]_small_round_end.png b/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_TitleAndCard[4]_small_round_end.png index a8c5fb0d1e..f06b531607 100644 --- a/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_TitleAndCard[4]_small_round_end.png +++ b/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_TitleAndCard[4]_small_round_end.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c3d7dd0134dfeb2f8758f9cf011a8cea727c6bb37c5c5e75a530c2b09efd3c4c -size 24949 +oid sha256:737f92491e2ecf93b825820bb7999c2ef2885366991ae168d5e7b3ed7734a98e +size 24924 diff --git a/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_TitleAndCard[5]_large_round.png b/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_TitleAndCard[5]_large_round.png index 91688f550d..e0478ca023 100644 --- a/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_TitleAndCard[5]_large_round.png +++ b/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_TitleAndCard[5]_large_round.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:52d37ff0fd830b1c2f20b43de7d69a485b7fe88bff42d2b6d4d30811b28ab58b -size 27804 +oid sha256:4da02a116be3b310969c6907793b1fcfe47aa034f7cdf2786902d93dce7f2754 +size 27738 diff --git a/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_TitleAndCard[5]_large_round_end.png b/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_TitleAndCard[5]_large_round_end.png index 0b844a6d5d..6c882e67d6 100644 --- a/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_TitleAndCard[5]_large_round_end.png +++ b/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_TitleAndCard[5]_large_round_end.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5b9143efa4b9ac2f6cd8f06d594d1134a686096d4dd21f1228f942547fd8f7f6 -size 28986 +oid sha256:79cabe661b80168f5e905e08edc707056dfefb35cd831714af1e9e59f2109c01 +size 28945 diff --git a/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_TitleAndCard[6]_galaxy_watch_6_small_font.png b/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_TitleAndCard[6]_galaxy_watch_6_small_font.png index 4ea34b2df4..2509c45a3a 100644 --- a/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_TitleAndCard[6]_galaxy_watch_6_small_font.png +++ b/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_TitleAndCard[6]_galaxy_watch_6_small_font.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9617e598e7a9487ed3c8b82789530420136884b7762893eb2880ca7b0c5b95fb -size 26821 +oid sha256:ad5a7ef0b9585b6ca6e4525ae645407581417acedb69f78efce17d83e6d26de8 +size 26765 diff --git a/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_TitleAndCard[6]_galaxy_watch_6_small_font_end.png b/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_TitleAndCard[6]_galaxy_watch_6_small_font_end.png index cec3e6a439..bac4c739cf 100644 --- a/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_TitleAndCard[6]_galaxy_watch_6_small_font_end.png +++ b/compose-layout/src/test/screenshots/TransformingLazyColumnDefaultsTest_TitleAndCard[6]_galaxy_watch_6_small_font_end.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:22465464fa7761ffee7b61a79fd03bbf9bcb68242bd5be27adc3a2b4ec0c2d88 -size 27742 +oid sha256:de9cbe75455056b204745c8e27589e832d4cea744604fcf54427ed92a6b2b308 +size 27667 diff --git a/compose-material/build.gradle.kts b/compose-material/build.gradle.kts index 233b778be3..6cd7eef7fe 100644 --- a/compose-material/build.gradle.kts +++ b/compose-material/build.gradle.kts @@ -101,6 +101,7 @@ dependencies { api(projects.composeLayout) api(projects.images.base) + implementation(platform(libs.compose.bom)) api(libs.compose.foundation.foundation) api(libs.compose.foundation.foundation.layout) api(libs.compose.runtime) diff --git a/compose-tools/build.gradle.kts b/compose-tools/build.gradle.kts index 2fe1afe22a..67cda2c113 100644 --- a/compose-tools/build.gradle.kts +++ b/compose-tools/build.gradle.kts @@ -87,6 +87,7 @@ dependencies { implementation(libs.kotlin.reflect) implementation(projects.tiles) + implementation(platform(libs.compose.bom)) implementation(libs.wearcompose.material) implementation(libs.wearcompose.foundation) implementation(libs.wearcompose.navigation) diff --git a/datalayer/core/build.gradle.kts b/datalayer/core/build.gradle.kts index d5f609d8fc..a600f25fd8 100644 --- a/datalayer/core/build.gradle.kts +++ b/datalayer/core/build.gradle.kts @@ -28,7 +28,7 @@ android { compileSdk = 36 defaultConfig { - minSdk = 21 + minSdk = 23 testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/datalayer/grpc/build.gradle.kts b/datalayer/grpc/build.gradle.kts index a86db3d01a..95e66c2566 100644 --- a/datalayer/grpc/build.gradle.kts +++ b/datalayer/grpc/build.gradle.kts @@ -26,7 +26,7 @@ android { compileSdk = 36 defaultConfig { - minSdk = 21 + minSdk = 23 } compileOptions { diff --git a/datalayer/phone-ui/build.gradle.kts b/datalayer/phone-ui/build.gradle.kts index 9dd19ed5b2..6cb716349d 100644 --- a/datalayer/phone-ui/build.gradle.kts +++ b/datalayer/phone-ui/build.gradle.kts @@ -27,7 +27,7 @@ android { compileSdk = 36 defaultConfig { - minSdk = 21 + minSdk = 23 testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/datalayer/phone/build.gradle.kts b/datalayer/phone/build.gradle.kts index dbb215c91b..2b27805d80 100644 --- a/datalayer/phone/build.gradle.kts +++ b/datalayer/phone/build.gradle.kts @@ -25,7 +25,7 @@ android { compileSdk = 36 defaultConfig { - minSdk = 21 + minSdk = 23 testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/datalayer/sample/phone/build.gradle.kts b/datalayer/sample/phone/build.gradle.kts index 4f33b56fec..b4f695c3a2 100644 --- a/datalayer/sample/phone/build.gradle.kts +++ b/datalayer/sample/phone/build.gradle.kts @@ -29,7 +29,7 @@ android { defaultConfig { applicationId = "com.google.android.horologist.datalayer.sample" - minSdk = 21 + minSdk = 23 targetSdk = 34 versionCode = 1 diff --git a/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/counter/CounterScreen.kt b/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/counter/CounterScreen.kt index 5a60b44724..fdfff9f51f 100644 --- a/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/counter/CounterScreen.kt +++ b/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/counter/CounterScreen.kt @@ -37,7 +37,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.google.android.horologist.datalayer.sample.R diff --git a/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/inappprompts/custom/installapp/InstallAppCustomPromptDemoScreen.kt b/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/inappprompts/custom/installapp/InstallAppCustomPromptDemoScreen.kt index 3363f91f51..c62a77f367 100644 --- a/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/inappprompts/custom/installapp/InstallAppCustomPromptDemoScreen.kt +++ b/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/inappprompts/custom/installapp/InstallAppCustomPromptDemoScreen.kt @@ -35,7 +35,7 @@ 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.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.google.android.horologist.datalayer.sample.R diff --git a/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/inappprompts/custom/installtile/InstallTileCustomPromptDemoScreen.kt b/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/inappprompts/custom/installtile/InstallTileCustomPromptDemoScreen.kt index cb80f3b8fb..a609ea70eb 100644 --- a/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/inappprompts/custom/installtile/InstallTileCustomPromptDemoScreen.kt +++ b/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/inappprompts/custom/installtile/InstallTileCustomPromptDemoScreen.kt @@ -35,7 +35,7 @@ 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.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.google.android.horologist.datalayer.sample.R diff --git a/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/inappprompts/custom/reengage/ReEngageCustomPromptDemoScreen.kt b/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/inappprompts/custom/reengage/ReEngageCustomPromptDemoScreen.kt index b60b277bc8..de322c080a 100644 --- a/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/inappprompts/custom/reengage/ReEngageCustomPromptDemoScreen.kt +++ b/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/inappprompts/custom/reengage/ReEngageCustomPromptDemoScreen.kt @@ -48,7 +48,7 @@ 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.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.google.android.horologist.datalayer.sample.R diff --git a/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/inappprompts/custom/signin/SignInCustomPromptDemoScreen.kt b/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/inappprompts/custom/signin/SignInCustomPromptDemoScreen.kt index 2e6993c463..57d2b839f5 100644 --- a/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/inappprompts/custom/signin/SignInCustomPromptDemoScreen.kt +++ b/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/inappprompts/custom/signin/SignInCustomPromptDemoScreen.kt @@ -37,7 +37,7 @@ import androidx.compose.ui.Modifier 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.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.google.android.horologist.datalayer.sample.R diff --git a/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/inappprompts/installapp/InstallAppPromptDemoScreen.kt b/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/inappprompts/installapp/InstallAppPromptDemoScreen.kt index b2f9ca4728..b8ac069664 100644 --- a/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/inappprompts/installapp/InstallAppPromptDemoScreen.kt +++ b/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/inappprompts/installapp/InstallAppPromptDemoScreen.kt @@ -39,7 +39,7 @@ 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.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.google.android.horologist.datalayer.sample.R diff --git a/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/inappprompts/installtile/InstallTilePromptDemoScreen.kt b/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/inappprompts/installtile/InstallTilePromptDemoScreen.kt index 7c77805c74..b14243336f 100644 --- a/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/inappprompts/installtile/InstallTilePromptDemoScreen.kt +++ b/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/inappprompts/installtile/InstallTilePromptDemoScreen.kt @@ -34,7 +34,7 @@ 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.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.google.android.horologist.datalayer.sample.R 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 index c59c7a503c..d02554898b 100644 --- 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 @@ -34,7 +34,7 @@ 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.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.google.android.horologist.datalayer.sample.R diff --git a/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/inappprompts/signin/SignInPromptDemoScreen.kt b/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/inappprompts/signin/SignInPromptDemoScreen.kt index 3102a68e06..e46f8ff7b9 100644 --- a/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/inappprompts/signin/SignInPromptDemoScreen.kt +++ b/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/inappprompts/signin/SignInPromptDemoScreen.kt @@ -34,7 +34,7 @@ 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.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.google.android.horologist.datalayer.sample.R diff --git a/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/nodes/NodesScreen.kt b/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/nodes/NodesScreen.kt index 76bfa7a2bd..ea317217c1 100644 --- a/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/nodes/NodesScreen.kt +++ b/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/nodes/NodesScreen.kt @@ -40,7 +40,7 @@ import androidx.compose.ui.Modifier 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.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.google.android.horologist.data.apphelper.AppHelperNodeStatus import com.google.android.horologist.data.apphelper.AppInstallationStatus diff --git a/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/nodeslistener/NodesListenerScreen.kt b/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/nodeslistener/NodesListenerScreen.kt index 91a223b4fd..f02b4819fb 100644 --- a/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/nodeslistener/NodesListenerScreen.kt +++ b/datalayer/sample/phone/src/main/java/com/google/android/horologist/datalayer/sample/screens/nodeslistener/NodesListenerScreen.kt @@ -35,7 +35,7 @@ import androidx.compose.ui.Modifier 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.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.google.android.horologist.datalayer.sample.R diff --git a/datalayer/sample/shared/build.gradle.kts b/datalayer/sample/shared/build.gradle.kts index afdadf9e98..fa952f8f6c 100644 --- a/datalayer/sample/shared/build.gradle.kts +++ b/datalayer/sample/shared/build.gradle.kts @@ -27,7 +27,7 @@ android { compileSdk = 36 defaultConfig { - minSdk = 21 + minSdk = 23 testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/datalayer/sample/wear/src/main/java/com/google/android/horologist/datalayer/sample/screens/datalayer/DataLayerScreen.kt b/datalayer/sample/wear/src/main/java/com/google/android/horologist/datalayer/sample/screens/datalayer/DataLayerScreen.kt index f2bf63c722..fc849895a8 100644 --- a/datalayer/sample/wear/src/main/java/com/google/android/horologist/datalayer/sample/screens/datalayer/DataLayerScreen.kt +++ b/datalayer/sample/wear/src/main/java/com/google/android/horologist/datalayer/sample/screens/datalayer/DataLayerScreen.kt @@ -27,7 +27,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.wear.compose.material.MaterialTheme import androidx.wear.compose.material.Text diff --git a/datalayer/sample/wear/src/main/java/com/google/android/horologist/datalayer/sample/screens/info/InfoScreen.kt b/datalayer/sample/wear/src/main/java/com/google/android/horologist/datalayer/sample/screens/info/InfoScreen.kt index 04c1002328..e97311230f 100644 --- a/datalayer/sample/wear/src/main/java/com/google/android/horologist/datalayer/sample/screens/info/InfoScreen.kt +++ b/datalayer/sample/wear/src/main/java/com/google/android/horologist/datalayer/sample/screens/info/InfoScreen.kt @@ -23,7 +23,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.wear.compose.material.Text import androidx.wear.compose.ui.tooling.preview.WearPreviewDevices import com.google.android.horologist.compose.layout.ScalingLazyColumn diff --git a/datalayer/sample/wear/src/main/java/com/google/android/horologist/datalayer/sample/screens/nodes/DataLayerNodesScreen.kt b/datalayer/sample/wear/src/main/java/com/google/android/horologist/datalayer/sample/screens/nodes/DataLayerNodesScreen.kt index 71e06075d8..c179d6b207 100644 --- a/datalayer/sample/wear/src/main/java/com/google/android/horologist/datalayer/sample/screens/nodes/DataLayerNodesScreen.kt +++ b/datalayer/sample/wear/src/main/java/com/google/android/horologist/datalayer/sample/screens/nodes/DataLayerNodesScreen.kt @@ -19,7 +19,7 @@ package com.google.android.horologist.datalayer.sample.screens.nodes import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.wear.compose.foundation.lazy.items import androidx.wear.compose.material.ListHeader diff --git a/datalayer/sample/wear/src/main/java/com/google/android/horologist/datalayer/sample/screens/nodesactions/NodeDetailsScreen.kt b/datalayer/sample/wear/src/main/java/com/google/android/horologist/datalayer/sample/screens/nodesactions/NodeDetailsScreen.kt index 9e81de0567..6bf66bebc9 100644 --- a/datalayer/sample/wear/src/main/java/com/google/android/horologist/datalayer/sample/screens/nodesactions/NodeDetailsScreen.kt +++ b/datalayer/sample/wear/src/main/java/com/google/android/horologist/datalayer/sample/screens/nodesactions/NodeDetailsScreen.kt @@ -33,7 +33,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.wear.compose.material.CircularProgressIndicator import androidx.wear.compose.material.Text diff --git a/datalayer/sample/wear/src/main/java/com/google/android/horologist/datalayer/sample/screens/nodesactions/NodesActionsScreen.kt b/datalayer/sample/wear/src/main/java/com/google/android/horologist/datalayer/sample/screens/nodesactions/NodesActionsScreen.kt index 3f7f950098..2096f4f787 100644 --- a/datalayer/sample/wear/src/main/java/com/google/android/horologist/datalayer/sample/screens/nodesactions/NodesActionsScreen.kt +++ b/datalayer/sample/wear/src/main/java/com/google/android/horologist/datalayer/sample/screens/nodesactions/NodesActionsScreen.kt @@ -29,7 +29,7 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.wear.compose.foundation.lazy.items import androidx.wear.compose.material.CircularProgressIndicator diff --git a/datalayer/sample/wear/src/main/java/com/google/android/horologist/datalayer/sample/screens/nodeslistener/NodesListenerScreen.kt b/datalayer/sample/wear/src/main/java/com/google/android/horologist/datalayer/sample/screens/nodeslistener/NodesListenerScreen.kt index 9d74d34457..c70b8241aa 100644 --- a/datalayer/sample/wear/src/main/java/com/google/android/horologist/datalayer/sample/screens/nodeslistener/NodesListenerScreen.kt +++ b/datalayer/sample/wear/src/main/java/com/google/android/horologist/datalayer/sample/screens/nodeslistener/NodesListenerScreen.kt @@ -24,7 +24,7 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.wear.compose.foundation.lazy.items import androidx.wear.compose.material.CircularProgressIndicator diff --git a/datalayer/sample/wear/src/main/java/com/google/android/horologist/datalayer/sample/screens/tracking/TrackingScreen.kt b/datalayer/sample/wear/src/main/java/com/google/android/horologist/datalayer/sample/screens/tracking/TrackingScreen.kt index 1c4d0fe17b..75383b0d9c 100644 --- a/datalayer/sample/wear/src/main/java/com/google/android/horologist/datalayer/sample/screens/tracking/TrackingScreen.kt +++ b/datalayer/sample/wear/src/main/java/com/google/android/horologist/datalayer/sample/screens/tracking/TrackingScreen.kt @@ -24,7 +24,7 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.wear.compose.material.CircularProgressIndicator import androidx.wear.compose.material.Text diff --git a/datalayer/watch/api/current.api b/datalayer/watch/api/current.api index 56e7154b2e..9f19b3d55a 100644 --- a/datalayer/watch/api/current.api +++ b/datalayer/watch/api/current.api @@ -11,8 +11,6 @@ package com.google.android.horologist.datalayer.watch { method public suspend Object? markComplicationAsDeactivated(int complicationInstanceId, kotlin.coroutines.Continuation); method public suspend Object? markSetupComplete(kotlin.coroutines.Continuation); method public suspend Object? markSetupNoLongerComplete(kotlin.coroutines.Continuation); - method @Deprecated public suspend Object? markTileAsInstalled(String tileName, kotlin.coroutines.Continuation); - method @Deprecated public suspend Object? markTileAsRemoved(String tileName, kotlin.coroutines.Continuation); method @CheckResult public suspend Object? startCompanion(String nodeId, kotlin.coroutines.Continuation); method public suspend Object? updateInstalledTiles(kotlin.coroutines.Continuation); property public kotlinx.coroutines.flow.Flow> connectedAndInstalledNodes; diff --git a/datalayer/watch/src/main/java/com/google/android/horologist/datalayer/watch/WearDataLayerAppHelper.kt b/datalayer/watch/src/main/java/com/google/android/horologist/datalayer/watch/WearDataLayerAppHelper.kt index c317577e3a..bf193d18c9 100644 --- a/datalayer/watch/src/main/java/com/google/android/horologist/datalayer/watch/WearDataLayerAppHelper.kt +++ b/datalayer/watch/src/main/java/com/google/android/horologist/datalayer/watch/WearDataLayerAppHelper.kt @@ -151,28 +151,6 @@ public class WearDataLayerAppHelper internal constructor( return sendRequestWithTimeout(nodeId, LAUNCH_APP, request.toByteArray()) } - /** - * Marks a tile as installed. Call this in [TileService#onTileAddEvent]. Supplying a name is - * mandatory to disambiguate from the installation or removal of other tiles your app may have. - * - * @param tileName The name of the tile. - */ - @Deprecated("Please use updateInstalledTiles instead") - public suspend fun markTileAsInstalled(tileName: String) { - surfacesInfoDataStore.updateData { info -> - val tile = tileInfo { - timestamp = System.currentTimeMillis().toProtoTimestamp() - name = tileName - } - info.copy { - val exists = tiles.find { it.equalWithoutTimestamp(tile) } != null - if (!exists) { - tiles.add(tile) - } - } - } - } - /** * Updates the list of currently installed tiles on this watch. * @@ -269,29 +247,6 @@ public class WearDataLayerAppHelper internal constructor( } } - /** - * Marks a tile as removed. Call this in [TileService#onTileRemoveEvent]. Supplying a name is - * mandatory to disambiguate from the installation or removal of other tiles your app may have. - * - * @param tileName The name of the tile. - */ - @Deprecated("Please use updateInstalledTiles instead") - public suspend fun markTileAsRemoved(tileName: String) { - surfacesInfoDataStore.updateData { info -> - val tile = tileInfo { - timestamp = System.currentTimeMillis().toProtoTimestamp() - name = tileName - } - info.copy { - val filtered = tiles.filter { !tile.equalWithoutTimestamp(it) } - if (filtered.size != tiles.size) { - tiles.clear() - tiles.addAll(filtered) - } - } - } - } - /** * Marks a complication as activated. Call this in * [ComplicationDataSourceService.onComplicationActivated]. diff --git a/datalayer/watch/src/test/java/com/google/android/horologist/datalayer/watch/WearDataLayerAppHelperTest.kt b/datalayer/watch/src/test/java/com/google/android/horologist/datalayer/watch/WearDataLayerAppHelperTest.kt index 66532912dd..258d0a1e3b 100644 --- a/datalayer/watch/src/test/java/com/google/android/horologist/datalayer/watch/WearDataLayerAppHelperTest.kt +++ b/datalayer/watch/src/test/java/com/google/android/horologist/datalayer/watch/WearDataLayerAppHelperTest.kt @@ -77,43 +77,6 @@ class WearDataLayerAppHelperTest { coroutineContext.cancelChildren() } - @Test - fun testTiles() = runTest { - val context = ApplicationProvider.getApplicationContext() - val registry = WearDataLayerRegistry.fromContext(context, this) - - val testDataStore: DataStore = - DataStoreFactory.create( - scope = this, - produceFile = { context.dataStoreFile("testTiles") }, - serializer = SurfacesInfoSerializer, - ) - - val helper = WearDataLayerAppHelper( - context = context, - registry = registry, - appStoreUri = null, - scope = this, - surfacesInfoDataStoreFn = { testDataStore }, - ) - - val infoInitial = testDataStore.data.first() - assertThat(infoInitial.tilesList).isEmpty() - - helper.markTileAsInstalled("my.SampleTileService") - - val infoUpdated = testDataStore.data.first() - assertThat(infoUpdated.tilesList).hasSize(1) - assertThat(infoUpdated.tilesList.first().name).isEqualTo("my.SampleTileService") - - helper.markTileAsRemoved("my.SampleTileService") - - val infoReverted = testDataStore.data.first() - assertThat(infoReverted.tilesList).isEmpty() - - coroutineContext.cancelChildren() - } - @Test fun testComplications() = runTest { val context = ApplicationProvider.getApplicationContext() diff --git a/docs/updating-old.md b/docs/updating-old.md deleted file mode 100644 index bdd852f5fb..0000000000 --- a/docs/updating-old.md +++ /dev/null @@ -1,119 +0,0 @@ -# Updating & releasing Horologist - -**This guide is currently not in use. See [updating.md](updating.md) instead.** - -This doc is mostly for maintainers. - -## New features & bugfixes -All new features should be uploaded as PRs against the `main` branch. - -Once merged into `main`, they will be automatically merged into the `snapshot` branch. - -## Jetpack Compose Snapshots - -We publish snapshot versions of Horologist, which depend on a `SNAPSHOT` versions of Jetpack Compose. These are built from the `snapshot` branch. - -### Updating to a newer Compose snapshot - -As mentioned above, updating to a new Compose snapshot is done by submitting a new PR against the `snapshot` branch: - -``` sh -git checkout snapshot && git pull -# Create branch for PR -git checkout -b update_snapshot -``` - -Now edit the project to depend on the new Compose SNAPSHOT version: - -Edit [`/gradle/libs.versions.toml`](https://github.com/google/horologist/blob/main/gradle/libs.versions.toml): - -Under `[versions]`: - -1. Update the `composesnapshot` property to be the snapshot number -2. Ensure that the `compose` property is correct - -Make sure the project builds and test pass: -``` -./gradlew check -``` - -Now `git commit` the changes and push to GitHub. - -Finally create a PR (with the base branch as `snapshot`) and send for review. - -## Releasing - -Once the next Jetpack Compose version is out, we're ready to push a new release: - -### #1: Merge `snapshot` into `main` - -First we merge the `snapshot` branch into `main`: - -``` sh -git checkout snapshot && git pull -git checkout main && git pull - -# Create branch for PR -git checkout -b main_snapshot_merge - -# Merge in the snapshot branch -git merge snapshot -``` - -### #2: Update dependencies - -Edit [`/gradle/libs.versions.toml`](https://github.com/google/horologist/blob/main/gradle/libs.versions.toml): - -Under `[versions]`: - -1. Update the `composesnapshot` property to a single character (usually `-`). This disables the snapshot repository. -2. Update the `compose` property to match the new release (i.e. `1.0.0-beta06`) - -Make sure the project builds and test pass: -``` -./gradlew check -``` - -Commit the changes. - -### #3: Bump the version number - -Edit [gradle.properties](https://github.com/google/horologist/blob/main/gradle.properties): - - * Update the `VERSION_NAME` property and remove the `-SNAPSHOT` suffix. - -Commit the changes, using the commit message containing the new version name. - -### #4: Push to GitHub - -Push the branch to GitHub and create a PR against the `main` branch, and send for review. Once approved and merged, it will be automatically deployed to Maven Central. - -### #5: Create release - -Once the above PR has been approved and merged, we need to create the GitHub release: - - * Open up the [Releases](https://github.com/google/horologist/releases) page. - * At the top you should see a 'Draft' release, auto populated with any PRs since the last release. Click 'Edit'. - * Make sure that the version number matches what we released (the tool guesses but is not always correct). - * Double check everything, then press 'Publish release'. - -At this point the release is published. This will trigger the docs action to run, which will auto-deploy a new version of the [website](https://google.github.io/horologist/). - -### #6: Prepare the next development version - -The current release is now finished, but we need to update the version for the next development version: - -Edit [gradle.properties](https://github.com/google/horologist/blob/main/gradle.properties): - - * Update the `VERSION_NAME` property, by increasing the version number, and adding the `-SNAPSHOT` suffix. - * Example: released version: `0.3.0`. Update to `0.3.1-SNAPSHOT` - - `git commit` and push to `main`. - -Finally, merge all of these changes back to `snapshot`: - -``` -git checkout snapshot && git pull -git merge main -git push -``` diff --git a/docs/updating.md b/docs/updating.md index 7eb51be9e4..26bd74bea5 100644 --- a/docs/updating.md +++ b/docs/updating.md @@ -2,27 +2,30 @@ This doc is mostly for maintainers. -Ensure your [Sonatype JIRA](https://issues.sonatype.org/login.jsp) credentials are set in your environment variables. +Ensure your Maven Central credentials are set in ~/.gradle/gradle.properties. +Follow https://vanniktech.github.io/gradle-maven-publish-plugin/central/#secrets -```bash -export ORG_GRADLE_PROJECT_mavenCentralUsername=username -export ORG_GRADLE_PROJECT_mavenCentralPassword=password +``` +mavenCentralUsername=username +mavenCentralPassword=the_password + +signing.keyId=12345678 +signing.password=some_password +signing.secretKeyRingFile=/Users/yourusername/.gnupg/secring.gpg ``` -Decrypt the signing key to release a public build. +Publish the artifacts to staging. ```bash -release/signing-setup.sh '' -gradlew clean publish --no-parallel --stacktrace -release/signing-cleanup.sh +./gradlew publishToMavenCentral ``` -The deployment then needs to be manually released via the [Nexus Repository Manager](https://oss.sonatype.org/#stagingRepositories). See [Releasing Deployment from OSSRH](https://central.sonatype.org/publish/release/). +The deployment then needs to be manually released via the [Central Portal](https://central.sonatype.com/publishing/deployments). ## Snapshot release -For a snapshot release, the signing key is not used. Ensure `VERSION_NAME` in [gradle.properties](https://github.com/google/horologist/blob/main/gradle.properties) has the `-SNAPSHOT` suffix or specify the version via `-PVERSION_NAME=...`. +For a snapshot release, change the version of `VERSION_NAME` property in `gradle.properties` to end with -SNAPSHOT e.g. 0.8.0-SNAPSHOT. ```bash -gradlew -PVERSION_NAME=0.0.1-SNAPSHOT clean publish --no-parallel --stacktrace +./gradlew publishToMavenCentral ``` diff --git a/gradle.properties b/gradle.properties index 7cb77fcd0c..9a1dafe5ab 100644 --- a/gradle.properties +++ b/gradle.properties @@ -43,11 +43,8 @@ org.gradle.jvmargs=-Dfile.encoding=UTF-8 -XX:+UseG1GC -XX:SoftRefLRUPolicyMSPerM # the Gradle JVM arg value for ReservedCodeCacheSize will be used. kotlin.daemon.jvmargs=-Dfile.encoding=UTF-8 -XX:+UseG1GC -XX:SoftRefLRUPolicyMSPerMB=1 -XX:ReservedCodeCacheSize=320m -XX:+HeapDumpOnOutOfMemoryError -Xmx4g -Xms4g -# Increase timeout when pushing to Sonatype (otherwise we get timeouts) -systemProp.org.gradle.internal.http.socketTimeout=120000 - GROUP=com.google.android.horologist -VERSION_NAME=0.8.0-alpha +VERSION_NAME=0.8.3 POM_DESCRIPTION=Utilities for Wear OS @@ -63,7 +60,8 @@ POM_LICENCE_DIST=repo POM_DEVELOPER_ID=google POM_DEVELOPER_NAME=Google -RELEASE_SIGNING_ENABLED=true +mavenCentralPublishing=true +signAllPublications=true # Plugin should be applied individually to modules that are reviewed, in order to avoid having # unnecessary performance hit in CI. Once all modules are reviewed, this line can be removed, and diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0218930fa3..49839d65f5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,75 +1,77 @@ [versions] accessibilityTestFramework = "4.1.1" accompanist = "0.36.0" -androidx-benchmark = "1.4.0-rc01" +androidx-benchmark = "1.4.0" androidx-complications-data = "1.3.0-alpha07" -androidx-concurrent = "1.2.0" +androidx-concurrent = "1.3.0" androidx-constraintlayout-compose = "1.1.1" androidx-datastore = "1.1.7" androidx-graphics = "1.0.1" androidx-health-services = "1.1.0-alpha05" -androidx-hilt = "1.3.0-alpha01" -androidx-media3 = "1.8.0-alpha01" -androidx-test-espresso = "3.6.1" -androidx-test-ext = "1.2.1" -androidx-test-runner = "1.6.2" +androidx-hilt = "1.3.0-rc01" +androidx-media3 = "1.8.0" +androidx-test-espresso = "3.7.0" +androidx-test-ext = "1.3.0" +androidx-test-runner = "1.7.0" androidx-wear-watchface = "1.3.0-alpha07" -androidxActivity = "1.12.0-alpha03" -androidxComposeBom = "2025.06.01" -androidxCore = "1.17.0-alpha01" -androidxLifecycle = "2.9.1" -androidxNavigation = "2.9.0" +androidxActivity = "1.12.0-alpha07" +androidxComposeBom = "2025.08.00" +androidxCore = "1.17.0" +androidxLifecycle = "2.9.3" +androidxNavigation = "2.9.3" androidxPhoneInteractions = "1.1.0" androidxRemoteInteractions = "1.1.0" androidxStartup = "1.2.0" androidxTracing = "1.3.0" -androidxWear = "1.4.0-alpha01" -androidxWork = "2.10.2" +androidxWear = "1.4.0-alpha02" +androidxWork = "2.10.3" androidxprotolayout = "1.3.0" androidxtiles = "1.5.0" annotation = "1.0.1" app-cash-turbine = "1.2.1" appcompat = "1.7.1" -com-squareup-okhttp3 = "5.0.0-alpha.17" +com-squareup-okhttp3 = "5.1.0" com-squareup-retrofit2 = "3.0.0" -compose-material3 = "1.4.0-alpha16" +compose-material3 = "1.4.0-beta03" composesnapshot = "-" dependencyAnalysis = "2.19.0" desugar_jdk_libs = "2.1.5" dokka = "2.0.0" -googledagger = "2.56.2" -gradlePlugin = "8.11.0" -gradlePublishPlugin = "0.33.0" +googledagger = "2.57.1" +gradlePlugin = "8.12.2" +gradlePublishPlugin = "0.34.0" +grpcStub = "1.73.0" io-coil-kt = "2.7.0" junit = "4.13.2" -kotlin = "2.2.0" +kotlin = "2.2.10" kotlinxCoroutine = "1.10.2" kotlinxSerialization = "1.9.0" -ksp = "2.2.0-2.0.2" +ksp = "2.2.10-2.0.2" ktlint = "0.50.0" material = "1.12.0" metalava = "0.4.0-alpha03" moshi = "1.15.2" -okio = "3.14.0" -org-robolectric = "4.15.1" -ossLicensesPlugin = "0.10.6" -osslicenses = "0.8.0" -playServicesAuth = "21.3.0" -playServicesOssLicenses = "17.1.0" +okio = "3.16.0" +org-robolectric = "4.16" +ossLicensesPlugin = "0.10.8" +osslicenses = "0.9.0" +playServicesAuth = "21.4.0" +playServicesOssLicenses = "17.2.2" # Stay on 4.26.1 due to https://github.com/firebase/firebase-android-sdk/issues/5997 protobuf = "4.26.1" protobuf-gen-grpc-java = "1.73.0" protobuf-gen-grpc-kotlin = "1.4.3:jdk8@jar" protobuf-gen-javalite = "3.0.0" -roborazzi = "1.45.1" +roborazzi = "1.50.0" room = "2.7.2" -runtimeTracing = "1.8.3" -spotless = "7.0.4" +runtimeTracing = "1.9.0" +spotless = "7.2.1" tiles-tooling-preview = "1.5.0" truth = "1.4.4" -wearInput = "1.2.0-alpha02" +wearInput = "1.2.0-rc01" wearToolingPreview = "1.0.0" -wearcompose = "1.5.0-beta04" +wearcompose = "1.5.0" +foundationLayout = "1.8.3" [libraries] accessibility-test-framework = { module = "com.google.android.apps.common.testing.accessibility.framework:accessibility-test-framework", version.ref = "accessibilityTestFramework" } @@ -115,8 +117,8 @@ androidx-media3-session = { module = "androidx.media3:media3-session", version.r androidx-media3-testutils = { module = "androidx.media3:media3-test-utils", version.ref = "androidx-media3" } androidx-media3-testutils-robolectric = { module = "androidx.media3:media3-test-utils-robolectric", version.ref = "androidx-media3" } androidx-media3-ui = { module = "androidx.media3:media3-ui", version.ref = "androidx-media3" } -androidx-mediarouter = "androidx.mediarouter:mediarouter:1.8.0" -androidx-metrics-performance = "androidx.metrics:metrics-performance:1.0.0-beta02" +androidx-mediarouter = "androidx.mediarouter:mediarouter:1.8.1" +androidx-metrics-performance = "androidx.metrics:metrics-performance:1.0.0-beta03" androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "androidxNavigation" } androidx-navigation-runtime = { module = "androidx.navigation:navigation-runtime", version.ref = "androidxNavigation" } androidx-navigation-testing = { module = "androidx.navigation:navigation-testing", version.ref = "androidxNavigation" } @@ -127,7 +129,7 @@ androidx-startup = { module = "androidx.startup:startup-runtime", version.ref = androidx-test-espressocore = { module = "androidx.test.espresso:espresso-core", version.ref = "androidx-test-espresso" } androidx-test-ext = { module = "androidx.test.ext:junit", version.ref = "androidx-test-ext" } androidx-test-ext-ktx = { module = "androidx.test.ext:junit-ktx", version.ref = "androidx-test-ext" } -androidx-test-rules = "androidx.test:rules:1.6.1" +androidx-test-rules = "androidx.test:rules:1.7.0" androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = "androidx-test-runner" } androidx-test-uiautomator = "androidx.test.uiautomator:uiautomator:2.3.0" androidx-tracing-ktx = { module = "androidx.tracing:tracing-ktx", version.ref = "androidxTracing" } @@ -157,8 +159,8 @@ compose-animation-animationgraphics = { group = "androidx.compose.animation", na compose-bom = { group = "androidx.compose", name = "compose-bom-alpha", version.ref = "androidxComposeBom" } compose-foundation-foundation = { group = "androidx.compose.foundation", name = "foundation" } compose-foundation-foundation-layout = { group = "androidx.compose.foundation", name = "foundation-layout" } -compose-material-iconscore = { group = "androidx.compose.material", name = "material-icons-core" } -compose-material-iconsext = { group = "androidx.compose.material", name = "material-icons-extended" } +compose-material-iconscore = { group = "androidx.compose.material", name = "material-icons-core", version="1.7.8" } +compose-material-iconsext = { group = "androidx.compose.material", name = "material-icons-extended", version="1.7.8" } compose-material-ripple = { group = "androidx.compose.material", name = "material-ripple" } compose-material3 = { module = "androidx.compose.material3:material3", version.ref = "compose-material3" } # version 2023.10.01 from BOM is crashing compose-runtime = { group = "androidx.compose.runtime", name = "runtime" } @@ -178,8 +180,9 @@ dagger-hiltandroidplugin = { module = "com.google.dagger:hilt-android-gradle-plu dagger-hiltandroidtesting = { module = "com.google.dagger:hilt-android-testing", version.ref = "googledagger" } desugar-jdk-libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar_jdk_libs" } dokka = { module = "org.jetbrains.dokka:dokka-gradle-plugin", version.ref = "dokka" } -google-generativeai = "com.google.ai.client.generativeai:generativeai:0.9.0" +google-genai = "com.google.genai:google-genai:1.15.0" gradleMavenPublishPlugin = { module = "com.vanniktech:gradle-maven-publish-plugin", version.ref = "gradlePublishPlugin" } +grpc-stub = { module = "io.grpc:grpc-stub", version.ref = "grpcStub" } hilt-ext-compiler = { module = "androidx.hilt:hilt-compiler", version.ref = "androidx-hilt" } hilt-ext-work = { module = "androidx.hilt:hilt-work", version.ref = "androidx-hilt" } hilt-navigationcompose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "androidx-hilt" } @@ -234,6 +237,7 @@ 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" } +androidx-foundation-layout = { group = "androidx.compose.foundation", name = "foundation-layout", version.ref = "foundationLayout" } [plugins] compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index be2dc79a8a..7705927e94 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.2-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-all.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/health/composables/build.gradle.kts b/health/composables/build.gradle.kts index 6968fbb4f0..90ac2c7c2c 100644 --- a/health/composables/build.gradle.kts +++ b/health/composables/build.gradle.kts @@ -100,6 +100,7 @@ dependencies { implementation(projects.composeLayout) + implementation(platform(libs.compose.bom)) implementation(libs.androidx.wear) implementation(libs.androidx.health.services) implementation(libs.kotlin.stdlib) diff --git a/media/audio-ui-material3/build.gradle.kts b/media/audio-ui-material3/build.gradle.kts index 681987e609..6e29acba68 100644 --- a/media/audio-ui-material3/build.gradle.kts +++ b/media/audio-ui-material3/build.gradle.kts @@ -98,6 +98,7 @@ dependencies { api(projects.media.audioUiModel) debugImplementation(projects.logo) + implementation(platform(libs.compose.bom)) api(libs.androidx.wear.compose.material3) api(libs.wearcompose.foundation) implementation(libs.androidx.corektx) diff --git a/media/audio-ui/build.gradle.kts b/media/audio-ui/build.gradle.kts index c9e9879f49..de6d6cf20b 100644 --- a/media/audio-ui/build.gradle.kts +++ b/media/audio-ui/build.gradle.kts @@ -99,6 +99,7 @@ dependencies { api(projects.media.audioUiModel) debugImplementation(projects.logo) + implementation(platform(libs.compose.bom)) api(libs.wearcompose.material) api(libs.wearcompose.foundation) implementation(libs.androidx.corektx) diff --git a/media/sample/build.gradle.kts b/media/sample/build.gradle.kts index b5f27cce3a..1d2241df65 100644 --- a/media/sample/build.gradle.kts +++ b/media/sample/build.gradle.kts @@ -42,7 +42,7 @@ android { applicationId = "com.google.android.horologist.mediasample" // Min because of Tiles minSdk = 26 - targetSdk = 34 + targetSdk = 36 versionCode = 1 versionName = "1.0" @@ -155,6 +155,7 @@ dependencies { api(projects.annotations) implementation(projects.media.audio) + implementation(projects.media.audioUiMaterial3) implementation(projects.media.audioUi) implementation(projects.composables) implementation(projects.composeLayout) @@ -164,6 +165,7 @@ dependencies { implementation(projects.media.backendMedia3) implementation(projects.media.data) implementation(projects.media.sync) + implementation(projects.media.uiMaterial3) implementation(projects.media.ui) implementation(projects.networkAwareness.core) implementation(projects.networkAwareness.ui) @@ -214,6 +216,7 @@ dependencies { implementation(libs.moshi.kotlin) api(projects.media.audioUiModel) api(projects.media.uiModel) + implementation(libs.androidx.foundation.layout) testImplementation(projects.media.audioUiModel) ksp(libs.moshi.kotlin.codegen) implementation(libs.kotlinx.serialization.core) diff --git a/media/sample/src/debug/java/com/google/android/horologist/mediasample/data/service/tile/SampleTilePreview.kt b/media/sample/src/debug/java/com/google/android/horologist/mediasample/data/service/tile/SampleTilePreview.kt index bdedbcb1fb..d3cfc90df7 100644 --- a/media/sample/src/debug/java/com/google/android/horologist/mediasample/data/service/tile/SampleTilePreview.kt +++ b/media/sample/src/debug/java/com/google/android/horologist/mediasample/data/service/tile/SampleTilePreview.kt @@ -36,7 +36,6 @@ import com.google.android.horologist.tiles.images.toImageResource fun SampleTilePreview(context: Context): TilePreviewData = tileRendererPreviewData( renderer = MediaCollectionsTileRenderer( context = context, - materialTheme = UampColors.toTileColors(), debugResourceMode = BuildConfig.DEBUG, ), tileState = MediaCollectionsTileRenderer.MediaCollectionsState( diff --git a/media/sample/src/main/java/com/google/android/horologist/mediasample/data/service/tile/MediaCollectionsTileService.kt b/media/sample/src/main/java/com/google/android/horologist/mediasample/data/service/tile/MediaCollectionsTileService.kt index f225bc8e33..19ff2ac72a 100644 --- a/media/sample/src/main/java/com/google/android/horologist/mediasample/data/service/tile/MediaCollectionsTileService.kt +++ b/media/sample/src/main/java/com/google/android/horologist/mediasample/data/service/tile/MediaCollectionsTileService.kt @@ -16,6 +16,7 @@ package com.google.android.horologist.mediasample.data.service.tile +import androidx.wear.protolayout.material.Colors import androidx.wear.protolayout.ActionBuilders import androidx.wear.protolayout.ActionBuilders.AndroidActivity import androidx.wear.protolayout.ResourceBuilders.Resources @@ -52,7 +53,8 @@ class MediaCollectionsTileService : SuspendingTileService() { private val renderer: MediaCollectionsTileRenderer = MediaCollectionsTileRenderer( context = this, - materialTheme = UampColors.toTileColors(), + // TO DO Migrate Tile to Material 3 theme from app + materialTheme = Colors.DEFAULT, debugResourceMode = BuildConfig.DEBUG, ) diff --git a/media/sample/src/main/java/com/google/android/horologist/mediasample/ui/app/UampTheme.kt b/media/sample/src/main/java/com/google/android/horologist/mediasample/ui/app/UampTheme.kt index db307f0fca..61c6406d41 100644 --- a/media/sample/src/main/java/com/google/android/horologist/mediasample/ui/app/UampTheme.kt +++ b/media/sample/src/main/java/com/google/android/horologist/mediasample/ui/app/UampTheme.kt @@ -18,23 +18,37 @@ package com.google.android.horologist.mediasample.ui.app import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color -import androidx.wear.compose.material.Colors -import androidx.wear.compose.material.MaterialTheme +import androidx.wear.compose.material3.ColorScheme +import androidx.wear.compose.material3.MaterialTheme -public val UampColors = Colors( - primary = Color(0xFF981F68), - primaryVariant = Color(0xFF66003d), - secondary = Color(0xFF981F68), - error = Color(0xFFE24444), - onPrimary = Color.White, - onSurfaceVariant = Color(0xFFDADCE0), - surface = Color(0xFF303133), - onError = Color.Black, +public val UampColors = ColorScheme( + primary = Color(0xFFFFB0C9), + onPrimary = Color(0xFF5F1142), + primaryContainer = Color(0xFF7A2A59), + onPrimaryContainer = Color(0xFFFFD9E5), + secondary = Color(0xFFE0BDC9), + onSecondary = Color(0xFF422C35), + secondaryContainer = Color(0xFF5A424C), + onSecondaryContainer = Color(0xFFFCD9E5), + tertiary = Color(0xFFF5B993), + onTertiary = Color(0xFF4E250A), + tertiaryContainer = Color(0xFF693C1F), + onTertiaryContainer = Color(0xFFFFDCC3), + error = Color(0xFFFFB4AB), + onError = Color(0xFF690005), + errorContainer = Color(0xFF93000A), + onErrorContainer = Color(0xFFFFDAD6), + background = Color(0xFF1F1A1C), + onBackground = Color(0xFFEBE0E2), + onSurface = Color(0xFFEBE0E2), + onSurfaceVariant = Color(0xFFD3C2C6), + outline = Color(0xFF9A8D91), + outlineVariant = Color(0xFF4F4347), ) @Composable public fun UampTheme(block: @Composable () -> Unit) { - MaterialTheme(colors = UampColors) { + MaterialTheme(colorScheme = UampColors) { block() } -} +} \ No newline at end of file diff --git a/media/sample/src/main/java/com/google/android/horologist/mediasample/ui/app/UampWearApp.kt b/media/sample/src/main/java/com/google/android/horologist/mediasample/ui/app/UampWearApp.kt index c127b21dba..992e311331 100644 --- a/media/sample/src/main/java/com/google/android/horologist/mediasample/ui/app/UampWearApp.kt +++ b/media/sample/src/main/java/com/google/android/horologist/mediasample/ui/app/UampWearApp.kt @@ -24,17 +24,20 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavHostController -import androidx.wear.compose.material.Text +import androidx.wear.compose.material3.Text +import androidx.wear.compose.navigation.composable import androidx.wear.compose.navigation.rememberSwipeDismissableNavHostState import com.google.android.horologist.auth.ui.googlesignin.signin.GoogleSignInScreen -import com.google.android.horologist.compose.nav.composable -import com.google.android.horologist.media.ui.navigation.MediaNavController.navigateToLibrary -import com.google.android.horologist.media.ui.navigation.MediaNavController.navigateToPlayer -import com.google.android.horologist.media.ui.navigation.MediaPlayerScaffold -import com.google.android.horologist.media.ui.navigation.NavigationScreen +import com.google.android.horologist.media.ui.material3.navigation.MediaNavController.navigateToCollection +import com.google.android.horologist.media.ui.material3.navigation.MediaNavController.navigateToCollections +import com.google.android.horologist.media.ui.material3.navigation.MediaNavController.navigateToLibrary +import com.google.android.horologist.media.ui.material3.navigation.MediaNavController.navigateToPlayer +import com.google.android.horologist.media.ui.material3.navigation.MediaNavController.navigateToSettings +import com.google.android.horologist.media.ui.material3.navigation.MediaNavController.navigateToVolume +import com.google.android.horologist.media.ui.material3.navigation.MediaPlayerScaffold import com.google.android.horologist.mediasample.BuildConfig import com.google.android.horologist.mediasample.ui.auth.prompt.GoogleSignInPromptScreen import com.google.android.horologist.mediasample.ui.auth.signin.UampGoogleSignInViewModel @@ -49,13 +52,7 @@ import com.google.android.horologist.mediasample.ui.entity.UampEntityScreen import com.google.android.horologist.mediasample.ui.entity.UampEntityScreenViewModel import com.google.android.horologist.mediasample.ui.entity.UampStreamingPlaylistScreen import com.google.android.horologist.mediasample.ui.entity.UampStreamingPlaylistScreenViewModel -import com.google.android.horologist.mediasample.ui.navigation.UampNavigationScreen.AudioDebug -import com.google.android.horologist.mediasample.ui.navigation.UampNavigationScreen.DeveloperOptions -import com.google.android.horologist.mediasample.ui.navigation.UampNavigationScreen.GoogleSignInPromptScreen -import com.google.android.horologist.mediasample.ui.navigation.UampNavigationScreen.GoogleSignInScreen -import com.google.android.horologist.mediasample.ui.navigation.UampNavigationScreen.GoogleSignOutScreen -import com.google.android.horologist.mediasample.ui.navigation.UampNavigationScreen.NewHotness -import com.google.android.horologist.mediasample.ui.navigation.UampNavigationScreen.Samples +import com.google.android.horologist.mediasample.ui.navigation.UampNavigationScreen import com.google.android.horologist.mediasample.ui.newhotness.NewHotnessPlayerScreen import com.google.android.horologist.mediasample.ui.player.UampMediaPlayerScreen import com.google.android.horologist.mediasample.ui.playlists.UampPlaylistsScreen @@ -85,7 +82,7 @@ fun UampWearApp( mediaPlayerScreenViewModel = hiltViewModel(), volumeViewModel = volumeViewModel, onVolumeClick = { - navController.navigate(NavigationScreen.Volume) + navController.navigateToVolume() }, ) }, @@ -93,38 +90,36 @@ fun UampWearApp( if (appState.streamingMode == true) { UampStreamingBrowseScreen( onPlaylistsClick = { - navController.navigate(NavigationScreen.Collections) + navController.navigateToCollections() }, onSettingsClick = { - navController.navigate(NavigationScreen.Settings) + navController.navigateToSettings() }, ) } else { UampBrowseScreen( uampBrowseScreenViewModel = hiltViewModel(), onDownloadItemClick = { - navController.navigate( - NavigationScreen.Collection( - it.playlistUiModel.id, - it.playlistUiModel.title, - ), + navController.navigateToCollection( + collectionId = it.playlistUiModel.id, + collectionName = it.playlistUiModel.title, ) }, onPlaylistsClick = { - navController.navigate(NavigationScreen.Collections) + navController.navigateToCollections() }, onSettingsClick = { - navController.navigate(NavigationScreen.Settings) + navController.navigateToSettings() }, ) } }, - categoryEntityScreen = { category -> + categoryEntityScreen = { id, name -> if (appState.streamingMode == true) { val viewModel: UampStreamingPlaylistScreenViewModel = hiltViewModel() UampStreamingPlaylistScreen( - playlistName = category.name.orEmpty(), + playlistName = name, viewModel = viewModel, onDownloadItemClick = { navController.navigateToPlayer() @@ -136,7 +131,7 @@ fun UampWearApp( val uampEntityScreenViewModel: UampEntityScreenViewModel = hiltViewModel() UampEntityScreen( - playlistName = category.name.orEmpty(), + playlistName = name, uampEntityScreenViewModel = uampEntityScreenViewModel, onDownloadItemClick = { navController.navigateToPlayer() @@ -159,11 +154,9 @@ fun UampWearApp( UampPlaylistsScreen( uampPlaylistsScreenViewModel = uampPlaylistsScreenViewModel, onPlaylistItemClick = { playlistUiModel -> - navController.navigate( - NavigationScreen.Collection( - playlistUiModel.id, - playlistUiModel.title, - ), + navController.navigateToCollection( + collectionId = playlistUiModel.id, + collectionName = playlistUiModel.title, ) }, onErrorDialogCancelClick = { navController.popBackStack() }, @@ -176,7 +169,6 @@ fun UampWearApp( ) }, navHostState = navHostState, - snackbarViewModel = hiltViewModel(), volumeViewModel = volumeViewModel, timeText = { MediaInfoTimeText( @@ -186,34 +178,34 @@ fun UampWearApp( deepLinkPrefix = appViewModel.deepLinkPrefix, navController = navController, additionalNavRoutes = { - composable { + composable(UampNavigationScreen.AudioDebug.navRoute) { AudioDebugScreen( audioDebugScreenViewModel = hiltViewModel(), ) } - composable { + composable(UampNavigationScreen.Samples.navRoute) { SamplesScreen( samplesScreenViewModel = hiltViewModel(), navController = navController, ) } - composable { + composable(UampNavigationScreen.DeveloperOptions.navRoute) { DeveloperOptionsScreen( developerOptionsScreenViewModel = hiltViewModel(), navController = navController, ) } - composable { + composable(UampNavigationScreen.GoogleSignInPromptScreen.navRoute) { GoogleSignInPromptScreen( navController = navController, viewModel = hiltViewModel(), ) } - composable { + composable(UampNavigationScreen.GoogleSignInScreen.navRoute) { GoogleSignInScreen( onAuthCancelled = { navController.popBackStack() }, onAuthSucceed = { navController.navigateToLibrary() }, @@ -221,14 +213,14 @@ fun UampWearApp( ) } - composable { + composable(UampNavigationScreen.GoogleSignOutScreen.navRoute) { GoogleSignOutScreen( navController = navController, viewModel = hiltViewModel(), ) } - composable { + composable(UampNavigationScreen.NewHotness.navRoute) { NewHotnessPlayerScreen() } }, @@ -279,7 +271,7 @@ private suspend fun startupNavigation( if (appViewModel.shouldShowLoginPrompt()) { // Allow screen to settle so it feels like a distinct step delay(200) - navController.navigate(GoogleSignInPromptScreen) + navController.navigate(UampNavigationScreen.GoogleSignInPromptScreen.navRoute) } } diff --git a/media/sample/src/main/java/com/google/android/horologist/mediasample/ui/auth/prompt/GoogleSignInPromptScreen.kt b/media/sample/src/main/java/com/google/android/horologist/mediasample/ui/auth/prompt/GoogleSignInPromptScreen.kt index 759951a03a..86b316e769 100644 --- a/media/sample/src/main/java/com/google/android/horologist/mediasample/ui/auth/prompt/GoogleSignInPromptScreen.kt +++ b/media/sample/src/main/java/com/google/android/horologist/mediasample/ui/auth/prompt/GoogleSignInPromptScreen.kt @@ -33,7 +33,7 @@ import com.google.android.horologist.auth.composables.chips.SignInChip import com.google.android.horologist.auth.ui.common.screens.prompt.SignInPromptScreen import com.google.android.horologist.compose.material.Confirmation import com.google.android.horologist.mediasample.R -import com.google.android.horologist.mediasample.ui.navigation.UampNavigationScreen.GoogleSignInScreen +import com.google.android.horologist.mediasample.ui.navigation.UampNavigationScreen @Composable fun GoogleSignInPromptScreen( @@ -54,7 +54,7 @@ fun GoogleSignInPromptScreen( item { SignInChip( onClick = { - navController.navigate(GoogleSignInScreen) + navController.navigate(UampNavigationScreen.GoogleSignInScreen.navRoute) }, colors = ChipDefaults.secondaryChipColors(), ) diff --git a/media/sample/src/main/java/com/google/android/horologist/mediasample/ui/browse/UampBrowseScreen.kt b/media/sample/src/main/java/com/google/android/horologist/mediasample/ui/browse/UampBrowseScreen.kt index 91dc09cda0..1ff0748617 100644 --- a/media/sample/src/main/java/com/google/android/horologist/mediasample/ui/browse/UampBrowseScreen.kt +++ b/media/sample/src/main/java/com/google/android/horologist/mediasample/ui/browse/UampBrowseScreen.kt @@ -21,7 +21,7 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.google.android.horologist.media.ui.screens.browse.PlaylistDownloadBrowseScreen +import com.google.android.horologist.media.ui.material3.screens.browse.PlaylistDownloadBrowseScreen import com.google.android.horologist.media.ui.state.model.PlaylistDownloadUiModel import com.google.android.horologist.mediasample.R diff --git a/media/sample/src/main/java/com/google/android/horologist/mediasample/ui/browse/UampBrowseScreenViewModel.kt b/media/sample/src/main/java/com/google/android/horologist/mediasample/ui/browse/UampBrowseScreenViewModel.kt index 6db8bbf79f..331ef14249 100644 --- a/media/sample/src/main/java/com/google/android/horologist/mediasample/ui/browse/UampBrowseScreenViewModel.kt +++ b/media/sample/src/main/java/com/google/android/horologist/mediasample/ui/browse/UampBrowseScreenViewModel.kt @@ -20,7 +20,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.google.android.horologist.media.model.Playlist import com.google.android.horologist.media.repository.PlaylistRepository -import com.google.android.horologist.media.ui.screens.browse.BrowseScreenState +import com.google.android.horologist.media.ui.material3.screens.browse.BrowseScreenState import com.google.android.horologist.media.ui.state.mapper.PlaylistDownloadUiModelMapper import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.SharingStarted diff --git a/media/sample/src/main/java/com/google/android/horologist/mediasample/ui/browse/UampStreamingBrowseScreen.kt b/media/sample/src/main/java/com/google/android/horologist/mediasample/ui/browse/UampStreamingBrowseScreen.kt index af59606602..962c59951c 100644 --- a/media/sample/src/main/java/com/google/android/horologist/mediasample/ui/browse/UampStreamingBrowseScreen.kt +++ b/media/sample/src/main/java/com/google/android/horologist/mediasample/ui/browse/UampStreamingBrowseScreen.kt @@ -22,8 +22,8 @@ import androidx.compose.material.icons.filled.Settings import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import com.google.android.horologist.media.ui.model.R -import com.google.android.horologist.media.ui.screens.browse.BrowseScreen -import com.google.android.horologist.media.ui.screens.browse.BrowseScreenPlaylistsSectionButton +import com.google.android.horologist.media.ui.material3.screens.browse.BrowseScreen +import com.google.android.horologist.media.ui.material3.screens.browse.BrowseScreenPlaylistsSectionButton @Composable fun UampStreamingBrowseScreen( diff --git a/media/sample/src/main/java/com/google/android/horologist/mediasample/ui/debug/SamplesScreen.kt b/media/sample/src/main/java/com/google/android/horologist/mediasample/ui/debug/SamplesScreen.kt index dce540782a..7bb1f44a0d 100644 --- a/media/sample/src/main/java/com/google/android/horologist/mediasample/ui/debug/SamplesScreen.kt +++ b/media/sample/src/main/java/com/google/android/horologist/mediasample/ui/debug/SamplesScreen.kt @@ -32,7 +32,7 @@ import com.google.android.horologist.compose.layout.ScalingLazyColumnDefaults.It import com.google.android.horologist.compose.layout.ScalingLazyColumnDefaults.padding import com.google.android.horologist.compose.layout.ScreenScaffold import com.google.android.horologist.compose.layout.rememberResponsiveColumnState -import com.google.android.horologist.media.ui.navigation.MediaNavController.navigateToPlayer +import com.google.android.horologist.media.ui.material3.navigation.MediaNavController.navigateToPlayer import com.google.android.horologist.mediasample.R import com.google.android.horologist.mediasample.ui.settings.ActionSetting diff --git a/media/sample/src/main/java/com/google/android/horologist/mediasample/ui/entity/UampEntityScreen.kt b/media/sample/src/main/java/com/google/android/horologist/mediasample/ui/entity/UampEntityScreen.kt index 5cef27d7ac..2905a7870d 100644 --- a/media/sample/src/main/java/com/google/android/horologist/mediasample/ui/entity/UampEntityScreen.kt +++ b/media/sample/src/main/java/com/google/android/horologist/mediasample/ui/entity/UampEntityScreen.kt @@ -23,9 +23,10 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.res.stringResource import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.google.android.horologist.compose.material.AlertDialog -import com.google.android.horologist.media.ui.screens.entity.PlaylistDownloadScreen -import com.google.android.horologist.media.ui.screens.entity.PlaylistDownloadScreenState +import androidx.wear.compose.material3.AlertDialog +import androidx.wear.compose.material3.AlertDialogDefaults +import com.google.android.horologist.media.ui.material3.screens.entity.PlaylistDownloadScreen +import com.google.android.horologist.media.ui.material3.screens.entity.PlaylistDownloadScreenState import com.google.android.horologist.media.ui.state.model.DownloadMediaUiModel import com.google.android.horologist.media.ui.state.model.PlaylistUiModel import com.google.android.horologist.mediasample.R @@ -83,52 +84,79 @@ fun UampEntityScreen( // b/243381431 - it should stop listening to uiState emissions while dialog is presented if (uiState == PlaylistDownloadScreenState.Failed) { AlertDialog( - message = stringResource(R.string.entity_no_playlists), - onDismiss = onErrorDialogCancelClick, - showDialog = true, - ) + visible = true, + title = { stringResource(R.string.entity_no_playlists)}, + onDismissRequest = onErrorDialogCancelClick ) } AlertDialog( - message = stringResource(R.string.entity_dialog_cancel_downloads), - onCancel = { + title = {stringResource(R.string.entity_dialog_cancel_downloads)}, + onDismissRequest = { showCancelDownloadsDialog = false }, - onOk = { - showCancelDownloadsDialog = false - uampEntityScreenViewModel.remove() + dismissButton = { + AlertDialogDefaults.DismissButton( + onClick = { + showCancelDownloadsDialog = false + } + ) + }, + confirmButton = { + AlertDialogDefaults.ConfirmButton( + onClick = { + showCancelDownloadsDialog = false + uampEntityScreenViewModel.remove() + } + ) }, - showDialog = showCancelDownloadsDialog, - okButtonContentDescription = stringResource(id = R.string.entity_dialog_proceed_button_content_description), - cancelButtonContentDescription = stringResource(id = R.string.entity_dialog_cancel_button_content_description), + visible = showCancelDownloadsDialog, ) AlertDialog( - message = stringResource(R.string.entity_dialog_remove_downloads, playlistName), - onCancel = { + title = {stringResource(R.string.entity_dialog_remove_downloads, playlistName)}, + onDismissRequest = { showRemoveDownloadsDialog = false }, - onOk = { - showRemoveDownloadsDialog = false - uampEntityScreenViewModel.remove() + confirmButton = { + AlertDialogDefaults.ConfirmButton( + onClick = { + showRemoveDownloadsDialog = false + uampEntityScreenViewModel.remove() + } + ) + }, + visible = showRemoveDownloadsDialog, + dismissButton = { + AlertDialogDefaults.DismissButton( + onClick = { + showRemoveDownloadsDialog = false + } + ) }, - showDialog = showRemoveDownloadsDialog, - okButtonContentDescription = stringResource(id = R.string.entity_dialog_proceed_button_content_description), - cancelButtonContentDescription = stringResource(id = R.string.entity_dialog_cancel_button_content_description), + ) AlertDialog( - message = stringResource(R.string.entity_dialog_remove_downloads, mediaTitleToDelete), - onCancel = { + title = {stringResource(R.string.entity_dialog_remove_downloads, mediaTitleToDelete)}, + onDismissRequest = { showRemoveSingleMediaDownloadDialog = false }, - onOk = { - showRemoveSingleMediaDownloadDialog = false - mediaIdToDelete?.let { uampEntityScreenViewModel.removeMediaItem(it) } + visible = showRemoveSingleMediaDownloadDialog, + dismissButton = { + AlertDialogDefaults.DismissButton( + onClick = { + showRemoveSingleMediaDownloadDialog = false + } + ) + }, + confirmButton = { + AlertDialogDefaults.ConfirmButton( + onClick = { + showRemoveSingleMediaDownloadDialog = false + mediaIdToDelete?.let { uampEntityScreenViewModel.removeMediaItem(it) } + } + ) }, - showDialog = showRemoveSingleMediaDownloadDialog, - okButtonContentDescription = stringResource(id = R.string.entity_dialog_proceed_button_content_description), - cancelButtonContentDescription = stringResource(id = R.string.entity_dialog_cancel_button_content_description), ) } diff --git a/media/sample/src/main/java/com/google/android/horologist/mediasample/ui/entity/UampEntityScreenViewModel.kt b/media/sample/src/main/java/com/google/android/horologist/mediasample/ui/entity/UampEntityScreenViewModel.kt index ea0105dc3e..13ace3d64a 100644 --- a/media/sample/src/main/java/com/google/android/horologist/mediasample/ui/entity/UampEntityScreenViewModel.kt +++ b/media/sample/src/main/java/com/google/android/horologist/mediasample/ui/entity/UampEntityScreenViewModel.kt @@ -25,8 +25,8 @@ import com.google.android.horologist.media.repository.MediaDownloadRepository import com.google.android.horologist.media.repository.PlayerRepository import com.google.android.horologist.media.repository.PlaylistDownloadRepository import com.google.android.horologist.media.ui.navigation.NavigationScreen -import com.google.android.horologist.media.ui.screens.entity.PlaylistDownloadScreenState -import com.google.android.horologist.media.ui.screens.entity.createPlaylistDownloadScreenStateLoaded +import com.google.android.horologist.media.ui.material3.screens.entity.PlaylistDownloadScreenState +import com.google.android.horologist.media.ui.material3.screens.entity.createPlaylistDownloadScreenStateLoaded import com.google.android.horologist.media.ui.state.mapper.DownloadMediaUiModelMapper import com.google.android.horologist.media.ui.state.mapper.PlaylistUiModelMapper import com.google.android.horologist.media.ui.state.model.DownloadMediaUiModel diff --git a/media/sample/src/main/java/com/google/android/horologist/mediasample/ui/entity/UampStreamingPlaylistScreen.kt b/media/sample/src/main/java/com/google/android/horologist/mediasample/ui/entity/UampStreamingPlaylistScreen.kt index 09e4699c8f..e24194bb0a 100644 --- a/media/sample/src/main/java/com/google/android/horologist/mediasample/ui/entity/UampStreamingPlaylistScreen.kt +++ b/media/sample/src/main/java/com/google/android/horologist/mediasample/ui/entity/UampStreamingPlaylistScreen.kt @@ -19,7 +19,7 @@ package com.google.android.horologist.mediasample.ui.entity import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.google.android.horologist.media.ui.screens.entity.PlaylistStreamingScreen +import com.google.android.horologist.media.ui.material3.screens.entity.PlaylistStreamingScreen import com.google.android.horologist.media.ui.state.model.DownloadMediaUiModel import com.google.android.horologist.media.ui.state.model.PlaylistUiModel diff --git a/media/sample/src/main/java/com/google/android/horologist/mediasample/ui/entity/UampStreamingPlaylistScreenViewModel.kt b/media/sample/src/main/java/com/google/android/horologist/mediasample/ui/entity/UampStreamingPlaylistScreenViewModel.kt index d7daf414c4..36d06418df 100644 --- a/media/sample/src/main/java/com/google/android/horologist/mediasample/ui/entity/UampStreamingPlaylistScreenViewModel.kt +++ b/media/sample/src/main/java/com/google/android/horologist/mediasample/ui/entity/UampStreamingPlaylistScreenViewModel.kt @@ -24,8 +24,8 @@ import com.google.android.horologist.media.model.PlaylistDownload import com.google.android.horologist.media.repository.PlayerRepository import com.google.android.horologist.media.repository.PlaylistDownloadRepository import com.google.android.horologist.media.ui.navigation.NavigationScreen -import com.google.android.horologist.media.ui.screens.entity.PlaylistDownloadScreenState -import com.google.android.horologist.media.ui.screens.entity.createPlaylistDownloadScreenStateLoaded +import com.google.android.horologist.media.ui.material3.screens.entity.PlaylistDownloadScreenState +import com.google.android.horologist.media.ui.material3.screens.entity.createPlaylistDownloadScreenStateLoaded import com.google.android.horologist.media.ui.state.mapper.DownloadMediaUiModelMapper import com.google.android.horologist.media.ui.state.mapper.PlaylistUiModelMapper import com.google.android.horologist.media.ui.state.model.DownloadMediaUiModel diff --git a/media/sample/src/main/java/com/google/android/horologist/mediasample/ui/navigation/UampNavigationScreen.kt b/media/sample/src/main/java/com/google/android/horologist/mediasample/ui/navigation/UampNavigationScreen.kt index f082a70cea..079e23bb68 100644 --- a/media/sample/src/main/java/com/google/android/horologist/mediasample/ui/navigation/UampNavigationScreen.kt +++ b/media/sample/src/main/java/com/google/android/horologist/mediasample/ui/navigation/UampNavigationScreen.kt @@ -16,28 +16,21 @@ package com.google.android.horologist.mediasample.ui.navigation -import com.google.android.horologist.media.ui.navigation.NavigationScreen -import kotlinx.serialization.Serializable +import com.google.android.horologist.media.ui.material3.navigation.NavigationScreens object UampNavigationScreen { - @Serializable - public data object AudioDebug : NavigationScreen - @Serializable - public data object Samples : NavigationScreen + public data object AudioDebug : NavigationScreens("audioDebug") - @Serializable - public data object GoogleSignInPromptScreen : NavigationScreen + public data object Samples : NavigationScreens("samples") - @Serializable - public data object GoogleSignInScreen : NavigationScreen + public data object GoogleSignInPromptScreen : NavigationScreens("googleSignInPromptScreen") - @Serializable - public object GoogleSignOutScreen : NavigationScreen + public data object GoogleSignInScreen : NavigationScreens("googleSignInScreen") - @Serializable - public object DeveloperOptions : NavigationScreen + public data object GoogleSignOutScreen : NavigationScreens("googleSignOutScreen") - @Serializable - public data object NewHotness : NavigationScreen + public data object DeveloperOptions : NavigationScreens("developerOptions") + + public data object NewHotness : NavigationScreens("newHotness") } diff --git a/media/sample/src/main/java/com/google/android/horologist/mediasample/ui/newhotness/NewHotnessPlayerScreen.kt b/media/sample/src/main/java/com/google/android/horologist/mediasample/ui/newhotness/NewHotnessPlayerScreen.kt index e0e92bbf9e..f56e3127e6 100644 --- a/media/sample/src/main/java/com/google/android/horologist/mediasample/ui/newhotness/NewHotnessPlayerScreen.kt +++ b/media/sample/src/main/java/com/google/android/horologist/mediasample/ui/newhotness/NewHotnessPlayerScreen.kt @@ -22,7 +22,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.media3.common.Player import androidx.wear.compose.material.Text diff --git a/media/sample/src/main/java/com/google/android/horologist/mediasample/ui/player/FavoriteButton.kt b/media/sample/src/main/java/com/google/android/horologist/mediasample/ui/player/FavoriteButton.kt index b82189f6c1..e5866514f1 100644 --- a/media/sample/src/main/java/com/google/android/horologist/mediasample/ui/player/FavoriteButton.kt +++ b/media/sample/src/main/java/com/google/android/horologist/mediasample/ui/player/FavoriteButton.kt @@ -26,7 +26,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import com.google.android.horologist.audio.ui.components.actions.SettingsButton +import com.google.android.horologist.audio.ui.material3.components.actions.SettingsButton import com.google.android.horologist.mediasample.R /** diff --git a/media/sample/src/main/java/com/google/android/horologist/mediasample/ui/player/UampMediaPlayerScreen.kt b/media/sample/src/main/java/com/google/android/horologist/mediasample/ui/player/UampMediaPlayerScreen.kt index f73cdaff55..afc9092574 100644 --- a/media/sample/src/main/java/com/google/android/horologist/mediasample/ui/player/UampMediaPlayerScreen.kt +++ b/media/sample/src/main/java/com/google/android/horologist/mediasample/ui/player/UampMediaPlayerScreen.kt @@ -22,18 +22,18 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.wear.compose.material.MaterialTheme +import androidx.wear.compose.material3.MaterialTheme import com.google.android.horologist.audio.ui.VolumeViewModel import com.google.android.horologist.audio.ui.components.toAudioOutputUi import com.google.android.horologist.images.coil.CoilPaintable -import com.google.android.horologist.media.ui.components.PodcastControlButtons -import com.google.android.horologist.media.ui.components.animated.AnimatedMediaControlButtons -import com.google.android.horologist.media.ui.components.animated.AnimatedMediaInfoDisplay -import com.google.android.horologist.media.ui.components.background.ArtworkColorBackground -import com.google.android.horologist.media.ui.components.background.ColorBackground -import com.google.android.horologist.media.ui.screens.player.DefaultMediaInfoDisplay -import com.google.android.horologist.media.ui.screens.player.DefaultPlayerScreenControlButtons -import com.google.android.horologist.media.ui.screens.player.PlayerScreen +import com.google.android.horologist.media.ui.material3.components.PodcastControlButtons +import com.google.android.horologist.media.ui.material3.components.animated.AnimatedMediaControlButtons +import com.google.android.horologist.media.ui.material3.components.animated.AnimatedMediaInfoDisplay +import com.google.android.horologist.media.ui.material3.components.background.ArtworkImageBackground +import com.google.android.horologist.media.ui.material3.components.background.ColorBackground +import com.google.android.horologist.media.ui.material3.screens.player.DefaultMediaInfoDisplay +import com.google.android.horologist.media.ui.material3.screens.player.DefaultPlayerScreenControlButtons +import com.google.android.horologist.media.ui.material3.screens.player.PlayerScreen import com.google.android.horologist.media.ui.state.PlayerUiController import com.google.android.horologist.media.ui.state.PlayerUiState import com.google.android.horologist.media.ui.state.model.MediaUiModel @@ -61,9 +61,9 @@ fun UampMediaPlayerScreen( modifier = Modifier.fillMaxSize(), ) } else { - ArtworkColorBackground( - paintable = (it.media as? MediaUiModel.Ready)?.artwork as? CoilPaintable, - defaultColor = MaterialTheme.colors.primary, + ArtworkImageBackground( + artwork = (it.media as? MediaUiModel.Ready)?.artwork as? CoilPaintable, + colorScheme = MaterialTheme.colorScheme, modifier = Modifier.fillMaxSize(), ) } @@ -99,10 +99,10 @@ fun UampMediaPlayerScreen( playPauseButtonEnabled = playerUiState.playPauseEnabled, playing = playerUiState.playing, onSeekToPreviousButtonClick = { playerUiController.skipToPreviousMedia() }, - onSeekToPreviousLongRepeatableClick = { playerUiController.seekBack() }, + onSeekToPreviousRepeatableClick = { playerUiController.seekBack() }, seekToPreviousButtonEnabled = playerUiState.seekToPreviousEnabled, onSeekToNextButtonClick = { playerUiController.skipToNextMedia() }, - onSeekToNextLongRepeatableClick = { playerUiController.seekForward() }, + onSeekToNextRepeatableClick = { playerUiController.seekForward() }, seekToNextButtonEnabled = playerUiState.seekToNextEnabled, trackPositionUiModel = playerUiState.trackPositionUiModel, ) diff --git a/media/sample/src/main/java/com/google/android/horologist/mediasample/ui/player/UampSettingsButtons.kt b/media/sample/src/main/java/com/google/android/horologist/mediasample/ui/player/UampSettingsButtons.kt index 201c1b50ee..d546a157ac 100644 --- a/media/sample/src/main/java/com/google/android/horologist/mediasample/ui/player/UampSettingsButtons.kt +++ b/media/sample/src/main/java/com/google/android/horologist/mediasample/ui/player/UampSettingsButtons.kt @@ -17,15 +17,17 @@ package com.google.android.horologist.mediasample.ui.player import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import com.google.android.horologist.audio.AudioOutput import com.google.android.horologist.audio.ui.VolumeUiState import com.google.android.horologist.audio.ui.components.AudioOutputUi -import com.google.android.horologist.audio.ui.components.SettingsButtonsDefaults -import com.google.android.horologist.audio.ui.components.actions.SetAudioOutputButton -import com.google.android.horologist.logo.R +import com.google.android.horologist.audio.ui.material3.components.actions.VolumeButtonWithBadge +import com.google.android.horologist.audio.ui.material3.components.toAudioOutputUi /** * Settings buttons for the UAMP media app. @@ -44,17 +46,18 @@ public fun UampSettingsButtons( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceEvenly, ) { - FavoriteButton() + Box(modifier = Modifier.weight(1f).fillMaxHeight()) { + VolumeButtonWithBadge( + onOutputClick = onVolumeClick, + audioOutputUi = AudioOutput.BluetoothHeadset(id = "id", name = "name") + .toAudioOutputUi(), + volumeUiState = volumeUiState, + enabled = enabled, + ) + } - SettingsButtonsDefaults.BrandIcon( - iconId = R.drawable.ic_stat_horologist, - enabled = enabled, - ) - - SetAudioOutputButton( - onVolumeClick = onVolumeClick, - volumeUiState = volumeUiState, - audioOutputUi = audioOutputUi, - ) + Box(modifier = Modifier.weight(1f).fillMaxHeight()) { + FavoriteButton() + } } } diff --git a/media/sample/src/main/java/com/google/android/horologist/mediasample/ui/playlists/UampPlaylistsScreen.kt b/media/sample/src/main/java/com/google/android/horologist/mediasample/ui/playlists/UampPlaylistsScreen.kt index 7012f279a8..4556ad7ae5 100644 --- a/media/sample/src/main/java/com/google/android/horologist/mediasample/ui/playlists/UampPlaylistsScreen.kt +++ b/media/sample/src/main/java/com/google/android/horologist/mediasample/ui/playlists/UampPlaylistsScreen.kt @@ -16,24 +16,19 @@ package com.google.android.horologist.mediasample.ui.playlists -import androidx.compose.foundation.layout.Row import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.wear.compose.foundation.lazy.rememberScalingLazyListState -import androidx.wear.compose.material.ButtonDefaults -import androidx.wear.compose.material.MaterialTheme -import androidx.wear.compose.material.Text -import androidx.wear.compose.material.dialog.Alert -import androidx.wear.compose.material.dialog.Dialog -import com.google.android.horologist.compose.material.Button -import com.google.android.horologist.media.ui.screens.playlists.PlaylistsScreen -import com.google.android.horologist.media.ui.screens.playlists.PlaylistsScreenState +import androidx.wear.compose.material3.AlertDialog +import androidx.wear.compose.material3.Icon +import androidx.wear.compose.material3.MaterialTheme +import androidx.wear.compose.material3.Text +import com.google.android.horologist.media.ui.material3.screens.playlists.PlaylistsScreen +import com.google.android.horologist.media.ui.material3.screens.playlists.PlaylistsScreenState import com.google.android.horologist.media.ui.state.model.PlaylistUiModel import com.google.android.horologist.mediasample.R @@ -69,34 +64,24 @@ fun UampPlaylistsScreen( // b/242302037 - it should stop listening to uiState emissions while dialog is presented if (modifiedState == PlaylistsScreenState.Failed) { - Dialog( - showDialog = true, + AlertDialog( + visible = true, onDismissRequest = onErrorDialogCancelClick, - scrollState = rememberScalingLazyListState(), - ) { - Alert( - title = { - Text( - text = stringResource(R.string.playlists_no_playlists), - color = MaterialTheme.colors.onBackground, - textAlign = TextAlign.Center, - style = MaterialTheme.typography.title3, - ) - }, - ) { - item { - Row( - verticalAlignment = Alignment.CenterVertically, - ) { - Button( - onClick = onErrorDialogCancelClick, - colors = ButtonDefaults.secondaryButtonColors(), - imageVector = Icons.Default.Close, - contentDescription = stringResource(id = R.string.playlists_failed_dialog_cancel_button_content_description), - ) - } - } - } - } + title = { + Text( + text = stringResource(R.string.playlists_no_playlists), + color = MaterialTheme.colorScheme.onBackground, + textAlign = TextAlign.Center, + style = MaterialTheme.typography.titleSmall, + ) + }, + icon = { + Icon( + imageVector = Icons.Default.Close, + contentDescription = + stringResource(id = R.string.playlists_failed_dialog_cancel_button_content_description), + ) + }, + ) } } diff --git a/media/sample/src/main/java/com/google/android/horologist/mediasample/ui/playlists/UampPlaylistsScreenViewModel.kt b/media/sample/src/main/java/com/google/android/horologist/mediasample/ui/playlists/UampPlaylistsScreenViewModel.kt index ea1547b802..176cd79e95 100644 --- a/media/sample/src/main/java/com/google/android/horologist/mediasample/ui/playlists/UampPlaylistsScreenViewModel.kt +++ b/media/sample/src/main/java/com/google/android/horologist/mediasample/ui/playlists/UampPlaylistsScreenViewModel.kt @@ -19,7 +19,7 @@ package com.google.android.horologist.mediasample.ui.playlists import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.google.android.horologist.media.repository.PlaylistRepository -import com.google.android.horologist.media.ui.screens.playlists.PlaylistsScreenState +import com.google.android.horologist.media.ui.material3.screens.playlists.PlaylistsScreenState import com.google.android.horologist.media.ui.state.mapper.PlaylistUiModelMapper import com.google.android.horologist.media.ui.state.model.PlaylistUiModel import dagger.hilt.android.lifecycle.HiltViewModel diff --git a/media/sample/src/main/java/com/google/android/horologist/mediasample/ui/settings/DeveloperOptionsScreen.kt b/media/sample/src/main/java/com/google/android/horologist/mediasample/ui/settings/DeveloperOptionsScreen.kt index 4fcc6fcc5e..1d82cc6602 100644 --- a/media/sample/src/main/java/com/google/android/horologist/mediasample/ui/settings/DeveloperOptionsScreen.kt +++ b/media/sample/src/main/java/com/google/android/horologist/mediasample/ui/settings/DeveloperOptionsScreen.kt @@ -67,7 +67,7 @@ fun DeveloperOptionsScreen( ActionSetting( "New Hotness Player", ) { - navController.navigate(NewHotness) + navController.navigate(NewHotness.navRoute) } } item { @@ -83,14 +83,14 @@ fun DeveloperOptionsScreen( ActionSetting( stringResource(id = R.string.sample_audio_debug), ) { - navController.navigate(AudioDebug) + navController.navigate(AudioDebug.navRoute) } } item { ActionSetting( stringResource(id = R.string.sample_samples), ) { - navController.navigate(Samples) + navController.navigate(Samples.navRoute) } } item { diff --git a/media/sample/src/main/java/com/google/android/horologist/mediasample/ui/settings/DeveloperOptionsScreenViewModel.kt b/media/sample/src/main/java/com/google/android/horologist/mediasample/ui/settings/DeveloperOptionsScreenViewModel.kt index 5b83b73077..26ca9f0777 100644 --- a/media/sample/src/main/java/com/google/android/horologist/mediasample/ui/settings/DeveloperOptionsScreenViewModel.kt +++ b/media/sample/src/main/java/com/google/android/horologist/mediasample/ui/settings/DeveloperOptionsScreenViewModel.kt @@ -20,7 +20,6 @@ import android.os.Process import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.google.android.horologist.media.ui.snackbar.SnackbarManager -import com.google.android.horologist.media.ui.snackbar.UiMessage import com.google.android.horologist.mediasample.di.IsEmulator import com.google.android.horologist.mediasample.domain.SettingsRepository import com.google.android.horologist.mediasample.domain.proto.copy @@ -126,12 +125,12 @@ class DeveloperOptionsScreenViewModel } fun showDialog(message: String) { - snackbarManager.showMessage( - UiMessage( - message = message, - error = true, - ), - ) +// snackbarManager.showMessage( +// UiMessage( +// message = message, +// error = true, +// ), +// ) } fun toggleNetworkRequest() { diff --git a/media/sample/src/main/java/com/google/android/horologist/mediasample/ui/settings/UampSettingsScreen.kt b/media/sample/src/main/java/com/google/android/horologist/mediasample/ui/settings/UampSettingsScreen.kt index 8c622b40d8..46187f8a2e 100644 --- a/media/sample/src/main/java/com/google/android/horologist/mediasample/ui/settings/UampSettingsScreen.kt +++ b/media/sample/src/main/java/com/google/android/horologist/mediasample/ui/settings/UampSettingsScreen.kt @@ -82,19 +82,21 @@ fun UampSettingsScreen( label = stringResource(id = R.string.login), modifier = Modifier.fillMaxWidth(), onClick = { - navController.navigate(GoogleSignInScreen) + navController.navigate(GoogleSignInScreen.navRoute) }, enabled = !screenState.guestMode, + colors = ChipDefaults.secondaryChipColors(), ) } else { Chip( label = stringResource(id = R.string.logout), modifier = Modifier.fillMaxWidth(), onClick = { - navController.navigate(GoogleSignOutScreen) { + navController.navigate(GoogleSignOutScreen.navRoute) { popUpTo() } }, + colors = ChipDefaults.secondaryChipColors(), ) } } @@ -114,7 +116,7 @@ fun UampSettingsScreen( icon = Icons.Default.DataObject, colors = ChipDefaults.secondaryChipColors(), onClick = { - navController.navigate(DeveloperOptions) + navController.navigate(DeveloperOptions.navRoute) }, ) } diff --git a/media/ui-material3/build.gradle.kts b/media/ui-material3/build.gradle.kts index 08e8a70325..5f76ba92ab 100644 --- a/media/ui-material3/build.gradle.kts +++ b/media/ui-material3/build.gradle.kts @@ -105,6 +105,7 @@ dependencies { api(projects.media.core) api(projects.tiles) + implementation(platform(libs.compose.bom)) api(projects.composables) api(libs.androidx.wear.compose.material3) api(libs.wearcompose.foundation) diff --git a/media/ui-material3/src/main/java/com/google/android/horologist/media/ui/material3/screens/browse/BrowseScreen.kt b/media/ui-material3/src/main/java/com/google/android/horologist/media/ui/material3/screens/browse/BrowseScreen.kt index d975f7f5e6..422ae3d6e3 100644 --- a/media/ui-material3/src/main/java/com/google/android/horologist/media/ui/material3/screens/browse/BrowseScreen.kt +++ b/media/ui-material3/src/main/java/com/google/android/horologist/media/ui/material3/screens/browse/BrowseScreen.kt @@ -17,6 +17,7 @@ package com.google.android.horologist.media.ui.material3.screens.browse import androidx.annotation.StringRes +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -90,7 +91,7 @@ public class BrowseScreenScope { Section( state = state, headerContent = { - ListHeader { Text(text = stringResource(id = titleId)) } + ListHeader(modifier = Modifier.fillMaxWidth()) { Text(text = stringResource(id = titleId)) } }, loadingContent = scope.loadingContent, loadedContent = scope.loadedContent, @@ -147,9 +148,9 @@ public class BrowseScreenScope { headerContent = { ListHeader( modifier = if (firstSectionAdded) { - Modifier.padding(bottom = 8.dp) + Modifier.padding(bottom = 8.dp).fillMaxWidth() } else { - Modifier.padding(top = 8.dp, bottom = 8.dp) + Modifier.padding(top = 8.dp, bottom = 8.dp).fillMaxWidth() }, ) { Text(stringResource(R.string.horologist_browse_library_playlists)) } }, @@ -163,6 +164,7 @@ public class BrowseScreenScope { contentDescription = null, ) }, + modifier = Modifier.fillMaxWidth() ) }, ), @@ -184,6 +186,7 @@ public class BrowseScreenScope { contentDescription = null, ) }, + modifier = Modifier.fillMaxWidth() ) }, ), diff --git a/media/ui-material3/src/main/java/com/google/android/horologist/media/ui/material3/screens/browse/PlaylistDownloadBrowseScreen.kt b/media/ui-material3/src/main/java/com/google/android/horologist/media/ui/material3/screens/browse/PlaylistDownloadBrowseScreen.kt index 70e3e57d13..257399f8bd 100644 --- a/media/ui-material3/src/main/java/com/google/android/horologist/media/ui/material3/screens/browse/PlaylistDownloadBrowseScreen.kt +++ b/media/ui-material3/src/main/java/com/google/android/horologist/media/ui/material3/screens/browse/PlaylistDownloadBrowseScreen.kt @@ -104,6 +104,7 @@ internal fun BrowseScreenScope.PlaylistDownloadBrowseScreenContent( when (download) { is PlaylistDownloadUiModel.Completed -> { FilledTonalButton( + modifier = Modifier.fillMaxWidth(), label = { Text(download.playlistUiModel.title) }, onClick = { onDownloadItemClick(download) }, icon = { diff --git a/media/ui-material3/src/main/java/com/google/android/horologist/media/ui/material3/screens/entity/PlaylistDownloadScreen.kt b/media/ui-material3/src/main/java/com/google/android/horologist/media/ui/material3/screens/entity/PlaylistDownloadScreen.kt index 06f38eb5c6..0ca215e7d0 100644 --- a/media/ui-material3/src/main/java/com/google/android/horologist/media/ui/material3/screens/entity/PlaylistDownloadScreen.kt +++ b/media/ui-material3/src/main/java/com/google/android/horologist/media/ui/material3/screens/entity/PlaylistDownloadScreen.kt @@ -181,6 +181,7 @@ private fun MediaContent( is DownloadMediaUiModel.NotDownloaded, -> { FilledTonalButton( + modifier = Modifier.fillMaxWidth(), label = { Text(mediaTitle) }, onClick = { onDownloadItemClick(downloadMediaUiModel) }, secondaryLabel = secondaryLabel?.let { { Text(secondaryLabel) } }, diff --git a/media/ui-material3/src/main/java/com/google/android/horologist/media/ui/material3/screens/playlists/PlaylistsScreen.kt b/media/ui-material3/src/main/java/com/google/android/horologist/media/ui/material3/screens/playlists/PlaylistsScreen.kt index 381cb5d861..797793ab6e 100644 --- a/media/ui-material3/src/main/java/com/google/android/horologist/media/ui/material3/screens/playlists/PlaylistsScreen.kt +++ b/media/ui-material3/src/main/java/com/google/android/horologist/media/ui/material3/screens/playlists/PlaylistsScreen.kt @@ -116,6 +116,7 @@ public fun PlaylistsScreen( ) { val playlistContent: @Composable (playlist: PlaylistUiModel) -> Unit = { playlist -> FilledTonalButton( + modifier = modifier.fillMaxWidth(), label = { Text(playlist.title) }, onClick = { onPlaylistItemClick(playlist) }, icon = { diff --git a/media/ui-model/build.gradle.kts b/media/ui-model/build.gradle.kts index 75a457fbf7..3d8965019a 100644 --- a/media/ui-model/build.gradle.kts +++ b/media/ui-model/build.gradle.kts @@ -100,6 +100,7 @@ metalava { } dependencies { + implementation(platform(libs.compose.bom)) api(projects.media.core) implementation(projects.images.coil) implementation(libs.compose.animation.animationgraphics) diff --git a/media/ui/build.gradle.kts b/media/ui/build.gradle.kts index e029bd0416..134e543355 100644 --- a/media/ui/build.gradle.kts +++ b/media/ui/build.gradle.kts @@ -105,8 +105,10 @@ dependencies { api(projects.media.core) api(projects.tiles) + implementation(platform(libs.compose.bom)) api(projects.composables) api(libs.wearcompose.material) + api(libs.androidx.wear.compose.material3) api(libs.wearcompose.foundation) implementation(projects.media.audio) diff --git a/media/ui/src/main/java/com/google/android/horologist/media/ui/tiles/MediaCollectionsTileRenderer.kt b/media/ui/src/main/java/com/google/android/horologist/media/ui/tiles/MediaCollectionsTileRenderer.kt index 6699f78671..b37529a4f4 100644 --- a/media/ui/src/main/java/com/google/android/horologist/media/ui/tiles/MediaCollectionsTileRenderer.kt +++ b/media/ui/src/main/java/com/google/android/horologist/media/ui/tiles/MediaCollectionsTileRenderer.kt @@ -44,7 +44,7 @@ import com.google.android.horologist.tiles.render.SingleTileLayoutRenderer @ExperimentalHorologistApi public class MediaCollectionsTileRenderer( context: Context, - private val materialTheme: Colors, + private val materialTheme: Colors = Colors.DEFAULT, debugResourceMode: Boolean, ) : SingleTileLayoutRenderer( context, diff --git a/release/signing-cleanup.sh b/release/signing-cleanup.sh deleted file mode 100755 index b3d283d994..0000000000 --- a/release/signing-cleanup.sh +++ /dev/null @@ -1,17 +0,0 @@ -#!/bin/sh - -# 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. - -rm -f release/*.properties diff --git a/release/signing-setup.sh b/release/signing-setup.sh deleted file mode 100755 index 4503c77e12..0000000000 --- a/release/signing-setup.sh +++ /dev/null @@ -1,23 +0,0 @@ -#!/bin/bash - -# 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. - -ENCRYPT_KEY=$1 - -if [[ ! -z "$ENCRYPT_KEY" ]]; then - openssl aes-256-cbc -md sha256 -pbkdf2 -iter 100000 -d -in release/signing.properties.aes -out release/signing.properties -k ${ENCRYPT_KEY} -else - echo "ENCRYPT_KEY is empty" -fi diff --git a/release/signing.properties.aes b/release/signing.properties.aes deleted file mode 100644 index a280b8f491..0000000000 Binary files a/release/signing.properties.aes and /dev/null differ diff --git a/roboscreenshots/build.gradle.kts b/roboscreenshots/build.gradle.kts index b13c3a85a2..8f03216a11 100644 --- a/roboscreenshots/build.gradle.kts +++ b/roboscreenshots/build.gradle.kts @@ -89,7 +89,7 @@ dependencies { api(projects.images.coil) api(projects.tiles) - api(libs.kotlin.stdlib) + implementation(platform(libs.compose.bom)) api(libs.okio) api(libs.compose.ui.test.junit4) api(libs.robolectric) diff --git a/sample/src/main/AndroidManifest.xml b/sample/src/main/AndroidManifest.xml index 1f759efa85..bf72e7113e 100644 --- a/sample/src/main/AndroidManifest.xml +++ b/sample/src/main/AndroidManifest.xml @@ -132,6 +132,22 @@ android:name="androidx.wear.tiles.PREVIEW" android:resource="@drawable/example_tile" /> + + + + + + + + diff --git a/sample/src/main/java/com/google/android/horologist/components/SampleApplication.kt b/sample/src/main/java/com/google/android/horologist/components/SampleApplication.kt index 4110d931aa..b996941981 100644 --- a/sample/src/main/java/com/google/android/horologist/components/SampleApplication.kt +++ b/sample/src/main/java/com/google/android/horologist/components/SampleApplication.kt @@ -17,6 +17,7 @@ package com.google.android.horologist.components import android.app.Application +import android.os.StrictMode import com.google.android.horologist.networks.InMemoryStatusLogger import com.google.android.horologist.networks.data.DataRequestRepository import com.google.android.horologist.networks.status.NetworkRepository @@ -36,6 +37,24 @@ class SampleApplication : Application() { override fun onCreate() { super.onCreate() + setStrictMode() + SampleAppDI.inject(this) } + + private fun setStrictMode() { + StrictMode.setThreadPolicy( + StrictMode.ThreadPolicy.Builder() + .detectDiskReads() + .detectDiskWrites() + .detectNetwork() + .penaltyDeath() + .build(), + ) + StrictMode.setVmPolicy( + StrictMode.VmPolicy.Builder() + .detectAll() + .build(), + ) + } } diff --git a/sample/src/main/java/com/google/android/horologist/m3/FastScrollingTLCScreen.kt b/sample/src/main/java/com/google/android/horologist/m3/FastScrollingTLCScreen.kt new file mode 100644 index 0000000000..72aee7da4e --- /dev/null +++ b/sample/src/main/java/com/google/android/horologist/m3/FastScrollingTLCScreen.kt @@ -0,0 +1,173 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.horologist.m3 + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.toMutableStateList +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.wear.compose.foundation.lazy.items +import androidx.wear.compose.foundation.lazy.rememberTransformingLazyColumnState +import androidx.wear.compose.material3.AppScaffold +import androidx.wear.compose.material3.ScreenScaffold +import androidx.wear.compose.material3.SurfaceTransformation +import androidx.wear.compose.material3.Text +import androidx.wear.compose.material3.TitleCard +import androidx.wear.compose.material3.lazy.rememberTransformationSpec +import androidx.wear.compose.material3.lazy.transformedHeight +import com.google.android.horologist.compose.layout.ColumnItemType +import com.google.android.horologist.compose.layout.m3.FastScrollingTransformingLazyColumn +import com.google.android.horologist.compose.layout.m3.HeaderInfo +import com.google.android.horologist.compose.layout.rememberResponsiveColumnPadding + +open class ScrollableContent(var content: String) + +class Header(val title: String) : ScrollableContent(title) +class Person(val name: String) : ScrollableContent(name) + +val peopleString = """ + Olivia Smith, Liam Johnson, Emma Williams, Noah Brown, Ava Jones, Isabella Garcia, + Sophia Miller, James Davis, William Rodriguez, Benjamin Martinez, Lucas Hernandez, + Henry Lopez, Alexander Gonzalez, Mia Wilson, Charlotte Anderson, Amelia Thomas, + Evelyn Taylor, Abigail Moore, Daniel Jackson, Harper Martin, Ella Lee, Grace Perez, + Aiden Thompson, Jackson White, Scarlett Harris, Emily Sanchez, Michael Clark, + Elizabeth Ramirez, David Lewis, Mila Robinson, Joseph Walker, Chloe Hall, + Samuel Allen, Aubrey Young, Julian King, Zoey Wright, Leo Scott, Layla Green, + Gabriel Baker, Nora Adams, Anthony Nelson, Luna Hill, Christopher Rivera, + Victoria Campbell, Ryan Mitchell, Hannah Roberts, Nathan Carter, Natalie Phillips, + Caleb Parker, Leah Evans, Isaac Edwards, Zoe Collins, Joshua Stewart, Stella Morris, + Matthew Rogers, Aurora Reed, Andrew Cook, Dylan Morgan, John Bell, Genesis Murphy, + Luke Bailey, Sarah Cooper, Gabriel Richardson, Eva Cox, Nathan Howard, Penelope Ward, + Jacob Torres, Alexander Peterson, Mason Gray, Ethan Ramirez, Oliver James, + Elijah Watson, Sebastian Brooks, Owen Kelly, Logan Sanders, Caleb Price, + Dylan Bennett, Isaac Wood, Liam Barnes, Noah Ross, Lucas Henderson, Aiden Coleman, + Jack Jenkins, Daniel Perry, Joseph Powell, Samuel Long, Benjamin Patterson, Leo Hughes, + Julian Flores, Chloe Washington, Zoe Butler, Stella Simmons, Layla Foster, + Nora Gonzales, Luna Bryant, Harper Alexander, Mila Russell, Charlotte Griffin, + Amelia Diaz, Evelyn Hayes, Abigail Myers, Michael Ford, Elizabeth Hamilton, + David Graham, Ella Sullivan, Grace Wallace, Jackson Woods, Scarlett Cole, Emily West, + William Jordan, Benjamin Owens, Lucas Reynolds, Henry Kennedy, Alexander Stone, + Mia Shaw, Charlotte Snyder, Amelia Burke, Evelyn Spencer, Abigail Walsh, Daniel Dean, + Harper Fisher, Ella Lane, Grace Boyd, Aiden Fuller, Jackson Fields, Scarlett Black, + Emily Ryan, Michael Olsen, Elizabeth Pierce, David Porter, Mila Freeman, + Joseph Cunningham, Chloe Lawrence, Samuel Newman, Aubrey Hunt, Julian Meyer, + Zoey Marshall, Leo Stevens, Layla Dixon, Gabriel Arnold, Nora Boyd, Anthony Fuller, + Luna Hayes, Christopher Cox, Victoria Ward, Ryan Gray, Hannah Bailey, Nathan Brooks, + Natalie Kelly, Caleb Price, Leah Bennett, Isaac Barnes, Zoe Henderson, Joshua Coleman, + Stella Jenkins, Matthew Perry, Aurora Powell, Andrew Long, Dylan Patterson, John Hughes, + Genesis Flores, Luke Washington, Sarah Butler, Gabriel Simmons, Eva Foster, + Nathan Gonzales, Penelope Bryant, Jacob Alexander, Alexander Russell, Mason Griffin, + Ethan Diaz, Oliver Hayes, Elijah Myers, Sebastian Ford, Owen Hamilton, Logan Graham, + Caleb Sullivan, Dylan Wallace, Isaac Woods, Liam Cole, Noah West, Lucas Jordan, + Aiden Owens, Jack Reynolds, Daniel Kennedy, Joseph Stone, Samuel Shaw, Benjamin Snyder, + Leo Burke, Julian Spencer, Chloe Walsh, Zoe Dean, Stella Fisher, Layla Lane, Nora Boyd, + Luna Fuller, Harper Fields, Mila Black, Charlotte Ryan, Amelia Olsen, Evelyn Pierce, + Abigail Porter, Michael Freeman, Elizabeth Cunningham, David Lawrence, Ella Newman, + Grace Hunt, Jackson Meyer, Scarlett Marshall, Emily Stevens, William Dixon, + Benjamin Arnold, Lucas Boyd, Henry Fuller, Alexander Hayes, Mia Cox, Charlotte Ward, + Amelia Gray, Evelyn Bailey, Abigail Brooks, Daniel Kelly, Harper Price, Ella Bennett, + Grace Barnes, Aiden Henderson, Jackson Coleman, Scarlett Jenkins, Emily Perry, + Michael Powell, Elizabeth Long, David Patterson, Mila Hughes, Joseph Flores, + Chloe Washington, Samuel Butler, Aubrey Simmons, Julian Foster, Zoey Gonzales, + Leo Bryant, Layla Alexander, Nora Russell, Luna Griffin, Christopher Diaz, + Victoria Hayes, Ryan Myers, Hannah Ford, Nathan Hamilton, Natalie Graham, + Caleb Sullivan, Leah Wallace, Isaac Woods, Zoe Cole, Joshua West, Stella Jordan, + Matthew Owens, Aurora Reynolds, Andrew Kennedy, Dylan Stone, John Shaw, Genesis Snyder, + Luke Burke, Sarah Spencer, Gabriel Walsh, Eva Dean, Nathan Fisher, Penelope Lane, + Jacob Boyd, Alexander Fuller, Mason Fields, Ethan Black, Oliver Ryan, Elijah Olsen, + Sebastian Pierce, Owen Porter, Logan Freeman, Caleb Cunningham, Dylan Lawrence, + Isaac Newman, Liam Hunt, Noah Meyer, Lucas Marshall, Aiden Stevens, Jack Dixon, + Daniel Arnold, Joseph Boyd, Samuel Fuller, Benjamin Hayes, Leo Cox, Julian Ward, + Chloe Gray, Zoe Bailey, Stella Brooks, Layla Kelly, Nora Price, Luna Bennett, + Harper Barnes, Mila Henderson, Charlotte Coleman, Amelia Jenkins, Evelyn Perry, + Abigail Powell +""".trimIndent() + +val people = peopleString.split(",").map { + Person(it.trim()) +} +val headers = people.map { + Header( + it.content.take(1), + ) +}.distinctBy { it.content } + +val tlcContent: List = (people + headers).sortedBy { it.content } + +@Composable +fun FastScrollingTLCScreen() { + // Disable other screen scaffold + com.google.android.horologist.compose.layout.ScreenScaffold( + timeText = {}, + positionIndicator = {}, + ) { + AppScaffold() { + val columnState = rememberTransformingLazyColumnState() + ScreenScaffold( + scrollState = columnState, + contentPadding = rememberResponsiveColumnPadding( + first = ColumnItemType.Card, + last = ColumnItemType.Card, + ), + ) { contentPadding -> + val transformationSpec = rememberTransformationSpec() + val headers = remember { + val letterIndexes = tlcContent.mapIndexed { index, item -> + HeaderInfo( + index, + item.content.take(1), + ) + }.distinctBy { it.value } + letterIndexes.toMutableStateList() + } + + FastScrollingTransformingLazyColumn( + state = columnState, + contentPadding = contentPadding, + modifier = Modifier + .fillMaxSize() + .testTag("TransformingLazyColumn"), + headers = headers, + ) { + items(tlcContent) { item -> + if (item is Header) { + Row(horizontalArrangement = Arrangement.Center) { + Text(item.content) + } + } else if (item is Person) { + TitleCard( + onClick = {}, + modifier = Modifier + .fillMaxWidth() + .transformedHeight(this, transformationSpec), + transformation = SurfaceTransformation(transformationSpec), + title = { Text(item.content) }, + ) { + Text("Visits to the ISS:") + } + } + } + } + } + } + } +} diff --git a/sample/src/main/java/com/google/android/horologist/sample/MenuScreen.kt b/sample/src/main/java/com/google/android/horologist/sample/MenuScreen.kt index 799b329004..e5036cbaef 100644 --- a/sample/src/main/java/com/google/android/horologist/sample/MenuScreen.kt +++ b/sample/src/main/java/com/google/android/horologist/sample/MenuScreen.kt @@ -59,6 +59,12 @@ fun MenuScreen( onClick = { navigateToRoute(Screen.Material3.route) }, ) } + item { + Chip( + label = "Fast Scrolling TLC", + onClick = { navigateToRoute(Screen.FastScrollingTLC.route) }, + ) + } item { Chip( label = "Networks", diff --git a/sample/src/main/java/com/google/android/horologist/sample/SampleWearApp.kt b/sample/src/main/java/com/google/android/horologist/sample/SampleWearApp.kt index 57f9b4d0ed..af1a60bb2a 100644 --- a/sample/src/main/java/com/google/android/horologist/sample/SampleWearApp.kt +++ b/sample/src/main/java/com/google/android/horologist/sample/SampleWearApp.kt @@ -35,6 +35,7 @@ import com.google.android.horologist.compose.layout.AppScaffold import com.google.android.horologist.compose.layout.ScalingLazyColumnDefaults.ItemType import com.google.android.horologist.compose.layout.ScreenScaffold import com.google.android.horologist.compose.layout.rememberResponsiveColumnState +import com.google.android.horologist.m3.FastScrollingTLCScreen import com.google.android.horologist.m3.M3TLCButtonAndEdgeButton import com.google.android.horologist.materialcomponents.SampleAlertDialog import com.google.android.horologist.materialcomponents.SampleAnimatedComponents @@ -90,6 +91,11 @@ fun SampleWearApp() { ) { M3TLCButtonAndEdgeButton() } + composable( + Screen.FastScrollingTLC.route, + ) { + FastScrollingTLCScreen() + } composable( Screen.Network.route, ) { diff --git a/sample/src/main/java/com/google/android/horologist/sample/Screen.kt b/sample/src/main/java/com/google/android/horologist/sample/Screen.kt index 6cced829c0..8ee922ef40 100644 --- a/sample/src/main/java/com/google/android/horologist/sample/Screen.kt +++ b/sample/src/main/java/com/google/android/horologist/sample/Screen.kt @@ -30,6 +30,7 @@ sealed class Screen( object TimeWithoutSecondsPicker : Screen("timeWithoutSecondsPicker") object Network : Screen("network") object Material3 : Screen("material3") + object FastScrollingTLC : Screen("FastScrollingTLC") object MaterialAlertDialog : Screen("materialAlertDialog") object MaterialAnimatedComponents : Screen("materialAnimatedComponents") diff --git a/sample/src/main/java/com/google/android/horologist/sample/tiles/ComposableTileService.kt b/sample/src/main/java/com/google/android/horologist/sample/tiles/ComposableTileService.kt new file mode 100644 index 0000000000..dc60a01d8f --- /dev/null +++ b/sample/src/main/java/com/google/android/horologist/sample/tiles/ComposableTileService.kt @@ -0,0 +1,89 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.horologist.sample.tiles + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.wear.compose.material3.FilledIconButton +import androidx.wear.compose.material3.Text +import androidx.wear.protolayout.DimensionBuilders.dp +import androidx.wear.protolayout.DimensionBuilders.expand +import androidx.wear.protolayout.LayoutElementBuilders +import androidx.wear.protolayout.ResourceBuilders.Resources +import androidx.wear.protolayout.TimelineBuilders.Timeline +import androidx.wear.tiles.RequestBuilders.ResourcesRequest +import androidx.wear.tiles.RequestBuilders.TileRequest +import androidx.wear.tiles.TileBuilders.Tile +import com.google.android.horologist.tiles.SuspendingTileService +import com.google.android.horologist.tiles.composable.ServiceComposableBitmapRenderer +import com.google.android.horologist.tiles.images.toImageResource +import java.util.UUID + +class ComposableTileService : SuspendingTileService() { + private lateinit var renderer: ServiceComposableBitmapRenderer + val ComposeId = "circleCompose" + + override fun onCreate() { + super.onCreate() + + renderer = ServiceComposableBitmapRenderer(this.application, this) + } + + /** This method returns a Tile object, which describes the layout of the Tile. */ + override suspend fun tileRequest(requestParams: TileRequest): Tile { + val layoutElement = + LayoutElementBuilders.Box.Builder().setWidth(expand()).setHeight(expand()).addContent( + LayoutElementBuilders.Image.Builder().setWidth(dp(100f)).setHeight(dp(100f)) + .setResourceId(ComposeId).build(), + ).build() + + return Tile.Builder().setResourcesVersion(UUID.randomUUID().toString()) + .setTileTimeline(Timeline.fromLayoutElement(layoutElement)).build() + } + + override suspend fun resourcesRequest(requestParams: ResourcesRequest): Resources { + // Add images to the Resources object, and return + val circleComposeBitmap = circleCompose() + return Resources.Builder().setVersion(requestParams.version).apply { + if (circleComposeBitmap != null) { + addIdToImageMapping( + ComposeId, + circleComposeBitmap.toImageResource(), + ) + } + }.build() + } + + private suspend fun circleCompose(): ImageBitmap? = + renderer.renderComposableToBitmap(DpSize(100.dp, 100.dp)) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Canvas(modifier = Modifier.fillMaxSize()) { + drawCircle(Color.DarkGray) + } + FilledIconButton(onClick = {}) { + Text("\uD83D\uDC6A") + } + } + } +} diff --git a/sample/src/main/res/drawable/composable_tile_preview.png b/sample/src/main/res/drawable/composable_tile_preview.png new file mode 100644 index 0000000000..5f70fcc49e Binary files /dev/null and b/sample/src/main/res/drawable/composable_tile_preview.png differ diff --git a/sample/src/main/res/values/strings.xml b/sample/src/main/res/values/strings.xml index eaff7f087b..31fa942d01 100644 --- a/sample/src/main/res/values/strings.xml +++ b/sample/src/main/res/values/strings.xml @@ -86,4 +86,6 @@ Pager Screen Vertical Pager Screen Material Cards Screen + Composable Tile + Composable Tile diff --git a/settings.gradle.kts b/settings.gradle.kts index 0291a947dc..dc898b0658 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -15,7 +15,7 @@ */ plugins { - id("com.gradle.develocity") version "4.0.2" + id("com.gradle.develocity") version "4.1.1" } develocity { @@ -32,16 +32,17 @@ include(":ai:sample:phone") include(":ai:sample:wear-core") include(":ai:sample:wear-prompt-app") include(":ai:sample:wear-gemini") +include(":ai:sample:wear-gemini-lib") include(":ai:ui") include(":annotations") include(":auth:composables") +include(":auth:composables-material3") include(":auth:data") include(":auth:data-phone") include(":auth:sample:phone") include(":auth:sample:shared") include(":auth:sample:wear") include(":auth:ui") -include(":compose:animation:animation-graphics") include(":compose-layout") include(":compose-material") include(":compose-tools") diff --git a/tiles/api/current.api b/tiles/api/current.api index 5859df077d..ac318677f3 100644 --- a/tiles/api/current.api +++ b/tiles/api/current.api @@ -83,6 +83,19 @@ package com.google.android.horologist.tiles.components { } +package com.google.android.horologist.tiles.composable { + + public interface ComposableBitmapRenderer { + method public suspend Object? renderComposableToBitmap(long canvasSize, optional androidx.compose.ui.graphics.ImageBitmapConfig? config, kotlin.jvm.functions.Function0 composableContent, kotlin.coroutines.Continuation); + } + + public final class ServiceComposableBitmapRenderer implements com.google.android.horologist.tiles.composable.ComposableBitmapRenderer { + ctor public ServiceComposableBitmapRenderer(android.app.Application application, optional androidx.lifecycle.LifecycleOwner lifecycleOwner); + method public suspend Object? renderComposableToBitmap(long canvasSize, androidx.compose.ui.graphics.ImageBitmapConfig? config, kotlin.jvm.functions.Function0 composableContent, kotlin.coroutines.Continuation); + } + +} + package com.google.android.horologist.tiles.images { public final class DrawableResToImageResourceKt { @@ -93,6 +106,7 @@ package com.google.android.horologist.tiles.images { method public static suspend Object? loadImage(coil.ImageLoader, android.content.Context context, Object? data, optional kotlin.jvm.functions.Function1 configurer, kotlin.coroutines.Continuation); method public static suspend Object? loadImageResource(coil.ImageLoader, android.content.Context context, Object? data, optional kotlin.jvm.functions.Function1 configurer, kotlin.coroutines.Continuation); method public static androidx.wear.protolayout.ResourceBuilders.ImageResource toImageResource(android.graphics.Bitmap); + method public static androidx.wear.protolayout.ResourceBuilders.ImageResource toImageResource(androidx.compose.ui.graphics.ImageBitmap); } } diff --git a/tiles/build.gradle.kts b/tiles/build.gradle.kts index 2c5b5a8d88..561108b5f5 100644 --- a/tiles/build.gradle.kts +++ b/tiles/build.gradle.kts @@ -98,6 +98,7 @@ dependencies { api(libs.androidx.wear.protolayout.material) api(libs.androidx.lifecycle.service) api(libs.androidx.concurrent.future.ktx) + api(libs.androidx.lifecycle.process) implementation(libs.coil) implementation(libs.wearcompose.foundation) @@ -114,6 +115,7 @@ dependencies { testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.androidx.concurrent.future.ktx) testImplementation(libs.androidx.lifecycle.testing) + testImplementation(libs.androidx.wear.compose.material3) debugImplementation(libs.compose.ui.test.manifest) } diff --git a/tiles/src/main/java/com/google/android/horologist/tiles/composable/CaptureComposable.kt b/tiles/src/main/java/com/google/android/horologist/tiles/composable/CaptureComposable.kt new file mode 100644 index 0000000000..7923952316 --- /dev/null +++ b/tiles/src/main/java/com/google/android/horologist/tiles/composable/CaptureComposable.kt @@ -0,0 +1,257 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.horologist.tiles.composable + +import android.app.Application +import android.app.Dialog +import android.app.Presentation +import android.graphics.Bitmap +import android.graphics.SurfaceTexture +import android.hardware.display.DisplayManager +import android.view.Display +import android.view.Surface +import android.view.ViewGroup +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.ImageBitmapConfig +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.rememberGraphicsLayer +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.IntSize +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ProcessLifecycleOwner +import androidx.lifecycle.setViewTreeLifecycleOwner +import androidx.savedstate.SavedStateRegistry +import androidx.savedstate.SavedStateRegistryController +import androidx.savedstate.SavedStateRegistryOwner +import androidx.savedstate.setViewTreeSavedStateRegistryOwner +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * A renderer that renders a composable to a bitmap. + */ +public interface ComposableBitmapRenderer { + + public suspend fun renderComposableToBitmap( + canvasSize: DpSize, + config: ImageBitmapConfig? = ImageBitmapConfig.Rgb565, + composableContent: @Composable () -> Unit, + ): ImageBitmap? +} + +/** + * Use a virtual display to capture composable content thats on a display. + * This is necessary because Compose doesn't yet support offscreen bitmap creation (https://issuetracker.google.com/298037598) + * + * Rebecca Frank's implementation https://gist.github.com/riggaroo/0e0072b3e85aa91443659031925fa47c + * + * Original source: https://gist.github.com/iamcalledrob/871568679ad58e64959b097d4ef30738 + */ +public class ServiceComposableBitmapRenderer( + private val application: Application, + private val lifecycleOwner: LifecycleOwner = ProcessLifecycleOwner.get(), +) : + ComposableBitmapRenderer { + + override suspend fun renderComposableToBitmap( + canvasSize: DpSize, + config: ImageBitmapConfig?, + composableContent: @Composable () -> Unit, + ): ImageBitmap? { + val bitmap = useVirtualDisplay { display -> + val presentation = Presentation(application, display).apply { + window?.decorView?.let { view -> + view.setViewTreeLifecycleOwner(lifecycleOwner) + view.setViewTreeSavedStateRegistryOwner(EmptySavedStateRegistryOwner()) + } + } + + coroutineScope { + try { + val result = captureComposable( + size = canvasSize, + presentation = presentation, + coroutineScope = this, + ) { + composableContent() + } + + result.await() + } finally { + presentation.dismiss() + } + } + } + return if (config != null && bitmap != null) { + bitmap.convert(config) + } else { + bitmap + } + } + + private fun Size.roundedToIntSize(): IntSize = + IntSize(width.toInt(), height.toInt()) + + private suspend fun useVirtualDisplay(callback: suspend (display: Display) -> T): T? { + val texture = SurfaceTexture(false) + val surface = Surface(texture) + + try { + val outerContext = application.resources.displayMetrics + val virtualDisplay = + application.getSystemService(DisplayManager::class.java).createVirtualDisplay( + "virtualDisplay", + outerContext.widthPixels, + outerContext.heightPixels, + outerContext.densityDpi, + surface, + DisplayManager.VIRTUAL_DISPLAY_FLAG_OWN_CONTENT_ONLY, + ) ?: return null + + try { + return withContext(Dispatchers.Main.immediate) { callback(virtualDisplay.display) } + } finally { + virtualDisplay.release() + } + } finally { + surface.release() + texture.release() + } + } + + private inner class EmptySavedStateRegistryOwner : SavedStateRegistryOwner { + private val controller = SavedStateRegistryController.create(this).apply { + performRestore(null) + } + + private val lifecycleOwner: LifecycleOwner = this@ServiceComposableBitmapRenderer.lifecycleOwner + + override val lifecycle: Lifecycle + get() = + object : Lifecycle() { + @Suppress("UNNECESSARY_SAFE_CALL") + override fun addObserver(observer: LifecycleObserver) { + lifecycleOwner?.lifecycle?.addObserver(observer) + } + + @Suppress("UNNECESSARY_SAFE_CALL") + override fun removeObserver(observer: LifecycleObserver) { + lifecycleOwner?.lifecycle?.removeObserver(observer) + } + + override val currentState = State.INITIALIZED + } + + override val savedStateRegistry: SavedStateRegistry + get() = controller.savedStateRegistry + } + + /** Captures composable content, by default using a hidden window on the default display. + * + * Be sure to invoke capture() within the composable content (e.g. in a LaunchedEffect) to perform the capture. + * This gives some level of control over when the capture occurs, so it's possible to wait for async resources */ + private fun captureComposable( + size: DpSize, + presentation: Dialog, + coroutineScope: CoroutineScope, + content: @Composable () -> Unit, + ): Deferred { + val density = Density(presentation.context) + + val composeView = ComposeView(presentation.context).apply { + val intSize = with(density) { size.toSize().roundedToIntSize() } + require(intSize.width > 0 && intSize.height > 0) { "pixel size must not have zero dimension" } + + layoutParams = ViewGroup.LayoutParams(intSize.width, intSize.height) + } + + presentation.setContentView(composeView, composeView.layoutParams) + presentation.show() + + val result = CompletableDeferred() + composeView.setContent { + InnerComposable( + coroutineScope = coroutineScope, + content = content, + onResult = { + result.complete(it) + }, + ) + } + + return result + } + + @Composable + private fun InnerComposable( + coroutineScope: CoroutineScope, + content: @Composable () -> Unit, + onResult: (ImageBitmap) -> Unit, + ) { + val graphicsLayer = rememberGraphicsLayer() + Box( + modifier = Modifier + .fillMaxSize() + .drawWithContent { + graphicsLayer.record { + this@drawWithContent.drawContent() + } + coroutineScope.launch { + onResult(graphicsLayer.toImageBitmap()) + } + }, + ) { + content() + } + } + } + +internal fun ImageBitmapConfig.toBitmapConfig(): Bitmap.Config { + return if (this == ImageBitmapConfig.Argb8888) { + Bitmap.Config.ARGB_8888 + } else if (this == ImageBitmapConfig.Alpha8) { + Bitmap.Config.ALPHA_8 + } else if (this == ImageBitmapConfig.Rgb565) { + Bitmap.Config.RGB_565 + } else if (this == ImageBitmapConfig.F16) { + Bitmap.Config.RGBA_F16 + } else if (this == ImageBitmapConfig.Gpu) { + Bitmap.Config.HARDWARE + } else { + Bitmap.Config.ARGB_8888 + } +} + +internal fun ImageBitmap.convert(config: ImageBitmapConfig): ImageBitmap { + return this.asAndroidBitmap().copy(config.toBitmapConfig(), false).asImageBitmap() +} diff --git a/tiles/src/main/java/com/google/android/horologist/tiles/images/Images.kt b/tiles/src/main/java/com/google/android/horologist/tiles/images/Images.kt index cf0f9e6bc2..cd089374ab 100644 --- a/tiles/src/main/java/com/google/android/horologist/tiles/images/Images.kt +++ b/tiles/src/main/java/com/google/android/horologist/tiles/images/Images.kt @@ -19,6 +19,8 @@ package com.google.android.horologist.tiles.images import android.content.Context import android.graphics.Bitmap import android.graphics.drawable.BitmapDrawable +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asAndroidBitmap import androidx.wear.protolayout.ResourceBuilders import androidx.wear.protolayout.ResourceBuilders.IMAGE_FORMAT_ARGB_8888 import androidx.wear.protolayout.ResourceBuilders.IMAGE_FORMAT_RGB_565 @@ -90,3 +92,11 @@ public fun Bitmap.toImageResource(): ImageResource { ) .build() } + +/** + * Convert a [ImageBitmap] to a ImageResource. + * + * Format will be one of IMAGE_FORMAT_ARGB_8888, IMAGE_FORMAT_RGB_565 or IMAGE_FORMAT_UNDEFINED, + * based on the bitmap. + */ +public fun ImageBitmap.toImageResource(): ImageResource = this.asAndroidBitmap().toImageResource() diff --git a/tiles/src/test/java/com/google/android/horologist/tiles/RobolectricComposableBitmapRenderer.kt b/tiles/src/test/java/com/google/android/horologist/tiles/RobolectricComposableBitmapRenderer.kt new file mode 100644 index 0000000000..7562760a26 --- /dev/null +++ b/tiles/src/test/java/com/google/android/horologist/tiles/RobolectricComposableBitmapRenderer.kt @@ -0,0 +1,69 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.horologist.tiles + +import android.os.Looper.getMainLooper +import android.view.View +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.ImageBitmapConfig +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.unit.DpSize +import androidx.concurrent.futures.await +import androidx.test.core.app.ActivityScenario +import androidx.test.core.view.captureToBitmapAsync +import com.google.android.horologist.tiles.composable.ComposableBitmapRenderer +import com.google.android.horologist.tiles.composable.convert +import org.robolectric.Shadows.shadowOf +import org.robolectric.shadows.ShadowLooper + +class RobolectricComposableBitmapRenderer() : ComposableBitmapRenderer { + override suspend fun renderComposableToBitmap( + canvasSize: DpSize, + config: ImageBitmapConfig?, + composableContent: @Composable () -> Unit, + ): ImageBitmap { + val scenario = ActivityScenario.launch(ComponentActivity::class.java) + + scenario.use { + lateinit var content: View + scenario.onActivity({ activity -> + activity.setContent { + Box(modifier = Modifier.size(canvasSize)) { + composableContent() + } + } + content = activity.findViewById(android.R.id.content) + }) + + shadowOf(getMainLooper()).idle() + ShadowLooper.idleMainLooper() + + val rawBitmap = content.captureToBitmapAsync().await().asImageBitmap() + return if (config != null) { + rawBitmap.convert(config) + } else { + rawBitmap + } + } + } +} diff --git a/tiles/src/test/java/com/google/android/horologist/tiles/TileScreenshotTest.kt b/tiles/src/test/java/com/google/android/horologist/tiles/TileScreenshotTest.kt index d794fea842..fdf481515f 100644 --- a/tiles/src/test/java/com/google/android/horologist/tiles/TileScreenshotTest.kt +++ b/tiles/src/test/java/com/google/android/horologist/tiles/TileScreenshotTest.kt @@ -15,6 +15,7 @@ */ @file:Suppress("UnstableApiUsage", "DEPRECATION") +@file:OptIn(ExperimentalTestApi::class) package com.google.android.horologist.tiles @@ -22,8 +23,19 @@ import android.content.Context import android.graphics.Bitmap import android.graphics.BitmapFactory import android.graphics.Color +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.asAndroidBitmap import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.wear.compose.material3.FilledIconButton +import androidx.wear.compose.material3.Text import androidx.wear.protolayout.ColorBuilders.argb import androidx.wear.protolayout.DeviceParametersBuilders.DeviceParameters import androidx.wear.protolayout.DimensionBuilders.dp @@ -43,15 +55,15 @@ import com.google.android.horologist.screenshots.rng.WearScreenshotTest import com.google.android.horologist.screenshots.tiles.TileLayoutPreview import com.google.android.horologist.tiles.images.toImageResource import com.google.android.horologist.tiles.render.SingleTileLayoutRenderer +import kotlinx.coroutines.runBlocking import org.junit.Test class TileScreenshotTest : WearScreenshotTest() { - override fun testName(suffix: String): String = "src/test/screenshots/" + "${javaClass.simpleName}_" + "${testInfo.methodName}_" + - "${super.device?.id ?: WearDevice.GenericLargeRound.id}" + + (super.device?.id ?: WearDevice.GenericLargeRound.id) + "$suffix.png" @Composable @@ -100,6 +112,36 @@ class TileScreenshotTest : WearScreenshotTest() { } } + @Test + fun composable() { + val capture = RobolectricComposableBitmapRenderer() + val bitmap = runBlocking { + capture.renderComposableToBitmap(DpSize(400.dp, 300.dp)) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Canvas(modifier = Modifier.fillMaxSize()) { + drawCircle(androidx.compose.ui.graphics.Color.DarkGray) + } + FilledIconButton(onClick = {}) { + Text("\uD83D\uDC6A") + } + } + } + } + + runTest { + val context = LocalContext.current + + TileLayoutPreview( + state = Unit, + resourceState = Unit, + renderer = TestImageTileRenderer( + context = context, + bitmap = bitmap.asAndroidBitmap(), + ), + ) + } + } + class TestImageTileRenderer( context: Context, val bitmap: Bitmap, diff --git a/tiles/src/test/screenshots/TileScreenshotTest_composable_large_round.png b/tiles/src/test/screenshots/TileScreenshotTest_composable_large_round.png new file mode 100644 index 0000000000..7027a6c65f --- /dev/null +++ b/tiles/src/test/screenshots/TileScreenshotTest_composable_large_round.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:98bd231f8920713899fba8a54ab61673caa55b66600c0ce2ab517435d042c8f6 +size 21131