diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 04a0a2a..4c4c9e3 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -70,6 +70,8 @@ dependencies { implementation(libs.firebase.storage) implementation(libs.firebase.firestore) implementation(libs.firebase.perf) + implementation(libs.play.services.location) + implementation(libs.compose.material.icons.core) //Library to handle Markdown in Compose implementation(libs.richtext.commonmark) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 4ab4840..7402536 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,6 +3,8 @@ xmlns:tools="http://schemas.android.com/tools"> + + { - GroceryListScreen() + GroceryListScreen( + showError = { + val message = this@MainActivity.getString(R.string.error_message) + scope.launch { snackbarHostState.showSnackbar(message) } + } + ) } } } 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 45e019c..d185cc9 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 @@ -20,18 +20,25 @@ import com.google.firebase.ai.type.PublicPreviewAPI import com.google.firebase.ai.type.content import com.google.firebase.example.friendlymeals.data.schema.MealSchema import com.google.firebase.example.friendlymeals.data.schema.RecipeSchema +import com.google.firebase.example.friendlymeals.data.schema.StoreLocalizerResult import com.google.firebase.perf.performance import com.google.firebase.perf.trace import com.google.firebase.remoteconfig.FirebaseRemoteConfig import kotlinx.serialization.json.Json import javax.inject.Inject +import com.google.firebase.ai.type.LatLng +import com.google.firebase.ai.type.Tool +import com.google.firebase.ai.type.ToolConfig +import com.google.firebase.ai.type.retrievalConfig +import com.google.firebase.example.friendlymeals.data.schema.StoreSchema @OptIn(PublicPreviewAPI::class) class AIRemoteDataSource @Inject constructor( - aiModel: FirebaseAI, + private val aiModel: FirebaseAI, private val remoteConfig: FirebaseRemoteConfig ) { private val json = Json { ignoreUnknownKeys = true } + private val hybridGenerativeModel = aiModel.generativeModel( modelName = remoteConfig.getString(HYBRID_CLOUD_MODEL_KEY), onDeviceConfig = OnDeviceConfig(mode = InferenceMode.PREFER_IN_CLOUD) @@ -39,6 +46,45 @@ class AIRemoteDataSource @Inject constructor( private val templateGenerativeModel = aiModel.templateGenerativeModel() + suspend fun localizeIngredients( + ingredients: List, + latitude: Double, + longitude: Double, + currentTime: String, + dayOfWeek: String + ): List { + val groundingModel = aiModel.generativeModel( + modelName = remoteConfig.getString(GROUNDING_MODEL_KEY), + tools = listOf(Tool.googleMaps()), + toolConfig = ToolConfig( + retrievalConfig = retrievalConfig { + latLng = LatLng(latitude = latitude, longitude = longitude) + languageCode = LANGUAGE + } + ) + ) + + val groundingPrompt = remoteConfig.getString(GROUNDING_PROMPT_KEY) + .replace("{{ingredients}}", ingredients.joinToString(", ")) + .replace("{{dayOfWeek}}", dayOfWeek) + .replace("{{currentTime}}", currentTime) + + return try { + val response = groundingModel.generateContent(groundingPrompt) + val rawText = response.text ?: return emptyList() + + val cleanJson = rawText + .replace("```json", "") + .replace("```", "") + .trim() + + json.decodeFromString(cleanJson).stores + } catch (e: Exception) { + Log.e(TAG, "Error localizing ingredients", e) + emptyList() + } + } + suspend fun generateIngredients(image: Bitmap): String { // Adding a Performance Monitoring trace is completely optional. Traces can help you // measure how long it takes to generate ingredients on device and in cloud. @@ -142,6 +188,8 @@ class AIRemoteDataSource @Inject constructor( private const val SCAN_MEAL_KEY = "scan_meal" private const val HYBRID_CLOUD_MODEL_KEY = "hybrid_cloud_model" private const val HYBRID_INGREDIENTS_PROMPT_KEY = "hybrid_ingredients_prompt" + private const val GROUNDING_MODEL_KEY = "grounding_model" + private const val GROUNDING_PROMPT_KEY = "grounding_prompt" //Template input fields private const val IMAGE_DATA_FIELD = "imageData" @@ -153,6 +201,9 @@ class AIRemoteDataSource @Inject constructor( //Template input values private const val MIME_TYPE_VALUE = "image/jpeg" + //Grounding with Maps config + private const val 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 5a1a37e..a19f6dc 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 @@ -4,6 +4,7 @@ import android.graphics.Bitmap import com.google.firebase.example.friendlymeals.data.datasource.AIRemoteDataSource import com.google.firebase.example.friendlymeals.data.schema.MealSchema import com.google.firebase.example.friendlymeals.data.schema.RecipeSchema +import com.google.firebase.example.friendlymeals.data.schema.StoreSchema import javax.inject.Inject class AIRepository @Inject constructor( @@ -13,6 +14,22 @@ class AIRepository @Inject constructor( return aiRemoteDataSource.generateIngredients(image) } + suspend fun localizeIngredients( + ingredients: List, + latitude: Double, + longitude: Double, + currentTime: String, + dayOfWeek: String + ): List { + return aiRemoteDataSource.localizeIngredients( + ingredients, + latitude, + longitude, + currentTime, + dayOfWeek + ) + } + suspend fun generateRecipe(ingredients: String, notes: String): RecipeSchema? { return aiRemoteDataSource.generateRecipe(ingredients, notes) } diff --git a/app/src/main/java/com/google/firebase/example/friendlymeals/data/schema/StoreSchema.kt b/app/src/main/java/com/google/firebase/example/friendlymeals/data/schema/StoreSchema.kt new file mode 100644 index 0000000..c057384 --- /dev/null +++ b/app/src/main/java/com/google/firebase/example/friendlymeals/data/schema/StoreSchema.kt @@ -0,0 +1,20 @@ +package com.google.firebase.example.friendlymeals.data.schema + +import kotlinx.serialization.Serializable + +@Serializable +data class StoreSchema( + val name: String = "", + val address: String = "", + val distance: String = "", + val openNow: Boolean = false, + val closingSoon: Boolean = false, + val hasParking: Boolean = false, + val parkingDetails: String = "", + val mapUrl: String = "" +) + +@Serializable +data class StoreLocalizerResult( + val stores: List = emptyList() +) diff --git a/app/src/main/java/com/google/firebase/example/friendlymeals/ui/groceryList/GroceryListScreen.kt b/app/src/main/java/com/google/firebase/example/friendlymeals/ui/groceryList/GroceryListScreen.kt index fd56772..1f8c394 100644 --- a/app/src/main/java/com/google/firebase/example/friendlymeals/ui/groceryList/GroceryListScreen.kt +++ b/app/src/main/java/com/google/firebase/example/friendlymeals/ui/groceryList/GroceryListScreen.kt @@ -1,5 +1,12 @@ package com.google.firebase.example.friendlymeals.ui.groceryList +import android.Manifest.permission.ACCESS_COARSE_LOCATION +import android.Manifest.permission.ACCESS_FINE_LOCATION +import android.annotation.SuppressLint +import android.content.Context +import android.content.pm.PackageManager +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -20,16 +27,22 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Place import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Scaffold import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -39,6 +52,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight @@ -47,10 +62,16 @@ import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.core.content.ContextCompat import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.google.android.gms.location.FusedLocationProviderClient +import com.google.android.gms.location.LocationServices +import com.google.android.gms.location.Priority +import com.google.android.gms.tasks.CancellationTokenSource import com.google.firebase.example.friendlymeals.R import com.google.firebase.example.friendlymeals.data.model.GroceryItem +import com.google.firebase.example.friendlymeals.data.schema.StoreSchema import com.google.firebase.example.friendlymeals.ui.theme.BorderColor import com.google.firebase.example.friendlymeals.ui.theme.FriendlyMealsTheme import com.google.firebase.example.friendlymeals.ui.theme.LightTeal @@ -63,26 +84,75 @@ object GroceryListRoute @Composable fun GroceryListScreen( - viewModel: GroceryListViewModel = hiltViewModel() + viewModel: GroceryListViewModel = hiltViewModel(), + showError: () -> Unit ) { val groceries = viewModel.groceries.collectAsStateWithLifecycle() + val uiState = viewModel.uiState.collectAsStateWithLifecycle() GroceryListScreenContent( groceries = groceries.value, + uiState = uiState.value, + showError = showError, onAddItem = viewModel::addItem, onToggleItem = viewModel::toggleItem, - onDeleteItem = viewModel::deleteItem + onDeleteItem = viewModel::deleteItem, + onLocalize = viewModel::localizeGroceryList, + onResetLocalizer = viewModel::resetLocalizer ) } @Composable fun GroceryListScreenContent( groceries: List, + uiState: StoreLocalizerUiState = StoreLocalizerUiState.Idle, + showError: () -> Unit = {}, onAddItem: (String) -> Unit = {}, onToggleItem: (GroceryItem) -> Unit = {}, - onDeleteItem: (GroceryItem) -> Unit = {} + onDeleteItem: (GroceryItem) -> Unit = {}, + onLocalize: (Double, Double) -> Unit = { _, _ -> }, + onResetLocalizer: () -> Unit = {} ) { var inputText by remember { mutableStateOf("") } + val context = LocalContext.current + val fusedLocationClient = remember { LocationServices.getFusedLocationProviderClient(context) } + var showBottomSheet by remember { mutableStateOf(false) } + + val permissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestMultiplePermissions() + ) { permissions -> + val fineLocationGranted = permissions[ACCESS_FINE_LOCATION] ?: false + val coarseLocationGranted = permissions[ACCESS_COARSE_LOCATION] ?: false + if (fineLocationGranted || coarseLocationGranted) { + showBottomSheet = true + getCurrentLocation(fusedLocationClient, + onSuccess = { lat, lng -> + onLocalize(lat, lng) + }, + onFailure = { + showError() + } + ) + } + } + + fun startLocalizer() { + if (hasLocationPermission(context)) { + showBottomSheet = true + getCurrentLocation(fusedLocationClient, + onSuccess = { lat, lng -> + onLocalize(lat, lng) + }, + onFailure = { + showError() + } + ) + } else { + permissionLauncher.launch( + arrayOf(ACCESS_FINE_LOCATION, ACCESS_COARSE_LOCATION) + ) + } + } fun handleAdd() { if (inputText.isNotBlank()) { @@ -104,6 +174,20 @@ fun GroceryListScreenContent( fontSize = 28.sp, fontWeight = FontWeight.Bold ) + + Spacer(modifier = Modifier.weight(1f)) + + IconButton( + onClick = { startLocalizer() }, + modifier = Modifier.size(48.dp) + ) { + Icon( + imageVector = Icons.Default.Place, + contentDescription = stringResource(R.string.store_localizer_button_content_description), + tint = Teal, + modifier = Modifier.size(28.dp) + ) + } } } ) { innerPadding -> @@ -182,6 +266,25 @@ fun GroceryListScreenContent( } } } + if (showBottomSheet) { + StoreLocalizerBottomSheet( + uiState = uiState, + onDismiss = { + showBottomSheet = false + onResetLocalizer() + }, + onRetry = { + getCurrentLocation(fusedLocationClient, + onSuccess = { lat, lng -> + onLocalize(lat, lng) + }, + onFailure = { + showError() + } + ) + } + ) + } } } } @@ -261,6 +364,333 @@ fun GroceryCard( } } +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun StoreLocalizerBottomSheet( + uiState: StoreLocalizerUiState, + onDismiss: () -> Unit, + onRetry: () -> Unit +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + shape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + .padding(bottom = 32.dp) + ) { + Text( + text = stringResource(R.string.store_localizer_title), + fontSize = 22.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 8.dp) + ) + Text( + text = stringResource(R.string.store_localizer_subtitle), + fontSize = 14.sp, + modifier = Modifier.padding(bottom = 20.dp) + ) + + when (uiState) { + is StoreLocalizerUiState.Idle -> { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 40.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + CircularProgressIndicator( + color = Teal, + modifier = Modifier.size(48.dp) + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = stringResource(R.string.store_localizer_determining_location), + fontSize = 16.sp, + fontWeight = FontWeight.Medium + ) + } + } + is StoreLocalizerUiState.Loading -> { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 40.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + CircularProgressIndicator( + color = Teal, + modifier = Modifier.size(48.dp) + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = stringResource(R.string.store_localizer_locating_stores), + fontSize = 16.sp, + fontWeight = FontWeight.Medium + ) + } + } + is StoreLocalizerUiState.Error -> { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = uiState.message, + fontSize = 16.sp, + color = Color.Red, + fontWeight = FontWeight.Medium + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Button( + onClick = onRetry, + colors = ButtonDefaults.buttonColors(containerColor = Teal) + ) { + Text(stringResource(R.string.store_localizer_retry)) + } + } + } + is StoreLocalizerUiState.Success -> { + if (uiState.stores.isEmpty()) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 40.dp), + contentAlignment = Alignment.Center + ) { + Text(stringResource(R.string.store_localizer_no_stores)) + } + } else { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.fillMaxWidth() + ) { + items(uiState.stores) { store -> + StoreCard(store = store) + } + } + } + } + } + } + } +} + +@Composable +fun StoreCard(store: StoreSchema) { + val uriHandler = LocalUriHandler.current + + Card( + shape = RoundedCornerShape(20.dp), + colors = CardDefaults.cardColors(containerColor = Color.White), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = store.mapUrl.isNotBlank()) { + uriHandler.openUri(store.mapUrl) + } + ) { + Column( + modifier = Modifier + .padding(18.dp) + .fillMaxWidth() + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = store.name, + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + color = TextColor + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = store.address, + fontSize = 14.sp, + color = Color.Gray + ) + } + + Spacer(modifier = Modifier.width(8.dp)) + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .background(Color(0xFFE8F5E9), RoundedCornerShape(8.dp)) + .padding(horizontal = 8.dp, vertical = 4.dp) + ) { + Icon( + imageVector = Icons.Default.Place, + contentDescription = "Place icon", + tint = Color(0xFF2E7D32), + modifier = Modifier.size(14.dp) + ) + + Spacer(modifier = Modifier.width(4.dp)) + + Text( + text = store.distance, + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + color = Color(0xFF2E7D32) + ) + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (store.openNow) { + Row( + modifier = Modifier + .background(Color(0xFFE8F5E9), RoundedCornerShape(8.dp)) + .padding(horizontal = 10.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(8.dp) + .background(Color(0xFF4CAF50), CircleShape) + ) + + Spacer(modifier = Modifier.width(6.dp)) + + Text( + text = stringResource(R.string.store_localizer_open_now), + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + color = Color(0xFF2E7D32) + ) + } + } else { + Row( + modifier = Modifier + .background(Color(0xFFFFEBEE), RoundedCornerShape(8.dp)) + .padding(horizontal = 10.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(8.dp) + .background(Color(0xFFE57373), CircleShape) + ) + + Spacer(modifier = Modifier.width(6.dp)) + + Text( + text = stringResource(R.string.store_localizer_closed), + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + color = Color(0xFFC62828) + ) + } + } + + if (store.closingSoon) { + Row( + modifier = Modifier + .background(Color(0xFFFFF3E0), RoundedCornerShape(8.dp)) + .padding(horizontal = 10.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(8.dp) + .background(Color(0xFFFF9800), CircleShape) + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = stringResource(R.string.store_localizer_closing_soon), + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + color = Color(0xFFEF6C00) + ) + } + } + + val parkingColor = if (store.hasParking) Color(0xFFE3F2FD) else Color(0xFFECEFF1) + val parkingTextColor = if (store.hasParking) Color(0xFF1565C0) else Color(0xFF455A64) + val text = if (store.hasParking) R.string.store_localizer_parking else R.string.store_localizer_no_parking + + Text( + text = stringResource(text), + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + color = parkingTextColor, + modifier = Modifier + .background(parkingColor, RoundedCornerShape(8.dp)) + .padding(horizontal = 10.dp, vertical = 6.dp) + ) + } + + if (store.parkingDetails.isNotBlank()) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = store.parkingDetails, + fontSize = 12.sp, + color = Color.Gray, + lineHeight = 16.sp + ) + } + } + } +} + +fun hasLocationPermission(context: Context): Boolean { + return ContextCompat.checkSelfPermission( + context, + ACCESS_FINE_LOCATION + ) == PackageManager.PERMISSION_GRANTED || ContextCompat.checkSelfPermission( + context, + ACCESS_COARSE_LOCATION + ) == PackageManager.PERMISSION_GRANTED +} + +@SuppressLint("MissingPermission") +fun getCurrentLocation( + fusedLocationClient: FusedLocationProviderClient, + onSuccess: (Double, Double) -> Unit, + onFailure: () -> Unit +) { + val priority = Priority.PRIORITY_HIGH_ACCURACY + val cancellationTokenSource = CancellationTokenSource() + + fusedLocationClient.getCurrentLocation(priority, cancellationTokenSource.token) + .addOnSuccessListener { location -> + if (location != null) { + onSuccess(location.latitude, location.longitude) + } else { + onFailure() + } + } + .addOnFailureListener { + onFailure() + } +} + @Preview @Composable fun GroceryListScreenPreview() { diff --git a/app/src/main/java/com/google/firebase/example/friendlymeals/ui/groceryList/GroceryListViewModel.kt b/app/src/main/java/com/google/firebase/example/friendlymeals/ui/groceryList/GroceryListViewModel.kt index 48f7d9f..c60eb5d 100644 --- a/app/src/main/java/com/google/firebase/example/friendlymeals/ui/groceryList/GroceryListViewModel.kt +++ b/app/src/main/java/com/google/firebase/example/friendlymeals/ui/groceryList/GroceryListViewModel.kt @@ -4,21 +4,29 @@ import com.google.firebase.example.friendlymeals.MainViewModel import com.google.firebase.example.friendlymeals.data.model.GroceryItem import com.google.firebase.example.friendlymeals.data.repository.AuthRepository import com.google.firebase.example.friendlymeals.data.repository.DatabaseRepository +import com.google.firebase.example.friendlymeals.data.repository.AIRepository 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.catch +import java.time.LocalDateTime +import java.time.format.TextStyle +import java.util.Locale import javax.inject.Inject @HiltViewModel class GroceryListViewModel @Inject constructor( private val authRepository: AuthRepository, - private val databaseRepository: DatabaseRepository + private val databaseRepository: DatabaseRepository, + private val aiRepository: AIRepository ) : MainViewModel() { private val _groceries = MutableStateFlow>(emptyList()) val groceries: StateFlow> = _groceries.asStateFlow() + private val _uiState = MutableStateFlow(StoreLocalizerUiState.Idle) + val uiState: StateFlow = _uiState.asStateFlow() + val userId: String get() = authRepository.currentUser?.uid.orEmpty() init { @@ -61,4 +69,46 @@ class GroceryListViewModel @Inject constructor( databaseRepository.deleteGroceryItem(item.id) } } + + fun resetLocalizer() { + _uiState.value = StoreLocalizerUiState.Idle + } + + fun localizeGroceryList(latitude: Double, longitude: Double) { + val uncheckedIngredients = _groceries.value + .filter { !it.checked } + .map { it.name } + + if (uncheckedIngredients.isEmpty()) { + _uiState.value = StoreLocalizerUiState.Error(EMPTY_ITEMS_ERROR) + return + } + + _uiState.value = StoreLocalizerUiState.Loading + + launchCatching { + val now = LocalDateTime.now() + val dayOfWeek = now.dayOfWeek.getDisplayName(TextStyle.FULL, Locale.US) + val currentTime = String.format(Locale.US, "%02d:%02d", now.hour, now.minute) + + val stores = aiRepository.localizeIngredients( + ingredients = uncheckedIngredients, + latitude = latitude, + longitude = longitude, + currentTime = currentTime, + dayOfWeek = dayOfWeek + ) + + if (stores.isEmpty()) { + _uiState.value = StoreLocalizerUiState.Error(EMPTY_STORE_ERROR) + } else { + _uiState.value = StoreLocalizerUiState.Success(stores) + } + } + } + + companion object { + private const val EMPTY_ITEMS_ERROR = "Your grocery list is empty or all items are checked." + private const val EMPTY_STORE_ERROR = "Failed to fetch local stores." + } } diff --git a/app/src/main/java/com/google/firebase/example/friendlymeals/ui/groceryList/StoreLocalizerUiState.kt b/app/src/main/java/com/google/firebase/example/friendlymeals/ui/groceryList/StoreLocalizerUiState.kt new file mode 100644 index 0000000..e6cd463 --- /dev/null +++ b/app/src/main/java/com/google/firebase/example/friendlymeals/ui/groceryList/StoreLocalizerUiState.kt @@ -0,0 +1,10 @@ +package com.google.firebase.example.friendlymeals.ui.groceryList + +import com.google.firebase.example.friendlymeals.data.schema.StoreSchema + +sealed interface StoreLocalizerUiState { + object Idle : StoreLocalizerUiState + object Loading : StoreLocalizerUiState + data class Success(val stores: List) : StoreLocalizerUiState + data class Error(val message: String) : StoreLocalizerUiState +} \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c9ca124..2f9ab68 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -64,4 +64,16 @@ Add ingredients to Grocery List Added to Grocery List! Your grocery list is empty + Ingredient-to-Store Localizer + Find stores nearby with the ingredients you need. + Determining your location… + Locating nearest stores with Google Maps… + No stores found matching your ingredients nearby. + Open Now + Closed + Closing Soon + Retry + 🅿️ Parking Available + No Parking + Store Localizer \ 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 91c3b0f..e4d0920 100644 --- a/app/src/main/res/xml/remote_config_defaults.xml +++ b/app/src/main/res/xml/remote_config_defaults.xml @@ -1,4 +1,12 @@ + + grounding_model + gemini-3.1-flash-lite + + + grounding_prompt + What are the nearest grocery stores or markets near me that stock these ingredients: {{ingredients}}? For each place, tell me their business hours, if it's open right now on {{dayOfWeek}} at {{currentTime}}, and if it's closing in less than 30 minutes. Tell me what the parking situation is like at each place: is there a dedicated lot or should I look for street parking? Tell the Map URL so I can open it in Google Maps. Format your response strictly as a JSON object with a "stores" array containing store objects. Each store object must have these EXACT keys: "name": string, "address": string, "distance": string, "openNow": boolean, "closingSoon": boolean, "hasParking": boolean, "parkingDetails": string, "mapUrl": string. Do NOT include markdown code block formatting (like ```json). Output only the raw JSON string. + hybrid_cloud_model gemini-3.1-flash-lite diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7a3479a..42b219d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -25,6 +25,7 @@ kotlinxSerializationJson = "1.11.0" richtextCommonmark = "1.0.0-alpha04" firebasePerf = "2.0.2" cameraView = "1.6.1" +playServicesLocation = "21.3.0" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -63,6 +64,8 @@ androidx-camera-core = { group = "androidx.camera", name = "camera-core", versio androidx-camera-camera2 = { group = "androidx.camera", name = "camera-camera2", version.ref = "cameraView" } androidx-camera-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "cameraView" } androidx-camera-view = { group = "androidx.camera", name = "camera-view", version.ref = "cameraView" } +play-services-location = { group = "com.google.android.gms", name = "play-services-location", version.ref = "playServicesLocation" } +compose-material-icons-core = { module = "androidx.compose.material:material-icons-core" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }