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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: >
Expand Down
27 changes: 18 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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)

Expand Down
77 changes: 59 additions & 18 deletions app/src/main/java/org/neteinstein/compareapp/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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).
Expand All @@ -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) }
)
}
}
}
}
Expand All @@ -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
Expand Down Expand Up @@ -153,6 +193,7 @@ class MainActivity : ComponentActivity() {
* [FoodDeliveryProvider]'s declaration order (see [MainViewModel.prepareFoodSearchLinks]).
*/
private fun openFoodSearch(links: Map<FoodDeliveryProvider, String>) {
showSplitScreenHint()
lifecycleScope.launch {
links.entries.forEachIndexed { index, (provider, link) ->
if (index > 0) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<FoodDeliveryProvider, String>) -> Unit,
Expand All @@ -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)
}

Expand All @@ -386,7 +383,6 @@ data class CompareUiState(
val pickupSuggestions: List<AddressSuggestion> = emptyList(),
val dropoffSuggestions: List<AddressSuggestion> = emptyList(),
val foodQuery: String = "",
val foodLocation: String = "",
val foodSearchMode: FoodSearchMode = FoodSearchMode.RESTAURANT,
val selectedFoodProviders: Set<FoodDeliveryProvider> = emptySet(),
val installedFoodProviders: Set<FoodDeliveryProvider> = emptySet()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
6 changes: 3 additions & 3 deletions app/src/main/res/values-pt/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@
<string name="location_error">Não foi possível obter a localização atual</string>
<string name="getting_location">Obtendo localização…</string>
<string name="deep_link_dropoff_set">Destino definido a partir do local partilhado</string>
<string name="info_label">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!</string>
<string name="info_label">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</string>
<string name="split_screen_hint">Deslize o meio para cima ou para baixo para escolher o que você quer</string>

<string name="settings_cd">Configurações</string>
<string name="settings_title">Configurações</string>
Expand Down Expand Up @@ -46,8 +47,7 @@
<string name="food_search_mode_dish">Prato</string>
<string name="food_search_query_restaurant_label">Nome do restaurante</string>
<string name="food_search_query_dish_label">Comida ou prato</string>
<string name="food_search_location_label">Local (cidade, bairro)</string>
<string name="food_search_button">Pesquisar Comida</string>
<string name="food_search_validation_message">Por favor, insira um restaurante ou prato para pesquisar</string>
<string name="food_search_disclaimer">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.</string>
<string name="food_search_disclaimer">O deep linking dos aplicativos não é oficialmente suportado. A pesquisa pode não ser acionada automaticamente</string>
</resources>
6 changes: 3 additions & 3 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@
<string name="location_error">Could not get current location</string>
<string name="getting_location">Getting location…</string>
<string name="deep_link_dropoff_set">Destination set from shared location</string>
<string name="info_label">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!</string>
<string name="info_label">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</string>
<string name="split_screen_hint">Swipe middle up or down to choose the one you want</string>

<string name="settings_cd">Settings</string>
<string name="settings_title">Settings</string>
Expand Down Expand Up @@ -46,8 +47,7 @@
<string name="food_search_mode_dish">Dish</string>
<string name="food_search_query_restaurant_label">Restaurant name</string>
<string name="food_search_query_dish_label">Food or dish</string>
<string name="food_search_location_label">Location (city, area)</string>
<string name="food_search_button">Search Food</string>
<string name="food_search_validation_message">Please enter a restaurant or dish to search for</string>
<string name="food_search_disclaimer">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.</string>
<string name="food_search_disclaimer">App deep linking is not officially supported. Search might not be triggered automatically</string>
</resources>
Loading
Loading