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" }