diff --git a/app/src/main/java/com/google/firebase/example/friendlymeals/data/datasource/AIRemoteDataSource.kt b/app/src/main/java/com/google/firebase/example/friendlymeals/data/datasource/AIRemoteDataSource.kt index 11d99b8..1bb3a09 100644 --- a/app/src/main/java/com/google/firebase/example/friendlymeals/data/datasource/AIRemoteDataSource.kt +++ b/app/src/main/java/com/google/firebase/example/friendlymeals/data/datasource/AIRemoteDataSource.kt @@ -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 @@ -152,6 +157,39 @@ class AIRemoteDataSource @Inject constructor( } } + suspend fun craftRecipePairing(dishTitle: String, ingredients: List): 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 -> { @@ -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" @@ -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" } diff --git a/app/src/main/java/com/google/firebase/example/friendlymeals/data/repository/AIRepository.kt b/app/src/main/java/com/google/firebase/example/friendlymeals/data/repository/AIRepository.kt index 2d945b4..3f574d7 100644 --- a/app/src/main/java/com/google/firebase/example/friendlymeals/data/repository/AIRepository.kt +++ b/app/src/main/java/com/google/firebase/example/friendlymeals/data/repository/AIRepository.kt @@ -42,6 +42,14 @@ class AIRepository @Inject constructor( return aiRemoteDataSource.scanMeal(imageData) } + suspend fun craftRecipePairing(dishTitle: String, ingredients: List): String { + return aiRemoteDataSource.craftRecipePairing(dishTitle, ingredients) + } + + suspend fun generateSpeech(text: String?): ByteArray? { + return aiRemoteDataSource.generateSpeech(text) + } + suspend fun loadOnDeviceModel() { aiRemoteDataSource.loadOnDeviceModel() } diff --git a/app/src/main/java/com/google/firebase/example/friendlymeals/ui/recipe/RecipeScreen.kt b/app/src/main/java/com/google/firebase/example/friendlymeals/ui/recipe/RecipeScreen.kt index f547bca..949c572 100644 --- a/app/src/main/java/com/google/firebase/example/friendlymeals/ui/recipe/RecipeScreen.kt +++ b/app/src/main/java/com/google/firebase/example/friendlymeals/ui/recipe/RecipeScreen.kt @@ -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 @@ -82,7 +83,8 @@ fun RecipeScreen( viewModel.addIngredientsToGroceryList(recipeViewState.value.recipe.ingredients) { Toast.makeText(context, groceryListToast, Toast.LENGTH_SHORT).show() } - } + }, + onPairingClick = viewModel::onPairingClick ) } @@ -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( @@ -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( @@ -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() { diff --git a/app/src/main/java/com/google/firebase/example/friendlymeals/ui/recipe/RecipeViewModel.kt b/app/src/main/java/com/google/firebase/example/friendlymeals/ui/recipe/RecipeViewModel.kt index 17d5d4d..c4b8cc9 100644 --- a/app/src/main/java/com/google/firebase/example/friendlymeals/ui/recipe/RecipeViewModel.kt +++ b/app/src/main/java/com/google/firebase/example/friendlymeals/ui/recipe/RecipeViewModel.kt @@ -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 @@ -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() private val recipeId: String = recipeRoute.recipeId @@ -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) } @@ -94,4 +176,9 @@ class RecipeViewModel @Inject constructor( onSuccess() } } + + override fun onCleared() { + audioComponent.release() + super.onCleared() + } } \ No newline at end of file diff --git a/app/src/main/java/com/google/firebase/example/friendlymeals/ui/recipe/RecipeViewState.kt b/app/src/main/java/com/google/firebase/example/friendlymeals/ui/recipe/RecipeViewState.kt index 1e8aee9..dcaa2e6 100644 --- a/app/src/main/java/com/google/firebase/example/friendlymeals/ui/recipe/RecipeViewState.kt +++ b/app/src/main/java/com/google/firebase/example/friendlymeals/ui/recipe/RecipeViewState.kt @@ -2,9 +2,18 @@ package com.google.firebase.example.friendlymeals.ui.recipe import com.google.firebase.example.friendlymeals.data.model.Recipe +sealed interface RecipeAudioState { + object Idle : RecipeAudioState + object LoadingAudio : RecipeAudioState + object Playing : RecipeAudioState + object Paused : RecipeAudioState + data class Error(val message: String) : RecipeAudioState +} + data class RecipeViewState( val recipeId: String = "", val recipe: Recipe = Recipe(), val favorite: Boolean = false, - val rating: Int = 0 + val rating: Int = 0, + val audioState: RecipeAudioState = RecipeAudioState.Idle ) \ No newline at end of file diff --git a/app/src/main/java/com/google/firebase/example/friendlymeals/ui/shared/AudioComponent.kt b/app/src/main/java/com/google/firebase/example/friendlymeals/ui/shared/AudioComponent.kt new file mode 100644 index 0000000..800132a --- /dev/null +++ b/app/src/main/java/com/google/firebase/example/friendlymeals/ui/shared/AudioComponent.kt @@ -0,0 +1,115 @@ +package com.google.firebase.example.friendlymeals.ui.shared + +import android.media.AudioAttributes +import android.media.AudioFormat +import android.media.AudioTrack +import android.os.Handler +import android.os.Looper +import android.util.Log + +class AudioComponent { + private var audioTrack: AudioTrack? = null + private val mainHandler = Handler(Looper.getMainLooper()) + private var isPaused = false + + fun play(data: ByteArray, onCompletion: () -> Unit) { + stop() + + if (data.isEmpty()) { + onCompletion() + return + } + + try { + val sampleRate = 24000 + val channelConfig = AudioFormat.CHANNEL_OUT_MONO + val audioFormat = AudioFormat.ENCODING_PCM_16BIT + val frameSize = 2 + val frameCount = data.size / frameSize + + val track = AudioTrack.Builder() + .setAudioAttributes( + AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_MEDIA) + .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) + .build() + ) + .setAudioFormat( + AudioFormat.Builder() + .setEncoding(audioFormat) + .setSampleRate(sampleRate) + .setChannelMask(channelConfig) + .build() + ) + .setBufferSizeInBytes(data.size) + .setTransferMode(AudioTrack.MODE_STATIC) + .build() + + track.setPlaybackPositionUpdateListener(object : AudioTrack.OnPlaybackPositionUpdateListener { + override fun onMarkerReached(track: AudioTrack?) { + mainHandler.post { + onCompletion() + } + } + + override fun onPeriodicNotification(track: AudioTrack?) {} + }, mainHandler) + + track.notificationMarkerPosition = frameCount + + track.write(data, 0, data.size) + track.play() + audioTrack = track + isPaused = false + } catch (e: Exception) { + Log.e(TAG, "Failed to initialize or play AudioTrack", e) + onCompletion() + } + } + + fun pause() { + try { + if (audioTrack?.playState == AudioTrack.PLAYSTATE_PLAYING) { + audioTrack?.pause() + isPaused = true + } + } catch (e: Exception) { + Log.e(TAG, "Error pausing AudioTrack", e) + } + } + + fun resume() { + try { + if (audioTrack?.playState == AudioTrack.PLAYSTATE_PAUSED) { + audioTrack?.play() + isPaused = false + } + } catch (e: Exception) { + Log.e(TAG, "Error resuming AudioTrack", e) + } + } + + fun stop() { + try { + audioTrack?.let { + if (it.playState != AudioTrack.PLAYSTATE_STOPPED) { + it.stop() + } + it.release() + } + } catch (e: Exception) { + Log.e(TAG, "Error stopping AudioTrack", e) + } finally { + audioTrack = null + isPaused = false + } + } + + fun release() { + stop() + } + + companion object { + private const val TAG = "AudioComponent" + } +} \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_drink.xml b/app/src/main/res/drawable/ic_drink.xml new file mode 100644 index 0000000..1a3680f --- /dev/null +++ b/app/src/main/res/drawable/ic_drink.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_pause.xml b/app/src/main/res/drawable/ic_pause.xml new file mode 100644 index 0000000..de759cf --- /dev/null +++ b/app/src/main/res/drawable/ic_pause.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_play_arrow.xml b/app/src/main/res/drawable/ic_play_arrow.xml new file mode 100644 index 0000000..370d19a --- /dev/null +++ b/app/src/main/res/drawable/ic_play_arrow.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index de67148..2758621 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -76,4 +76,9 @@ 🅿️ Parking Available No Parking Store Finder + Pair this dish with French wine + Loading audio… + Playing pairing guide • Tap to pause + Paused • Tap to resume + Error playing audio • Tap to retry \ No newline at end of file diff --git a/app/src/main/res/xml/remote_config_defaults.xml b/app/src/main/res/xml/remote_config_defaults.xml index 4673261..91176ae 100644 --- a/app/src/main/res/xml/remote_config_defaults.xml +++ b/app/src/main/res/xml/remote_config_defaults.xml @@ -3,6 +3,18 @@ find_stores find-stores-template-v1-0-0 + + recipe_pairing + recipe-pairing-v1-0-0 + + + recipe_pairing_voice_profile + [Audio Profile: A French chef with a charming French accent][Scene: An elegant Parisian restaurant during a masterclass on food and wine pairing][Director's Notes: Speak as if you're a French chef who is an expert in pairing dishes with wines. Speak with warm enthusiasm and culinary sophistication] + + + tts_model + gemini-3.1-flash-tts-preview + hybrid_cloud_model gemini-3.1-flash-lite