Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,13 @@ import com.google.firebase.ai.OnDeviceModelStatus.Companion.DOWNLOADABLE
import com.google.firebase.ai.OnDeviceModelStatus.Companion.DOWNLOADING
import com.google.firebase.ai.OnDeviceModelStatus.Companion.UNAVAILABLE
import com.google.firebase.ai.type.ImagePart
import com.google.firebase.ai.type.InlineDataPart
import com.google.firebase.ai.type.PublicPreviewAPI
import com.google.firebase.ai.type.ResponseModality
import com.google.firebase.ai.type.SpeechConfig
import com.google.firebase.ai.type.Voice
import com.google.firebase.ai.type.content
import com.google.firebase.ai.type.generationConfig
import com.google.firebase.example.friendlymeals.data.schema.MealSchema
import com.google.firebase.example.friendlymeals.data.schema.RecipeSchema
import com.google.firebase.perf.performance
Expand Down Expand Up @@ -152,6 +157,39 @@ class AIRemoteDataSource @Inject constructor(
}
}

suspend fun craftRecipePairing(dishTitle: String, ingredients: List<String>): String {
val response = templateGenerativeModel.generateContent(
templateId = remoteConfig.getString(RECIPE_PAIRING_KEY),
inputs = mapOf(
RECIPE_TITLE_FIELD to dishTitle,
INGREDIENTS_FIELD to ingredients.joinToString()
)
)

return response.text.orEmpty().trim()
}

suspend fun generateSpeech(text: String?): ByteArray? {
if (text.isNullOrBlank()) return null

val model = aiModel.generativeModel(
modelName = remoteConfig.getString(TTS_MODEL_KEY),
generationConfig = generationConfig {
responseModalities = listOf(ResponseModality.AUDIO)
speechConfig = SpeechConfig(
voice = Voice(TTS_VOICE),
languageCode = TTS_LANGUAGE
)
}
)

val voiceProfile = remoteConfig.getString(VOICE_PROFILE_KEY)
val response = model.generateContent("$voiceProfile $text")
val part = response.candidates.firstOrNull()?.content?.parts?.firstOrNull()

return (part as? InlineDataPart)?.inlineData
}

suspend fun loadOnDeviceModel() {
when (hybridGenerativeModel.onDeviceExtension?.checkStatus()) {
UNAVAILABLE -> {
Expand Down Expand Up @@ -191,6 +229,9 @@ class AIRemoteDataSource @Inject constructor(
private const val FIND_STORES_KEY = "find_stores"
private const val HYBRID_CLOUD_MODEL_KEY = "hybrid_cloud_model"
private const val HYBRID_INGREDIENTS_PROMPT_KEY = "hybrid_ingredients_prompt"
private const val RECIPE_PAIRING_KEY = "recipe_pairing"
private const val VOICE_PROFILE_KEY = "recipe_pairing_voice_profile"
private const val TTS_MODEL_KEY = "tts_model"

//Template input fields
private const val IMAGE_DATA_FIELD = "imageData"
Expand All @@ -207,6 +248,10 @@ class AIRemoteDataSource @Inject constructor(
//Grounding with Maps config
private const val LANGUAGE = "en_US"

//TTS config
private const val TTS_VOICE = "Charon"
private const val TTS_LANGUAGE = "en-US"

//Class TAG
private const val TAG = "AIRemoteDataSource"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,14 @@ class AIRepository @Inject constructor(
return aiRemoteDataSource.scanMeal(imageData)
}

suspend fun craftRecipePairing(dishTitle: String, ingredients: List<String>): String {
return aiRemoteDataSource.craftRecipePairing(dishTitle, ingredients)
}

suspend fun generateSpeech(text: String?): ByteArray? {
return aiRemoteDataSource.generateSpeech(text)
}

suspend fun loadOnDeviceModel() {
aiRemoteDataSource.loadOnDeviceModel()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
Expand Down Expand Up @@ -82,7 +83,8 @@ fun RecipeScreen(
viewModel.addIngredientsToGroceryList(recipeViewState.value.recipe.ingredients) {
Toast.makeText(context, groceryListToast, Toast.LENGTH_SHORT).show()
}
}
},
onPairingClick = viewModel::onPairingClick
)
}

Expand All @@ -93,7 +95,8 @@ fun RecipeScreenContent(
leaveReview: (Int) -> Unit = {},
recipeViewState: RecipeViewState,
onLiveAssistantClick: () -> Unit = {},
onAddIngredientsToGrocery: () -> Unit = {}
onAddIngredientsToGrocery: () -> Unit = {},
onPairingClick: () -> Unit = {}
) {
val context = LocalContext.current
val multiplePermissionsLauncher = rememberLauncherForActivityResult(
Expand Down Expand Up @@ -238,6 +241,13 @@ fun RecipeScreenContent(
}
}

Spacer(modifier = Modifier.height(16.dp))

PairingGuideButton(
audioState = recipeViewState.audioState,
onClick = onPairingClick
)

Spacer(modifier = Modifier.height(24.dp))

Row(
Expand Down Expand Up @@ -461,6 +471,117 @@ fun IngredientRow(text: String) {
}
}

@Composable
fun PairingGuideButton(
audioState: RecipeAudioState,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
val containerColor = when (audioState) {
is RecipeAudioState.Playing, is RecipeAudioState.Paused -> Teal
is RecipeAudioState.LoadingAudio -> LightTeal
is RecipeAudioState.Error -> Color(0xFFFFEBEE)
is RecipeAudioState.Idle -> LightTeal
}

val contentColor = when (audioState) {
is RecipeAudioState.Playing, is RecipeAudioState.Paused -> Color.White
is RecipeAudioState.Error -> Color.Red
else -> Teal
}

Button(
onClick = onClick,
colors = ButtonDefaults.buttonColors(
containerColor = containerColor,
contentColor = contentColor
),
shape = RoundedCornerShape(16.dp),
modifier = modifier
.fillMaxWidth()
.height(52.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
when (audioState) {
is RecipeAudioState.LoadingAudio -> {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
color = Teal,
strokeWidth = 2.dp
)
Spacer(modifier = Modifier.width(10.dp))
Text(
text = stringResource(R.string.recipe_pairing_loading_audio),
fontSize = 15.sp,
fontWeight = FontWeight.Bold,
color = Teal
)
}

is RecipeAudioState.Playing -> {
Icon(
painter = painterResource(R.drawable.ic_pause),
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(10.dp))
Text(
text = stringResource(R.string.recipe_pairing_playing),
fontSize = 15.sp,
fontWeight = FontWeight.Bold,
color = Color.White
)
}

is RecipeAudioState.Paused -> {
Icon(
painter = painterResource(R.drawable.ic_play_arrow),
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(10.dp))
Text(
text = stringResource(R.string.recipe_pairing_paused),
fontSize = 15.sp,
fontWeight = FontWeight.Bold,
color = Color.White
)
}

is RecipeAudioState.Error -> {
Text(
text = stringResource(R.string.recipe_pairing_retry),
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = Color.Red
)
}

is RecipeAudioState.Idle -> {
Icon(
painter = painterResource(R.drawable.ic_drink),
contentDescription = null,
tint = Teal,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(10.dp))
Text(
text = stringResource(R.string.recipe_pairing_french_wine),
fontSize = 15.sp,
fontWeight = FontWeight.Bold,
color = Teal
)
}
}
}
}
}

@Preview
@Composable
fun RecipeScreenPreview() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@ package com.google.firebase.example.friendlymeals.ui.recipe
import androidx.lifecycle.SavedStateHandle
import androidx.navigation.toRoute
import com.google.firebase.example.friendlymeals.MainViewModel
import com.google.firebase.example.friendlymeals.data.model.Review
import com.google.firebase.example.friendlymeals.data.model.Like
import com.google.firebase.example.friendlymeals.data.model.Review
import com.google.firebase.example.friendlymeals.data.repository.AIRepository
import com.google.firebase.example.friendlymeals.data.repository.AuthRepository
import com.google.firebase.example.friendlymeals.data.repository.DatabaseRepository
import com.google.firebase.example.friendlymeals.ui.shared.AudioComponent
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
Expand All @@ -17,7 +19,8 @@ import javax.inject.Inject
class RecipeViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val authRepository: AuthRepository,
private val databaseRepository: DatabaseRepository
private val databaseRepository: DatabaseRepository,
private val aiRepository: AIRepository
) : MainViewModel() {
private val recipeRoute = savedStateHandle.toRoute<RecipeRoute>()
private val recipeId: String = recipeRoute.recipeId
Expand All @@ -28,21 +31,100 @@ class RecipeViewModel @Inject constructor(

val userId: String get() = authRepository.currentUser?.uid.orEmpty()

private val audioComponent = AudioComponent()
private var cachedAudioData: ByteArray? = null
private var cachedPairingText: String? = null

init {
loadRecipe()
}

fun loadRecipe() {
launchCatching {
val recipe = databaseRepository.getRecipe(recipeId)
_recipeViewState.value = RecipeViewState(
recipeId = recipeId,
recipe = databaseRepository.getRecipe(recipeId),
recipe = recipe,
favorite = loadFavorite(),
rating = loadRating()
rating = loadRating(),
audioState = RecipeAudioState.Idle
)
}
}

fun onPairingClick() {
when (_recipeViewState.value.audioState) {
is RecipeAudioState.Playing -> {
audioComponent.pause()
_recipeViewState.value = _recipeViewState.value.copy(
audioState = RecipeAudioState.Paused
)
}
is RecipeAudioState.Paused -> {
audioComponent.resume()
_recipeViewState.value = _recipeViewState.value.copy(
audioState = RecipeAudioState.Playing
)
}
is RecipeAudioState.LoadingAudio -> {
// Already loading audio, do nothing
}
is RecipeAudioState.Idle, is RecipeAudioState.Error -> {
if (cachedAudioData != null) {
playCachedAudio()
} else {
launchCatching { generatePairingAndAudio() }
}
}
}
}

private fun playCachedAudio() {
val audioData = cachedAudioData ?: return

_recipeViewState.value = _recipeViewState.value.copy(
audioState = RecipeAudioState.Playing

)
audioComponent.play(audioData) {
_recipeViewState.value = _recipeViewState.value.copy(
audioState = RecipeAudioState.Idle
)
}
}

private suspend fun generatePairingAndAudio() {
_recipeViewState.value = _recipeViewState.value.copy(
audioState = RecipeAudioState.LoadingAudio
)


if (cachedPairingText.isNullOrBlank()) {
val recipe = _recipeViewState.value.recipe
val generated = aiRepository.craftRecipePairing(recipe.title, recipe.ingredients)
cachedPairingText = generated
}

val audio = aiRepository.generateSpeech(cachedPairingText)

if (audio != null && audio.isNotEmpty()) {
cachedAudioData = audio
_recipeViewState.value = _recipeViewState.value.copy(
audioState = RecipeAudioState.Playing
)
audioComponent.play(audio) {
_recipeViewState.value = _recipeViewState.value.copy(
audioState = RecipeAudioState.Idle
)
}
} else {
_recipeViewState.value = _recipeViewState.value.copy(
audioState = RecipeAudioState.Error("Could not generate audio")
)
}

}

private suspend fun loadFavorite(): Boolean {
return databaseRepository.getFavorite(userId, recipeId)
}
Expand Down Expand Up @@ -94,4 +176,9 @@ class RecipeViewModel @Inject constructor(
onSuccess()
}
}

override fun onCleared() {
audioComponent.release()
super.onCleared()
}
}
Loading
Loading