From 1fe118e497151216a58b0d6b52821542f4497b3f Mon Sep 17 00:00:00 2001 From: Thomas Ezan Date: Wed, 2 Jul 2025 21:21:37 -0700 Subject: [PATCH 01/19] Add Gemini live todo sample --- ai-catalog/README.md | 1 + ai-catalog/app/build.gradle.kts | 1 + ai-catalog/app/src/main/AndroidManifest.xml | 2 + .../ai/catalog/ui/domain/SampleCatalog.kt | 12 +- .../app/src/main/res/values/strings.xml | 2 + ai-catalog/gradle/libs.versions.toml | 8 + .../samples/gemini-live-todo/.gitignore | 1 + .../samples/gemini-live-todo/build.gradle.kts | 56 +++++ .../gemini-live-todo/consumer-rules.pro | 0 .../gemini-live-todo/proguard-rules.pro | 21 ++ .../ai/samples/geminilivetodo/data/Todo.kt | 24 ++ .../geminilivetodo/data/TodoRepository.kt | 72 ++++++ .../samples/geminilivetodo/ui/TodoScreen.kt | 224 +++++++++++++++++ .../geminilivetodo/ui/TodoScreenUiState.kt | 26 ++ .../geminilivetodo/ui/TodoScreenViewModel.kt | 236 ++++++++++++++++++ .../src/main/res/values/strings.xml | 22 ++ ai-catalog/settings.gradle.kts | 1 + 17 files changed, 708 insertions(+), 1 deletion(-) create mode 100644 ai-catalog/samples/gemini-live-todo/.gitignore create mode 100644 ai-catalog/samples/gemini-live-todo/build.gradle.kts create mode 100644 ai-catalog/samples/gemini-live-todo/consumer-rules.pro create mode 100644 ai-catalog/samples/gemini-live-todo/proguard-rules.pro create mode 100644 ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/data/Todo.kt create mode 100644 ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/data/TodoRepository.kt create mode 100644 ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreen.kt create mode 100644 ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreenUiState.kt create mode 100644 ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreenViewModel.kt create mode 100644 ai-catalog/samples/gemini-live-todo/src/main/res/values/strings.xml diff --git a/ai-catalog/README.md b/ai-catalog/README.md index 416f2373..86369866 100644 --- a/ai-catalog/README.md +++ b/ai-catalog/README.md @@ -21,6 +21,7 @@ Browse the samples inside the `/samples` folder: - **imagen**: an image generation sample using Imagen - **magic-selfie**: an sample using ML Kit subject segmentation and Imagen for image generation - **gemini-video-summarization**: a video summarization sample using Gemini 2.0 Flash +- **gemini-live-todo**: a todo list app using Gemini Live - More to come... > **Requires Firebase setup** the samples relying on Google Cloud models (Gemini Pro, Gemini Flash, etc...) diff --git a/ai-catalog/app/build.gradle.kts b/ai-catalog/app/build.gradle.kts index 0170570f..7e46c13d 100644 --- a/ai-catalog/app/build.gradle.kts +++ b/ai-catalog/app/build.gradle.kts @@ -88,6 +88,7 @@ dependencies { implementation(project(":samples:imagen")) implementation(project(":samples:magic-selfie")) implementation(project(":samples:gemini-video-summarization")) + implementation(project(":samples:gemini-live-todo")) testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) diff --git a/ai-catalog/app/src/main/AndroidManifest.xml b/ai-catalog/app/src/main/AndroidManifest.xml index 8a8f61f1..c0b29615 100644 --- a/ai-catalog/app/src/main/AndroidManifest.xml +++ b/ai-catalog/app/src/main/AndroidManifest.xml @@ -2,6 +2,8 @@ + + ( +@androidx.annotation.RequiresPermission(android.Manifest.permission.RECORD_AUDIO) +val sampleCatalog = listOf( SampleCatalogItem( title = R.string.gemini_multimodal_sample_title, description = R.string.gemini_multimodal_sample_description, @@ -90,6 +92,14 @@ val sampleCatalog = listOf( tags = listOf(SampleTags.GEMINI_2_0_FLASH, SampleTags.FIREBASE, SampleTags.MEDIA3), needsFirebase = true, ), + SampleCatalogItem( + title = R.string.gemini_live_todo_title, + description = R.string.gemini_live_todo_description, + route = "GeminiLiveTodoScreen", + sampleEntryScreen = { TodoScreen() }, + tags = listOf(SampleTags.GEMINI_2_0_FLASH, SampleTags.FIREBASE), + needsFirebase = true + ) // To create a new sample entry, add a new SampleCatalogItem here. ) diff --git a/ai-catalog/app/src/main/res/values/strings.xml b/ai-catalog/app/src/main/res/values/strings.xml index d867866b..a01b888a 100644 --- a/ai-catalog/app/src/main/res/values/strings.xml +++ b/ai-catalog/app/src/main/res/values/strings.xml @@ -18,6 +18,8 @@ Change the background of your selfies with Imagen and the ML Kit Segmentation API Video Summarization with Gemini and Firebase "Generate a summary of a video (from a cloud URL or Youtube) with Gemini API powered by Firebase" + Gemini Live Todo + "Simple Todo app using the Gemini Live API to interact with the items in the list" Firebase Required This feature requires Firebase to be initialized. Close diff --git a/ai-catalog/gradle/libs.versions.toml b/ai-catalog/gradle/libs.versions.toml index 411ab97d..381fb8b9 100644 --- a/ai-catalog/gradle/libs.versions.toml +++ b/ai-catalog/gradle/libs.versions.toml @@ -29,6 +29,10 @@ uiToolingPreviewAndroid = "1.8.1" spotless = "7.0.4" uiToolingPreview = "1.8.3" uiTooling = "1.8.3" +material = "1.10.0" +firebaseAi = "16.2.0" +lifecycleViewmodelAndroid = "2.8.7" +material3 = "1.3.2" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -70,6 +74,10 @@ androidx-media3-ui = { module = "androidx.media3:media3-ui", version.ref = "medi androidx-ui-tooling-preview-android = { group = "androidx.compose.ui", name = "ui-tooling-preview-android", version.ref = "uiToolingPreviewAndroid" } ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview", version.ref = "uiToolingPreview" } ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling", version.ref = "uiTooling" } +material = { group = "com.google.android.material", name = "material", version.ref = "material" } +google-firebase-ai = { group = "com.google.firebase", name = "firebase-ai", version.ref = "firebaseAi" } +androidx-lifecycle-viewmodel-android = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-android", version.ref = "lifecycleViewmodelAndroid" } +material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } diff --git a/ai-catalog/samples/gemini-live-todo/.gitignore b/ai-catalog/samples/gemini-live-todo/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/ai-catalog/samples/gemini-live-todo/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/ai-catalog/samples/gemini-live-todo/build.gradle.kts b/ai-catalog/samples/gemini-live-todo/build.gradle.kts new file mode 100644 index 00000000..6d546710 --- /dev/null +++ b/ai-catalog/samples/gemini-live-todo/build.gradle.kts @@ -0,0 +1,56 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.jetbrains.kotlin.android) + alias(libs.plugins.ksp) + alias(libs.plugins.compose.compiler) +} + +android { + namespace = "com.android.ai.samples.geminilivetodo" + compileSdk = 35 + + defaultConfig { + minSdk = 24 + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + kotlinOptions { + jvmTarget = "11" + } +} + +dependencies { + + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.appcompat) + implementation(libs.material) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.material.icons.extended) + implementation(platform(libs.firebase.bom)) + implementation(libs.google.firebase.ai) + implementation(libs.androidx.lifecycle.viewmodel.android) + implementation(libs.material3) + implementation(libs.hilt.android) + ksp(libs.hilt.compiler) + implementation(libs.hilt.navigation.compose) + implementation(libs.androidx.material3.android) + implementation(libs.kotlinx.serialization.json) + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) +} \ No newline at end of file diff --git a/ai-catalog/samples/gemini-live-todo/consumer-rules.pro b/ai-catalog/samples/gemini-live-todo/consumer-rules.pro new file mode 100644 index 00000000..e69de29b diff --git a/ai-catalog/samples/gemini-live-todo/proguard-rules.pro b/ai-catalog/samples/gemini-live-todo/proguard-rules.pro new file mode 100644 index 00000000..481bb434 --- /dev/null +++ b/ai-catalog/samples/gemini-live-todo/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/data/Todo.kt b/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/data/Todo.kt new file mode 100644 index 00000000..dbc86cc4 --- /dev/null +++ b/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/data/Todo.kt @@ -0,0 +1,24 @@ +/* + * 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.android.ai.samples.geminilivetodo.data + +data class Todo( + val id: Long = System.currentTimeMillis(), + val task: String, + val isCompleted: Boolean = false +) \ No newline at end of file diff --git a/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/data/TodoRepository.kt b/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/data/TodoRepository.kt new file mode 100644 index 00000000..00d3b973 --- /dev/null +++ b/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/data/TodoRepository.kt @@ -0,0 +1,72 @@ +/* + * 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.android.ai.samples.geminilivetodo.data + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.collections.filterNot +import kotlin.collections.map +import kotlin.collections.plus +import kotlin.text.isNotBlank + +@Singleton +class TodoRepository @Inject constructor() { + + private val _todos = MutableStateFlow>( + listOf( + Todo(1234, "buy bread", false), + Todo(1235, "do the dishes", false), + Todo(1236, "buy eggs", false), + Todo(1237, "read a book", false), + ) + ) + val todos: Flow> = _todos.asStateFlow() + + fun getTodoList(): List = _todos.value + + fun addTodo(taskDescription: String) { + if (taskDescription.isNotBlank()) { + val newTodo = Todo(task = taskDescription) + _todos.update { currentList -> + currentList + newTodo + } + } + } + + fun removeTodo(todoId: Long) { + _todos.update { currentList -> + currentList.filterNot { it.id == todoId } + } + } + + fun toggleTodoStatus(todoId: Long) { + _todos.update { currentList -> + currentList.map { todo -> + if (todo.id == todoId) { + todo.copy(isCompleted = !todo.isCompleted) + } else { + todo + } + } + } + } +} diff --git a/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreen.kt b/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreen.kt new file mode 100644 index 00000000..369f7094 --- /dev/null +++ b/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreen.kt @@ -0,0 +1,224 @@ +package com.android.ai.samples.geminilivetodo.ui + +import android.Manifest +import androidx.annotation.RequiresPermission +import androidx.compose.animation.Animatable +import androidx.compose.animation.animateColor +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Mic +import androidx.compose.material.icons.filled.MicNone +import androidx.compose.material.icons.filled.MicOff +import androidx.compose.material3.Button +import androidx.compose.material3.Checkbox +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FabPosition +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +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.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel +import com.android.ai.samples.geminilive.ui.TodoScreenViewModel +import com.android.ai.samples.geminilivetodo.R +import com.android.ai.samples.geminilivetodo.data.Todo +import kotlin.collections.reversed + +/** + * The main screen for the To-do list application. + * This composable is stateful, connecting to the ViewModel to manage UI state and events. + */ +@OptIn(ExperimentalMaterial3Api::class) +@RequiresPermission(Manifest.permission.RECORD_AUDIO) +@Composable +fun TodoScreen( + viewModel: TodoScreenViewModel = hiltViewModel() +) { + val uiState by viewModel.uiState.collectAsState() + var text by remember { mutableStateOf("") } + + Scaffold( + topBar = { + TopAppBar( + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + titleContentColor = MaterialTheme.colorScheme.primary, + ), + title = { Text(stringResource(R.string.gemini_live_title)) } + ) + }, + floatingActionButton = { + val micIcon = when { + !uiState.isLiveSessionReady -> Icons.Filled.MicOff + uiState.isLiveSessionRunning -> Icons.Filled.Mic + else -> Icons.Filled.MicNone + } + + val containerColor = if (uiState.isLiveSessionRunning) { + val infiniteTransition = rememberInfiniteTransition(label = "mic_color_transition") + infiniteTransition.animateColor( + initialValue = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f), + targetValue = MaterialTheme.colorScheme.primary.copy(alpha = 0.9f), + animationSpec = infiniteRepeatable( + animation = tween(1000, easing = LinearEasing), + repeatMode = RepeatMode.Reverse + ), + label = "mic_color" + ).value + } else { + MaterialTheme.colorScheme.primaryContainer + } + + FloatingActionButton( + onClick = { if (uiState.isLiveSessionReady) viewModel.toggleLiveSession() }, + containerColor = containerColor + ) { + Icon(micIcon, "Interact with todolist by voice") + } + }, + floatingActionButtonPosition = FabPosition.Center, + modifier = Modifier.fillMaxSize() + ) { paddingValues -> + Column( + modifier = Modifier + .padding(paddingValues) + .padding(16.dp) + .imePadding() + .fillMaxSize() + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedTextField( + value = text, + onValueChange = { text = it }, + label = { Text(stringResource(R.string.new_task_placeholder)) }, + modifier = Modifier.weight(1f), + singleLine = true + ) + Spacer(modifier = Modifier.width(8.dp)) + Button( + onClick = { + if (text.isNotBlank()) { + viewModel.addTodo(text) + text = "" + } + } + ) { + Text(stringResource(R.string.add_button)) + } + } + + LazyColumn(modifier = Modifier.fillMaxSize()) { + items(uiState.todos.reversed(), key = { it.id }) { todo -> + TodoItem( + task = todo, + onToggle = { viewModel.toggleTodoStatus(todo.id) }, + onDelete = { viewModel.removeTodo(todo.id) } + ) + HorizontalDivider() + } + } + } + } +} + + +@Composable +fun TodoItem( + task: Todo, + onToggle: () -> Unit, + onDelete: () -> Unit +) { + val defaultBackgroundColor = Color.Transparent + val highlightColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.4f) + val backgroundColor = remember { Animatable(defaultBackgroundColor) } + +// // Animate background on completion change +// LaunchedEffect(task.isCompleted) { +// if (backgroundColor.value == defaultBackgroundColor) { +// launch { +// backgroundColor.animateTo(highlightColor, animationSpec = tween(durationMillis = 150)) +// delay(150) +// backgroundColor.animateTo(defaultBackgroundColor, animationSpec = tween(durationMillis = 150)) +// delay(150) +// backgroundColor.animateTo(highlightColor, animationSpec = tween(durationMillis = 150)) +// delay(150) +// backgroundColor.animateTo(defaultBackgroundColor, animationSpec = tween(durationMillis = 150)) +// } +// } +// } +// +// // Ensure background is reset for item reuse +// LaunchedEffect(task.id) { +// if (!backgroundColor.isRunning) { +// backgroundColor.snapTo(defaultBackgroundColor) +// } +// } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 12.dp, horizontal = 8.dp) + .background(backgroundColor.value), + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox( + checked = task.isCompleted, + onCheckedChange = { onToggle() } + ) + Text( + text = task.task, + style = if (task.isCompleted) { + TextStyle(fontSize = 16.sp, textDecoration = TextDecoration.LineThrough) + } else { + TextStyle(fontSize = 16.sp, textDecoration = TextDecoration.None) + }, + modifier = Modifier.weight(1f) + ) + IconButton(onClick = onDelete) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = "Delete", + ) + } + } +} diff --git a/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreenUiState.kt b/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreenUiState.kt new file mode 100644 index 00000000..00f2d42e --- /dev/null +++ b/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreenUiState.kt @@ -0,0 +1,26 @@ +/* + * 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.android.ai.samples.geminilivetodo.ui + +import com.android.ai.samples.geminilivetodo.data.Todo + +data class TodoScreenUiState( + val todos: List = emptyList(), + val isLiveSessionReady: Boolean = false, + val isLiveSessionRunning: Boolean = false +) \ No newline at end of file diff --git a/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreenViewModel.kt b/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreenViewModel.kt new file mode 100644 index 00000000..0efbb7fc --- /dev/null +++ b/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreenViewModel.kt @@ -0,0 +1,236 @@ +/* + * 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.android.ai.samples.geminilive.ui + +import android.util.Log +import androidx.annotation.RequiresPermission +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.android.ai.samples.geminilivetodo.data.TodoRepository +import com.android.ai.samples.geminilivetodo.ui.TodoScreenUiState +import com.google.firebase.Firebase +import com.google.firebase.ai.ai +import com.google.firebase.ai.type.FunctionCallPart +import com.google.firebase.ai.type.FunctionDeclaration +import com.google.firebase.ai.type.FunctionResponsePart +import com.google.firebase.ai.type.GenerativeBackend +import com.google.firebase.ai.type.LiveSession +import com.google.firebase.ai.type.PublicPreviewAPI +import com.google.firebase.ai.type.ResponseModality +import com.google.firebase.ai.type.Schema +import com.google.firebase.ai.type.SpeechConfig +import com.google.firebase.ai.type.Tool +import com.google.firebase.ai.type.Voice +import com.google.firebase.ai.type.content +import com.google.firebase.ai.type.liveGenerationConfig +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.long +import javax.inject.Inject + +@OptIn(PublicPreviewAPI::class) +@HiltViewModel +class TodoScreenViewModel @Inject constructor( + private val todoRepository: TodoRepository +) : ViewModel() { + private val TAG = "TodoScreenViewModel" + + private var isLiveSessionReady = false + private var isLiveSessionRunning = false + + private var session: LiveSession? = null + + private val _uiState = MutableStateFlow(TodoScreenUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + + + init { + viewModelScope.launch { + todoRepository.todos.collect { todos -> + _uiState.update { currentState -> + currentState.copy(todos = todos) + } + } + } + + viewModelScope.launch { + initializeGeminiLive() + } + } + + fun addTodo(taskDescription: String) { + todoRepository.addTodo(taskDescription) + } + + fun removeTodo(todoId: Long) { + todoRepository.removeTodo(todoId) + } + + fun toggleTodoStatus(todoId: Long) { + todoRepository.toggleTodoStatus(todoId) + } + + @RequiresPermission(android.Manifest.permission.RECORD_AUDIO) + fun toggleLiveSession() { + viewModelScope.launch { + session?.let { + if (!isLiveSessionRunning) { + it.startAudioConversation(::handleFunctionCall) + isLiveSessionRunning= true + _uiState.update { currentState -> currentState.copy(isLiveSessionRunning = true) + } + + } else { + it.stopAudioConversation() + isLiveSessionRunning = false + _uiState.update { currentState -> currentState.copy(isLiveSessionRunning = false) } + } + } + } + } + + private suspend fun initializeGeminiLive() { + Log.d(TAG, "Start Gemini Live initialization") + val liveGenerationConfig = liveGenerationConfig { + speechConfig = SpeechConfig(voice = Voice("FENRIR")) + responseModality = ResponseModality.AUDIO + } + + val systemInstruction = content { + text( + """ + **Your Role:** You are a friendly and helpful voice assistant in this app. + Your main job is to change update the tasks in the todo list based on user requests. + + **Interaction Steps:** + **Get the task id to remove or toggle a task:** If you need to remove or check/uncheck a task, + you'll need to retrieve the list of items in the list first to get the task id. Don't share + the id with the user, just identify the id of the task mentioned and directly pass this id to the + tool. + + **Never share the id with the user:** you don't need to share the id with the user. It is + just here to help you perform the check/uncheck and remove operations to the list. + + **If Unsure:** If you can't determine the update from the request, politely ask the user to rephrase or try something else. + """.trimIndent() + ) + } + + val addTodo = FunctionDeclaration( + "addTodo", + "Add a task to the todo list", + mapOf("taskDescription" to Schema.string("A succinct string describing the task")) + ) + + val removeTodo = FunctionDeclaration( + "removeTodo", + "Remove a task from the todo list", + mapOf("todoId" to Schema.string("The id of the task to remove from the todo list")) + ) + + val toggleTodoStatus = FunctionDeclaration( + "toggleTodoStatus", + "Change the status of the task", + mapOf("todoId" to Schema.string("The id of the task to remove from the todo list")) + ) + + val getTodoList = FunctionDeclaration( + "getTodoList", + "Get the list of all the tasks in the todo list", + emptyMap() + ) + + val generativeModel = Firebase.ai(backend = GenerativeBackend.vertexAI()).liveModel( + "gemini-2.0-flash-live-preview-04-09", + generationConfig = liveGenerationConfig, + systemInstruction = systemInstruction, + tools = listOf( + Tool.functionDeclarations( + listOf(getTodoList, addTodo, removeTodo, toggleTodoStatus) + ) + ) + ) + + session = generativeModel.connect() + Log.d(TAG, "Gemini Live session initialized") + isLiveSessionReady = true + _uiState.update { currentState -> currentState.copy(isLiveSessionReady = true) } + } + + private fun handleFunctionCall(functionCall: FunctionCallPart): FunctionResponsePart { + return when (functionCall.name) { + "getTodoList" -> { + val todoList = todoRepository.getTodoList().reversed() + val response = JsonObject( + mapOf( + "success" to JsonPrimitive(true), + "message" to JsonPrimitive("List of tasks in the todo list: $todoList") + ) + ) + FunctionResponsePart(functionCall.name, response) + } + "addTodo" -> { + val taskDescription = functionCall.args["taskDescription"]!!.jsonPrimitive.content + todoRepository.addTodo(taskDescription) + val response = JsonObject( + mapOf( + "success" to JsonPrimitive(true), + "message" to JsonPrimitive("Task $taskDescription added to the todo list") + ) + ) + FunctionResponsePart(functionCall.name, response) + } + "removeTodo" -> { + val taskId = functionCall.args["todoId"]!!.jsonPrimitive.long + todoRepository.removeTodo(taskId) + val response = JsonObject( + mapOf( + "success" to JsonPrimitive(true), + "message" to JsonPrimitive("Task was removed from the todo list") + ) + ) + FunctionResponsePart(functionCall.name, response) + } + "toggleTodoStatus" -> { + val taskId = functionCall.args["todoId"]!!.jsonPrimitive.long + todoRepository.toggleTodoStatus(taskId) + val response = JsonObject( + mapOf( + "success" to JsonPrimitive(true), + "message" to JsonPrimitive("Task was toggled in the todo list") + ) + ) + FunctionResponsePart(functionCall.name, response) + } + else -> { + val response = JsonObject( + mapOf("error" to JsonPrimitive("Unknown function: ${functionCall.name}")) + ) + FunctionResponsePart(functionCall.name, response) + } + } + } +} \ No newline at end of file diff --git a/ai-catalog/samples/gemini-live-todo/src/main/res/values/strings.xml b/ai-catalog/samples/gemini-live-todo/src/main/res/values/strings.xml new file mode 100644 index 00000000..b1677332 --- /dev/null +++ b/ai-catalog/samples/gemini-live-todo/src/main/res/values/strings.xml @@ -0,0 +1,22 @@ + + + + Gemini Live Todo + New Task + Add + \ No newline at end of file diff --git a/ai-catalog/settings.gradle.kts b/ai-catalog/settings.gradle.kts index 9fa0bfea..6ba4d0ff 100644 --- a/ai-catalog/settings.gradle.kts +++ b/ai-catalog/settings.gradle.kts @@ -46,3 +46,4 @@ include(":samples:genai-image-description") include(":samples:imagen") include(":samples:magic-selfie") include(":samples:gemini-video-summarization") +include(":samples:gemini-live-todo") From 0f0d40b0a3f3f34062d7e39bc05e54ea77e96979 Mon Sep 17 00:00:00 2001 From: Thomas Ezan Date: Wed, 2 Jul 2025 22:25:21 -0700 Subject: [PATCH 02/19] Refactoring --- .../samples/geminilivetodo/ui/TodoScreen.kt | 87 ++++++----- .../geminilivetodo/ui/TodoScreenUiState.kt | 14 +- .../geminilivetodo/ui/TodoScreenViewModel.kt | 135 ++++++++++-------- 3 files changed, 136 insertions(+), 100 deletions(-) diff --git a/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreen.kt b/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreen.kt index 369f7094..0d5d1711 100644 --- a/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreen.kt +++ b/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreen.kt @@ -40,7 +40,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -54,6 +54,7 @@ import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.android.ai.samples.geminilive.ui.TodoScreenViewModel import com.android.ai.samples.geminilivetodo.R import com.android.ai.samples.geminilivetodo.data.Todo @@ -69,9 +70,13 @@ import kotlin.collections.reversed fun TodoScreen( viewModel: TodoScreenViewModel = hiltViewModel() ) { - val uiState by viewModel.uiState.collectAsState() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() var text by remember { mutableStateOf("") } + LaunchedEffect(Unit) { + viewModel.initializeGeminiLive() + } + Scaffold( topBar = { TopAppBar( @@ -83,32 +88,36 @@ fun TodoScreen( ) }, floatingActionButton = { - val micIcon = when { - !uiState.isLiveSessionReady -> Icons.Filled.MicOff - uiState.isLiveSessionRunning -> Icons.Filled.Mic - else -> Icons.Filled.MicNone - } + if (uiState is TodoScreenUiState.Success) { + val successState = uiState as TodoScreenUiState.Success + val micIcon = when { + !successState.isLiveSessionReady -> Icons.Filled.MicOff + successState.isLiveSessionRunning -> Icons.Filled.Mic + else -> Icons.Filled.MicNone + } - val containerColor = if (uiState.isLiveSessionRunning) { - val infiniteTransition = rememberInfiniteTransition(label = "mic_color_transition") - infiniteTransition.animateColor( - initialValue = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f), - targetValue = MaterialTheme.colorScheme.primary.copy(alpha = 0.9f), - animationSpec = infiniteRepeatable( - animation = tween(1000, easing = LinearEasing), - repeatMode = RepeatMode.Reverse - ), - label = "mic_color" - ).value - } else { - MaterialTheme.colorScheme.primaryContainer - } + val containerColor = if (successState.isLiveSessionRunning) { + val infiniteTransition = + rememberInfiniteTransition(label = "mic_color_transition") + infiniteTransition.animateColor( + initialValue = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f), + targetValue = MaterialTheme.colorScheme.primary.copy(alpha = 0.9f), + animationSpec = infiniteRepeatable( + animation = tween(1000, easing = LinearEasing), + repeatMode = RepeatMode.Reverse + ), + label = "mic_color" + ).value + } else { + MaterialTheme.colorScheme.primaryContainer + } - FloatingActionButton( - onClick = { if (uiState.isLiveSessionReady) viewModel.toggleLiveSession() }, - containerColor = containerColor - ) { - Icon(micIcon, "Interact with todolist by voice") + FloatingActionButton( + onClick = { if (successState.isLiveSessionReady) viewModel.toggleLiveSession() }, + containerColor = containerColor + ) { + Icon(micIcon, "Interact with todolist by voice") + } } }, floatingActionButtonPosition = FabPosition.Center, @@ -147,14 +156,22 @@ fun TodoScreen( } } - LazyColumn(modifier = Modifier.fillMaxSize()) { - items(uiState.todos.reversed(), key = { it.id }) { todo -> - TodoItem( - task = todo, - onToggle = { viewModel.toggleTodoStatus(todo.id) }, - onDelete = { viewModel.removeTodo(todo.id) } - ) - HorizontalDivider() + when (uiState) { + is TodoScreenUiState.Initial -> { + // Show a loading indicator or initial state + } + is TodoScreenUiState.Success -> { + val todos = (uiState as TodoScreenUiState.Success).todos + LazyColumn(modifier = Modifier.fillMaxSize()) { + items(todos.reversed(), key = { it.id }) { todo -> + TodoItem( + task = todo, + onToggle = { viewModel.toggleTodoStatus(todo.id) }, + onDelete = { viewModel.removeTodo(todo.id) } + ) + HorizontalDivider() + } + } } } } @@ -221,4 +238,4 @@ fun TodoItem( ) } } -} +} \ No newline at end of file diff --git a/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreenUiState.kt b/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreenUiState.kt index 00f2d42e..957702e8 100644 --- a/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreenUiState.kt +++ b/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreenUiState.kt @@ -19,8 +19,12 @@ package com.android.ai.samples.geminilivetodo.ui import com.android.ai.samples.geminilivetodo.data.Todo -data class TodoScreenUiState( - val todos: List = emptyList(), - val isLiveSessionReady: Boolean = false, - val isLiveSessionRunning: Boolean = false -) \ No newline at end of file +sealed interface TodoScreenUiState { + data object Initial : TodoScreenUiState + + data class Success( + val todos: List = emptyList(), + val isLiveSessionReady: Boolean = false, + val isLiveSessionRunning: Boolean = false + ) : TodoScreenUiState +} \ No newline at end of file diff --git a/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreenViewModel.kt b/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreenViewModel.kt index 0efbb7fc..72cdc450 100644 --- a/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreenViewModel.kt +++ b/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreenViewModel.kt @@ -57,28 +57,25 @@ class TodoScreenViewModel @Inject constructor( ) : ViewModel() { private val TAG = "TodoScreenViewModel" - private var isLiveSessionReady = false private var isLiveSessionRunning = false private var session: LiveSession? = null - private val _uiState = MutableStateFlow(TodoScreenUiState()) + private val _uiState = MutableStateFlow(TodoScreenUiState.Initial) val uiState: StateFlow = _uiState.asStateFlow() - - init { viewModelScope.launch { todoRepository.todos.collect { todos -> - _uiState.update { currentState -> - currentState.copy(todos = todos) + _uiState.update { + if (it is TodoScreenUiState.Success) { + it.copy(todos = todos) + } else { + TodoScreenUiState.Success(todos = todos) + } } } } - - viewModelScope.launch { - initializeGeminiLive() - } } fun addTodo(taskDescription: String) { @@ -99,29 +96,41 @@ class TodoScreenViewModel @Inject constructor( session?.let { if (!isLiveSessionRunning) { it.startAudioConversation(::handleFunctionCall) - isLiveSessionRunning= true - _uiState.update { currentState -> currentState.copy(isLiveSessionRunning = true) + isLiveSessionRunning = true + _uiState.update { + if (it is TodoScreenUiState.Success) { + it.copy(isLiveSessionRunning = true) + } else { + it + } } } else { it.stopAudioConversation() isLiveSessionRunning = false - _uiState.update { currentState -> currentState.copy(isLiveSessionRunning = false) } + _uiState.update { + if (it is TodoScreenUiState.Success) { + it.copy(isLiveSessionRunning = false) + } else { + it + } + } } } } } - private suspend fun initializeGeminiLive() { - Log.d(TAG, "Start Gemini Live initialization") - val liveGenerationConfig = liveGenerationConfig { - speechConfig = SpeechConfig(voice = Voice("FENRIR")) - responseModality = ResponseModality.AUDIO - } + fun initializeGeminiLive() { + viewModelScope.launch { + Log.d(TAG, "Start Gemini Live initialization") + val liveGenerationConfig = liveGenerationConfig { + speechConfig = SpeechConfig(voice = Voice("FENRIR")) + responseModality = ResponseModality.AUDIO + } - val systemInstruction = content { - text( - """ + val systemInstruction = content { + text( + """ **Your Role:** You are a friendly and helpful voice assistant in this app. Your main job is to change update the tasks in the todo list based on user requests. @@ -136,48 +145,54 @@ class TodoScreenViewModel @Inject constructor( **If Unsure:** If you can't determine the update from the request, politely ask the user to rephrase or try something else. """.trimIndent() + ) + } + + val addTodo = FunctionDeclaration( + "addTodo", + "Add a task to the todo list", + mapOf("taskDescription" to Schema.string("A succinct string describing the task")) + ) + + val removeTodo = FunctionDeclaration( + "removeTodo", + "Remove a task from the todo list", + mapOf("todoId" to Schema.string("The id of the task to remove from the todo list")) + ) + + val toggleTodoStatus = FunctionDeclaration( + "toggleTodoStatus", + "Change the status of the task", + mapOf("todoId" to Schema.string("The id of the task to remove from the todo list")) + ) + + val getTodoList = FunctionDeclaration( + "getTodoList", + "Get the list of all the tasks in the todo list", + emptyMap() ) - } - val addTodo = FunctionDeclaration( - "addTodo", - "Add a task to the todo list", - mapOf("taskDescription" to Schema.string("A succinct string describing the task")) - ) - - val removeTodo = FunctionDeclaration( - "removeTodo", - "Remove a task from the todo list", - mapOf("todoId" to Schema.string("The id of the task to remove from the todo list")) - ) - - val toggleTodoStatus = FunctionDeclaration( - "toggleTodoStatus", - "Change the status of the task", - mapOf("todoId" to Schema.string("The id of the task to remove from the todo list")) - ) - - val getTodoList = FunctionDeclaration( - "getTodoList", - "Get the list of all the tasks in the todo list", - emptyMap() - ) - - val generativeModel = Firebase.ai(backend = GenerativeBackend.vertexAI()).liveModel( - "gemini-2.0-flash-live-preview-04-09", - generationConfig = liveGenerationConfig, - systemInstruction = systemInstruction, - tools = listOf( - Tool.functionDeclarations( - listOf(getTodoList, addTodo, removeTodo, toggleTodoStatus) + val generativeModel = Firebase.ai(backend = GenerativeBackend.vertexAI()).liveModel( + "gemini-2.0-flash-live-preview-04-09", + generationConfig = liveGenerationConfig, + systemInstruction = systemInstruction, + tools = listOf( + Tool.functionDeclarations( + listOf(getTodoList, addTodo, removeTodo, toggleTodoStatus) + ) ) ) - ) - session = generativeModel.connect() - Log.d(TAG, "Gemini Live session initialized") - isLiveSessionReady = true - _uiState.update { currentState -> currentState.copy(isLiveSessionReady = true) } + session = generativeModel.connect() + Log.d(TAG, "Gemini Live session initialized") + _uiState.update { + if (it is TodoScreenUiState.Success) { + it.copy(isLiveSessionReady = true) + } else { + it + } + } + } } private fun handleFunctionCall(functionCall: FunctionCallPart): FunctionResponsePart { @@ -233,4 +248,4 @@ class TodoScreenViewModel @Inject constructor( } } } -} \ No newline at end of file +} From db4f399f2e036f05e493604bcc8059d3812b09ce Mon Sep 17 00:00:00 2001 From: lethargicpanda <205574+lethargicpanda@users.noreply.github.com> Date: Mon, 7 Jul 2025 18:04:13 +0000 Subject: [PATCH 03/19] =?UTF-8?q?=F0=9F=A4=96=20Apply=20Spotless=20formatt?= =?UTF-8?q?ing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ai/catalog/ui/domain/SampleCatalog.kt | 4 +- .../samples/gemini-live-todo/build.gradle.kts | 19 ++++++- .../ai/samples/geminilivetodo/data/Todo.kt | 8 ++- .../geminilivetodo/data/TodoRepository.kt | 14 +++-- .../samples/geminilivetodo/ui/TodoScreen.kt | 54 +++++++++++-------- .../geminilivetodo/ui/TodoScreenUiState.kt | 8 ++- .../geminilivetodo/ui/TodoScreenViewModel.kt | 45 +++++++--------- 7 files changed, 82 insertions(+), 70 deletions(-) diff --git a/ai-catalog/app/src/main/java/com/android/ai/catalog/ui/domain/SampleCatalog.kt b/ai-catalog/app/src/main/java/com/android/ai/catalog/ui/domain/SampleCatalog.kt index e088611b..44187d12 100644 --- a/ai-catalog/app/src/main/java/com/android/ai/catalog/ui/domain/SampleCatalog.kt +++ b/ai-catalog/app/src/main/java/com/android/ai/catalog/ui/domain/SampleCatalog.kt @@ -98,8 +98,8 @@ val sampleCatalog = listOf( route = "GeminiLiveTodoScreen", sampleEntryScreen = { TodoScreen() }, tags = listOf(SampleTags.GEMINI_2_0_FLASH, SampleTags.FIREBASE), - needsFirebase = true - ) + needsFirebase = true, + ), // To create a new sample entry, add a new SampleCatalogItem here. ) diff --git a/ai-catalog/samples/gemini-live-todo/build.gradle.kts b/ai-catalog/samples/gemini-live-todo/build.gradle.kts index 6d546710..51925feb 100644 --- a/ai-catalog/samples/gemini-live-todo/build.gradle.kts +++ b/ai-catalog/samples/gemini-live-todo/build.gradle.kts @@ -1,3 +1,18 @@ +/* + * 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. + */ plugins { alias(libs.plugins.android.library) alias(libs.plugins.jetbrains.kotlin.android) @@ -21,7 +36,7 @@ android { isMinifyEnabled = false proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), - "proguard-rules.pro" + "proguard-rules.pro", ) } } @@ -53,4 +68,4 @@ dependencies { testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) -} \ No newline at end of file +} diff --git a/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/data/Todo.kt b/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/data/Todo.kt index dbc86cc4..5ad7b6a4 100644 --- a/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/data/Todo.kt +++ b/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/data/Todo.kt @@ -5,20 +5,18 @@ * 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 + * 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.android.ai.samples.geminilivetodo.data data class Todo( val id: Long = System.currentTimeMillis(), val task: String, - val isCompleted: Boolean = false -) \ No newline at end of file + val isCompleted: Boolean = false, +) diff --git a/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/data/TodoRepository.kt b/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/data/TodoRepository.kt index 00d3b973..f28b2c4c 100644 --- a/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/data/TodoRepository.kt +++ b/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/data/TodoRepository.kt @@ -5,28 +5,26 @@ * 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 + * 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.android.ai.samples.geminilivetodo.data -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update import javax.inject.Inject import javax.inject.Singleton import kotlin.collections.filterNot import kotlin.collections.map import kotlin.collections.plus import kotlin.text.isNotBlank +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update @Singleton class TodoRepository @Inject constructor() { @@ -37,7 +35,7 @@ class TodoRepository @Inject constructor() { Todo(1235, "do the dishes", false), Todo(1236, "buy eggs", false), Todo(1237, "read a book", false), - ) + ), ) val todos: Flow> = _todos.asStateFlow() diff --git a/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreen.kt b/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreen.kt index 0d5d1711..2553c86d 100644 --- a/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreen.kt +++ b/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreen.kt @@ -1,3 +1,18 @@ +/* + * 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.android.ai.samples.geminilivetodo.ui import android.Manifest @@ -67,9 +82,7 @@ import kotlin.collections.reversed @OptIn(ExperimentalMaterial3Api::class) @RequiresPermission(Manifest.permission.RECORD_AUDIO) @Composable -fun TodoScreen( - viewModel: TodoScreenViewModel = hiltViewModel() -) { +fun TodoScreen(viewModel: TodoScreenViewModel = hiltViewModel()) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() var text by remember { mutableStateOf("") } @@ -84,7 +97,7 @@ fun TodoScreen( containerColor = MaterialTheme.colorScheme.primaryContainer, titleContentColor = MaterialTheme.colorScheme.primary, ), - title = { Text(stringResource(R.string.gemini_live_title)) } + title = { Text(stringResource(R.string.gemini_live_title)) }, ) }, floatingActionButton = { @@ -104,9 +117,9 @@ fun TodoScreen( targetValue = MaterialTheme.colorScheme.primary.copy(alpha = 0.9f), animationSpec = infiniteRepeatable( animation = tween(1000, easing = LinearEasing), - repeatMode = RepeatMode.Reverse + repeatMode = RepeatMode.Reverse, ), - label = "mic_color" + label = "mic_color", ).value } else { MaterialTheme.colorScheme.primaryContainer @@ -114,34 +127,34 @@ fun TodoScreen( FloatingActionButton( onClick = { if (successState.isLiveSessionReady) viewModel.toggleLiveSession() }, - containerColor = containerColor + containerColor = containerColor, ) { Icon(micIcon, "Interact with todolist by voice") } } }, floatingActionButtonPosition = FabPosition.Center, - modifier = Modifier.fillMaxSize() + modifier = Modifier.fillMaxSize(), ) { paddingValues -> Column( modifier = Modifier .padding(paddingValues) .padding(16.dp) .imePadding() - .fillMaxSize() + .fillMaxSize(), ) { Row( modifier = Modifier .fillMaxWidth() .padding(bottom = 16.dp), - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, ) { OutlinedTextField( value = text, onValueChange = { text = it }, label = { Text(stringResource(R.string.new_task_placeholder)) }, modifier = Modifier.weight(1f), - singleLine = true + singleLine = true, ) Spacer(modifier = Modifier.width(8.dp)) Button( @@ -150,7 +163,7 @@ fun TodoScreen( viewModel.addTodo(text) text = "" } - } + }, ) { Text(stringResource(R.string.add_button)) } @@ -167,7 +180,7 @@ fun TodoScreen( TodoItem( task = todo, onToggle = { viewModel.toggleTodoStatus(todo.id) }, - onDelete = { viewModel.removeTodo(todo.id) } + onDelete = { viewModel.removeTodo(todo.id) }, ) HorizontalDivider() } @@ -178,13 +191,8 @@ fun TodoScreen( } } - @Composable -fun TodoItem( - task: Todo, - onToggle: () -> Unit, - onDelete: () -> Unit -) { +fun TodoItem(task: Todo, onToggle: () -> Unit, onDelete: () -> Unit) { val defaultBackgroundColor = Color.Transparent val highlightColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.4f) val backgroundColor = remember { Animatable(defaultBackgroundColor) } @@ -216,11 +224,11 @@ fun TodoItem( .fillMaxWidth() .padding(vertical = 12.dp, horizontal = 8.dp) .background(backgroundColor.value), - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, ) { Checkbox( checked = task.isCompleted, - onCheckedChange = { onToggle() } + onCheckedChange = { onToggle() }, ) Text( text = task.task, @@ -229,7 +237,7 @@ fun TodoItem( } else { TextStyle(fontSize = 16.sp, textDecoration = TextDecoration.None) }, - modifier = Modifier.weight(1f) + modifier = Modifier.weight(1f), ) IconButton(onClick = onDelete) { Icon( @@ -238,4 +246,4 @@ fun TodoItem( ) } } -} \ No newline at end of file +} diff --git a/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreenUiState.kt b/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreenUiState.kt index 957702e8..37953b39 100644 --- a/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreenUiState.kt +++ b/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreenUiState.kt @@ -5,16 +5,14 @@ * 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 + * 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.android.ai.samples.geminilivetodo.ui import com.android.ai.samples.geminilivetodo.data.Todo @@ -25,6 +23,6 @@ sealed interface TodoScreenUiState { data class Success( val todos: List = emptyList(), val isLiveSessionReady: Boolean = false, - val isLiveSessionRunning: Boolean = false + val isLiveSessionRunning: Boolean = false, ) : TodoScreenUiState -} \ No newline at end of file +} diff --git a/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreenViewModel.kt b/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreenViewModel.kt index 72cdc450..a8c54e0e 100644 --- a/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreenViewModel.kt +++ b/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreenViewModel.kt @@ -5,16 +5,14 @@ * 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 + * 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.android.ai.samples.geminilive.ui import android.util.Log @@ -39,6 +37,7 @@ import com.google.firebase.ai.type.Voice import com.google.firebase.ai.type.content import com.google.firebase.ai.type.liveGenerationConfig import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -48,13 +47,10 @@ import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.long -import javax.inject.Inject @OptIn(PublicPreviewAPI::class) @HiltViewModel -class TodoScreenViewModel @Inject constructor( - private val todoRepository: TodoRepository -) : ViewModel() { +class TodoScreenViewModel @Inject constructor(private val todoRepository: TodoRepository) : ViewModel() { private val TAG = "TodoScreenViewModel" private var isLiveSessionRunning = false @@ -104,7 +100,6 @@ class TodoScreenViewModel @Inject constructor( it } } - } else { it.stopAudioConversation() isLiveSessionRunning = false @@ -144,32 +139,32 @@ class TodoScreenViewModel @Inject constructor( just here to help you perform the check/uncheck and remove operations to the list. **If Unsure:** If you can't determine the update from the request, politely ask the user to rephrase or try something else. - """.trimIndent() + """.trimIndent(), ) } val addTodo = FunctionDeclaration( "addTodo", "Add a task to the todo list", - mapOf("taskDescription" to Schema.string("A succinct string describing the task")) + mapOf("taskDescription" to Schema.string("A succinct string describing the task")), ) val removeTodo = FunctionDeclaration( "removeTodo", "Remove a task from the todo list", - mapOf("todoId" to Schema.string("The id of the task to remove from the todo list")) + mapOf("todoId" to Schema.string("The id of the task to remove from the todo list")), ) val toggleTodoStatus = FunctionDeclaration( "toggleTodoStatus", "Change the status of the task", - mapOf("todoId" to Schema.string("The id of the task to remove from the todo list")) + mapOf("todoId" to Schema.string("The id of the task to remove from the todo list")), ) val getTodoList = FunctionDeclaration( "getTodoList", "Get the list of all the tasks in the todo list", - emptyMap() + emptyMap(), ) val generativeModel = Firebase.ai(backend = GenerativeBackend.vertexAI()).liveModel( @@ -178,9 +173,9 @@ class TodoScreenViewModel @Inject constructor( systemInstruction = systemInstruction, tools = listOf( Tool.functionDeclarations( - listOf(getTodoList, addTodo, removeTodo, toggleTodoStatus) - ) - ) + listOf(getTodoList, addTodo, removeTodo, toggleTodoStatus), + ), + ), ) session = generativeModel.connect() @@ -202,8 +197,8 @@ class TodoScreenViewModel @Inject constructor( val response = JsonObject( mapOf( "success" to JsonPrimitive(true), - "message" to JsonPrimitive("List of tasks in the todo list: $todoList") - ) + "message" to JsonPrimitive("List of tasks in the todo list: $todoList"), + ), ) FunctionResponsePart(functionCall.name, response) } @@ -213,8 +208,8 @@ class TodoScreenViewModel @Inject constructor( val response = JsonObject( mapOf( "success" to JsonPrimitive(true), - "message" to JsonPrimitive("Task $taskDescription added to the todo list") - ) + "message" to JsonPrimitive("Task $taskDescription added to the todo list"), + ), ) FunctionResponsePart(functionCall.name, response) } @@ -224,8 +219,8 @@ class TodoScreenViewModel @Inject constructor( val response = JsonObject( mapOf( "success" to JsonPrimitive(true), - "message" to JsonPrimitive("Task was removed from the todo list") - ) + "message" to JsonPrimitive("Task was removed from the todo list"), + ), ) FunctionResponsePart(functionCall.name, response) } @@ -235,14 +230,14 @@ class TodoScreenViewModel @Inject constructor( val response = JsonObject( mapOf( "success" to JsonPrimitive(true), - "message" to JsonPrimitive("Task was toggled in the todo list") - ) + "message" to JsonPrimitive("Task was toggled in the todo list"), + ), ) FunctionResponsePart(functionCall.name, response) } else -> { val response = JsonObject( - mapOf("error" to JsonPrimitive("Unknown function: ${functionCall.name}")) + mapOf("error" to JsonPrimitive("Unknown function: ${functionCall.name}")), ) FunctionResponsePart(functionCall.name, response) } From b6629ce46d6ffc81176f26ed0091b6a3c4593f4f Mon Sep 17 00:00:00 2001 From: Thomas Ezan Date: Mon, 7 Jul 2025 11:25:13 -0700 Subject: [PATCH 04/19] Address Gemini in Github feedback --- .../samples/gemini-live-todo/build.gradle.kts | 6 ++--- .../ai/samples/geminilivetodo/data/Todo.kt | 4 ++- .../samples/geminilivetodo/ui/TodoScreen.kt | 26 +------------------ .../geminilivetodo/ui/TodoScreenViewModel.kt | 15 +++++------ .../src/main/res/values/strings.xml | 1 + 5 files changed, 15 insertions(+), 37 deletions(-) diff --git a/ai-catalog/samples/gemini-live-todo/build.gradle.kts b/ai-catalog/samples/gemini-live-todo/build.gradle.kts index 51925feb..5df82614 100644 --- a/ai-catalog/samples/gemini-live-todo/build.gradle.kts +++ b/ai-catalog/samples/gemini-live-todo/build.gradle.kts @@ -41,11 +41,11 @@ android { } } compileOptions { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 } kotlinOptions { - jvmTarget = "11" + jvmTarget = "17" } } diff --git a/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/data/Todo.kt b/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/data/Todo.kt index 5ad7b6a4..e3e1e8dd 100644 --- a/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/data/Todo.kt +++ b/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/data/Todo.kt @@ -15,8 +15,10 @@ */ package com.android.ai.samples.geminilivetodo.data +import java.util.UUID.randomUUID + data class Todo( - val id: Long = System.currentTimeMillis(), + val id: Long = randomUUID().mostSignificantBits, val task: String, val isCompleted: Boolean = false, ) diff --git a/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreen.kt b/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreen.kt index 2553c86d..62398d97 100644 --- a/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreen.kt +++ b/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreen.kt @@ -70,7 +70,6 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.android.ai.samples.geminilive.ui.TodoScreenViewModel import com.android.ai.samples.geminilivetodo.R import com.android.ai.samples.geminilivetodo.data.Todo import kotlin.collections.reversed @@ -129,7 +128,7 @@ fun TodoScreen(viewModel: TodoScreenViewModel = hiltViewModel()) { onClick = { if (successState.isLiveSessionReady) viewModel.toggleLiveSession() }, containerColor = containerColor, ) { - Icon(micIcon, "Interact with todolist by voice") + Icon(micIcon, stringResource(R.string.interact_with_todolist_by_voice)) } } }, @@ -194,31 +193,8 @@ fun TodoScreen(viewModel: TodoScreenViewModel = hiltViewModel()) { @Composable fun TodoItem(task: Todo, onToggle: () -> Unit, onDelete: () -> Unit) { val defaultBackgroundColor = Color.Transparent - val highlightColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.4f) val backgroundColor = remember { Animatable(defaultBackgroundColor) } -// // Animate background on completion change -// LaunchedEffect(task.isCompleted) { -// if (backgroundColor.value == defaultBackgroundColor) { -// launch { -// backgroundColor.animateTo(highlightColor, animationSpec = tween(durationMillis = 150)) -// delay(150) -// backgroundColor.animateTo(defaultBackgroundColor, animationSpec = tween(durationMillis = 150)) -// delay(150) -// backgroundColor.animateTo(highlightColor, animationSpec = tween(durationMillis = 150)) -// delay(150) -// backgroundColor.animateTo(defaultBackgroundColor, animationSpec = tween(durationMillis = 150)) -// } -// } -// } -// -// // Ensure background is reset for item reuse -// LaunchedEffect(task.id) { -// if (!backgroundColor.isRunning) { -// backgroundColor.snapTo(defaultBackgroundColor) -// } -// } - Row( modifier = Modifier .fillMaxWidth() diff --git a/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreenViewModel.kt b/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreenViewModel.kt index a8c54e0e..bceb74a5 100644 --- a/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreenViewModel.kt +++ b/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreenViewModel.kt @@ -13,14 +13,14 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.android.ai.samples.geminilive.ui + +package com.android.ai.samples.geminilivetodo.ui import android.util.Log import androidx.annotation.RequiresPermission import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.android.ai.samples.geminilivetodo.data.TodoRepository -import com.android.ai.samples.geminilivetodo.ui.TodoScreenUiState import com.google.firebase.Firebase import com.google.firebase.ai.ai import com.google.firebase.ai.type.FunctionCallPart @@ -52,9 +52,7 @@ import kotlinx.serialization.json.long @HiltViewModel class TodoScreenViewModel @Inject constructor(private val todoRepository: TodoRepository) : ViewModel() { private val TAG = "TodoScreenViewModel" - - private var isLiveSessionRunning = false - + private var session: LiveSession? = null private val _uiState = MutableStateFlow(TodoScreenUiState.Initial) @@ -89,10 +87,12 @@ class TodoScreenViewModel @Inject constructor(private val todoRepository: TodoRe @RequiresPermission(android.Manifest.permission.RECORD_AUDIO) fun toggleLiveSession() { viewModelScope.launch { + val currentState = _uiState.value + if (currentState !is TodoScreenUiState.Success) return@launch + session?.let { - if (!isLiveSessionRunning) { + if (currentState.isLiveSessionRunning) { it.startAudioConversation(::handleFunctionCall) - isLiveSessionRunning = true _uiState.update { if (it is TodoScreenUiState.Success) { it.copy(isLiveSessionRunning = true) @@ -102,7 +102,6 @@ class TodoScreenViewModel @Inject constructor(private val todoRepository: TodoRe } } else { it.stopAudioConversation() - isLiveSessionRunning = false _uiState.update { if (it is TodoScreenUiState.Success) { it.copy(isLiveSessionRunning = false) diff --git a/ai-catalog/samples/gemini-live-todo/src/main/res/values/strings.xml b/ai-catalog/samples/gemini-live-todo/src/main/res/values/strings.xml index b1677332..4628cf54 100644 --- a/ai-catalog/samples/gemini-live-todo/src/main/res/values/strings.xml +++ b/ai-catalog/samples/gemini-live-todo/src/main/res/values/strings.xml @@ -19,4 +19,5 @@ Gemini Live Todo New Task Add + Button to start the live session and interact with the todo list by voice \ No newline at end of file From 26aeb1618bbec63bb439705cf6af95ae6617c48f Mon Sep 17 00:00:00 2001 From: lethargicpanda <205574+lethargicpanda@users.noreply.github.com> Date: Mon, 7 Jul 2025 18:26:23 +0000 Subject: [PATCH 05/19] =?UTF-8?q?=F0=9F=A4=96=20Apply=20Spotless=20formatt?= =?UTF-8?q?ing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ai/samples/geminilivetodo/ui/TodoScreenViewModel.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreenViewModel.kt b/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreenViewModel.kt index bceb74a5..89a1aa50 100644 --- a/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreenViewModel.kt +++ b/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreenViewModel.kt @@ -13,7 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - package com.android.ai.samples.geminilivetodo.ui import android.util.Log @@ -52,7 +51,7 @@ import kotlinx.serialization.json.long @HiltViewModel class TodoScreenViewModel @Inject constructor(private val todoRepository: TodoRepository) : ViewModel() { private val TAG = "TodoScreenViewModel" - + private var session: LiveSession? = null private val _uiState = MutableStateFlow(TodoScreenUiState.Initial) From 5ca376ea9afeb13660e4172ff6684396d5410435 Mon Sep 17 00:00:00 2001 From: Thomas Ezan Date: Wed, 9 Jul 2025 14:15:48 -0700 Subject: [PATCH 06/19] Add UiState.Error for connection error Add UiState.Error to manage Live Sesssion connection error --- .../samples/geminilivetodo/ui/TodoScreen.kt | 28 +++++++++++++++++++ .../geminilivetodo/ui/TodoScreenUiState.kt | 6 ++++ .../geminilivetodo/ui/TodoScreenViewModel.kt | 16 +++++++++-- .../src/main/res/values/strings.xml | 3 ++ 4 files changed, 50 insertions(+), 3 deletions(-) diff --git a/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreen.kt b/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreen.kt index 62398d97..501ea290 100644 --- a/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreen.kt +++ b/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreen.kt @@ -40,6 +40,7 @@ import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Mic import androidx.compose.material.icons.filled.MicNone import androidx.compose.material.icons.filled.MicOff +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.Checkbox import androidx.compose.material3.ExperimentalMaterial3Api @@ -130,6 +131,20 @@ fun TodoScreen(viewModel: TodoScreenViewModel = hiltViewModel()) { ) { Icon(micIcon, stringResource(R.string.interact_with_todolist_by_voice)) } + } else if (uiState is TodoScreenUiState.Error) { + val isDialogDisplayed = remember { mutableStateOf(true) } + if (isDialogDisplayed.value) { + AlertDialog( + onDismissRequest = { isDialogDisplayed.value = false }, + title = { Text(text = stringResource(R.string.error_title)) }, + text = { Text(text = stringResource(R.string.error_message)) }, + confirmButton = { + Button(onClick = { isDialogDisplayed.value = false }) { + Text(text = stringResource(R.string.dismiss_button)) + } + }, + ) + } } }, floatingActionButtonPosition = FabPosition.Center, @@ -185,6 +200,19 @@ fun TodoScreen(viewModel: TodoScreenViewModel = hiltViewModel()) { } } } + is TodoScreenUiState.Error -> { + val todos = (uiState as TodoScreenUiState.Error).todos + LazyColumn(modifier = Modifier.fillMaxSize()) { + items(todos.reversed(), key = { it.id }) { todo -> + TodoItem( + task = todo, + onToggle = { viewModel.toggleTodoStatus(todo.id) }, + onDelete = { viewModel.removeTodo(todo.id) }, + ) + HorizontalDivider() + } + } + } } } } diff --git a/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreenUiState.kt b/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreenUiState.kt index 37953b39..43d35a77 100644 --- a/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreenUiState.kt +++ b/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreenUiState.kt @@ -25,4 +25,10 @@ sealed interface TodoScreenUiState { val isLiveSessionReady: Boolean = false, val isLiveSessionRunning: Boolean = false, ) : TodoScreenUiState + + data class Error( + val todos: List = emptyList(), + val isLiveSessionReady: Boolean = false, + val isLiveSessionRunning: Boolean = false, + ) : TodoScreenUiState } diff --git a/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreenViewModel.kt b/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreenViewModel.kt index 89a1aa50..87a63a51 100644 --- a/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreenViewModel.kt +++ b/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreenViewModel.kt @@ -113,7 +113,7 @@ class TodoScreenViewModel @Inject constructor(private val todoRepository: TodoRe } } - fun initializeGeminiLive() { + fun initializeGeminiLive(){ viewModelScope.launch { Log.d(TAG, "Start Gemini Live initialization") val liveGenerationConfig = liveGenerationConfig { @@ -176,8 +176,18 @@ class TodoScreenViewModel @Inject constructor(private val todoRepository: TodoRe ), ) - session = generativeModel.connect() - Log.d(TAG, "Gemini Live session initialized") + try { + session = generativeModel.connect() + } catch (e: Exception) { + Log.e(TAG, "Error connecting to the model", e) + _uiState.update { + TodoScreenUiState.Error( + isLiveSessionReady = false, + isLiveSessionRunning = false, + ) + } + } + _uiState.update { if (it is TodoScreenUiState.Success) { it.copy(isLiveSessionReady = true) diff --git a/ai-catalog/samples/gemini-live-todo/src/main/res/values/strings.xml b/ai-catalog/samples/gemini-live-todo/src/main/res/values/strings.xml index 4628cf54..60932bb7 100644 --- a/ai-catalog/samples/gemini-live-todo/src/main/res/values/strings.xml +++ b/ai-catalog/samples/gemini-live-todo/src/main/res/values/strings.xml @@ -19,5 +19,8 @@ Gemini Live Todo New Task Add + Error + The live session model could not be initialized. + Dismiss Button to start the live session and interact with the todo list by voice \ No newline at end of file From a5bab1777622e19d308242e83a145d082f2faf0a Mon Sep 17 00:00:00 2001 From: lethargicpanda <205574+lethargicpanda@users.noreply.github.com> Date: Wed, 9 Jul 2025 21:16:57 +0000 Subject: [PATCH 07/19] =?UTF-8?q?=F0=9F=A4=96=20Apply=20Spotless=20formatt?= =?UTF-8?q?ing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../samples/geminilivetodo/ui/TodoScreenViewModel.kt | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreenViewModel.kt b/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreenViewModel.kt index 87a63a51..3312a5aa 100644 --- a/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreenViewModel.kt +++ b/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreenViewModel.kt @@ -113,7 +113,7 @@ class TodoScreenViewModel @Inject constructor(private val todoRepository: TodoRe } } - fun initializeGeminiLive(){ + fun initializeGeminiLive() { viewModelScope.launch { Log.d(TAG, "Start Gemini Live initialization") val liveGenerationConfig = liveGenerationConfig { @@ -181,10 +181,10 @@ class TodoScreenViewModel @Inject constructor(private val todoRepository: TodoRe } catch (e: Exception) { Log.e(TAG, "Error connecting to the model", e) _uiState.update { - TodoScreenUiState.Error( - isLiveSessionReady = false, - isLiveSessionRunning = false, - ) + TodoScreenUiState.Error( + isLiveSessionReady = false, + isLiveSessionRunning = false, + ) } } From 9a1c018463c1d2fcab5d8ebc60968812675a92a8 Mon Sep 17 00:00:00 2001 From: Thomas Ezan Date: Thu, 10 Jul 2025 16:18:10 +0200 Subject: [PATCH 08/19] Address comments --- ai-catalog/gradle/libs.versions.toml | 3 +- .../samples/gemini-live-todo/build.gradle.kts | 1 - .../samples/geminilivetodo/ui/TodoScreen.kt | 171 ++++++++++-------- 3 files changed, 101 insertions(+), 74 deletions(-) diff --git a/ai-catalog/gradle/libs.versions.toml b/ai-catalog/gradle/libs.versions.toml index 381fb8b9..f822b41b 100644 --- a/ai-catalog/gradle/libs.versions.toml +++ b/ai-catalog/gradle/libs.versions.toml @@ -58,6 +58,7 @@ androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-toolin androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-material3 = { group = "androidx.compose.material3", name = "material3" } +androidx-material3-android = { group = "androidx.compose.material3", name = "material3-android", version.ref = "material3Android" } androidx-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" } androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" } androidx-navigation-runtime-ktx = { group = "androidx.navigation", name = "navigation-runtime-ktx", version.ref = "navigationRuntimeKtx" } @@ -68,13 +69,11 @@ hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt"} hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" } androidx-runtime-livedata = { group = "androidx.compose.runtime", name = "runtime-livedata", version.ref = "runtimeLivedata" } -androidx-material3-android = { group = "androidx.compose.material3", name = "material3-android", version.ref = "material3Android" } androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" } androidx-media3-ui = { module = "androidx.media3:media3-ui", version.ref = "media3" } androidx-ui-tooling-preview-android = { group = "androidx.compose.ui", name = "ui-tooling-preview-android", version.ref = "uiToolingPreviewAndroid" } ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview", version.ref = "uiToolingPreview" } ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling", version.ref = "uiTooling" } -material = { group = "com.google.android.material", name = "material", version.ref = "material" } google-firebase-ai = { group = "com.google.firebase", name = "firebase-ai", version.ref = "firebaseAi" } androidx-lifecycle-viewmodel-android = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-android", version.ref = "lifecycleViewmodelAndroid" } material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" } diff --git a/ai-catalog/samples/gemini-live-todo/build.gradle.kts b/ai-catalog/samples/gemini-live-todo/build.gradle.kts index 5df82614..bf947627 100644 --- a/ai-catalog/samples/gemini-live-todo/build.gradle.kts +++ b/ai-catalog/samples/gemini-live-todo/build.gradle.kts @@ -53,7 +53,6 @@ dependencies { implementation(libs.androidx.core.ktx) implementation(libs.androidx.appcompat) - implementation(libs.material) implementation(platform(libs.androidx.compose.bom)) implementation(libs.androidx.material.icons.extended) implementation(platform(libs.firebase.bom)) diff --git a/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreen.kt b/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreen.kt index 501ea290..787ed4f2 100644 --- a/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreen.kt +++ b/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreen.kt @@ -95,57 +95,16 @@ fun TodoScreen(viewModel: TodoScreenViewModel = hiltViewModel()) { TopAppBar( colors = TopAppBarDefaults.topAppBarColors( containerColor = MaterialTheme.colorScheme.primaryContainer, - titleContentColor = MaterialTheme.colorScheme.primary, + titleContentColor = MaterialTheme.colorScheme.onPrimary, ), title = { Text(stringResource(R.string.gemini_live_title)) }, ) }, floatingActionButton = { - if (uiState is TodoScreenUiState.Success) { - val successState = uiState as TodoScreenUiState.Success - val micIcon = when { - !successState.isLiveSessionReady -> Icons.Filled.MicOff - successState.isLiveSessionRunning -> Icons.Filled.Mic - else -> Icons.Filled.MicNone - } - - val containerColor = if (successState.isLiveSessionRunning) { - val infiniteTransition = - rememberInfiniteTransition(label = "mic_color_transition") - infiniteTransition.animateColor( - initialValue = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f), - targetValue = MaterialTheme.colorScheme.primary.copy(alpha = 0.9f), - animationSpec = infiniteRepeatable( - animation = tween(1000, easing = LinearEasing), - repeatMode = RepeatMode.Reverse, - ), - label = "mic_color", - ).value - } else { - MaterialTheme.colorScheme.primaryContainer - } - - FloatingActionButton( - onClick = { if (successState.isLiveSessionReady) viewModel.toggleLiveSession() }, - containerColor = containerColor, - ) { - Icon(micIcon, stringResource(R.string.interact_with_todolist_by_voice)) - } - } else if (uiState is TodoScreenUiState.Error) { - val isDialogDisplayed = remember { mutableStateOf(true) } - if (isDialogDisplayed.value) { - AlertDialog( - onDismissRequest = { isDialogDisplayed.value = false }, - title = { Text(text = stringResource(R.string.error_title)) }, - text = { Text(text = stringResource(R.string.error_message)) }, - confirmButton = { - Button(onClick = { isDialogDisplayed.value = false }) { - Text(text = stringResource(R.string.dismiss_button)) - } - }, - ) - } - } + MicButton( + uiState = uiState, + onToggle = { viewModel.toggleLiveSession() } + ) }, floatingActionButtonPosition = FabPosition.Center, modifier = Modifier.fillMaxSize(), @@ -157,31 +116,14 @@ fun TodoScreen(viewModel: TodoScreenViewModel = hiltViewModel()) { .imePadding() .fillMaxSize(), ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 16.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - OutlinedTextField( - value = text, - onValueChange = { text = it }, - label = { Text(stringResource(R.string.new_task_placeholder)) }, - modifier = Modifier.weight(1f), - singleLine = true, - ) - Spacer(modifier = Modifier.width(8.dp)) - Button( - onClick = { - if (text.isNotBlank()) { - viewModel.addTodo(text) - text = "" - } - }, - ) { - Text(stringResource(R.string.add_button)) + TodoInput( + text = text, + onTextChange = { text = it }, + onAddClick = { + viewModel.addTodo(text) + text = "" } - } + ) when (uiState) { is TodoScreenUiState.Initial -> { @@ -192,6 +134,7 @@ fun TodoScreen(viewModel: TodoScreenViewModel = hiltViewModel()) { LazyColumn(modifier = Modifier.fillMaxSize()) { items(todos.reversed(), key = { it.id }) { todo -> TodoItem( + modifier = Modifier, task = todo, onToggle = { viewModel.toggleTodoStatus(todo.id) }, onDelete = { viewModel.removeTodo(todo.id) }, @@ -205,6 +148,7 @@ fun TodoScreen(viewModel: TodoScreenViewModel = hiltViewModel()) { LazyColumn(modifier = Modifier.fillMaxSize()) { items(todos.reversed(), key = { it.id }) { todo -> TodoItem( + modifier = Modifier, task = todo, onToggle = { viewModel.toggleTodoStatus(todo.id) }, onDelete = { viewModel.removeTodo(todo.id) }, @@ -219,7 +163,92 @@ fun TodoScreen(viewModel: TodoScreenViewModel = hiltViewModel()) { } @Composable -fun TodoItem(task: Todo, onToggle: () -> Unit, onDelete: () -> Unit) { +fun TodoInput( + text: String, + onTextChange: (String) -> Unit, + onAddClick: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedTextField( + value = text, + onValueChange = onTextChange, + label = { Text(stringResource(R.string.new_task_placeholder)) }, + modifier = Modifier.weight(1f), + singleLine = true, + ) + Spacer(modifier = Modifier.width(8.dp)) + Button( + enabled = text.isNotBlank(), + onClick = onAddClick, + ) { + Text(stringResource(R.string.add_button)) + } + } +} + +@Composable +fun MicButton( + uiState: TodoScreenUiState, + onToggle: () -> Unit +) { + if (uiState is TodoScreenUiState.Success) { + val successState = uiState as TodoScreenUiState.Success + val micIcon = when { + !successState.isLiveSessionReady -> Icons.Filled.MicOff + successState.isLiveSessionRunning -> Icons.Filled.Mic + else -> Icons.Filled.MicNone + } + + val containerColor = if (successState.isLiveSessionRunning) { + val infiniteTransition = + rememberInfiniteTransition(label = "mic_color_transition") + infiniteTransition.animateColor( + initialValue = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f), + targetValue = MaterialTheme.colorScheme.primary.copy(alpha = 0.9f), + animationSpec = infiniteRepeatable( + animation = tween(1000, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + ), + label = "mic_color", + ).value + } else { + MaterialTheme.colorScheme.primaryContainer + } + + FloatingActionButton( + onClick = { if (successState.isLiveSessionReady) onToggle() }, + containerColor = containerColor, + ) { + Icon(micIcon, stringResource(R.string.interact_with_todolist_by_voice)) + } + } else if (uiState is TodoScreenUiState.Error) { + val isDialogDisplayed = remember { mutableStateOf(true) } + if (isDialogDisplayed.value) { + AlertDialog( + onDismissRequest = { isDialogDisplayed.value = false }, + title = { Text(text = stringResource(R.string.error_title)) }, + text = { Text(text = stringResource(R.string.error_message)) }, + confirmButton = { + Button(onClick = { isDialogDisplayed.value = false }) { + Text(text = stringResource(R.string.dismiss_button)) + } + }, + ) + } + } +} + +@Composable +fun TodoItem( + modifier: Modifier, + task: Todo, + onToggle: () -> Unit, + onDelete: () -> Unit) { val defaultBackgroundColor = Color.Transparent val backgroundColor = remember { Animatable(defaultBackgroundColor) } From 8a49cd63e19eda4a6c31c5b63a2bcadcb6935a95 Mon Sep 17 00:00:00 2001 From: lethargicpanda <205574+lethargicpanda@users.noreply.github.com> Date: Thu, 10 Jul 2025 14:19:22 +0000 Subject: [PATCH 09/19] =?UTF-8?q?=F0=9F=A4=96=20Apply=20Spotless=20formatt?= =?UTF-8?q?ing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../samples/geminilivetodo/ui/TodoScreen.kt | 21 +++++-------------- 1 file changed, 5 insertions(+), 16 deletions(-) diff --git a/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreen.kt b/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreen.kt index 787ed4f2..5dff90f9 100644 --- a/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreen.kt +++ b/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreen.kt @@ -103,7 +103,7 @@ fun TodoScreen(viewModel: TodoScreenViewModel = hiltViewModel()) { floatingActionButton = { MicButton( uiState = uiState, - onToggle = { viewModel.toggleLiveSession() } + onToggle = { viewModel.toggleLiveSession() }, ) }, floatingActionButtonPosition = FabPosition.Center, @@ -122,7 +122,7 @@ fun TodoScreen(viewModel: TodoScreenViewModel = hiltViewModel()) { onAddClick = { viewModel.addTodo(text) text = "" - } + }, ) when (uiState) { @@ -163,11 +163,7 @@ fun TodoScreen(viewModel: TodoScreenViewModel = hiltViewModel()) { } @Composable -fun TodoInput( - text: String, - onTextChange: (String) -> Unit, - onAddClick: () -> Unit -) { +fun TodoInput(text: String, onTextChange: (String) -> Unit, onAddClick: () -> Unit) { Row( modifier = Modifier .fillMaxWidth() @@ -192,10 +188,7 @@ fun TodoInput( } @Composable -fun MicButton( - uiState: TodoScreenUiState, - onToggle: () -> Unit -) { +fun MicButton(uiState: TodoScreenUiState, onToggle: () -> Unit) { if (uiState is TodoScreenUiState.Success) { val successState = uiState as TodoScreenUiState.Success val micIcon = when { @@ -244,11 +237,7 @@ fun MicButton( } @Composable -fun TodoItem( - modifier: Modifier, - task: Todo, - onToggle: () -> Unit, - onDelete: () -> Unit) { +fun TodoItem(modifier: Modifier, task: Todo, onToggle: () -> Unit, onDelete: () -> Unit) { val defaultBackgroundColor = Color.Transparent val backgroundColor = remember { Animatable(defaultBackgroundColor) } From e0768ee80e0307e7406a97e6181e43cd8fed8461 Mon Sep 17 00:00:00 2001 From: Thomas Ezan Date: Thu, 10 Jul 2025 17:30:21 +0200 Subject: [PATCH 10/19] Add permission runtime --- .../samples/gemini-live-todo/build.gradle.kts | 1 + .../samples/geminilivetodo/ui/TodoScreen.kt | 37 ++++++++++++++++--- .../geminilivetodo/ui/TodoScreenViewModel.kt | 29 ++++++++++++--- 3 files changed, 55 insertions(+), 12 deletions(-) diff --git a/ai-catalog/samples/gemini-live-todo/build.gradle.kts b/ai-catalog/samples/gemini-live-todo/build.gradle.kts index bf947627..8f8b6542 100644 --- a/ai-catalog/samples/gemini-live-todo/build.gradle.kts +++ b/ai-catalog/samples/gemini-live-todo/build.gradle.kts @@ -62,6 +62,7 @@ dependencies { implementation(libs.hilt.android) ksp(libs.hilt.compiler) implementation(libs.hilt.navigation.compose) + implementation(libs.androidx.activity.compose) implementation(libs.androidx.material3.android) implementation(libs.kotlinx.serialization.json) testImplementation(libs.junit) diff --git a/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreen.kt b/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreen.kt index 5dff90f9..58142e06 100644 --- a/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreen.kt +++ b/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreen.kt @@ -16,6 +16,8 @@ package com.android.ai.samples.geminilivetodo.ui import android.Manifest +import android.app.Activity +import androidx.activity.compose.LocalActivity import androidx.annotation.RequiresPermission import androidx.compose.animation.Animatable import androidx.compose.animation.animateColor @@ -25,6 +27,7 @@ import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.tween import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -43,6 +46,7 @@ import androidx.compose.material.icons.filled.MicOff import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.Checkbox +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FabPosition import androidx.compose.material3.FloatingActionButton @@ -64,6 +68,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.style.TextDecoration @@ -86,8 +91,10 @@ fun TodoScreen(viewModel: TodoScreenViewModel = hiltViewModel()) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() var text by remember { mutableStateOf("") } + val activity = LocalActivity.current as Activity + LaunchedEffect(Unit) { - viewModel.initializeGeminiLive() + viewModel.initializeGeminiLive(activity) } Scaffold( @@ -95,7 +102,7 @@ fun TodoScreen(viewModel: TodoScreenViewModel = hiltViewModel()) { TopAppBar( colors = TopAppBarDefaults.topAppBarColors( containerColor = MaterialTheme.colorScheme.primaryContainer, - titleContentColor = MaterialTheme.colorScheme.onPrimary, + titleContentColor = MaterialTheme.colorScheme.primary, ), title = { Text(stringResource(R.string.gemini_live_title)) }, ) @@ -127,7 +134,13 @@ fun TodoScreen(viewModel: TodoScreenViewModel = hiltViewModel()) { when (uiState) { is TodoScreenUiState.Initial -> { - // Show a loading indicator or initial state + Box( + modifier = Modifier + .fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } } is TodoScreenUiState.Success -> { val todos = (uiState as TodoScreenUiState.Success).todos @@ -163,7 +176,11 @@ fun TodoScreen(viewModel: TodoScreenViewModel = hiltViewModel()) { } @Composable -fun TodoInput(text: String, onTextChange: (String) -> Unit, onAddClick: () -> Unit) { +fun TodoInput( + text: String, + onTextChange: (String) -> Unit, + onAddClick: () -> Unit, +) { Row( modifier = Modifier .fillMaxWidth() @@ -188,7 +205,10 @@ fun TodoInput(text: String, onTextChange: (String) -> Unit, onAddClick: () -> Un } @Composable -fun MicButton(uiState: TodoScreenUiState, onToggle: () -> Unit) { +fun MicButton( + uiState: TodoScreenUiState, + onToggle: () -> Unit, +) { if (uiState is TodoScreenUiState.Success) { val successState = uiState as TodoScreenUiState.Success val micIcon = when { @@ -237,7 +257,12 @@ fun MicButton(uiState: TodoScreenUiState, onToggle: () -> Unit) { } @Composable -fun TodoItem(modifier: Modifier, task: Todo, onToggle: () -> Unit, onDelete: () -> Unit) { +fun TodoItem( + modifier: Modifier, + task: Todo, + onToggle: () -> Unit, + onDelete: () -> Unit, +) { val defaultBackgroundColor = Color.Transparent val backgroundColor = remember { Animatable(defaultBackgroundColor) } diff --git a/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreenViewModel.kt b/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreenViewModel.kt index 3312a5aa..8356968a 100644 --- a/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreenViewModel.kt +++ b/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreenViewModel.kt @@ -15,8 +15,14 @@ */ package com.android.ai.samples.geminilivetodo.ui +import android.Manifest +import android.app.Activity +import android.content.Context +import android.content.pm.PackageManager import android.util.Log import androidx.annotation.RequiresPermission +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.android.ai.samples.geminilivetodo.data.TodoRepository @@ -90,7 +96,7 @@ class TodoScreenViewModel @Inject constructor(private val todoRepository: TodoRe if (currentState !is TodoScreenUiState.Success) return@launch session?.let { - if (currentState.isLiveSessionRunning) { + if (!currentState.isLiveSessionRunning) { it.startAudioConversation(::handleFunctionCall) _uiState.update { if (it is TodoScreenUiState.Success) { @@ -113,7 +119,8 @@ class TodoScreenViewModel @Inject constructor(private val todoRepository: TodoRe } } - fun initializeGeminiLive() { + fun initializeGeminiLive(activity: Activity) { + requestAudioPermissionIfNeeded(activity) viewModelScope.launch { Log.d(TAG, "Start Gemini Live initialization") val liveGenerationConfig = liveGenerationConfig { @@ -181,10 +188,10 @@ class TodoScreenViewModel @Inject constructor(private val todoRepository: TodoRe } catch (e: Exception) { Log.e(TAG, "Error connecting to the model", e) _uiState.update { - TodoScreenUiState.Error( - isLiveSessionReady = false, - isLiveSessionRunning = false, - ) + TodoScreenUiState.Error( + isLiveSessionReady = false, + isLiveSessionRunning = false, + ) } } @@ -251,4 +258,14 @@ class TodoScreenViewModel @Inject constructor(private val todoRepository: TodoRe } } } + + fun requestAudioPermissionIfNeeded(activity: Activity){ + if (ContextCompat.checkSelfPermission( + activity, + Manifest.permission.RECORD_AUDIO + ) != PackageManager.PERMISSION_GRANTED + ) { + ActivityCompat.requestPermissions(activity, arrayOf(Manifest.permission.RECORD_AUDIO), 1) + } + } } From 8c7ee46fbdebd35546c85346d7f9061669d44394 Mon Sep 17 00:00:00 2001 From: lethargicpanda <205574+lethargicpanda@users.noreply.github.com> Date: Thu, 10 Jul 2025 15:31:49 +0000 Subject: [PATCH 11/19] =?UTF-8?q?=F0=9F=A4=96=20Apply=20Spotless=20formatt?= =?UTF-8?q?ing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../samples/geminilivetodo/ui/TodoScreen.kt | 19 +++---------------- .../geminilivetodo/ui/TodoScreenViewModel.kt | 13 ++++++------- 2 files changed, 9 insertions(+), 23 deletions(-) diff --git a/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreen.kt b/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreen.kt index 58142e06..ecb20abb 100644 --- a/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreen.kt +++ b/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreen.kt @@ -68,7 +68,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.style.TextDecoration @@ -176,11 +175,7 @@ fun TodoScreen(viewModel: TodoScreenViewModel = hiltViewModel()) { } @Composable -fun TodoInput( - text: String, - onTextChange: (String) -> Unit, - onAddClick: () -> Unit, -) { +fun TodoInput(text: String, onTextChange: (String) -> Unit, onAddClick: () -> Unit) { Row( modifier = Modifier .fillMaxWidth() @@ -205,10 +200,7 @@ fun TodoInput( } @Composable -fun MicButton( - uiState: TodoScreenUiState, - onToggle: () -> Unit, -) { +fun MicButton(uiState: TodoScreenUiState, onToggle: () -> Unit) { if (uiState is TodoScreenUiState.Success) { val successState = uiState as TodoScreenUiState.Success val micIcon = when { @@ -257,12 +249,7 @@ fun MicButton( } @Composable -fun TodoItem( - modifier: Modifier, - task: Todo, - onToggle: () -> Unit, - onDelete: () -> Unit, -) { +fun TodoItem(modifier: Modifier, task: Todo, onToggle: () -> Unit, onDelete: () -> Unit) { val defaultBackgroundColor = Color.Transparent val backgroundColor = remember { Animatable(defaultBackgroundColor) } diff --git a/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreenViewModel.kt b/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreenViewModel.kt index 8356968a..cf48350a 100644 --- a/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreenViewModel.kt +++ b/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreenViewModel.kt @@ -17,7 +17,6 @@ package com.android.ai.samples.geminilivetodo.ui import android.Manifest import android.app.Activity -import android.content.Context import android.content.pm.PackageManager import android.util.Log import androidx.annotation.RequiresPermission @@ -188,10 +187,10 @@ class TodoScreenViewModel @Inject constructor(private val todoRepository: TodoRe } catch (e: Exception) { Log.e(TAG, "Error connecting to the model", e) _uiState.update { - TodoScreenUiState.Error( - isLiveSessionReady = false, - isLiveSessionRunning = false, - ) + TodoScreenUiState.Error( + isLiveSessionReady = false, + isLiveSessionRunning = false, + ) } } @@ -259,10 +258,10 @@ class TodoScreenViewModel @Inject constructor(private val todoRepository: TodoRe } } - fun requestAudioPermissionIfNeeded(activity: Activity){ + fun requestAudioPermissionIfNeeded(activity: Activity) { if (ContextCompat.checkSelfPermission( activity, - Manifest.permission.RECORD_AUDIO + Manifest.permission.RECORD_AUDIO, ) != PackageManager.PERMISSION_GRANTED ) { ActivityCompat.requestPermissions(activity, arrayOf(Manifest.permission.RECORD_AUDIO), 1) From b0101fee04ee7d248497ab3b759ea0c3cee76dc5 Mon Sep 17 00:00:00 2001 From: Thomas Ezan Date: Thu, 10 Jul 2025 18:22:57 +0200 Subject: [PATCH 12/19] Add premission checks --- .../samples/geminilivetodo/ui/TodoScreen.kt | 3 +- .../geminilivetodo/ui/TodoScreenViewModel.kt | 50 ++++++++++--------- 2 files changed, 28 insertions(+), 25 deletions(-) diff --git a/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreen.kt b/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreen.kt index ecb20abb..62524187 100644 --- a/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreen.kt +++ b/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreen.kt @@ -84,7 +84,6 @@ import kotlin.collections.reversed * This composable is stateful, connecting to the ViewModel to manage UI state and events. */ @OptIn(ExperimentalMaterial3Api::class) -@RequiresPermission(Manifest.permission.RECORD_AUDIO) @Composable fun TodoScreen(viewModel: TodoScreenViewModel = hiltViewModel()) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() @@ -109,7 +108,7 @@ fun TodoScreen(viewModel: TodoScreenViewModel = hiltViewModel()) { floatingActionButton = { MicButton( uiState = uiState, - onToggle = { viewModel.toggleLiveSession() }, + onToggle = { viewModel.toggleLiveSession(activity) }, ) }, floatingActionButtonPosition = FabPosition.Center, diff --git a/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreenViewModel.kt b/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreenViewModel.kt index cf48350a..0c011cf3 100644 --- a/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreenViewModel.kt +++ b/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreenViewModel.kt @@ -19,7 +19,6 @@ import android.Manifest import android.app.Activity import android.content.pm.PackageManager import android.util.Log -import androidx.annotation.RequiresPermission import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat import androidx.lifecycle.ViewModel @@ -88,34 +87,39 @@ class TodoScreenViewModel @Inject constructor(private val todoRepository: TodoRe todoRepository.toggleTodoStatus(todoId) } - @RequiresPermission(android.Manifest.permission.RECORD_AUDIO) - fun toggleLiveSession() { - viewModelScope.launch { - val currentState = _uiState.value - if (currentState !is TodoScreenUiState.Success) return@launch + fun toggleLiveSession(activity: Activity) { + viewModelScope.launch { + val currentState = _uiState.value + if (currentState !is TodoScreenUiState.Success) return@launch - session?.let { - if (!currentState.isLiveSessionRunning) { - it.startAudioConversation(::handleFunctionCall) - _uiState.update { - if (it is TodoScreenUiState.Success) { - it.copy(isLiveSessionRunning = true) - } else { - it + session?.let { + if (!currentState.isLiveSessionRunning) { + if (ContextCompat.checkSelfPermission( + activity, + Manifest.permission.RECORD_AUDIO + ) == PackageManager.PERMISSION_GRANTED + ) { + it.startAudioConversation(::handleFunctionCall) + _uiState.update { + if (it is TodoScreenUiState.Success) { + it.copy(isLiveSessionRunning = true) + } else { + it + } + } } - } - } else { - it.stopAudioConversation() - _uiState.update { - if (it is TodoScreenUiState.Success) { - it.copy(isLiveSessionRunning = false) - } else { - it + } else { + it.stopAudioConversation() + _uiState.update { + if (it is TodoScreenUiState.Success) { + it.copy(isLiveSessionRunning = false) + } else { + it + } } } } } - } } fun initializeGeminiLive(activity: Activity) { From 1cc4753fd8a85b7dafb722d409abda78cfb5badd Mon Sep 17 00:00:00 2001 From: lethargicpanda <205574+lethargicpanda@users.noreply.github.com> Date: Thu, 10 Jul 2025 16:24:07 +0000 Subject: [PATCH 13/19] =?UTF-8?q?=F0=9F=A4=96=20Apply=20Spotless=20formatt?= =?UTF-8?q?ing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../samples/geminilivetodo/ui/TodoScreen.kt | 2 - .../geminilivetodo/ui/TodoScreenViewModel.kt | 44 +++++++++---------- 2 files changed, 22 insertions(+), 24 deletions(-) diff --git a/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreen.kt b/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreen.kt index 62524187..f9047c7f 100644 --- a/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreen.kt +++ b/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreen.kt @@ -15,10 +15,8 @@ */ package com.android.ai.samples.geminilivetodo.ui -import android.Manifest import android.app.Activity import androidx.activity.compose.LocalActivity -import androidx.annotation.RequiresPermission import androidx.compose.animation.Animatable import androidx.compose.animation.animateColor import androidx.compose.animation.core.LinearEasing diff --git a/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreenViewModel.kt b/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreenViewModel.kt index 0c011cf3..e50150a4 100644 --- a/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreenViewModel.kt +++ b/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreenViewModel.kt @@ -88,38 +88,38 @@ class TodoScreenViewModel @Inject constructor(private val todoRepository: TodoRe } fun toggleLiveSession(activity: Activity) { - viewModelScope.launch { - val currentState = _uiState.value - if (currentState !is TodoScreenUiState.Success) return@launch + viewModelScope.launch { + val currentState = _uiState.value + if (currentState !is TodoScreenUiState.Success) return@launch - session?.let { - if (!currentState.isLiveSessionRunning) { - if (ContextCompat.checkSelfPermission( - activity, - Manifest.permission.RECORD_AUDIO - ) == PackageManager.PERMISSION_GRANTED - ) { - it.startAudioConversation(::handleFunctionCall) - _uiState.update { - if (it is TodoScreenUiState.Success) { - it.copy(isLiveSessionRunning = true) - } else { - it - } - } - } - } else { - it.stopAudioConversation() + session?.let { + if (!currentState.isLiveSessionRunning) { + if (ContextCompat.checkSelfPermission( + activity, + Manifest.permission.RECORD_AUDIO, + ) == PackageManager.PERMISSION_GRANTED + ) { + it.startAudioConversation(::handleFunctionCall) _uiState.update { if (it is TodoScreenUiState.Success) { - it.copy(isLiveSessionRunning = false) + it.copy(isLiveSessionRunning = true) } else { it } } } + } else { + it.stopAudioConversation() + _uiState.update { + if (it is TodoScreenUiState.Success) { + it.copy(isLiveSessionRunning = false) + } else { + it + } + } } } + } } fun initializeGeminiLive(activity: Activity) { From 1bd8c72932949e324818a16f600d3d41051fad66 Mon Sep 17 00:00:00 2001 From: Thomas Ezan Date: Thu, 10 Jul 2025 22:14:06 +0200 Subject: [PATCH 14/19] Supress lint warning --- .../ai/samples/geminilivetodo/ui/TodoScreenViewModel.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreenViewModel.kt b/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreenViewModel.kt index e50150a4..d7200898 100644 --- a/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreenViewModel.kt +++ b/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreenViewModel.kt @@ -16,9 +16,11 @@ package com.android.ai.samples.geminilivetodo.ui import android.Manifest +import android.annotation.SuppressLint import android.app.Activity import android.content.pm.PackageManager import android.util.Log +import androidx.annotation.RequiresPermission import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat import androidx.lifecycle.ViewModel @@ -87,6 +89,7 @@ class TodoScreenViewModel @Inject constructor(private val todoRepository: TodoRe todoRepository.toggleTodoStatus(todoId) } + @SuppressLint("MissingPermission") fun toggleLiveSession(activity: Activity) { viewModelScope.launch { val currentState = _uiState.value From 6f6173ec697c9980ad13a25f6724b921518a2e76 Mon Sep 17 00:00:00 2001 From: lethargicpanda <205574+lethargicpanda@users.noreply.github.com> Date: Thu, 10 Jul 2025 20:15:23 +0000 Subject: [PATCH 15/19] =?UTF-8?q?=F0=9F=A4=96=20Apply=20Spotless=20formatt?= =?UTF-8?q?ing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../android/ai/samples/geminilivetodo/ui/TodoScreenViewModel.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreenViewModel.kt b/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreenViewModel.kt index d7200898..c453e229 100644 --- a/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreenViewModel.kt +++ b/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreenViewModel.kt @@ -20,7 +20,6 @@ import android.annotation.SuppressLint import android.app.Activity import android.content.pm.PackageManager import android.util.Log -import androidx.annotation.RequiresPermission import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat import androidx.lifecycle.ViewModel From 9175077f24ac918945129d61b9f4b231df7e87aa Mon Sep 17 00:00:00 2001 From: Thomas Ezan Date: Fri, 11 Jul 2025 15:35:08 +0200 Subject: [PATCH 16/19] Address comments --- .../ai/samples/geminilivetodo/ui/TodoScreen.kt | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreen.kt b/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreen.kt index f9047c7f..f89c90f6 100644 --- a/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreen.kt +++ b/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreen.kt @@ -36,6 +36,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Mic @@ -98,7 +99,7 @@ fun TodoScreen(viewModel: TodoScreenViewModel = hiltViewModel()) { TopAppBar( colors = TopAppBarDefaults.topAppBarColors( containerColor = MaterialTheme.colorScheme.primaryContainer, - titleContentColor = MaterialTheme.colorScheme.primary, + titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer, ), title = { Text(stringResource(R.string.gemini_live_title)) }, ) @@ -141,28 +142,32 @@ fun TodoScreen(viewModel: TodoScreenViewModel = hiltViewModel()) { is TodoScreenUiState.Success -> { val todos = (uiState as TodoScreenUiState.Success).todos LazyColumn(modifier = Modifier.fillMaxSize()) { - items(todos.reversed(), key = { it.id }) { todo -> + itemsIndexed(todos.reversed(), key = { index: Int, item: Todo -> item.id }) { index, todo -> TodoItem( modifier = Modifier, task = todo, onToggle = { viewModel.toggleTodoStatus(todo.id) }, onDelete = { viewModel.removeTodo(todo.id) }, ) - HorizontalDivider() + if (index!=todos.size-1) { + HorizontalDivider() + } } } } is TodoScreenUiState.Error -> { val todos = (uiState as TodoScreenUiState.Error).todos LazyColumn(modifier = Modifier.fillMaxSize()) { - items(todos.reversed(), key = { it.id }) { todo -> + itemsIndexed(todos.reversed(), key = { index: Int, item: Todo -> item.id }) { index, todo -> TodoItem( modifier = Modifier, task = todo, onToggle = { viewModel.toggleTodoStatus(todo.id) }, onDelete = { viewModel.removeTodo(todo.id) }, ) - HorizontalDivider() + if (index!=todos.size-1) { + HorizontalDivider() + } } } } From a0ed5099b1a27e01454470a35e159a3bec1c0540 Mon Sep 17 00:00:00 2001 From: lethargicpanda <205574+lethargicpanda@users.noreply.github.com> Date: Fri, 11 Jul 2025 13:36:17 +0000 Subject: [PATCH 17/19] =?UTF-8?q?=F0=9F=A4=96=20Apply=20Spotless=20formatt?= =?UTF-8?q?ing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/android/ai/samples/geminilivetodo/ui/TodoScreen.kt | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreen.kt b/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreen.kt index f89c90f6..9a957206 100644 --- a/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreen.kt +++ b/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreen.kt @@ -35,7 +35,6 @@ import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Delete @@ -149,7 +148,7 @@ fun TodoScreen(viewModel: TodoScreenViewModel = hiltViewModel()) { onToggle = { viewModel.toggleTodoStatus(todo.id) }, onDelete = { viewModel.removeTodo(todo.id) }, ) - if (index!=todos.size-1) { + if (index != todos.size - 1) { HorizontalDivider() } } @@ -165,7 +164,7 @@ fun TodoScreen(viewModel: TodoScreenViewModel = hiltViewModel()) { onToggle = { viewModel.toggleTodoStatus(todo.id) }, onDelete = { viewModel.removeTodo(todo.id) }, ) - if (index!=todos.size-1) { + if (index != todos.size - 1) { HorizontalDivider() } } From 29da0499af856c4a84e5109af05c7681070390fe Mon Sep 17 00:00:00 2001 From: Thomas Ezan Date: Mon, 14 Jul 2025 12:31:29 +0200 Subject: [PATCH 18/19] Introduce liveSessionState --- .../samples/geminilivetodo/ui/TodoScreen.kt | 11 ++-- .../geminilivetodo/ui/TodoScreenUiState.kt | 13 ++-- .../geminilivetodo/ui/TodoScreenViewModel.kt | 65 ++++++------------- 3 files changed, 34 insertions(+), 55 deletions(-) diff --git a/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreen.kt b/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreen.kt index 9a957206..3de62179 100644 --- a/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreen.kt +++ b/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreen.kt @@ -203,14 +203,15 @@ fun TodoInput(text: String, onTextChange: (String) -> Unit, onAddClick: () -> Un @Composable fun MicButton(uiState: TodoScreenUiState, onToggle: () -> Unit) { if (uiState is TodoScreenUiState.Success) { - val successState = uiState as TodoScreenUiState.Success val micIcon = when { - !successState.isLiveSessionReady -> Icons.Filled.MicOff - successState.isLiveSessionRunning -> Icons.Filled.Mic + uiState.liveSessionState is LiveSessionState.Ready -> Icons.Filled.MicOff + uiState.liveSessionState is LiveSessionState.Running -> Icons.Filled.Mic + uiState.liveSessionState is LiveSessionState.NotReady -> Icons.Filled.MicNone + uiState.liveSessionState is LiveSessionState.Error -> Icons.Filled.MicNone else -> Icons.Filled.MicNone } - val containerColor = if (successState.isLiveSessionRunning) { + val containerColor = if (uiState.liveSessionState is LiveSessionState.Running) { val infiniteTransition = rememberInfiniteTransition(label = "mic_color_transition") infiniteTransition.animateColor( @@ -227,7 +228,7 @@ fun MicButton(uiState: TodoScreenUiState, onToggle: () -> Unit) { } FloatingActionButton( - onClick = { if (successState.isLiveSessionReady) onToggle() }, + onClick = { if (uiState.liveSessionState !is LiveSessionState.NotReady) onToggle() }, containerColor = containerColor, ) { Icon(micIcon, stringResource(R.string.interact_with_todolist_by_voice)) diff --git a/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreenUiState.kt b/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreenUiState.kt index 43d35a77..6ec2116d 100644 --- a/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreenUiState.kt +++ b/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreenUiState.kt @@ -22,13 +22,18 @@ sealed interface TodoScreenUiState { data class Success( val todos: List = emptyList(), - val isLiveSessionReady: Boolean = false, - val isLiveSessionRunning: Boolean = false, + val liveSessionState: LiveSessionState ) : TodoScreenUiState data class Error( val todos: List = emptyList(), - val isLiveSessionReady: Boolean = false, - val isLiveSessionRunning: Boolean = false, + val liveSessionState: LiveSessionState ) : TodoScreenUiState } + +sealed interface LiveSessionState { + data object NotReady : LiveSessionState + data object Ready : LiveSessionState + data object Running : LiveSessionState + data object Error: LiveSessionState +} \ No newline at end of file diff --git a/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreenViewModel.kt b/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreenViewModel.kt index c453e229..6bc95ad2 100644 --- a/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreenViewModel.kt +++ b/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreenViewModel.kt @@ -43,8 +43,12 @@ import com.google.firebase.ai.type.liveGenerationConfig import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.serialization.json.JsonObject @@ -56,25 +60,18 @@ import kotlinx.serialization.json.long @HiltViewModel class TodoScreenViewModel @Inject constructor(private val todoRepository: TodoRepository) : ViewModel() { private val TAG = "TodoScreenViewModel" - private var session: LiveSession? = null - private val _uiState = MutableStateFlow(TodoScreenUiState.Initial) - val uiState: StateFlow = _uiState.asStateFlow() + private val liveSessionState = MutableStateFlow(LiveSessionState.NotReady) + private val todos = todoRepository.todos - init { - viewModelScope.launch { - todoRepository.todos.collect { todos -> - _uiState.update { - if (it is TodoScreenUiState.Success) { - it.copy(todos = todos) - } else { - TodoScreenUiState.Success(todos = todos) - } - } - } - } - } + val uiState: StateFlow = combine(liveSessionState, todos) { liveSessionState, todos -> + TodoScreenUiState.Success(todos, liveSessionState) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000L), + initialValue = TodoScreenUiState.Initial + ) fun addTodo(taskDescription: String) { todoRepository.addTodo(taskDescription) @@ -91,34 +88,21 @@ class TodoScreenViewModel @Inject constructor(private val todoRepository: TodoRe @SuppressLint("MissingPermission") fun toggleLiveSession(activity: Activity) { viewModelScope.launch { - val currentState = _uiState.value - if (currentState !is TodoScreenUiState.Success) return@launch + if (liveSessionState.value is LiveSessionState.NotReady) return@launch session?.let { - if (!currentState.isLiveSessionRunning) { + if (liveSessionState.value is LiveSessionState.Ready) { if (ContextCompat.checkSelfPermission( activity, Manifest.permission.RECORD_AUDIO, ) == PackageManager.PERMISSION_GRANTED ) { it.startAudioConversation(::handleFunctionCall) - _uiState.update { - if (it is TodoScreenUiState.Success) { - it.copy(isLiveSessionRunning = true) - } else { - it - } - } + liveSessionState.value = LiveSessionState.Running } } else { it.stopAudioConversation() - _uiState.update { - if (it is TodoScreenUiState.Success) { - it.copy(isLiveSessionRunning = false) - } else { - it - } - } + liveSessionState.value = LiveSessionState.Ready } } } @@ -192,21 +176,10 @@ class TodoScreenViewModel @Inject constructor(private val todoRepository: TodoRe session = generativeModel.connect() } catch (e: Exception) { Log.e(TAG, "Error connecting to the model", e) - _uiState.update { - TodoScreenUiState.Error( - isLiveSessionReady = false, - isLiveSessionRunning = false, - ) - } + liveSessionState.value = LiveSessionState.Error } - _uiState.update { - if (it is TodoScreenUiState.Success) { - it.copy(isLiveSessionReady = true) - } else { - it - } - } + liveSessionState.value = LiveSessionState.Ready } } From 32712ffc51e96fe1fc976eafea805d0a90041d7c Mon Sep 17 00:00:00 2001 From: lethargicpanda <205574+lethargicpanda@users.noreply.github.com> Date: Mon, 14 Jul 2025 10:32:37 +0000 Subject: [PATCH 19/19] =?UTF-8?q?=F0=9F=A4=96=20Apply=20Spotless=20formatt?= =?UTF-8?q?ing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ai/samples/geminilivetodo/ui/TodoScreenUiState.kt | 8 ++++---- .../ai/samples/geminilivetodo/ui/TodoScreenViewModel.kt | 7 ++----- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreenUiState.kt b/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreenUiState.kt index 6ec2116d..fc95618b 100644 --- a/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreenUiState.kt +++ b/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreenUiState.kt @@ -22,12 +22,12 @@ sealed interface TodoScreenUiState { data class Success( val todos: List = emptyList(), - val liveSessionState: LiveSessionState + val liveSessionState: LiveSessionState, ) : TodoScreenUiState data class Error( val todos: List = emptyList(), - val liveSessionState: LiveSessionState + val liveSessionState: LiveSessionState, ) : TodoScreenUiState } @@ -35,5 +35,5 @@ sealed interface LiveSessionState { data object NotReady : LiveSessionState data object Ready : LiveSessionState data object Running : LiveSessionState - data object Error: LiveSessionState -} \ No newline at end of file + data object Error : LiveSessionState +} diff --git a/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreenViewModel.kt b/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreenViewModel.kt index 6bc95ad2..1e0e83bd 100644 --- a/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreenViewModel.kt +++ b/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreenViewModel.kt @@ -45,11 +45,8 @@ import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive @@ -65,12 +62,12 @@ class TodoScreenViewModel @Inject constructor(private val todoRepository: TodoRe private val liveSessionState = MutableStateFlow(LiveSessionState.NotReady) private val todos = todoRepository.todos - val uiState: StateFlow = combine(liveSessionState, todos) { liveSessionState, todos -> + val uiState: StateFlow = combine(liveSessionState, todos) { liveSessionState, todos -> TodoScreenUiState.Success(todos, liveSessionState) }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5000L), - initialValue = TodoScreenUiState.Initial + initialValue = TodoScreenUiState.Initial, ) fun addTodo(taskDescription: String) {