diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index aabdf99..8bff115 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -82,7 +82,7 @@ jobs: id: rename - name: Create Release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v3 with: tag_name: ${{ steps.version.outputs.release_tag }} name: > diff --git a/README.md b/README.md index aebbf4f..8a85f6d 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,19 @@ # CompareApp -An Android app that allows users to compare ride-sharing services (Uber and Bolt) side-by-side in split screen mode. +An Android app that lets users compare 2 providers of a service at a time - ride-sharing (Uber and Bolt) or food delivery (pick any 2 of Uber Eats, Bolt Food, and Glovo) - side-by-side in split screen mode. ## Overview -CompareApp simplifies the process of comparing ride prices between Uber and Bolt by automatically opening both apps side-by-side with your pickup and dropoff locations pre-filled. This allows you to make quick, informed decisions about which service offers the best value for your journey. +CompareApp simplifies comparing prices between two apps at once by opening both side-by-side in split screen with your search already filled in - locations for rides, or a restaurant/dish for food delivery. A snackbar reminds you to swipe the middle divider up or down to pick the cheaper one. Which food delivery apps are compared is configurable from Settings. ## Features - **Modern UI**: Built with Jetpack Compose and Material3 design system -- **Split Screen Mode**: Automatically opens Uber and Bolt apps side-by-side +- **Split Screen Mode**: Automatically opens two apps side-by-side, with a hint on how to swipe to the one you want +- **Ride Comparison**: Compares Uber and Bolt for a given pickup/dropoff +- **Food Delivery Comparison**: Compares any 2 of Uber Eats, Bolt Food, and Glovo for a restaurant or dish search, configurable from Settings - **Smart Geocoding**: Converts text addresses to coordinates for accurate location matching -- **Deep Linking**: Seamlessly integrates with Uber and Bolt apps using their deep link APIs +- **Deep Linking**: Seamlessly integrates with each provider's app using their deep link APIs - **Incoming Location Links**: Share a location from Maps ("Open with" a `geo:` link) to prepopulate the dropoff field, using your current location as pickup - **Location Services**: Supports current location detection with Google Play Services - **Offline Fallback**: Gracefully handles geocoding failures with text-based fallbacks @@ -22,8 +24,8 @@ CompareApp simplifies the process of comparing ride prices between Uber and Bolt ### Prerequisites - Android device or emulator running Android 7.0 (API 24) or higher -- Uber app installed (for Uber comparison) -- Bolt app installed (for Bolt comparison) +- Uber and Bolt apps installed (for ride comparison) +- 2 of Uber Eats, Bolt Food, and Glovo installed (for food delivery comparison) - Android Studio Hedgehog (2023.1.1) or later (for development) ### Installation @@ -45,12 +47,19 @@ CompareApp simplifies the process of comparing ride prices between Uber and Bolt ### How to Use +**Rides:** 1. Launch the CompareApp 2. Enter your **pickup location** (e.g., "Times Square, New York") 3. Enter your **dropoff location** (e.g., "Central Park, New York") 4. Tap the **Compare** button 5. Both Uber and Bolt apps will open in split screen mode with your locations pre-filled -6. Compare prices and features to choose the best option +6. Swipe the middle divider up or down to bring the cheaper one to full screen + +**Food delivery:** +1. Enter a restaurant name or a dish to search for +2. Tap **Search Food** +3. Your 2 selected food delivery apps (configurable under Settings > Comparison configuration) open in split screen with the search pre-filled where supported +4. Swipe the middle divider up or down to bring the cheaper one to full screen ## High-Level Architecture @@ -154,8 +163,8 @@ For signed releases and Play Store deployment, see [docs/DEPLOYMENT.md](docs/DEP - Android device with API 24+ (Android 7.0 or higher) - Split screen support (available on Android 7.0+) -- Uber app installed from Play Store -- Bolt app installed from Play Store +- Uber and Bolt apps installed from Play Store (for ride comparison) +- The 2 food delivery apps selected in Settings installed from Play Store (for food delivery comparison) - Internet connection (for geocoding) - Location permissions (for current location feature) diff --git a/app/src/main/java/org/neteinstein/compareapp/MainActivity.kt b/app/src/main/java/org/neteinstein/compareapp/MainActivity.kt index 7c9eb56..a608e7a 100644 --- a/app/src/main/java/org/neteinstein/compareapp/MainActivity.kt +++ b/app/src/main/java/org/neteinstein/compareapp/MainActivity.kt @@ -9,8 +9,14 @@ import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.compose.BackHandler import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Surface import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -35,8 +41,13 @@ class MainActivity : ComponentActivity() { companion object { // Delay to ensure split screen mode is ready before launching second app private const val SPLIT_SCREEN_DELAY_MS = 500L + + // How long the "swipe to choose" split-screen hint stays visible + private const val SPLIT_SCREEN_HINT_DURATION_MS = 3000L } + private val snackbarHostState = SnackbarHostState() + // No Navigation-Compose in this app: opening Settings (the top-right button on the main // screen) is just a local flag flipped back by Settings' own back arrow or the system back // gesture/button (see BackHandler below). @@ -56,31 +67,42 @@ class MainActivity : ComponentActivity() { setContent { CompareAppTheme { - Surface( + Scaffold( modifier = Modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.background - ) { + containerColor = MaterialTheme.colorScheme.background, + // Screens handle their own edge-to-edge insets (e.g. CompareScreen's own + // statusBarsPadding()); don't let Scaffold apply them a second time. + contentWindowInsets = WindowInsets(0, 0, 0, 0), + snackbarHost = { SnackbarHost(hostState = snackbarHostState) } + ) { innerPadding -> var screen by rememberSaveable { mutableStateOf(Screen.MAIN) } BackHandler(enabled = screen != Screen.MAIN) { screen = if (screen == Screen.BOLT_LINK_LAB) Screen.SETTINGS else Screen.MAIN } - when (screen) { - Screen.SETTINGS -> SettingsRoute( - onBack = { screen = Screen.MAIN }, - onOpenBoltLinkLab = { screen = Screen.BOLT_LINK_LAB } - ) - Screen.BOLT_LINK_LAB -> BoltLinkLabRoute(onBack = { screen = Screen.SETTINGS }) - Screen.MAIN -> { - val locationUri by incomingLocationUri - CompareScreen( - incomingLocationUri = locationUri, - onOpenSettings = { screen = Screen.SETTINGS }, - onOpenDeepLinks = { uberDeepLink, boltDeepLink, boltDeepLinkWeb -> - openInSplitScreen(uberDeepLink, boltDeepLink, boltDeepLinkWeb) - }, - onOpenFoodSearch = { links -> openFoodSearch(links) } + Surface( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + color = MaterialTheme.colorScheme.background + ) { + when (screen) { + Screen.SETTINGS -> SettingsRoute( + onBack = { screen = Screen.MAIN }, + onOpenBoltLinkLab = { screen = Screen.BOLT_LINK_LAB } ) + Screen.BOLT_LINK_LAB -> BoltLinkLabRoute(onBack = { screen = Screen.SETTINGS }) + Screen.MAIN -> { + val locationUri by incomingLocationUri + CompareScreen( + incomingLocationUri = locationUri, + onOpenSettings = { screen = Screen.SETTINGS }, + onOpenDeepLinks = { uberDeepLink, boltDeepLink, boltDeepLinkWeb -> + openInSplitScreen(uberDeepLink, boltDeepLink, boltDeepLinkWeb) + }, + onOpenFoodSearch = { links -> openFoodSearch(links) } + ) + } } } } @@ -94,7 +116,25 @@ class MainActivity : ComponentActivity() { incomingLocationUri.value = locationUriFromIntent(intent) } + /** + * Shows the "swipe middle up or down to choose" hint and auto-dismisses it after + * [SPLIT_SCREEN_HINT_DURATION_MS] instead of relying on [SnackbarDuration]'s fixed presets. + */ + private fun showSplitScreenHint() { + lifecycleScope.launch { + snackbarHostState.showSnackbar( + message = getString(R.string.split_screen_hint), + duration = SnackbarDuration.Indefinite + ) + } + lifecycleScope.launch { + kotlinx.coroutines.delay(SPLIT_SCREEN_HINT_DURATION_MS) + snackbarHostState.currentSnackbarData?.dismiss() + } + } + private fun openInSplitScreen(uberDeepLink: String, boltDeepLink: String, boltDeepLinkWeb: String) { + showSplitScreenHint() lifecycleScope.launch { try { // Open Uber deep link @@ -153,6 +193,7 @@ class MainActivity : ComponentActivity() { * [FoodDeliveryProvider]'s declaration order (see [MainViewModel.prepareFoodSearchLinks]). */ private fun openFoodSearch(links: Map) { + showSplitScreenHint() lifecycleScope.launch { links.entries.forEachIndexed { index, (provider, link) -> if (index > 0) { diff --git a/app/src/main/java/org/neteinstein/compareapp/ui/screens/CompareScreen.kt b/app/src/main/java/org/neteinstein/compareapp/ui/screens/CompareScreen.kt index a6e2f46..f7d6631 100644 --- a/app/src/main/java/org/neteinstein/compareapp/ui/screens/CompareScreen.kt +++ b/app/src/main/java/org/neteinstein/compareapp/ui/screens/CompareScreen.kt @@ -475,24 +475,6 @@ fun CompareScreen( modifier = Modifier.fillMaxWidth() ) - Spacer(modifier = Modifier.height(14.dp)) - - OutlinedTextField( - value = uiState.foodLocation, - onValueChange = viewModel::updateFoodLocation, - label = { Text(stringResource(R.string.food_search_location_label)) }, - leadingIcon = { - Icon( - imageVector = Icons.Filled.Place, - contentDescription = null, - tint = MaterialTheme.colorScheme.secondary - ) - }, - shape = RoundedCornerShape(16.dp), - singleLine = true, - modifier = Modifier.fillMaxWidth() - ) - Spacer(modifier = Modifier.height(20.dp)) Button( diff --git a/app/src/main/java/org/neteinstein/compareapp/ui/screens/MainViewModel.kt b/app/src/main/java/org/neteinstein/compareapp/ui/screens/MainViewModel.kt index f843163..9b24ef3 100644 --- a/app/src/main/java/org/neteinstein/compareapp/ui/screens/MainViewModel.kt +++ b/app/src/main/java/org/neteinstein/compareapp/ui/screens/MainViewModel.kt @@ -335,21 +335,18 @@ class MainViewModel @Inject constructor( _uiState.update { it.copy(foodQuery = value) } } - fun updateFoodLocation(value: String) { - _uiState.update { it.copy(foodLocation = value) } - } - fun setFoodSearchMode(mode: FoodSearchMode) { _uiState.update { it.copy(foodSearchMode = mode) } } /** * Builds the search links for the currently selected pair of food providers (Settings > - * Comparison configuration) from the current query + location - see [FoodDeepLinks] for why - * these are unverified best-effort guesses rather than a confirmed format. No geocoding needed - * here (unlike [prepareDeepLinks]): all these links take free-text search terms, not - * coordinates. Ordered by [FoodDeliveryProvider]'s declaration order so which app opens first - * is stable regardless of the order the pair was selected in. + * Comparison configuration) from the current query - see [FoodDeepLinks] for why these are + * unverified best-effort guesses rather than a confirmed format. No geocoding needed here + * (unlike [prepareDeepLinks]): all these links take free-text search terms, not coordinates. + * No location is sent either - the food apps already know it from being logged in on-device. + * Ordered by [FoodDeliveryProvider]'s declaration order so which app opens first is stable + * regardless of the order the pair was selected in. */ fun prepareFoodSearchLinks( onSuccess: (links: Map) -> Unit, @@ -363,7 +360,7 @@ class MainViewModel @Inject constructor( val links = FoodDeliveryProvider.entries .filter { it in currentState.selectedFoodProviders } - .associateWith { FoodDeepLinks.createSearchLink(it, currentState.foodQuery, currentState.foodLocation) } + .associateWith { FoodDeepLinks.createSearchLink(it, currentState.foodQuery) } onSuccess(links) } @@ -386,7 +383,6 @@ data class CompareUiState( val pickupSuggestions: List = emptyList(), val dropoffSuggestions: List = emptyList(), val foodQuery: String = "", - val foodLocation: String = "", val foodSearchMode: FoodSearchMode = FoodSearchMode.RESTAURANT, val selectedFoodProviders: Set = emptySet(), val installedFoodProviders: Set = emptySet() diff --git a/app/src/main/java/org/neteinstein/compareapp/utils/FoodDeepLinks.kt b/app/src/main/java/org/neteinstein/compareapp/utils/FoodDeepLinks.kt index 0d2e028..e1eb847 100644 --- a/app/src/main/java/org/neteinstein/compareapp/utils/FoodDeepLinks.kt +++ b/app/src/main/java/org/neteinstein/compareapp/utils/FoodDeepLinks.kt @@ -30,18 +30,12 @@ enum class FoodSearchMode { */ object FoodDeepLinks { - fun createSearchLink(provider: FoodDeliveryProvider, query: String, location: String): String { - val encoded = URLEncoder.encode(combinedQuery(query, location), "UTF-8") + fun createSearchLink(provider: FoodDeliveryProvider, query: String): String { + val encoded = URLEncoder.encode(query.trim(), "UTF-8") return when (provider) { FoodDeliveryProvider.UBER_EATS -> "https://www.ubereats.com/search?q=$encoded" FoodDeliveryProvider.BOLT_FOOD -> "https://food.bolt.eu/search?q=$encoded" FoodDeliveryProvider.GLOVO -> "https://glovoapp.com/search/?query=$encoded" } } - - private fun combinedQuery(query: String, location: String): String { - val trimmedQuery = query.trim() - val trimmedLocation = location.trim() - return if (trimmedLocation.isEmpty()) trimmedQuery else "$trimmedQuery $trimmedLocation" - } } diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 9a4a567..dadc5a0 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -16,7 +16,8 @@ Não foi possível obter a localização atual Obtendo localização… Destino definido a partir do local partilhado - Este aplicativo é usado para comparar preços entre Uber e Bolt. Escolha os locais de recolha e entrega e ambos os aplicativos abrirão em tela dividida já com o destino e custo definidos. Então é só deslizar para tela cheia o mais barato! + Este aplicativo é usado para comparar preços entre 2 fornecedores de cada serviço ao mesmo tempo. Verifique as Configurações para definir quais são usados + Deslize o meio para cima ou para baixo para escolher o que você quer Configurações Configurações @@ -46,8 +47,7 @@ Prato Nome do restaurante Comida ou prato - Local (cidade, bairro) Pesquisar Comida Por favor, insira um restaurante ou prato para pesquisar - Nenhum destes aplicativos de entrega de comida publica um formato de link direto para pesquisa, então isso é uma tentativa - os aplicativos abrirão, mas a pesquisa pode não ser preenchida automaticamente. + O deep linking dos aplicativos não é oficialmente suportado. A pesquisa pode não ser acionada automaticamente diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b0e3872..2d03589 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -16,7 +16,8 @@ Could not get current location Getting location… Destination set from shared location - This app is used to compare prices between Uber and Bolt. Choose the pickup and dropoff locations and both app will open in split screen already with the destination and cost set. Then it\'s just swiping to full screen the cheapest! + This app is used to compare prices between 2 provides of each service at the same time. Please check Settings to configure which are used + Swipe middle up or down to choose the one you want Settings Settings @@ -46,8 +47,7 @@ Dish Restaurant name Food or dish - Location (city, area) Search Food Please enter a restaurant or dish to search for - None of these food delivery apps publish a search deep-link format, so this is a best-effort guess - both apps will open, but the search may not pre-fill. + App deep linking is not officially supported. Search might not be triggered automatically diff --git a/app/src/test/java/org/neteinstein/compareapp/MainViewModelFoodSearchTest.kt b/app/src/test/java/org/neteinstein/compareapp/MainViewModelFoodSearchTest.kt index df65378..5b817ec 100644 --- a/app/src/test/java/org/neteinstein/compareapp/MainViewModelFoodSearchTest.kt +++ b/app/src/test/java/org/neteinstein/compareapp/MainViewModelFoodSearchTest.kt @@ -25,8 +25,8 @@ import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config /** - * Tests for the food search side of [MainViewModel] - the query/location fields, restaurant-vs-dish - * mode toggle, and building search links for the currently selected pair of food providers. Unlike + * Tests for the food search side of [MainViewModel] - the query field, restaurant-vs-dish mode + * toggle, and building search links for the currently selected pair of food providers. Unlike * [MainViewModel.prepareDeepLinks], this never geocodes - all these links take free-text search terms. */ @RunWith(RobolectricTestRunner::class) @@ -65,13 +65,11 @@ class MainViewModelFoodSearchTest { } @Test - fun testUpdateFoodQueryAndLocation_updateState() { + fun testUpdateFoodQuery_updateState() { viewModel.updateFoodQuery("Ramen") - viewModel.updateFoodLocation("Porto") val state = viewModel.uiState.value assertEquals("Ramen", state.foodQuery) - assertEquals("Porto", state.foodLocation) } @Test @@ -92,7 +90,6 @@ class MainViewModelFoodSearchTest { @Test fun testPrepareFoodSearchLinks_buildsLinksForBothSelectedProviders() { viewModel.updateFoodQuery("Tacos") - viewModel.updateFoodLocation("Madrid") var links: Map? = null viewModel.prepareFoodSearchLinks(onSuccess = { links = it }) @@ -100,10 +97,8 @@ class MainViewModelFoodSearchTest { val uberEatsLink = links?.get(FoodDeliveryProvider.UBER_EATS) val boltFoodLink = links?.get(FoodDeliveryProvider.BOLT_FOOD) assertEquals(2, links?.size) - assertTrue(uberEatsLink?.startsWith("https://www.ubereats.com/search?q=") == true) - assertTrue(uberEatsLink?.contains("Tacos+Madrid") == true) - assertTrue(boltFoodLink?.startsWith("https://food.bolt.eu/search?q=") == true) - assertTrue(boltFoodLink?.contains("Tacos+Madrid") == true) + assertEquals("https://www.ubereats.com/search?q=Tacos", uberEatsLink) + assertEquals("https://food.bolt.eu/search?q=Tacos", boltFoodLink) } @Test diff --git a/app/src/test/java/org/neteinstein/compareapp/utils/FoodDeepLinksTest.kt b/app/src/test/java/org/neteinstein/compareapp/utils/FoodDeepLinksTest.kt index f23623f..0bd12db 100644 --- a/app/src/test/java/org/neteinstein/compareapp/utils/FoodDeepLinksTest.kt +++ b/app/src/test/java/org/neteinstein/compareapp/utils/FoodDeepLinksTest.kt @@ -1,52 +1,27 @@ package org.neteinstein.compareapp.utils import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue import org.junit.Test class FoodDeepLinksTest { @Test - fun testCreateSearchLink_uberEats_combinesQueryAndLocation() { - val link = FoodDeepLinks.createSearchLink(FoodDeliveryProvider.UBER_EATS, "Sushi Place", "Downtown") - - assertTrue(link.startsWith("https://www.ubereats.com/search?q=")) - assertTrue(link.contains("Sushi+Place+Downtown") || link.contains("Sushi%20Place%20Downtown")) - } - - @Test - fun testCreateSearchLink_uberEats_withoutLocationUsesQueryOnly() { - val link = FoodDeepLinks.createSearchLink(FoodDeliveryProvider.UBER_EATS, "Sushi Place", "") + fun testCreateSearchLink_uberEats() { + val link = FoodDeepLinks.createSearchLink(FoodDeliveryProvider.UBER_EATS, "Sushi Place") assertEquals("https://www.ubereats.com/search?q=Sushi+Place", link) } @Test - fun testCreateSearchLink_boltFood_combinesQueryAndLocation() { - val link = FoodDeepLinks.createSearchLink(FoodDeliveryProvider.BOLT_FOOD, "Pizza", "Lisbon") - - assertTrue(link.startsWith("https://food.bolt.eu/search?q=")) - assertTrue(link.contains("Pizza+Lisbon")) - } - - @Test - fun testCreateSearchLink_boltFood_withoutLocationUsesQueryOnly() { - val link = FoodDeepLinks.createSearchLink(FoodDeliveryProvider.BOLT_FOOD, "Pizza", " ") + fun testCreateSearchLink_boltFood() { + val link = FoodDeepLinks.createSearchLink(FoodDeliveryProvider.BOLT_FOOD, "Pizza") assertEquals("https://food.bolt.eu/search?q=Pizza", link) } @Test - fun testCreateSearchLink_glovo_combinesQueryAndLocation() { - val link = FoodDeepLinks.createSearchLink(FoodDeliveryProvider.GLOVO, "Burger", "Barcelona") - - assertTrue(link.startsWith("https://glovoapp.com/search/?query=")) - assertTrue(link.contains("Burger+Barcelona")) - } - - @Test - fun testCreateSearchLink_glovo_withoutLocationUsesQueryOnly() { - val link = FoodDeepLinks.createSearchLink(FoodDeliveryProvider.GLOVO, "Burger", "") + fun testCreateSearchLink_glovo() { + val link = FoodDeepLinks.createSearchLink(FoodDeliveryProvider.GLOVO, "Burger") assertEquals("https://glovoapp.com/search/?query=Burger", link) }