diff --git a/app/src/main/java/org/scottishtecharmy/soundscape/SoundscapeIntents.kt b/app/src/main/java/org/scottishtecharmy/soundscape/SoundscapeIntents.kt index 04a26abc..70d57634 100644 --- a/app/src/main/java/org/scottishtecharmy/soundscape/SoundscapeIntents.kt +++ b/app/src/main/java/org/scottishtecharmy/soundscape/SoundscapeIntents.kt @@ -12,8 +12,8 @@ import kotlinx.coroutines.runBlocking import org.scottishtecharmy.soundscape.database.local.RealmConfiguration import org.scottishtecharmy.soundscape.database.local.dao.RoutesDao import org.scottishtecharmy.soundscape.database.repository.RoutesRepository -import org.scottishtecharmy.soundscape.screens.home.locationDetails.LocationDescription import org.scottishtecharmy.soundscape.screens.home.Navigator +import org.scottishtecharmy.soundscape.screens.home.data.LocationDescription import org.scottishtecharmy.soundscape.screens.home.locationDetails.generateLocationDetailsRoute import org.scottishtecharmy.soundscape.utils.parseGpxFile import java.io.IOException @@ -24,188 +24,207 @@ import java.net.URLDecoder import java.net.URLEncoder import javax.inject.Inject -class SoundscapeIntents @Inject constructor(private val navigator : Navigator) { +class SoundscapeIntents + @Inject + constructor( + private val navigator: Navigator, + ) { + private lateinit var geocoder: Geocoder + + private fun useGeocoderToGetAddress( + location: String, + context: Context, + ) { + geocoder = Geocoder(context) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val geocodeListener = + Geocoder.GeocodeListener { addresses -> + Log.d(TAG, "getFromLocationName results count " + addresses.size.toString()) + val address = addresses.firstOrNull() + if (address != null) { + Log.d(TAG, "$address") + val ld = + LocationDescription( + adressName = address.getAddressLine(0), + latitude = address.latitude, + longitude = address.longitude, + ) + navigator.navigate(generateLocationDetailsRoute(ld)) + } + } + Log.d(TAG, "Call getFromLocationName on $location") + geocoder.getFromLocationName(location, 1, geocodeListener) + } else { + Log.d(TAG, "Pre-API33: $location") - private lateinit var geocoder: Geocoder - private fun useGeocoderToGetAddress(location : String, - context : Context) { - geocoder = Geocoder(context) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - val geocodeListener = Geocoder.GeocodeListener { addresses -> - Log.d(TAG, "getFromLocationName results count " + addresses.size.toString()) - val address = addresses.firstOrNull() + @Suppress("DEPRECATION") + val address = geocoder.getFromLocationName(location, 1)?.firstOrNull() if (address != null) { - Log.d(TAG, "$address") - val ld = LocationDescription(address.getAddressLine(0), address.latitude, address.longitude) + Log.d(TAG, "Address: $address") + val ld = + LocationDescription( + adressName = address.getAddressLine(0), + latitude = address.latitude, + longitude = address.longitude, + ) navigator.navigate(generateLocationDetailsRoute(ld)) } } - Log.d(TAG, "Call getFromLocationName on $location") - geocoder.getFromLocationName(location, 1, geocodeListener) - } else { - Log.d(TAG, "Pre-API33: $location") - - @Suppress("DEPRECATION") - val address = geocoder.getFromLocationName(location, 1)?.firstOrNull() - if(address != null) { - Log.d(TAG, "Address: $address") - val ld = LocationDescription( - address.getAddressLine(0), - address.latitude, - address.longitude - ) - navigator.navigate(generateLocationDetailsRoute(ld)) - } } - } - private fun getRedirectUrl(url: String, context: Context) { - CoroutineScope(Dispatchers.IO).launch { - var urlTmp: URL? = null - var connection: HttpURLConnection? = null - - try { - Log.d(TAG, "Open URL $url") - urlTmp = URL(url) - } catch (e1: MalformedURLException) { - e1.printStackTrace() - } - try { - Log.d(TAG, "Open connection") - connection = urlTmp!!.openConnection() as HttpURLConnection - } - catch (e: IOException) { - e.printStackTrace() - } - try { - connection!!.responseCode - Log.d(TAG, "Response ${connection.responseCode}") - } catch (e: IOException) { - e.printStackTrace() - } + private fun getRedirectUrl( + url: String, + context: Context, + ) { + CoroutineScope(Dispatchers.IO).launch { + var urlTmp: URL? = null + var connection: HttpURLConnection? = null + + try { + Log.d(TAG, "Open URL $url") + urlTmp = URL(url) + } catch (e1: MalformedURLException) { + e1.printStackTrace() + } - val redUrl = connection!!.url.toString() - connection.disconnect() + try { + Log.d(TAG, "Open connection") + connection = urlTmp!!.openConnection() as HttpURLConnection + } catch (e: IOException) { + e.printStackTrace() + } + try { + connection!!.responseCode + Log.d(TAG, "Response ${connection.responseCode}") + } catch (e: IOException) { + e.printStackTrace() + } - Log.d(TAG, "Maps URL: $redUrl") - useGeocoderToGetAddress(redUrl, context) + val redUrl = connection!!.url.toString() + connection.disconnect() + + Log.d(TAG, "Maps URL: $redUrl") + useGeocoderToGetAddress(redUrl, context) + } } - } - /** There are several different types of Intent that we handle in our app - - geo: These come from clicking on a location in another app e.g. Google Calendar. It - contains a latitude and longitude and an optional text description. - - soundscape: This is our own format and we can do what we want with it. Initially it was - the same format as geo but put the app into 'Street Preview' mode with the user - positioned at the location provided. - - shared plain/text : If a user selects 'share' in Google Maps and Soundscape as the - destination app, then we receive a Google Maps URL via this type of intent. To use it - we need to follow it to get the real (non-tiny) URL and then pass that into the - Android Geocoder to parse it. - - The behaviour for all of these URLs is now to open a LocationDetails screen which then - gives the options of: - Create Beacon - Street Preview - Add Marker - - Navigation to the LocationDetails is done via the main activity navigator. - */ - fun parse(intent : Intent, mainActivity: MainActivity) { - when { - intent.action == Intent.ACTION_SEND -> { - if ("text/plain" == intent.type) { - intent.getStringExtra(Intent.EXTRA_TEXT)?.let { plainText -> - Log.d(TAG, "Intent text: $plainText") - if (plainText.contains("maps.app.goo.gl")) { - try { - getRedirectUrl(plainText, mainActivity) - } catch (e: Exception) { - Log.e(TAG, "Exception: $e") + /** There are several different types of Intent that we handle in our app + + geo: These come from clicking on a location in another app e.g. Google Calendar. It + contains a latitude and longitude and an optional text description. + + soundscape: This is our own format and we can do what we want with it. Initially it was + the same format as geo but put the app into 'Street Preview' mode with the user + positioned at the location provided. + + shared plain/text : If a user selects 'share' in Google Maps and Soundscape as the + destination app, then we receive a Google Maps URL via this type of intent. To use it + we need to follow it to get the real (non-tiny) URL and then pass that into the + Android Geocoder to parse it. + + The behaviour for all of these URLs is now to open a LocationDetails screen which then + gives the options of: + Create Beacon + Street Preview + Add Marker + + Navigation to the LocationDetails is done via the main activity navigator. + */ + fun parse( + intent: Intent, + mainActivity: MainActivity, + ) { + when { + intent.action == Intent.ACTION_SEND -> { + if ("text/plain" == intent.type) { + intent.getStringExtra(Intent.EXTRA_TEXT)?.let { plainText -> + Log.d(TAG, "Intent text: $plainText") + if (plainText.contains("maps.app.goo.gl")) { + try { + getRedirectUrl(plainText, mainActivity) + } catch (e: Exception) { + Log.e(TAG, "Exception: $e") + } } } } } - } - else -> { - val uriData: String = - URLDecoder.decode(intent.data.toString(), Charsets.UTF_8.name()) - - // Check for geo or soundscape intent which is simply a latitude and longitude - val regex = - Regex("(geo|soundscape):/*([-+]?[0-9]*\\.[0-9]+|[0-9]+),([-+]?[0-9]*\\.[0-9]+|[0-9]+).*") - - val matchResult = regex.find(uriData) - if (matchResult != null) { - // We have a match on the link - val latitude = matchResult.groupValues[2] - val longitude = matchResult.groupValues[3] - - if(matchResult.groupValues[1] == "soundscape") { - // Switch to Street Preview mode - mainActivity.soundscapeServiceConnection.setStreetPreviewMode( - true, - latitude.toDouble(), - longitude.toDouble() - ) - } - else { - try { - check(Geocoder.isPresent()) - useGeocoderToGetAddress("$latitude,$longitude", mainActivity) - } catch (e: Exception) { - // No Geocoder available, so just report the uriData - val ld = - LocationDescription( - URLEncoder.encode(uriData, "utf-8"), - latitude.toDouble(), - longitude.toDouble() - ) - mainActivity.navigator.navigate(generateLocationDetailsRoute(ld)) + + else -> { + val uriData: String = + URLDecoder.decode(intent.data.toString(), Charsets.UTF_8.name()) + + // Check for geo or soundscape intent which is simply a latitude and longitude + val regex = + Regex("(geo|soundscape):/*([-+]?[0-9]*\\.[0-9]+|[0-9]+),([-+]?[0-9]*\\.[0-9]+|[0-9]+).*") + + val matchResult = regex.find(uriData) + if (matchResult != null) { + // We have a match on the link + val latitude = matchResult.groupValues[2] + val longitude = matchResult.groupValues[3] + + if (matchResult.groupValues[1] == "soundscape") { + // Switch to Street Preview mode + mainActivity.soundscapeServiceConnection.setStreetPreviewMode( + true, + latitude.toDouble(), + longitude.toDouble(), + ) + } else { + try { + check(Geocoder.isPresent()) + useGeocoderToGetAddress("$latitude,$longitude", mainActivity) + } catch (e: Exception) { + // No Geocoder available, so just report the uriData + val ld = + LocationDescription( + adressName = URLEncoder.encode(uriData, "utf-8"), + latitude = latitude.toDouble(), + longitude = longitude.toDouble(), + ) + mainActivity.navigator.navigate(generateLocationDetailsRoute(ld)) + } } - } - } - else { - if (Intent.ACTION_VIEW == intent.action || Intent.ACTION_MAIN == intent.action) { - val data = intent.data - if (data != null) { - if ("file" == data.scheme) { - if (data.path != null) { - Log.e(TAG, "Import data from ${data.path}") - } - } else if ("content" == data.scheme) { - Log.d(TAG, "Import data from content ${data.path}") - val uri = intent.data - if(uri != null) { - try { - val input = - mainActivity.contentResolver.openInputStream(uri) - - if (input != null) { - val routeData = parseGpxFile(input) - - // The parsing has succeeded, write the result to a - // new markers database. - // TODO: This intent should really open up the RouteDetails - // page and defer to that the action of inserting the - // route into the database. This is a temporary solution - // so that we can work on the code that uses the routes. - val realm = RealmConfiguration.getMarkersInstance(true) - val routesDao = RoutesDao(realm) - val routesRepository = RoutesRepository(routesDao) - runBlocking { - launch { - // Write the routeData to the database - Log.d("gpx", "Inserting route") - routesRepository.insertRoute(routeData) + } else { + if (Intent.ACTION_VIEW == intent.action || Intent.ACTION_MAIN == intent.action) { + val data = intent.data + if (data != null) { + if ("file" == data.scheme) { + if (data.path != null) { + Log.e(TAG, "Import data from ${data.path}") + } + } else if ("content" == data.scheme) { + Log.d(TAG, "Import data from content ${data.path}") + val uri = intent.data + if (uri != null) { + try { + val input = + mainActivity.contentResolver.openInputStream(uri) + + if (input != null) { + val routeData = parseGpxFile(input) + + // The parsing has succeeded, write the result to a + // new markers database. + // TODO: This intent should really open up the RouteDetails + // page and defer to that the action of inserting the + // route into the database. This is a temporary solution + // so that we can work on the code that uses the routes. + val realm = RealmConfiguration.getMarkersInstance(true) + val routesDao = RoutesDao(realm) + val routesRepository = RoutesRepository(routesDao) + runBlocking { + launch { + // Write the routeData to the database + Log.d("gpx", "Inserting route") + routesRepository.insertRoute(routeData) + } } } + } catch (e: Exception) { + Log.e(TAG, "Failed to import GPX from intent: $e") } - } catch(e: Exception) { - Log.e(TAG, "Failed to import GPX from intent: $e") } } } @@ -214,8 +233,8 @@ class SoundscapeIntents @Inject constructor(private val navigator : Navigator) { } } } + + companion object { + private const val TAG = "SoundscapeIntents" + } } - companion object { - private const val TAG = "SoundscapeIntents" - } -} \ No newline at end of file diff --git a/app/src/main/java/org/scottishtecharmy/soundscape/components/MainSearchBar.kt b/app/src/main/java/org/scottishtecharmy/soundscape/components/MainSearchBar.kt index b707ff2d..11c150d1 100644 --- a/app/src/main/java/org/scottishtecharmy/soundscape/components/MainSearchBar.kt +++ b/app/src/main/java/org/scottishtecharmy/soundscape/components/MainSearchBar.kt @@ -6,102 +6,129 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.rounded.Search import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SearchBar import androidx.compose.material3.SearchBarDefaults import androidx.compose.material3.Text -import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.CollectionInfo +import androidx.compose.ui.semantics.CollectionItemInfo +import androidx.compose.ui.semantics.collectionInfo +import androidx.compose.ui.semantics.collectionItemInfo +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp +import org.scottishtecharmy.soundscape.R +import org.scottishtecharmy.soundscape.screens.home.data.LocationDescription @OptIn(ExperimentalMaterial3Api::class) @Composable fun MainSearchBar( searchText: String, isSearching: Boolean, - itemList: List, + itemList: List, onSearchTextChange: (String) -> Unit, onToggleSearch: () -> Unit, - onItemClick: (SearchItem) -> Unit + onItemClick: (LocationDescription) -> Unit, ) { - val containerColor = Color.White SearchBar( - query = searchText, - onQueryChange = onSearchTextChange, - onSearch = onSearchTextChange, - active = isSearching, + modifier = + Modifier + .fillMaxWidth() + .then( + if (!isSearching) { + Modifier.padding(horizontal = 16.dp) + } else { + Modifier.padding(horizontal = 0.dp) + }, + ), shape = RoundedCornerShape(8.dp), - onActiveChange = { onToggleSearch() }, - colors = SearchBarDefaults.colors( - containerColor = containerColor, - inputFieldColors = TextFieldDefaults.colors( - focusedTextColor = Color.Black, - focusedContainerColor = containerColor, - unfocusedContainerColor = containerColor, - disabledContainerColor = containerColor, - ) - ), - leadingIcon = { - if (!isSearching) { - Icon( - Icons.Rounded.Search, - null, - tint = Color.Gray - ) - } else { - IconButton( - onClick = { onToggleSearch() }, - ) { - Icon( - Icons.AutoMirrored.Rounded.ArrowBack, - contentDescription = "Cancel search", - tint = Color.Gray - ) - } - } - }, - placeholder = { - Text( - text = "Search destination", - color = Color.Gray + colors = SearchBarDefaults.colors(containerColor = Color.White), + inputField = { + SearchBarDefaults.InputField( + query = searchText, + onQueryChange = onSearchTextChange, + onSearch = onSearchTextChange, + expanded = isSearching, + onExpandedChange = { onToggleSearch() }, + placeholder = { Text(stringResource(id = R.string.search_hint_input)) }, + leadingIcon = { + when { + !isSearching -> { + Icon( + imageVector = Icons.Rounded.Search, + contentDescription = null, + tint = Color.Gray, + ) + } + + else -> { + IconButton( + onClick = { onToggleSearch() }, + ) { + Icon( + Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = + stringResource(R.string.cancel_search_contentDescription), + tint = Color.Gray, + ) + } + } + } + }, + colors = SearchBarDefaults.inputFieldColors(focusedTextColor = Color.Black), ) }, - modifier = Modifier - .fillMaxWidth() - .then( - if (!isSearching) { - Modifier.padding(horizontal = 16.dp) - } else { - Modifier.padding(horizontal = 0.dp) - } - ) + expanded = isSearching, + onExpandedChange = { onToggleSearch() }, ) { Column( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.background) + modifier = + Modifier + .semantics { + this.collectionInfo = + CollectionInfo( + rowCount = itemList.size, // Total number of items + columnCount = 1, // Single-column list + ) + }.fillMaxSize() + .background(MaterialTheme.colorScheme.background), ) { LazyColumn(modifier = Modifier.padding(top = 16.dp)) { - items(itemList) { item -> - SearchItemButton( - item = item, - onClick = { - onItemClick(item) - }, - modifier = Modifier - ) + itemsIndexed(itemList) { index, item -> + Column { + SearchItemButton( + item = item, + onClick = { + onItemClick(item) + onToggleSearch() + }, + modifier = + Modifier.semantics { + this.collectionItemInfo = + CollectionItemInfo( + rowSpan = 1, + columnSpan = 1, + rowIndex = index, + columnIndex = 0, + ) + }, + ) + HorizontalDivider(color = Color.White) + } } } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/scottishtecharmy/soundscape/components/SearchItem.kt b/app/src/main/java/org/scottishtecharmy/soundscape/components/SearchItem.kt index 42c56531..bf426819 100644 --- a/app/src/main/java/org/scottishtecharmy/soundscape/components/SearchItem.kt +++ b/app/src/main/java/org/scottishtecharmy/soundscape/components/SearchItem.kt @@ -19,52 +19,66 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import org.scottishtecharmy.soundscape.R +import org.scottishtecharmy.soundscape.screens.home.data.LocationDescription import org.scottishtecharmy.soundscape.ui.theme.Foreground2 import org.scottishtecharmy.soundscape.ui.theme.IntroductionTheme - - -data class SearchItem( - val text: String, - val label: String, -) +import org.scottishtecharmy.soundscape.ui.theme.PaleBlue +import org.scottishtecharmy.soundscape.utils.buildAddressFormat @Composable -fun SearchItemButton(item: SearchItem, onClick: () -> Unit, modifier: Modifier,) { +fun SearchItemButton( + item: LocationDescription, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { Button( onClick = onClick, shape = RoundedCornerShape(0), - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.tertiary, - ), + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.tertiary, + ), ) { Row( - modifier = Modifier - .fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically + modifier = modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, ) { Icon( Icons.Rounded.LocationOn, - contentDescription = "Choose destination", - tint = Color.White + contentDescription = null, + tint = Color.White, ) - Column(modifier = Modifier.padding(start = 18.dp)) { - Text( - item.text, - fontWeight = FontWeight(400), - fontSize = 18.sp, - color = Color.White, - ) - Text( - item.label, - color = Foreground2, - fontWeight = FontWeight(350), - ) + Column( + modifier = Modifier.padding(start = 18.dp), + verticalArrangement = Arrangement.spacedBy(5.dp), + ) { + item.adressName?.let { + Text( + text = it, + fontWeight = FontWeight(700), + fontSize = 22.sp, + color = Color.White, + ) + } + item.distance?.let { + Text( + text = it, + color = Foreground2, + fontWeight = FontWeight(450), + ) + } + item.buildAddressFormat()?.let { + Text( + text = it, + fontWeight = FontWeight(400), + fontSize = 18.sp, + color = PaleBlue, + ) + } } } } @@ -74,15 +88,25 @@ fun SearchItemButton(item: SearchItem, onClick: () -> Unit, modifier: Modifier,) @Composable fun PreviewSearchItemButton() { IntroductionTheme { - Column(modifier = Modifier - .fillMaxWidth() - .fillMaxHeight(), + Column( + modifier = + Modifier + .fillMaxWidth() + .fillMaxHeight(), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) - { - val test = SearchItem("Bristol", "1") + verticalArrangement = Arrangement.Center, + ) { + val test = + LocationDescription( + adressName = "Bristol", + streetNumberAndName = "18 Street", + postcodeAndLocality = "59000 Lille", + distance = "17 Km", + country = "France", + latitude = 9.55, + longitude = 8.00, + ) SearchItemButton(test, onClick = {}, Modifier.width(200.dp)) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/scottishtecharmy/soundscape/geoengine/GeoEngine.kt b/app/src/main/java/org/scottishtecharmy/soundscape/geoengine/GeoEngine.kt index af2c1d1d..56c1ae45 100644 --- a/app/src/main/java/org/scottishtecharmy/soundscape/geoengine/GeoEngine.kt +++ b/app/src/main/java/org/scottishtecharmy/soundscape/geoengine/GeoEngine.kt @@ -27,22 +27,13 @@ import org.scottishtecharmy.soundscape.MainActivity.Companion.MOBILITY_KEY import org.scottishtecharmy.soundscape.MainActivity.Companion.PLACES_AND_LANDMARKS_KEY import org.scottishtecharmy.soundscape.R import org.scottishtecharmy.soundscape.audio.NativeAudioEngine -import org.scottishtecharmy.soundscape.geojsonparser.geojson.Feature import org.scottishtecharmy.soundscape.dto.BoundingBox import org.scottishtecharmy.soundscape.geoengine.filters.CalloutHistory import org.scottishtecharmy.soundscape.geoengine.filters.LocationUpdateFilter import org.scottishtecharmy.soundscape.geoengine.filters.TrackedCallout -import org.scottishtecharmy.soundscape.geojsonparser.geojson.FeatureCollection -import org.scottishtecharmy.soundscape.geojsonparser.geojson.LngLatAlt -import org.scottishtecharmy.soundscape.geojsonparser.geojson.Point -import org.scottishtecharmy.soundscape.geojsonparser.geojson.Polygon -import org.scottishtecharmy.soundscape.locationprovider.DirectionProvider -import org.scottishtecharmy.soundscape.locationprovider.LocationProvider -import org.scottishtecharmy.soundscape.network.ITileDAO -import org.scottishtecharmy.soundscape.network.ProtomapsTileClient -import org.scottishtecharmy.soundscape.network.SoundscapeBackendTileClient -import org.scottishtecharmy.soundscape.network.TileClient import org.scottishtecharmy.soundscape.geoengine.mvttranslation.InterpolatedPointsJoiner +import org.scottishtecharmy.soundscape.geoengine.mvttranslation.vectorTileToGeoJson +import org.scottishtecharmy.soundscape.geoengine.utils.FeatureTree import org.scottishtecharmy.soundscape.geoengine.utils.RelativeDirections import org.scottishtecharmy.soundscape.geoengine.utils.TileGrid import org.scottishtecharmy.soundscape.geoengine.utils.TileGrid.Companion.getTileGrid @@ -53,7 +44,7 @@ import org.scottishtecharmy.soundscape.geoengine.utils.distance import org.scottishtecharmy.soundscape.geoengine.utils.distanceToPolygon import org.scottishtecharmy.soundscape.geoengine.utils.getCompassLabelFacingDirection import org.scottishtecharmy.soundscape.geoengine.utils.getCompassLabelFacingDirectionAlong -import org.scottishtecharmy.soundscape.utils.getCurrentLocale +import org.scottishtecharmy.soundscape.geoengine.utils.getFeatureNearestPoint import org.scottishtecharmy.soundscape.geoengine.utils.getFovIntersectionFeatureCollection import org.scottishtecharmy.soundscape.geoengine.utils.getFovRoadsFeatureCollection import org.scottishtecharmy.soundscape.geoengine.utils.getIntersectionRoadNames @@ -70,20 +61,33 @@ import org.scottishtecharmy.soundscape.geoengine.utils.processTileFeatureCollect import org.scottishtecharmy.soundscape.geoengine.utils.processTileString import org.scottishtecharmy.soundscape.geoengine.utils.removeDuplicateOsmIds import org.scottishtecharmy.soundscape.geoengine.utils.sortedByDistanceTo -import org.scottishtecharmy.soundscape.geoengine.mvttranslation.vectorTileToGeoJson -import org.scottishtecharmy.soundscape.geoengine.utils.FeatureTree -import org.scottishtecharmy.soundscape.geoengine.utils.getFeatureNearestPoint +import org.scottishtecharmy.soundscape.geojsonparser.geojson.Feature +import org.scottishtecharmy.soundscape.geojsonparser.geojson.FeatureCollection +import org.scottishtecharmy.soundscape.geojsonparser.geojson.LngLatAlt +import org.scottishtecharmy.soundscape.geojsonparser.geojson.Point +import org.scottishtecharmy.soundscape.geojsonparser.geojson.Polygon +import org.scottishtecharmy.soundscape.locationprovider.DirectionProvider +import org.scottishtecharmy.soundscape.locationprovider.LocationProvider +import org.scottishtecharmy.soundscape.network.ITileDAO +import org.scottishtecharmy.soundscape.network.PhotonSearchProvider +import org.scottishtecharmy.soundscape.network.ProtomapsTileClient +import org.scottishtecharmy.soundscape.network.SoundscapeBackendTileClient +import org.scottishtecharmy.soundscape.network.TileClient import org.scottishtecharmy.soundscape.services.SoundscapeService import org.scottishtecharmy.soundscape.services.getOttoBus +import org.scottishtecharmy.soundscape.utils.getCurrentLocale import retrofit2.awaitResponse import java.util.Locale import kotlin.coroutines.cancellation.CancellationException import kotlin.time.TimeSource -data class PositionedString(val text : String, val location : LngLatAlt? = null, val earcon : String? = null) +data class PositionedString( + val text: String, + val location: LngLatAlt? = null, + val earcon: String? = null, +) class GeoEngine { - private val coroutineScope = CoroutineScope(Job()) // GeoJSON tiles job @@ -94,17 +98,17 @@ class GeoEngine { var tileGridFlow: StateFlow = _tileGridFlow // HTTP connection to soundscape-backend or protomaps tile server - private lateinit var tileClient : TileClient + private lateinit var tileClient: TileClient - private lateinit var locationProvider : LocationProvider - private lateinit var directionProvider : DirectionProvider + private lateinit var locationProvider: LocationProvider + private lateinit var directionProvider: DirectionProvider // Resource string locale configuration - private lateinit var configLocale : Locale - private lateinit var configuration : Configuration - private lateinit var localizedContext : Context + private lateinit var configLocale: Locale + private lateinit var configuration: Configuration + private lateinit var localizedContext: Context - private lateinit var sharedPreferences : SharedPreferences + private lateinit var sharedPreferences: SharedPreferences private var centralBoundingBox = BoundingBox() private var inVehicle = false @@ -113,12 +117,15 @@ class GeoEngine { @Subscribe fun onActivityTransitionEvent(event: ActivityTransitionEvent) { - if(event.transitionType == ActivityTransition.ACTIVITY_TRANSITION_ENTER) { - inVehicle = when(event.activityType) { - DetectedActivity.ON_BICYCLE, - DetectedActivity.IN_VEHICLE -> true - else -> false - } + if (event.transitionType == ActivityTransition.ACTIVITY_TRANSITION_ENTER) { + inVehicle = + when (event.activityType) { + DetectedActivity.ON_BICYCLE, + DetectedActivity.IN_VEHICLE, + -> true + + else -> false + } } } @@ -126,16 +133,17 @@ class GeoEngine { application: Application, newLocationProvider: LocationProvider, newDirectionProvider: DirectionProvider, - soundscapeService: SoundscapeService + soundscapeService: SoundscapeService, ) { + sharedPreferences = + PreferenceManager.getDefaultSharedPreferences(application.applicationContext) - sharedPreferences = PreferenceManager.getDefaultSharedPreferences(application.applicationContext) - - tileClient = if(SOUNDSCAPE_TILE_BACKEND) { - SoundscapeBackendTileClient(application) - } else { - ProtomapsTileClient(application) - } + tileClient = + if (SOUNDSCAPE_TILE_BACKEND) { + SoundscapeBackendTileClient(application) + } else { + ProtomapsTileClient(application) + } configLocale = getCurrentLocale() configuration = Configuration(application.applicationContext.resources.configuration) @@ -165,114 +173,124 @@ class GeoEngine { private fun startTileGridService(soundscapeService: SoundscapeService) { Log.e(TAG, "startTileGridService") tilesJob?.cancel() - tilesJob = coroutineScope.launch { - locationProvider.locationFlow.collectLatest { newLocation -> - // Check if we've moved out of the bounds of the central area - newLocation?.let { location -> - // Check if we're still within the central area of our grid - if (!pointIsWithinBoundingBox(LngLatAlt(location.longitude, location.latitude), - centralBoundingBox)) { - - val timeSource = TimeSource.Monotonic - val gridStartTime = timeSource.markNow() - - Log.d(TAG, "Update central grid area") - // The current location has moved from within the central area, so get the - // new grid and the new central area. - val tileGrid = getTileGrid( - locationProvider.getCurrentLatitude() ?: 0.0, - locationProvider.getCurrentLongitude() ?: 0.0 - ) + tilesJob = + coroutineScope.launch { + locationProvider.locationFlow.collectLatest { newLocation -> + // Check if we've moved out of the bounds of the central area + newLocation?.let { location -> + // Check if we're still within the central area of our grid + if (!pointIsWithinBoundingBox( + LngLatAlt(location.longitude, location.latitude), + centralBoundingBox, + ) + ) { + val timeSource = TimeSource.Monotonic + val gridStartTime = timeSource.markNow() + + Log.d(TAG, "Update central grid area") + // The current location has moved from within the central area, so get the + // new grid and the new central area. + val tileGrid = + getTileGrid( + locationProvider.getCurrentLatitude() ?: 0.0, + locationProvider.getCurrentLongitude() ?: 0.0, + ) - // We have a new centralBoundingBox, so update the tiles - val featureCollections = Array(Fc.MAX_COLLECTION_ID.id) { FeatureCollection() } - if(updateTileGrid(tileGrid, featureCollections)) { - - // We have got a new grid, so create our new central region - centralBoundingBox = tileGrid.centralBoundingBox - - val localTrees = Array(Fc.MAX_COLLECTION_ID.id) { FeatureTree(null) } - if(SOUNDSCAPE_TILE_BACKEND) { - // De-duplicate - val deDuplicatedCollection = Array(Fc.MAX_COLLECTION_ID.id) { FeatureCollection() } - for((index, fc) in featureCollections.withIndex()) { - val existingSet: MutableSet = mutableSetOf() - deduplicateFeatureCollection( - deDuplicatedCollection[index], - fc, - existingSet - ) - } - // Create rtrees for each feature collection - for((index, fc) in deDuplicatedCollection.withIndex()) { - localTrees[index] = FeatureTree(fc) - } - } else { - // Join up roads/paths at the tile boundary - val joiner = InterpolatedPointsJoiner() - for (ip in featureCollections[Fc.INTERPOLATIONS.id]) { - joiner.addInterpolatedPoints(ip) - } - joiner.addJoiningLines(featureCollections[Fc.ROADS.id]) + // We have a new centralBoundingBox, so update the tiles + val featureCollections = + Array(Fc.MAX_COLLECTION_ID.id) { FeatureCollection() } + if (updateTileGrid(tileGrid, featureCollections)) { + // We have got a new grid, so create our new central region + centralBoundingBox = tileGrid.centralBoundingBox + + val localTrees = Array(Fc.MAX_COLLECTION_ID.id) { FeatureTree(null) } + if (SOUNDSCAPE_TILE_BACKEND) { + // De-duplicate + val deDuplicatedCollection = + Array(Fc.MAX_COLLECTION_ID.id) { FeatureCollection() } + for ((index, fc) in featureCollections.withIndex()) { + val existingSet: MutableSet = mutableSetOf() + deduplicateFeatureCollection( + deDuplicatedCollection[index], + fc, + existingSet, + ) + } + // Create rtrees for each feature collection + for ((index, fc) in deDuplicatedCollection.withIndex()) { + localTrees[index] = FeatureTree(fc) + } + } else { + // Join up roads/paths at the tile boundary + val joiner = InterpolatedPointsJoiner() + for (ip in featureCollections[Fc.INTERPOLATIONS.id]) { + joiner.addInterpolatedPoints(ip) + } + joiner.addJoiningLines(featureCollections[Fc.ROADS.id]) - // Create rtrees for each feature collection - for((index, fc) in featureCollections.withIndex()) { - localTrees[index] = FeatureTree(fc) + // Create rtrees for each feature collection + for ((index, fc) in featureCollections.withIndex()) { + localTrees[index] = FeatureTree(fc) + } } - } - // Assign rtrees to our shared trees with mutex taken - treeMutex.lock() - for(fc in featureCollections.withIndex()) { - featureTrees[fc.index] = localTrees[fc.index] - } - treeMutex.unlock() + // Assign rtrees to our shared trees with mutex taken + treeMutex.lock() + for (fc in featureCollections.withIndex()) { + featureTrees[fc.index] = localTrees[fc.index] + } + treeMutex.unlock() - val gridFinishTime = timeSource.markNow() - Log.e(TAG, "Time to populate grid: ${gridFinishTime - gridStartTime}") + val gridFinishTime = timeSource.markNow() + Log.e(TAG, "Time to populate grid: ${gridFinishTime - gridStartTime}") - // Update the flow with our new tile grid - if (sharedPreferences.getBoolean(MAP_DEBUG_KEY, false)) { - _tileGridFlow.value = tileGrid + // Update the flow with our new tile grid + if (sharedPreferences.getBoolean(MAP_DEBUG_KEY, false)) { + _tileGridFlow.value = tileGrid + } else { + _tileGridFlow.value = TileGrid(mutableListOf(), BoundingBox()) + } } else { - _tileGridFlow.value = TileGrid(mutableListOf(), BoundingBox()) + // Updating the tile grid failed, due to a lack of cached tile and then + // a lack of network/server issue. There's nothing that we can do, so + // simply retry on the next location update. } - } else { - // Updating the tile grid failed, due to a lack of cached tile and then - // a lack of network/server issue. There's nothing that we can do, so - // simply retry on the next location update. } - } - // Run any auto callouts that we need - val callouts = autoCallout(location) - if(callouts.isNotEmpty()) { - // Tell the service that we've got some callouts to tell the user about - soundscapeService.speakCallout(callouts) + // Run any auto callouts that we need + val callouts = autoCallout(location) + if (callouts.isNotEmpty()) { + // Tell the service that we've got some callouts to tell the user about + soundscapeService.speakCallout(callouts) + } } } } - } } - private suspend fun updateTileFromProtomaps(x : Int, y: Int, featureCollections: Array) : Boolean { + private suspend fun updateTileFromProtomaps( + x: Int, + y: Int, + featureCollections: Array, + ): Boolean { var ret = false withContext(Dispatchers.IO) { try { val service = tileClient.retrofitInstance?.create(ITileDAO::class.java) - val tileReq = async { - service?.getVectorTileWithCache(x, y, ZOOM_LEVEL) - } + val tileReq = + async { + service?.getVectorTileWithCache(x, y, ZOOM_LEVEL) + } val result = tileReq.await()?.awaitResponse()?.body() if (result != null) { Log.e(TAG, "Tile size ${result.serializedSize}") val tileFeatureCollection = vectorTileToGeoJson(x, y, result) val collections = processTileFeatureCollection(tileFeatureCollection) - for((index, collection) in collections.withIndex()) + for ((index, collection) in collections.withIndex()) { featureCollections[index].plusAssign(collection) + } ret = true - } - else { + } else { Log.e(TAG, "No response for protomaps tile") } } catch (ce: CancellationException) { @@ -285,16 +303,21 @@ class GeoEngine { return ret } - private suspend fun updateTileFromSoundscapeBackend(x : Int, y: Int, featureCollections: Array) : Boolean { + private suspend fun updateTileFromSoundscapeBackend( + x: Int, + y: Int, + featureCollections: Array, + ): Boolean { var ret = false withContext(Dispatchers.IO) { try { val service = tileClient.retrofitInstance?.create(ITileDAO::class.java) - val tileReq = async { + val tileReq = + async { service?.getTileWithCache(x, y) - } + } val result = tileReq.await()?.awaitResponse()?.body() // clean the tile, process the string, perform an insert into db using the clean tile data Log.e(TAG, "Tile size ${result?.length}") @@ -303,12 +326,12 @@ class GeoEngine { if (cleanedTile != null) { val tileData = processTileString(cleanedTile) - for((index, collection) in tileData.withIndex()) + for ((index, collection) in tileData.withIndex()) { featureCollections[index].plusAssign(collection) + } ret = true - } - else { + } else { Log.e(TAG, "Failed to get clean soundscape-backend tile") } } catch (ce: CancellationException) { @@ -321,24 +344,31 @@ class GeoEngine { return ret } - private suspend fun updateTile(x : Int, y: Int, featureCollections: Array) : Boolean { - if(!SOUNDSCAPE_TILE_BACKEND) { + private suspend fun updateTile( + x: Int, + y: Int, + featureCollections: Array, + ): Boolean { + if (!SOUNDSCAPE_TILE_BACKEND) { return updateTileFromProtomaps(x, y, featureCollections) } return updateTileFromSoundscapeBackend(x, y, featureCollections) } - private suspend fun updateTileGrid(tileGrid : TileGrid, featureCollections: Array) : Boolean { + private suspend fun updateTileGrid( + tileGrid: TileGrid, + featureCollections: Array, + ): Boolean { for (tile in tileGrid.tiles) { Log.d(TAG, "Tile quad key: ${tile.quadkey}") var ret = false - for(retry in 1..5) { + for (retry in 1..5) { ret = updateTile(tile.tileX, tile.tileY, featureCollections) - if(ret) { + if (ret) { break } } - if(!ret) { + if (!ret) { return false } } @@ -348,16 +378,18 @@ class GeoEngine { private val locationFilter = LocationUpdateFilter(10000, 50.0) private val poiFilter = LocationUpdateFilter(5000, 5.0) - private fun buildCalloutForRoadSense(location: LngLatAlt) : List { - val results : MutableList = mutableListOf() + private fun buildCalloutForRoadSense(location: LngLatAlt): List { + val results: MutableList = mutableListOf() // Check that our location/time has changed enough to generate this callout - if(!locationFilter.shouldUpdate(location)) + if (!locationFilter.shouldUpdate(location)) { return emptyList() + } // Check that we're in a vehicle - if(!inVehicle) + if (!inVehicle) { return emptyList() + } // Update time/location filter for our new position locationFilter.update(location) @@ -372,16 +404,18 @@ class GeoEngine { private val intersectionFilter = LocationUpdateFilter(5000, 5.0) private val intersectionCalloutHistory = CalloutHistory(30000) - private suspend fun buildCalloutForIntersections(location: LngLatAlt) : List { - val results : MutableList = mutableListOf() + private suspend fun buildCalloutForIntersections(location: LngLatAlt): List { + val results: MutableList = mutableListOf() // Check that our location/time has changed enough to generate this callout - if(!intersectionFilter.shouldUpdate(location)) + if (!intersectionFilter.shouldUpdate(location)) { return emptyList() + } // Check that we're not in a vehicle - if(inVehicle) + if (inVehicle) { return emptyList() + } // Update time/location filter for our new position intersectionFilter.update(location) @@ -390,91 +424,116 @@ class GeoEngine { val orientation = directionProvider.getCurrentDirection().toDouble() val fovDistance = 50.0 val roadsGridFeatureCollection = getGridFeatureCollection(Fc.ROADS.id, location, 60.0) - val intersectionsGridFeatureCollection = getGridFeatureCollection(Fc.INTERSECTIONS.id, location, 60.0) + val intersectionsGridFeatureCollection = + getGridFeatureCollection(Fc.INTERSECTIONS.id, location, 60.0) if (roadsGridFeatureCollection.features.isNotEmpty()) { - val fovRoadsFeatureCollection = getFovRoadsFeatureCollection( - location, - orientation, - fovDistance, - roadsGridFeatureCollection - ) - val fovIntersectionsFeatureCollection = getFovIntersectionFeatureCollection( - location, - orientation, - fovDistance, - intersectionsGridFeatureCollection - ) - - if (fovIntersectionsFeatureCollection.features.isNotEmpty() && - fovRoadsFeatureCollection.features.isNotEmpty()) { - - val intersectionsSortedByDistance = sortedByDistanceTo( - locationProvider.getCurrentLatitude() ?: 0.0, - locationProvider.getCurrentLongitude() ?: 0.0, - fovIntersectionsFeatureCollection + val fovRoadsFeatureCollection = + getFovRoadsFeatureCollection( + location, + orientation, + fovDistance, + roadsGridFeatureCollection, ) - - val testNearestRoad = getNearestRoad( + val fovIntersectionsFeatureCollection = + getFovIntersectionFeatureCollection( location, - fovRoadsFeatureCollection + orientation, + fovDistance, + intersectionsGridFeatureCollection, ) + + if (fovIntersectionsFeatureCollection.features.isNotEmpty() && + fovRoadsFeatureCollection.features.isNotEmpty() + ) { + val intersectionsSortedByDistance = + sortedByDistanceTo( + locationProvider.getCurrentLatitude() ?: 0.0, + locationProvider.getCurrentLongitude() ?: 0.0, + fovIntersectionsFeatureCollection, + ) + + val testNearestRoad = + getNearestRoad( + location, + fovRoadsFeatureCollection, + ) val intersectionsNeedsFurtherCheckingFC = FeatureCollection() for (i in 0 until intersectionsSortedByDistance.features.size) { val testNearestIntersection = FeatureCollection() testNearestIntersection.addFeature(intersectionsSortedByDistance.features[i]) - val intersectionRoadNames = getIntersectionRoadNames(testNearestIntersection, fovRoadsFeatureCollection) - val intersectionsNeedsFurtherChecking = checkIntersection(i, intersectionRoadNames, testNearestRoad) - if(intersectionsNeedsFurtherChecking) { + val intersectionRoadNames = + getIntersectionRoadNames(testNearestIntersection, fovRoadsFeatureCollection) + val intersectionsNeedsFurtherChecking = + checkIntersection(i, intersectionRoadNames, testNearestRoad) + if (intersectionsNeedsFurtherChecking) { intersectionsNeedsFurtherCheckingFC.addFeature(intersectionsSortedByDistance.features[i]) } } if (intersectionsNeedsFurtherCheckingFC.features.isNotEmpty()) { // Approach 1: find the intersection feature with the most osm_ids and use that? - val featureWithMostOsmIds: Feature? = intersectionsNeedsFurtherCheckingFC.features.maxByOrNull { - feature -> - (feature.foreign?.get("osm_ids") as? List<*>)?.size ?: 0 - } + val featureWithMostOsmIds: Feature? = + intersectionsNeedsFurtherCheckingFC.features.maxByOrNull { feature -> + (feature.foreign?.get("osm_ids") as? List<*>)?.size ?: 0 + } val newIntersectionFeatureCollection = FeatureCollection() if (featureWithMostOsmIds != null) { newIntersectionFeatureCollection.addFeature(featureWithMostOsmIds) } - val nearestIntersection = getNearestIntersection( - LngLatAlt(locationProvider.getCurrentLongitude() ?: 0.0, - locationProvider.getCurrentLatitude() ?: 0.0), - fovIntersectionsFeatureCollection - ) - val nearestRoadBearing = getRoadBearingToIntersection(nearestIntersection, testNearestRoad, orientation) - if(newIntersectionFeatureCollection.features.isNotEmpty()) { - val intersectionLocation = - newIntersectionFeatureCollection.features[0].geometry as Point - val intersectionLngLat = LngLatAlt( - intersectionLocation.coordinates.longitude, - intersectionLocation.coordinates.latitude - ) - val intersectionRelativeDirections = getRelativeDirectionsPolygons( - intersectionLngLat, - nearestRoadBearing, - //fovDistance, - 5.0, - RelativeDirections.COMBINED - ) - val distanceToNearestIntersection = distance( - locationProvider.getCurrentLatitude() ?: 0.0, - locationProvider.getCurrentLongitude() ?: 0.0, - intersectionLocation.coordinates.latitude, - intersectionLocation.coordinates.longitude + val nearestIntersection = + getNearestIntersection( + LngLatAlt( + locationProvider.getCurrentLongitude() ?: 0.0, + locationProvider.getCurrentLatitude() ?: 0.0, + ), + fovIntersectionsFeatureCollection, ) - val intersectionRoadNames = getIntersectionRoadNames( - newIntersectionFeatureCollection, - fovRoadsFeatureCollection + val nearestRoadBearing = + getRoadBearingToIntersection( + nearestIntersection, + testNearestRoad, + orientation, ) + if (newIntersectionFeatureCollection.features.isNotEmpty()) { + val intersectionLocation = + newIntersectionFeatureCollection.features[0].geometry as Point + val intersectionLngLat = + LngLatAlt( + intersectionLocation.coordinates.longitude, + intersectionLocation.coordinates.latitude, + ) + val intersectionRelativeDirections = + getRelativeDirectionsPolygons( + intersectionLngLat, + nearestRoadBearing, + // fovDistance, + 5.0, + RelativeDirections.COMBINED, + ) + val distanceToNearestIntersection = + distance( + locationProvider.getCurrentLatitude() ?: 0.0, + locationProvider.getCurrentLongitude() ?: 0.0, + intersectionLocation.coordinates.latitude, + intersectionLocation.coordinates.longitude, + ) + val intersectionRoadNames = + getIntersectionRoadNames( + newIntersectionFeatureCollection, + fovRoadsFeatureCollection, + ) - val intersectionName = newIntersectionFeatureCollection.features[0].properties?.get("name") as String - val callout = TrackedCallout(intersectionName, - intersectionLngLat, true, false) + val intersectionName = + newIntersectionFeatureCollection.features[0].properties?.get("name") as String + val callout = + TrackedCallout( + intersectionName, + intersectionLngLat, + true, + false, + ) if (intersectionCalloutHistory.find(callout)) { Log.d(TAG, "Discard ${callout.callout}") return emptyList() @@ -484,39 +543,46 @@ class GeoEngine { "${localizedContext.getString(R.string.intersection_approaching_intersection)} ${ localizedContext.getString( R.string.distance_format_meters, - distanceToNearestIntersection.toInt().toString() + distanceToNearestIntersection.toInt().toString(), ) - }" - ) + }", + ), ) intersectionCalloutHistory.add(callout) } - val roadRelativeDirections = getIntersectionRoadNamesRelativeDirections( - intersectionRoadNames, - newIntersectionFeatureCollection, - intersectionRelativeDirections - ) + val roadRelativeDirections = + getIntersectionRoadNamesRelativeDirections( + intersectionRoadNames, + newIntersectionFeatureCollection, + intersectionRelativeDirections, + ) for (feature in roadRelativeDirections.features) { val direction = - feature.properties?.get("Direction").toString().toIntOrNull() + feature.properties + ?.get("Direction") + .toString() + .toIntOrNull() // Don't call out the road we are on (0) as part of the intersection if (direction != null && direction != 0) { val relativeDirectionString = getRelativeDirectionLabel( localizedContext, direction, - configLocale + configLocale, ) if (feature.properties?.get("name") != null) { - val intersectionCallout = localizedContext.getString( - R.string.directions_intersection_with_name_direction, - feature.properties?.get("name"), - relativeDirectionString + val intersectionCallout = + localizedContext.getString( + R.string.directions_intersection_with_name_direction, + feature.properties?.get("name"), + relativeDirectionString, + ) + results.add( + PositionedString( + intersectionCallout, + ), ) - results.add(PositionedString( - intersectionCallout - )) } } } @@ -528,7 +594,11 @@ class GeoEngine { } private val poiCalloutHistory = CalloutHistory() - private suspend fun buildCalloutForNearbyPOI(location: LngLatAlt, speed: Float) : List { + + private suspend fun buildCalloutForNearbyPOI( + location: LngLatAlt, + speed: Float, + ): List { if (!poiFilter.shouldUpdateActivity(location, speed, inVehicle)) { return emptyList() } @@ -580,7 +650,7 @@ class GeoEngine { val landmarkSuperCategory = getPoiFeatureCollectionBySuperCategory( "landmark", - gridPoiFeatureCollection + gridPoiFeatureCollection, ) for (feature in landmarkSuperCategory.features) { settingsFeatureCollection.features.add(feature) @@ -588,19 +658,18 @@ class GeoEngine { val mobilitySuperCategory = getPoiFeatureCollectionBySuperCategory( "mobility", - gridPoiFeatureCollection + gridPoiFeatureCollection, ) for (feature in mobilitySuperCategory.features) { settingsFeatureCollection.features.add(feature) } - } else { - val placeSuperCategory = getPoiFeatureCollectionBySuperCategory("place", gridPoiFeatureCollection) for (feature in placeSuperCategory.features) { - if (feature.foreign?.get("feature_type") != "building" && feature.foreign?.get( - "feature_value" + if (feature.foreign?.get("feature_type") != "building" && + feature.foreign?.get( + "feature_value", ) != "house" ) { settingsFeatureCollection.features.add(feature) @@ -609,7 +678,7 @@ class GeoEngine { val landmarkSuperCategory = getPoiFeatureCollectionBySuperCategory( "landmark", - gridPoiFeatureCollection + gridPoiFeatureCollection, ) for (feature in landmarkSuperCategory.features) { settingsFeatureCollection.features.add(feature) @@ -617,11 +686,11 @@ class GeoEngine { } } else { if (mobility) { - //Log.d(TAG, "placesAndLandmarks is false and mobility is true") + // Log.d(TAG, "placesAndLandmarks is false and mobility is true") val mobilitySuperCategory = getPoiFeatureCollectionBySuperCategory( "mobility", - gridPoiFeatureCollection + gridPoiFeatureCollection, ) for (feature in mobilitySuperCategory.features) { settingsFeatureCollection.features.add(feature) @@ -638,31 +707,44 @@ class GeoEngine { // If the POI is outside the trigger range for that POI category, skip it (see CalloutRangeContext) if (settingsFeatureCollection.features.isNotEmpty()) { // Original Soundscape doesn't work like this as it doesn't order them by distance - val sortedByDistanceToFeatureCollection = sortedByDistanceTo( - locationProvider.getCurrentLatitude() ?: 0.0, - locationProvider.getCurrentLongitude() ?: 0.0, - settingsFeatureCollection - ) + val sortedByDistanceToFeatureCollection = + sortedByDistanceTo( + locationProvider.getCurrentLatitude() ?: 0.0, + locationProvider.getCurrentLongitude() ?: 0.0, + settingsFeatureCollection, + ) for (feature in sortedByDistanceToFeatureCollection) { val distance = feature.foreign?.get("distance_to") as Double? if (distance != null) { if (distance < 10.0) { var name = feature.properties?.get("name") as String? var generic = false - if(name == null) { + if (name == null) { name = feature.properties?.get("class") as String? generic = true } // Check the history and if the POI has been called out recently, skip it (iOS uses 60 seconds) val nearestPoint = getFeatureNearestPoint(location, feature) - if((name != null) && ( nearestPoint != null)) { - val callout = TrackedCallout(name, nearestPoint, feature.geometry.type == "Point", generic) + if ((name != null) && (nearestPoint != null)) { + val callout = + TrackedCallout( + name, + nearestPoint, + feature.geometry.type == "Point", + generic, + ) if (poiCalloutHistory.find(callout)) { Log.d(TAG, "Discard ${callout.callout}") } else { - results.add(PositionedString(name, nearestPoint, NativeAudioEngine.EARCON_SENSE_POI)) - //Add the entries to the history + results.add( + PositionedString( + name, + nearestPoint, + NativeAudioEngine.EARCON_SENSE_POI, + ), + ) + // Add the entries to the history poiCalloutHistory.add(callout) } } @@ -674,9 +756,7 @@ class GeoEngine { return results } - - private suspend fun autoCallout(androidLocation: Location) : List { - + private suspend fun autoCallout(androidLocation: Location): List { // The autoCallout logic comes straight from the iOS app. val location = LngLatAlt(androidLocation.longitude, androidLocation.latitude) @@ -689,12 +769,12 @@ class GeoEngine { // buildCalloutForRoadSense val roadSenseCallout = buildCalloutForRoadSense(location) - if(roadSenseCallout.isNotEmpty()) { + if (roadSenseCallout.isNotEmpty()) { return roadSenseCallout } val intersectionCallout = buildCalloutForIntersections(location) - if(intersectionCallout.isNotEmpty()) { + if (intersectionCallout.isNotEmpty()) { intersectionFilter.update(location) return intersectionCallout } @@ -703,7 +783,7 @@ class GeoEngine { val poiCallout = buildCalloutForNearbyPOI(location, speed) // Update time/location filter for our new position - if(poiCallout.isNotEmpty()) { + if (poiCallout.isNotEmpty()) { poiFilter.update(location) return poiCallout } @@ -711,21 +791,22 @@ class GeoEngine { return emptyList() } - suspend fun myLocation() : List { + suspend fun myLocation(): List { // getCurrentDirection() from the direction provider has a default of 0.0 // even if we don't have a valid current direction. - val results : MutableList = mutableListOf() + val results: MutableList = mutableListOf() if (locationProvider.getCurrentLatitude() == null || locationProvider.getCurrentLongitude() == null) { // Should be null but let's check - //Log.d(TAG, "Airplane mode On and GPS off. Current location: ${locationProvider.getCurrentLatitude()} , ${locationProvider.getCurrentLongitude()}") + // Log.d(TAG, "Airplane mode On and GPS off. Current location: ${locationProvider.getCurrentLatitude()} , ${locationProvider.getCurrentLongitude()}") val noLocationString = localizedContext.getString(R.string.general_error_location_services_find_location_error) results.add(PositionedString(noLocationString)) } else { - val location = LngLatAlt( - locationProvider.getCurrentLongitude() ?: 0.0, - locationProvider.getCurrentLatitude() ?: 0.0 - ) + val location = + LngLatAlt( + locationProvider.getCurrentLongitude() ?: 0.0, + locationProvider.getCurrentLatitude() ?: 0.0, + ) val roadGridFeatureCollection = FeatureCollection() val roadFeatureCollection = getGridFeatureCollection(Fc.ROADS.id, location, 100.0) @@ -735,17 +816,16 @@ class GeoEngine { roadGridFeatureCollection.features.addAll(pathFeatureCollection) if (roadGridFeatureCollection.features.isNotEmpty()) { - //Log.d(TAG, "Found roads in tile") + // Log.d(TAG, "Found roads in tile") val nearestRoad = getNearestRoad( LngLatAlt( locationProvider.getCurrentLongitude() ?: 0.0, - locationProvider.getCurrentLatitude() ?: 0.0 + locationProvider.getCurrentLatitude() ?: 0.0, ), - roadGridFeatureCollection + roadGridFeatureCollection, ) - if(nearestRoad.features.isNotEmpty()) { - + if (nearestRoad.features.isNotEmpty()) { val properties = nearestRoad.features[0].properties if (properties != null) { val orientation = directionProvider.getCurrentDirection() @@ -758,7 +838,7 @@ class GeoEngine { localizedContext, orientation.toInt(), roadName.toString(), - configLocale + configLocale, ) results.add(PositionedString(facingDirectionAlongRoad)) } else { @@ -766,13 +846,13 @@ class GeoEngine { } } } else { - //Log.d(TAG, "No roads found in tile just give device direction") + // Log.d(TAG, "No roads found in tile just give device direction") val orientation = directionProvider.getCurrentDirection() val facingDirection = getCompassLabelFacingDirection( localizedContext, orientation.toInt(), - configLocale + configLocale, ) results.add(PositionedString(facingDirection)) } @@ -780,30 +860,42 @@ class GeoEngine { return results } - suspend fun whatsAroundMe() : List { + suspend fun searchResult(searchString: String) = + withContext(Dispatchers.IO) { + return@withContext PhotonSearchProvider + .getInstance() + .getSearchResults( + searchString = searchString, + latitude = locationProvider.getCurrentLatitude(), + longitude = locationProvider.getCurrentLongitude(), + ).execute() + .body() + } + + suspend fun whatsAroundMe(): List { // TODO This is just a rough POC at the moment. Lots more to do... // setup settings in the menu so we can pass in the filters, etc. // Original Soundscape just splats out a list in no particular order which is odd. // If you press the button again in original Soundscape it can give you the same list but in a different sequence or // it can add one to the list even if you haven't moved. It also only seems to give a thing and a distance but not a heading. - val results : MutableList = mutableListOf() + val results: MutableList = mutableListOf() // super categories are "information", "object", "place", "landmark", "mobility", "safety" val placesAndLandmarks = sharedPreferences.getBoolean(PLACES_AND_LANDMARKS_KEY, true) val mobility = sharedPreferences.getBoolean(MOBILITY_KEY, true) // TODO unnamed roads switch is not used yet - //val unnamedRoads = sharedPrefs.getBoolean(UNNAMED_ROADS_KEY, false) + // val unnamedRoads = sharedPrefs.getBoolean(UNNAMED_ROADS_KEY, false) if (locationProvider.getCurrentLatitude() == null || locationProvider.getCurrentLongitude() == null) { val noLocationString = localizedContext.getString(R.string.general_error_location_services_find_location_error) results.add(PositionedString(noLocationString)) } else { - - val location = LngLatAlt( - locationProvider.getCurrentLongitude() ?: 0.0, - locationProvider.getCurrentLatitude() ?: 0.0 - ) + val location = + LngLatAlt( + locationProvider.getCurrentLongitude() ?: 0.0, + locationProvider.getCurrentLatitude() ?: 0.0, + ) // TODO: We could build separate rtrees for each of the super categories i.e. landmarks, // places etc. and that would make this code simpler. We could ask for a maximum number // of results when calling getGridFeatureCollection. For now, we just limit arbitrarily @@ -817,9 +909,12 @@ class GeoEngine { val settingsFeatureCollection = FeatureCollection() if (placesAndLandmarks) { if (mobility) { - //Log.d(TAG, "placesAndLandmarks and mobility are both true") + // Log.d(TAG, "placesAndLandmarks and mobility are both true") val placeSuperCategory = - getPoiFeatureCollectionBySuperCategory("place", gridPoiFeatureCollection) + getPoiFeatureCollectionBySuperCategory( + "place", + gridPoiFeatureCollection, + ) val tempFeatureCollection = FeatureCollection() for (feature in placeSuperCategory.features) { if (feature.foreign?.get("feature_value") != "house") { @@ -833,9 +928,9 @@ class GeoEngine { tempFeatureCollection.features.add(feature) found = true } - if(found) break + if (found) break } - if(found) break + if (found) break } } } @@ -848,7 +943,7 @@ class GeoEngine { val landmarkSuperCategory = getPoiFeatureCollectionBySuperCategory( "landmark", - gridPoiFeatureCollection + gridPoiFeatureCollection, ) for (feature in landmarkSuperCategory.features) { settingsFeatureCollection.features.add(feature) @@ -856,19 +951,21 @@ class GeoEngine { val mobilitySuperCategory = getPoiFeatureCollectionBySuperCategory( "mobility", - gridPoiFeatureCollection + gridPoiFeatureCollection, ) for (feature in mobilitySuperCategory.features) { settingsFeatureCollection.features.add(feature) } - } else { - val placeSuperCategory = - getPoiFeatureCollectionBySuperCategory("place", gridPoiFeatureCollection) + getPoiFeatureCollectionBySuperCategory( + "place", + gridPoiFeatureCollection, + ) for (feature in placeSuperCategory.features) { - if (feature.foreign?.get("feature_type") != "building" && feature.foreign?.get( - "feature_value" + if (feature.foreign?.get("feature_type") != "building" && + feature.foreign?.get( + "feature_value", ) != "house" ) { settingsFeatureCollection.features.add(feature) @@ -877,7 +974,7 @@ class GeoEngine { val landmarkSuperCategory = getPoiFeatureCollectionBySuperCategory( "landmark", - gridPoiFeatureCollection + gridPoiFeatureCollection, ) for (feature in landmarkSuperCategory.features) { settingsFeatureCollection.features.add(feature) @@ -885,11 +982,11 @@ class GeoEngine { } } else { if (mobility) { - //Log.d(TAG, "placesAndLandmarks is false and mobility is true") + // Log.d(TAG, "placesAndLandmarks is false and mobility is true") val mobilitySuperCategory = getPoiFeatureCollectionBySuperCategory( "mobility", - gridPoiFeatureCollection + gridPoiFeatureCollection, ) for (feature in mobilitySuperCategory.features) { settingsFeatureCollection.features.add(feature) @@ -903,42 +1000,58 @@ class GeoEngine { // "information", "object", "place", "landmark", "mobility", "safety" if (settingsFeatureCollection.features.isNotEmpty()) { // Original Soundscape doesn't work like this as it doesn't order them by distance - val sortedByDistanceToFeatureCollection = sortedByDistanceTo( - locationProvider.getCurrentLatitude() ?: 0.0, - locationProvider.getCurrentLongitude() ?: 0.0, - settingsFeatureCollection - ) + val sortedByDistanceToFeatureCollection = + sortedByDistanceTo( + locationProvider.getCurrentLatitude() ?: 0.0, + locationProvider.getCurrentLongitude() ?: 0.0, + settingsFeatureCollection, + ) for (feature in sortedByDistanceToFeatureCollection) { if (feature.geometry is Polygon) { // found that if a thing has a name property that ends in a number // "data 365" then the 365 and distance away get merged into a large number "365200 meters". Hoping a full stop will fix it if (feature.properties?.get("name") != null) { - val userLocation = LngLatAlt( - locationProvider.getCurrentLongitude() ?: 0.0, - locationProvider.getCurrentLatitude() ?: 0.0 - ) + val userLocation = + LngLatAlt( + locationProvider.getCurrentLongitude() ?: 0.0, + locationProvider.getCurrentLatitude() ?: 0.0, + ) val text = "${feature.properties?.get("name")}. ${ distanceToPolygon( - userLocation, - feature.geometry as Polygon - ).toInt() - } meters." - - val poiLocation = getFeatureNearestPoint(userLocation, feature) - results.add(PositionedString(text, poiLocation, NativeAudioEngine.EARCON_SENSE_POI)) + userLocation, + feature.geometry as Polygon, + ).toInt() + } meters." + + val poiLocation = getFeatureNearestPoint(userLocation, feature) + results.add( + PositionedString( + text, + poiLocation, + NativeAudioEngine.EARCON_SENSE_POI, + ), + ) } } else if (feature.geometry is Point) { if (feature.properties?.get("name") != null) { val point = feature.geometry as Point - val d = distance(locationProvider.getCurrentLatitude() ?: 0.0, - locationProvider.getCurrentLongitude() ?: 0.0, - point.coordinates.latitude, - point.coordinates.longitude).toInt() + val d = + distance( + locationProvider.getCurrentLatitude() ?: 0.0, + locationProvider.getCurrentLongitude() ?: 0.0, + point.coordinates.latitude, + point.coordinates.longitude, + ).toInt() val text = "${feature.properties?.get("name")}. $d meters." - results.add(PositionedString(text, point.coordinates, NativeAudioEngine.EARCON_SENSE_POI)) + results.add( + PositionedString( + text, + point.coordinates, + NativeAudioEngine.EARCON_SENSE_POI, + ), + ) } } - } } else { results.add(PositionedString(localizedContext.getString(R.string.callouts_nothing_to_call_out_now))) @@ -951,172 +1064,216 @@ class GeoEngine { return results } - suspend fun aheadOfMe() : List { + suspend fun aheadOfMe(): List { // TODO This is just a rough POC at the moment. Lots more to do... - val results : MutableList = mutableListOf() + val results: MutableList = mutableListOf() if (locationProvider.getCurrentLatitude() == null || locationProvider.getCurrentLongitude() == null) { // Should be null but let's check - //Log.d(TAG, "Airplane mode On and GPS off. Current location: ${locationProvider.getCurrentLatitude()} , ${locationProvider.getCurrentLongitude()}") + // Log.d(TAG, "Airplane mode On and GPS off. Current location: ${locationProvider.getCurrentLatitude()} , ${locationProvider.getCurrentLongitude()}") val noLocationString = localizedContext.getString(R.string.general_error_location_services_find_location_error) results.add(PositionedString(noLocationString)) } else { // get device direction - val location = LngLatAlt( - locationProvider.getCurrentLongitude() ?: 0.0, - locationProvider.getCurrentLatitude() ?: 0.0 - ) + val location = + LngLatAlt( + locationProvider.getCurrentLongitude() ?: 0.0, + locationProvider.getCurrentLatitude() ?: 0.0, + ) val orientation = directionProvider.getCurrentDirection().toDouble() val fovDistance = 50.0 val roadsGridFeatureCollection = getGridFeatureCollection(Fc.ROADS.id, location, 100.0) - val intersectionsGridFeatureCollection = getGridFeatureCollection(Fc.INTERSECTIONS.id, location, 100.0) - val crossingsGridFeatureCollection = getGridFeatureCollection(Fc.CROSSINGS.id, location, 100.0) - val busStopsGridFeatureCollection = getGridFeatureCollection(Fc.BUS_STOPS.id, location, 100.0) + val intersectionsGridFeatureCollection = + getGridFeatureCollection(Fc.INTERSECTIONS.id, location, 100.0) + val crossingsGridFeatureCollection = + getGridFeatureCollection(Fc.CROSSINGS.id, location, 100.0) + val busStopsGridFeatureCollection = + getGridFeatureCollection(Fc.BUS_STOPS.id, location, 100.0) if (roadsGridFeatureCollection.features.isNotEmpty()) { - val fovRoadsFeatureCollection = getFovRoadsFeatureCollection( - location, - orientation, - fovDistance, - roadsGridFeatureCollection - ) - val fovIntersectionsFeatureCollection = getFovIntersectionFeatureCollection( - location, - orientation, - fovDistance, - intersectionsGridFeatureCollection - ) - val fovCrossingsFeatureCollection = getFovIntersectionFeatureCollection( - location, - orientation, - fovDistance, - crossingsGridFeatureCollection - ) - val fovBusStopsFeatureCollection = getFovIntersectionFeatureCollection( - location, - orientation, - fovDistance, - busStopsGridFeatureCollection - ) - - if (fovRoadsFeatureCollection.features.isNotEmpty()) { - val nearestRoad = getNearestRoad( + val fovRoadsFeatureCollection = + getFovRoadsFeatureCollection( + location, + orientation, + fovDistance, + roadsGridFeatureCollection, + ) + val fovIntersectionsFeatureCollection = + getFovIntersectionFeatureCollection( + location, + orientation, + fovDistance, + intersectionsGridFeatureCollection, + ) + val fovCrossingsFeatureCollection = + getFovIntersectionFeatureCollection( + location, + orientation, + fovDistance, + crossingsGridFeatureCollection, + ) + val fovBusStopsFeatureCollection = + getFovIntersectionFeatureCollection( location, - fovRoadsFeatureCollection + orientation, + fovDistance, + busStopsGridFeatureCollection, ) + + if (fovRoadsFeatureCollection.features.isNotEmpty()) { + val nearestRoad = + getNearestRoad( + location, + fovRoadsFeatureCollection, + ) // TODO check for Settings, Unnamed roads on/off here if (nearestRoad.features.isNotEmpty()) { if (nearestRoad.features[0].properties?.get("name") != null) { - results.add(PositionedString( - "${localizedContext.getString(R.string.directions_direction_ahead)} ${nearestRoad.features[0].properties!!["name"]}" - )) + results.add( + PositionedString( + "${localizedContext.getString( + R.string.directions_direction_ahead, + )} ${nearestRoad.features[0].properties!!["name"]}", + ), + ) } else { // we are detecting an unnamed road here but pretending there is nothing here - results.add(PositionedString( - localizedContext.getString(R.string.callouts_nothing_to_call_out_now) - )) + results.add( + PositionedString( + localizedContext.getString(R.string.callouts_nothing_to_call_out_now), + ), + ) } } if (fovIntersectionsFeatureCollection.features.isNotEmpty() && - fovRoadsFeatureCollection.features.isNotEmpty()) { - - val intersectionsSortedByDistance = sortedByDistanceTo( - locationProvider.getCurrentLatitude() ?: 0.0, - locationProvider.getCurrentLongitude() ?: 0.0, - fovIntersectionsFeatureCollection - ) + fovRoadsFeatureCollection.features.isNotEmpty() + ) { + val intersectionsSortedByDistance = + sortedByDistanceTo( + locationProvider.getCurrentLatitude() ?: 0.0, + locationProvider.getCurrentLongitude() ?: 0.0, + fovIntersectionsFeatureCollection, + ) - val testNearestRoad = getNearestRoad( - location, - fovRoadsFeatureCollection - ) + val testNearestRoad = + getNearestRoad( + location, + fovRoadsFeatureCollection, + ) val intersectionsNeedsFurtherCheckingFC = FeatureCollection() for (i in 0 until intersectionsSortedByDistance.features.size) { val testNearestIntersection = FeatureCollection() testNearestIntersection.addFeature(intersectionsSortedByDistance.features[i]) - val intersectionRoadNames = getIntersectionRoadNames(testNearestIntersection, fovRoadsFeatureCollection) - val intersectionsNeedsFurtherChecking = checkIntersection(i, intersectionRoadNames, testNearestRoad) - if(intersectionsNeedsFurtherChecking) { - intersectionsNeedsFurtherCheckingFC.addFeature(intersectionsSortedByDistance.features[i]) + val intersectionRoadNames = + getIntersectionRoadNames( + testNearestIntersection, + fovRoadsFeatureCollection, + ) + val intersectionsNeedsFurtherChecking = + checkIntersection(i, intersectionRoadNames, testNearestRoad) + if (intersectionsNeedsFurtherChecking) { + intersectionsNeedsFurtherCheckingFC.addFeature( + intersectionsSortedByDistance.features[i], + ) } } if (intersectionsNeedsFurtherCheckingFC.features.isNotEmpty()) { // Approach 1: find the intersection feature with the most osm_ids and use that? - val featureWithMostOsmIds: Feature? = intersectionsNeedsFurtherCheckingFC.features.maxByOrNull { - feature -> - (feature.foreign?.get("osm_ids") as? List<*>)?.size ?: 0 - } + val featureWithMostOsmIds: Feature? = + intersectionsNeedsFurtherCheckingFC.features.maxByOrNull { feature -> + (feature.foreign?.get("osm_ids") as? List<*>)?.size ?: 0 + } val newIntersectionFeatureCollection = FeatureCollection() if (featureWithMostOsmIds != null) { newIntersectionFeatureCollection.addFeature(featureWithMostOsmIds) } - val nearestIntersection = getNearestIntersection( - LngLatAlt(locationProvider.getCurrentLongitude() ?: 0.0, - locationProvider.getCurrentLatitude() ?: 0.0), - fovIntersectionsFeatureCollection - ) - val nearestRoadBearing = getRoadBearingToIntersection(nearestIntersection, testNearestRoad, orientation) - if(newIntersectionFeatureCollection.features.isNotEmpty()) { - val intersectionLocation = - newIntersectionFeatureCollection.features[0].geometry as Point - val intersectionRelativeDirections = getRelativeDirectionsPolygons( + val nearestIntersection = + getNearestIntersection( LngLatAlt( - intersectionLocation.coordinates.longitude, - intersectionLocation.coordinates.latitude + locationProvider.getCurrentLongitude() ?: 0.0, + locationProvider.getCurrentLatitude() ?: 0.0, ), - nearestRoadBearing, - //fovDistance, - 5.0, - RelativeDirections.COMBINED + fovIntersectionsFeatureCollection, ) - val distanceToNearestIntersection = distance( - locationProvider.getCurrentLatitude() ?: 0.0, - locationProvider.getCurrentLongitude() ?: 0.0, - intersectionLocation.coordinates.latitude, - intersectionLocation.coordinates.longitude + val nearestRoadBearing = + getRoadBearingToIntersection( + nearestIntersection, + testNearestRoad, + orientation, ) - val intersectionRoadNames = getIntersectionRoadNames( - newIntersectionFeatureCollection, - fovRoadsFeatureCollection + if (newIntersectionFeatureCollection.features.isNotEmpty()) { + val intersectionLocation = + newIntersectionFeatureCollection.features[0].geometry as Point + val intersectionRelativeDirections = + getRelativeDirectionsPolygons( + LngLatAlt( + intersectionLocation.coordinates.longitude, + intersectionLocation.coordinates.latitude, + ), + nearestRoadBearing, + // fovDistance, + 5.0, + RelativeDirections.COMBINED, + ) + val distanceToNearestIntersection = + distance( + locationProvider.getCurrentLatitude() ?: 0.0, + locationProvider.getCurrentLongitude() ?: 0.0, + intersectionLocation.coordinates.latitude, + intersectionLocation.coordinates.longitude, + ) + val intersectionRoadNames = + getIntersectionRoadNames( + newIntersectionFeatureCollection, + fovRoadsFeatureCollection, + ) + results.add( + PositionedString( + "${localizedContext.getString(R.string.intersection_approaching_intersection)} ${ + localizedContext.getString( + R.string.distance_format_meters, + distanceToNearestIntersection.toInt().toString(), + ) + }", + ), ) - results.add(PositionedString( - "${localizedContext.getString(R.string.intersection_approaching_intersection)} ${ - localizedContext.getString( - R.string.distance_format_meters, - distanceToNearestIntersection.toInt().toString() - ) - }" - )) - val roadRelativeDirections = getIntersectionRoadNamesRelativeDirections( - intersectionRoadNames, - newIntersectionFeatureCollection, - intersectionRelativeDirections - ) + val roadRelativeDirections = + getIntersectionRoadNamesRelativeDirections( + intersectionRoadNames, + newIntersectionFeatureCollection, + intersectionRelativeDirections, + ) for (feature in roadRelativeDirections.features) { val direction = - feature.properties?.get("Direction").toString().toIntOrNull() + feature.properties + ?.get("Direction") + .toString() + .toIntOrNull() // Don't call out the road we are on (0) as part of the intersection if (direction != null && direction != 0) { val relativeDirectionString = getRelativeDirectionLabel( localizedContext, direction, - configLocale + configLocale, ) if (feature.properties?.get("name") != null) { - val intersectionCallout = localizedContext.getString( - R.string.directions_intersection_with_name_direction, - feature.properties?.get("name"), - relativeDirectionString + val intersectionCallout = + localizedContext.getString( + R.string.directions_intersection_with_name_direction, + feature.properties?.get("name"), + relativeDirectionString, + ) + results.add( + PositionedString( + intersectionCallout, + ), ) - results.add(PositionedString( - intersectionCallout - )) } } } @@ -1126,42 +1283,45 @@ class GeoEngine { } // detect if there is a crossing in the FOV if (fovCrossingsFeatureCollection.features.isNotEmpty()) { - - val nearestCrossing = getNearestIntersection( - location, - fovCrossingsFeatureCollection - ) + val nearestCrossing = + getNearestIntersection( + location, + fovCrossingsFeatureCollection, + ) if (nearestCrossing.features.isNotEmpty()) { val crossingLocation = nearestCrossing.features[0].geometry as Point - val distanceToCrossing = distance( - locationProvider.getCurrentLatitude() ?: 0.0, - locationProvider.getCurrentLongitude() ?: 0.0, - crossingLocation.coordinates.latitude, - crossingLocation.coordinates.longitude - ) - // Confirm which road the crossing is on - val nearestRoadToCrossing = getNearestRoad( - LngLatAlt( + val distanceToCrossing = + distance( + locationProvider.getCurrentLatitude() ?: 0.0, + locationProvider.getCurrentLongitude() ?: 0.0, + crossingLocation.coordinates.latitude, crossingLocation.coordinates.longitude, - crossingLocation.coordinates.latitude - ), - fovRoadsFeatureCollection - ) + ) + // Confirm which road the crossing is on + val nearestRoadToCrossing = + getNearestRoad( + LngLatAlt( + crossingLocation.coordinates.longitude, + crossingLocation.coordinates.latitude, + ), + fovRoadsFeatureCollection, + ) if (nearestRoadToCrossing.features.isNotEmpty()) { - val crossingText = buildString { - append(localizedContext.getString(R.string.osm_tag_crossing)) - append(". ") - append( - localizedContext.getString( - R.string.distance_format_meters, - distanceToCrossing.toInt().toString() + val crossingText = + buildString { + append(localizedContext.getString(R.string.osm_tag_crossing)) + append(". ") + append( + localizedContext.getString( + R.string.distance_format_meters, + distanceToCrossing.toInt().toString(), + ), ) - ) - append(". ") - if (nearestRoadToCrossing.features[0].properties?.get("name") != null) { - append(nearestRoadToCrossing.features[0].properties?.get("name")) + append(". ") + if (nearestRoadToCrossing.features[0].properties?.get("name") != null) { + append(nearestRoadToCrossing.features[0].properties?.get("name")) + } } - } results.add(PositionedString(crossingText)) } } @@ -1169,56 +1329,63 @@ class GeoEngine { // detect if there is a bus_stop in the FOV if (fovBusStopsFeatureCollection.features.isNotEmpty()) { - val nearestBusStop = getNearestIntersection( - location, - fovBusStopsFeatureCollection - ) + val nearestBusStop = + getNearestIntersection( + location, + fovBusStopsFeatureCollection, + ) if (nearestBusStop.features.isNotEmpty()) { val busStopLocation = nearestBusStop.features[0].geometry as Point - val distanceToBusStop = distance( - locationProvider.getCurrentLatitude() ?: 0.0, - locationProvider.getCurrentLongitude() ?: 0.0, - busStopLocation.coordinates.latitude, - busStopLocation.coordinates.longitude - ) - // Confirm which road the crossing is on - val nearestRoadToBus = getNearestRoad( - LngLatAlt( + val distanceToBusStop = + distance( + locationProvider.getCurrentLatitude() ?: 0.0, + locationProvider.getCurrentLongitude() ?: 0.0, + busStopLocation.coordinates.latitude, busStopLocation.coordinates.longitude, - busStopLocation.coordinates.latitude - ), - fovRoadsFeatureCollection - ) - if(nearestRoadToBus.features.isNotEmpty()) { - val busText = buildString { - append(localizedContext.getString(R.string.osm_tag_bus_stop)) - append(". ") - append( - localizedContext.getString( - R.string.distance_format_meters, - distanceToBusStop.toInt().toString() + ) + // Confirm which road the crossing is on + val nearestRoadToBus = + getNearestRoad( + LngLatAlt( + busStopLocation.coordinates.longitude, + busStopLocation.coordinates.latitude, + ), + fovRoadsFeatureCollection, + ) + if (nearestRoadToBus.features.isNotEmpty()) { + val busText = + buildString { + append(localizedContext.getString(R.string.osm_tag_bus_stop)) + append(". ") + append( + localizedContext.getString( + R.string.distance_format_meters, + distanceToBusStop.toInt().toString(), + ), ) - ) - append(". ") - if (nearestRoadToBus.features[0].properties?.get("name") != null) { - append(nearestRoadToBus.features[0].properties?.get("name")) + append(". ") + if (nearestRoadToBus.features[0].properties?.get("name") != null) { + append(nearestRoadToBus.features[0].properties?.get("name")) + } } - } results.add(PositionedString(busText)) } } } } else { - results.add(PositionedString( - localizedContext.getString(R.string.callouts_nothing_to_call_out_now) - )) - + results.add( + PositionedString( + localizedContext.getString(R.string.callouts_nothing_to_call_out_now), + ), + ) } } return results } - enum class Fc(val id: Int) { + enum class Fc( + val id: Int, + ) { ROADS(0), PATHS(1), INTERSECTIONS(2), @@ -1227,23 +1394,26 @@ class GeoEngine { POIS(5), BUS_STOPS(6), INTERPOLATIONS(7), - MAX_COLLECTION_ID(8) + MAX_COLLECTION_ID(8), } - private suspend fun getGridFeatureCollection(id: Int, - location: LngLatAlt = LngLatAlt(), - distance : Double = Double.POSITIVE_INFINITY, - maxCount : Int = 0): FeatureCollection { + private suspend fun getGridFeatureCollection( + id: Int, + location: LngLatAlt = LngLatAlt(), + distance: Double = Double.POSITIVE_INFINITY, + maxCount: Int = 0, + ): FeatureCollection { treeMutex.lock() - val result = if(distance == Double.POSITIVE_INFINITY) { - featureTrees[id].generateFeatureCollection() - } else { - if(maxCount == 0) { - featureTrees[id].generateNearbyFeatureCollection(location, distance) + val result = + if (distance == Double.POSITIVE_INFINITY) { + featureTrees[id].generateFeatureCollection() } else { - featureTrees[id].generateNearestFeatureCollection(location, distance, maxCount) + if (maxCount == 0) { + featureTrees[id].generateNearbyFeatureCollection(location, distance) + } else { + featureTrees[id].generateNearestFeatureCollection(location, distance, maxCount) + } } - } treeMutex.unlock() return result } @@ -1251,4 +1421,4 @@ class GeoEngine { companion object { private const val TAG = "GeoEngine" } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/scottishtecharmy/soundscape/network/SearchProvider.kt b/app/src/main/java/org/scottishtecharmy/soundscape/network/SearchProvider.kt index 141c070c..a17e679a 100644 --- a/app/src/main/java/org/scottishtecharmy/soundscape/network/SearchProvider.kt +++ b/app/src/main/java/org/scottishtecharmy/soundscape/network/SearchProvider.kt @@ -1,14 +1,18 @@ package org.scottishtecharmy.soundscape.network import com.squareup.moshi.Moshi +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import org.scottishtecharmy.soundscape.BuildConfig import org.scottishtecharmy.soundscape.geojsonparser.geojson.FeatureCollection import org.scottishtecharmy.soundscape.geojsonparser.geojson.GeoMoshi import retrofit2.Call -import retrofit2.Response import retrofit2.Retrofit import retrofit2.converter.moshi.MoshiConverterFactory import retrofit2.http.GET +import retrofit2.http.Headers import retrofit2.http.Query +import java.util.Locale const val BASE_URL = "https://photon.komoot.io/" @@ -23,23 +27,42 @@ const val BASE_URL = "https://photon.komoot.io/" * @param limit is the number of results to return. */ interface PhotonSearchProvider { + @Headers( + "Cache-control: max-age=0", + "Connection: keep-alive" + ) @GET("api/") - fun getSearchResults(@Query("q") searchString : String, - @Query("lat") latitude : Double? = null, - @Query("lon") longitude : Double? = null, - @Query("limit") limit : UInt = 5U - ): Call + fun getSearchResults( + @Query("q") searchString: String, + @Query("lat") latitude: Double? = null, + @Query("lon") longitude: Double? = null, + @Query("lang") language: String = Locale.getDefault().language, + @Query("limit") limit: UInt = 5U, + @Query("location_bias_scale") bias: Float = 0.2f + ): Call companion object { private var searchProvider: PhotonSearchProvider? = null private var moshi: Moshi? = null + private val loggingInterceptor = HttpLoggingInterceptor().apply { + level = if (BuildConfig.DEBUG) { + HttpLoggingInterceptor.Level.BASIC + } else { + HttpLoggingInterceptor.Level.NONE // Disable logging in release builds + } + } + private val logging = OkHttpClient.Builder() + .addInterceptor(loggingInterceptor) + .build() + fun getInstance(): PhotonSearchProvider { if (searchProvider == null) { moshi = GeoMoshi.registerAdapters(Moshi.Builder()).build() searchProvider = Retrofit.Builder() - .baseUrl(BASE_URL) + .baseUrl(BASE_URL).client(logging) .addConverterFactory(MoshiConverterFactory.create(moshi!!)) - .build().create(PhotonSearchProvider::class.java) + .build() + .create(PhotonSearchProvider::class.java) } return searchProvider!! } diff --git a/app/src/main/java/org/scottishtecharmy/soundscape/screens/home/HomeScreen.kt b/app/src/main/java/org/scottishtecharmy/soundscape/screens/home/HomeScreen.kt index 1e255860..3f70f395 100644 --- a/app/src/main/java/org/scottishtecharmy/soundscape/screens/home/HomeScreen.kt +++ b/app/src/main/java/org/scottishtecharmy/soundscape/screens/home/HomeScreen.kt @@ -11,14 +11,14 @@ import androidx.navigation.compose.composable import androidx.navigation.navigation import com.google.gson.GsonBuilder import kotlinx.coroutines.flow.MutableStateFlow +import org.scottishtecharmy.soundscape.screens.home.data.LocationDescription import org.scottishtecharmy.soundscape.screens.home.home.Home -import org.scottishtecharmy.soundscape.screens.home.locationDetails.LocationDescription import org.scottishtecharmy.soundscape.screens.home.locationDetails.LocationDetailsScreen import org.scottishtecharmy.soundscape.screens.home.settings.Settings import org.scottishtecharmy.soundscape.screens.markers_routes.screens.MarkersAndRoutesScreen import org.scottishtecharmy.soundscape.screens.markers_routes.screens.addroute.AddRouteScreen -import org.scottishtecharmy.soundscape.viewmodels.HomeViewModel import org.scottishtecharmy.soundscape.viewmodels.SettingsViewModel +import org.scottishtecharmy.soundscape.viewmodels.home.HomeViewModel class Navigator { var destination = MutableStateFlow(HomeRoutes.Home.route) @@ -35,11 +35,8 @@ fun HomeScreen( viewModel: HomeViewModel = hiltViewModel(), rateSoundscape: () -> Unit, ) { - val location = viewModel.location.collectAsStateWithLifecycle() - val heading = viewModel.heading.collectAsStateWithLifecycle() - val beaconLocation = viewModel.beaconLocation.collectAsStateWithLifecycle() - val streetPreviewMode = viewModel.streetPreviewMode.collectAsStateWithLifecycle() - val tileGridGeoJson = viewModel.tileGridGeoJson.collectAsStateWithLifecycle() + val state = viewModel.state.collectAsStateWithLifecycle() + val searchText = viewModel.searchText.collectAsStateWithLifecycle() NavHost( navController = navController, @@ -49,13 +46,11 @@ fun HomeScreen( composable(HomeRoutes.Home.route) { val context = LocalContext.current Home( - latitude = location.value?.latitude, - longitude = location.value?.longitude, - beaconLocation = beaconLocation.value, - heading = heading.value, - onNavigate = { - dest -> navController.navigate(dest) - }, + latitude = state.value.location?.latitude, + longitude = state.value.location?.longitude, + beaconLocation = state.value.beaconLocation, + heading = state.value.heading, + onNavigate = { dest -> navController.navigate(dest) }, onMapLongClick = { latLong -> viewModel.createBeacon(latLong) true @@ -66,21 +61,26 @@ fun HomeScreen( getMyLocation = { viewModel.myLocation() }, getWhatsAheadOfMe = { viewModel.aheadOfMe() }, getWhatsAroundMe = { viewModel.whatsAroundMe() }, + searchText = searchText.value, + isSearching = state.value.isSearching, + onToogleSearch = viewModel::onToogleSearch, + onSearchTextChange = viewModel::onSearchTextChange, + searchItems = state.value.searchItems.orEmpty(), shareLocation = { viewModel.shareLocation(context) }, rateSoundscape = rateSoundscape, - streetPreviewEnabled = streetPreviewMode.value, - tileGridGeoJson = tileGridGeoJson.value + streetPreviewEnabled = state.value.streetPreviewMode, + tileGridGeoJson = state.value.tileGridGeoJson, ) } // Settings screen composable(HomeRoutes.Settings.route) { // Always just pop back out of settings, don't add to the queue - val settingsViewModel : SettingsViewModel = hiltViewModel() + val settingsViewModel: SettingsViewModel = hiltViewModel() val uiState = settingsViewModel.state.collectAsStateWithLifecycle() Settings( onNavigateUp = { navController.navigateUp() }, - uiState = uiState.value + uiState = uiState.value, ) } @@ -90,21 +90,21 @@ fun HomeScreen( // Parse the LocationDescription ot of the json provided by the caller val gson = GsonBuilder().create() val json = navBackStackEntry.arguments?.getString("json") - val ld = gson.fromJson(json, LocationDescription::class.java) + val locationDescription = gson.fromJson(json, LocationDescription::class.java) LocationDetailsScreen( - locationDescription = ld, + locationDescription = locationDescription, onNavigateUp = { navController.navigate(HomeRoutes.Home.route) { popUpTo(HomeRoutes.Home.route) { - inclusive = false // Ensures Home screen is not popped from the stack + inclusive = false // Ensures Home screen is not popped from the stack } - launchSingleTop = true // Prevents multiple instances of Home + launchSingleTop = true // Prevents multiple instances of Home } }, - latitude = location.value?.latitude, - longitude = location.value?.longitude, - heading = heading.value, + latitude = state.value.location?.latitude, + longitude = state.value.location?.longitude, + heading = state.value.heading, ) } diff --git a/app/src/main/java/org/scottishtecharmy/soundscape/screens/home/data/LocationDescription.kt b/app/src/main/java/org/scottishtecharmy/soundscape/screens/home/data/LocationDescription.kt new file mode 100644 index 00000000..2da9303a --- /dev/null +++ b/app/src/main/java/org/scottishtecharmy/soundscape/screens/home/data/LocationDescription.kt @@ -0,0 +1,11 @@ +package org.scottishtecharmy.soundscape.screens.home.data + +data class LocationDescription( + val adressName: String? = null, + val streetNumberAndName: String? = null, + val postcodeAndLocality: String? = null, + val country: String? = null, + val distance: String? = null, + val latitude: Double, + val longitude: Double, +) diff --git a/app/src/main/java/org/scottishtecharmy/soundscape/screens/home/home/Home.kt b/app/src/main/java/org/scottishtecharmy/soundscape/screens/home/home/Home.kt index 7d5143b5..a64fe584 100644 --- a/app/src/main/java/org/scottishtecharmy/soundscape/screens/home/home/Home.kt +++ b/app/src/main/java/org/scottishtecharmy/soundscape/screens/home/home/Home.kt @@ -42,6 +42,8 @@ import org.scottishtecharmy.soundscape.MainActivity import org.scottishtecharmy.soundscape.R import org.scottishtecharmy.soundscape.components.MainSearchBar import org.scottishtecharmy.soundscape.screens.home.DrawerContent +import org.scottishtecharmy.soundscape.screens.home.data.LocationDescription +import org.scottishtecharmy.soundscape.screens.home.locationDetails.generateLocationDetailsRoute @Preview(device = "spec:parent=pixel_5,orientation=landscape") @Preview @@ -56,12 +58,17 @@ fun HomePreview() { onMapLongClick = { false }, onMarkerClick = { true }, getMyLocation = {}, - getWhatsAheadOfMe = {}, getWhatsAroundMe = {}, + getWhatsAheadOfMe = {}, shareLocation = {}, rateSoundscape = {}, streetPreviewEnabled = false, - tileGridGeoJson = "" + tileGridGeoJson = "", + searchText = "Lille", + isSearching = true, + onSearchTextChange = {}, + onToogleSearch = {}, + searchItems = emptyList(), ) } @@ -79,9 +86,14 @@ fun Home( getWhatsAheadOfMe: () -> Unit, shareLocation: () -> Unit, rateSoundscape: () -> Unit, - streetPreviewEnabled : Boolean, + streetPreviewEnabled: Boolean, modifier: Modifier = Modifier, - tileGridGeoJson: String + tileGridGeoJson: String, + searchText: String, + isSearching: Boolean, + onSearchTextChange: (String) -> Unit, + onToogleSearch: () -> Unit, + searchItems: List, ) { val coroutineScope = rememberCoroutineScope() val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) @@ -93,9 +105,9 @@ fun Home( onNavigate = onNavigate, drawerState = drawerState, shareLocation = shareLocation, - rateSoundscape = rateSoundscape + rateSoundscape = rateSoundscape, ) - }, + }, gesturesEnabled = false, modifier = modifier, ) { @@ -104,7 +116,7 @@ fun Home( HomeTopAppBar( drawerState, coroutineScope, - streetPreviewEnabled + streetPreviewEnabled, ) }, bottomBar = { @@ -126,17 +138,31 @@ fun Home( onNavigate = onNavigate, searchBar = { MainSearchBar( - searchText = "", - isSearching = false, - itemList = emptyList(), - onSearchTextChange = { }, - onToggleSearch = { }, - onItemClick = { }, + searchText = searchText, + isSearching = isSearching, + itemList = searchItems, + onSearchTextChange = onSearchTextChange, + onToggleSearch = onToogleSearch, + onItemClick = { item -> + onNavigate( + generateLocationDetailsRoute( + LocationDescription( + adressName = item.adressName, + streetNumberAndName = item.streetNumberAndName, + postcodeAndLocality = item.postcodeAndLocality, + country = item.country, + distance = item.distance, + latitude = item.latitude, + longitude = item.longitude, + ), + ), + ) + }, ) }, onMapLongClick = onMapLongClick, onMarkerClick = onMarkerClick, - tileGridGeoJson = tileGridGeoJson + tileGridGeoJson = tileGridGeoJson, ) } } @@ -147,19 +173,21 @@ fun Home( fun HomeTopAppBar( drawerState: DrawerState, coroutineScope: CoroutineScope, - streetPreviewEnabled : Boolean + streetPreviewEnabled: Boolean, ) { val context = LocalContext.current TopAppBar( colors = TopAppBarDefaults.topAppBarColors( containerColor = MaterialTheme.colorScheme.background, - titleContentColor = Color.White, + titleContentColor = MaterialTheme.colorScheme.onBackground, ), - title = { Text( - text = stringResource(R.string.app_name), - modifier = Modifier.semantics { heading() } - ) }, + title = { + Text( + text = stringResource(R.string.app_name), + modifier = Modifier.semantics { heading() }, + ) + }, navigationIcon = { IconButton( onClick = { @@ -179,9 +207,9 @@ fun HomeTopAppBar( checked = streetPreviewEnabled, enabled = true, onCheckedChange = { state -> - if(!state) { + if (!state) { (context as MainActivity).soundscapeServiceConnection.setStreetPreviewMode( - false + false, ) } }, @@ -189,14 +217,14 @@ fun HomeTopAppBar( if (streetPreviewEnabled) { Icon( Icons.Rounded.Preview, - tint = MaterialTheme.colorScheme.primary, - contentDescription = stringResource(R.string.street_preview_enabled) + tint = MaterialTheme.colorScheme.primary, + contentDescription = stringResource(R.string.street_preview_enabled), ) } else { Icon( painterResource(R.drawable.preview_off), tint = MaterialTheme.colorScheme.secondary, - contentDescription = stringResource(R.string.street_preview_disabled) + contentDescription = stringResource(R.string.street_preview_disabled), ) } } diff --git a/app/src/main/java/org/scottishtecharmy/soundscape/screens/home/home/HomeContent.kt b/app/src/main/java/org/scottishtecharmy/soundscape/screens/home/home/HomeContent.kt index 3be1189b..eb3a5d7f 100644 --- a/app/src/main/java/org/scottishtecharmy/soundscape/screens/home/home/HomeContent.kt +++ b/app/src/main/java/org/scottishtecharmy/soundscape/screens/home/home/HomeContent.kt @@ -13,7 +13,7 @@ import org.maplibre.android.geometry.LatLng import org.scottishtecharmy.soundscape.R import org.scottishtecharmy.soundscape.components.NavigationButton import org.scottishtecharmy.soundscape.screens.home.HomeRoutes -import org.scottishtecharmy.soundscape.screens.home.locationDetails.LocationDescription +import org.scottishtecharmy.soundscape.screens.home.data.LocationDescription import org.scottishtecharmy.soundscape.screens.home.locationDetails.generateLocationDetailsRoute @Composable @@ -27,7 +27,7 @@ fun HomeContent( onMarkerClick: (Marker) -> Boolean, searchBar: @Composable () -> Unit, modifier: Modifier = Modifier, - tileGridGeoJson: String + tileGridGeoJson: String, ) { Column( modifier = modifier, @@ -46,9 +46,9 @@ fun HomeContent( // screen which location to provide details of. The JSON is appended to the route. val ld = LocationDescription( - "Barrowland Ballroom", - 55.8552688, - -4.2366753, + adressName = "Barrowland Ballroom", + latitude = 55.8552688, + longitude = -4.2366753, ) onNavigate(generateLocationDetailsRoute(ld)) }, @@ -72,16 +72,16 @@ fun HomeContent( val ld = LocationDescription( // TODO handle LocationDescription instantiation in viewmodel ? - "Current location", - latitude, - longitude, + adressName = "Current location", + latitude = latitude, + longitude = longitude, ) onNavigate(generateLocationDetailsRoute(ld)) // TODO handle at top level the generateLocationDetailsRoute ? } }, text = stringResource(R.string.search_use_current_location), ) - if(latitude != null && longitude != null) { + if (latitude != null && longitude != null) { MapContainerLibre( beaconLocation = beaconLocation, mapCenter = LatLng(latitude, longitude), @@ -91,7 +91,7 @@ fun HomeContent( userSymbolRotation = heading, onMapLongClick = onMapLongClick, onMarkerClick = onMarkerClick, - tileGridGeoJson = tileGridGeoJson + tileGridGeoJson = tileGridGeoJson, ) } } @@ -100,7 +100,7 @@ fun HomeContent( @Preview @Composable -fun PreviewHomeContent(){ +fun PreviewHomeContent() { HomeContent( latitude = null, longitude = null, @@ -110,6 +110,6 @@ fun PreviewHomeContent(){ onMapLongClick = { false }, onMarkerClick = { true }, searchBar = {}, - tileGridGeoJson = "" + tileGridGeoJson = "", ) -} \ No newline at end of file +} diff --git a/app/src/main/java/org/scottishtecharmy/soundscape/screens/home/locationDetails/LocationDetailsScreen.kt b/app/src/main/java/org/scottishtecharmy/soundscape/screens/home/locationDetails/LocationDetailsScreen.kt index 4997d512..4680cfba 100644 --- a/app/src/main/java/org/scottishtecharmy/soundscape/screens/home/locationDetails/LocationDetailsScreen.kt +++ b/app/src/main/java/org/scottishtecharmy/soundscape/screens/home/locationDetails/LocationDetailsScreen.kt @@ -1,15 +1,30 @@ package org.scottishtecharmy.soundscape.screens.home.locationDetails +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Button +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.LocationOn +import androidx.compose.material.icons.filled.Map +import androidx.compose.material.icons.filled.Navigation +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview @@ -19,33 +34,31 @@ import com.google.gson.GsonBuilder import org.maplibre.android.geometry.LatLng import org.scottishtecharmy.soundscape.R import org.scottishtecharmy.soundscape.screens.home.HomeRoutes +import org.scottishtecharmy.soundscape.screens.home.data.LocationDescription import org.scottishtecharmy.soundscape.screens.home.home.MapContainerLibre import org.scottishtecharmy.soundscape.screens.markers_routes.components.CustomAppBar +import org.scottishtecharmy.soundscape.ui.theme.Foreground2 +import org.scottishtecharmy.soundscape.ui.theme.IntroPrimary +import org.scottishtecharmy.soundscape.ui.theme.PaleBlue import org.scottishtecharmy.soundscape.ui.theme.SoundscapeTheme +import org.scottishtecharmy.soundscape.utils.buildAddressFormat import org.scottishtecharmy.soundscape.viewmodels.LocationDetailsViewModel -data class LocationDescription(val title : String, - val latitude : Double, - val longitude : Double) - -fun generateLocationDetailsRoute(locationDescription: LocationDescription) : String { +fun generateLocationDetailsRoute(locationDescription: LocationDescription): String { // Generate JSON for the LocationDescription and append it to the rout - val gson = GsonBuilder().create() - val json = gson.toJson(locationDescription) + val json = GsonBuilder().create().toJson(locationDescription) return HomeRoutes.LocationDetails.route + "/" + json } - - @Composable fun LocationDetailsScreen( - locationDescription : LocationDescription, - latitude : Double?, - longitude: Double?, - heading : Float, - onNavigateUp: () -> Unit, - viewModel: LocationDetailsViewModel = hiltViewModel(), + locationDescription: LocationDescription, + latitude: Double?, + longitude: Double?, + heading: Float, + onNavigateUp: () -> Unit, + viewModel: LocationDetailsViewModel = hiltViewModel(), ) { LocationDetails( onNavigateUp = onNavigateUp, @@ -58,90 +71,197 @@ fun LocationDetailsScreen( }, latitude = latitude, longitude = longitude, - heading = heading + heading = heading, ) } @Composable fun LocationDetails( - locationDescription : LocationDescription, - onNavigateUp: () -> Unit, - latitude: Double?, - longitude: Double?, - heading: Float, - createBeacon: (latitude: Double, longitude: Double) -> Unit, - enableStreetPreview: (latitude: Double, longitude: Double) -> Unit, - modifier: Modifier = Modifier) { - + locationDescription: LocationDescription, + onNavigateUp: () -> Unit, + latitude: Double?, + longitude: Double?, + heading: Float, + createBeacon: (latitude: Double, longitude: Double) -> Unit, + enableStreetPreview: (latitude: Double, longitude: Double) -> Unit, + modifier: Modifier = Modifier, +) { Column( - modifier = modifier - .fillMaxHeight(), + modifier = modifier.fillMaxHeight(), ) { CustomAppBar( - title = stringResource(R.string.location_detail_title_default), + title = stringResource(R.string.location_detail_title_default), onNavigateUp = onNavigateUp, ) - Text( - text = locationDescription.title, - modifier = Modifier.padding(top = 20.dp, bottom = 5.dp), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.surfaceBright - ) - Text( - text = "A DESCRIPTION BASED ON THE TILE DATA!", - modifier = Modifier.padding(top = 20.dp, bottom = 5.dp), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.surfaceBright - ) + Column( + modifier = + Modifier + .padding(horizontal = 15.dp, vertical = 20.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + LocationDescriptionTextsSection(locationDescription = locationDescription) + HorizontalDivider() + LocationDescriptionButtonsSection( + createBeacon = createBeacon, + locationDescription = locationDescription, + enableStreetPreview = enableStreetPreview, + onNavigateUp = onNavigateUp, + ) + } MapContainerLibre( - beaconLocation = LatLng( - locationDescription.latitude, - locationDescription.longitude - ), + beaconLocation = + LatLng( + locationDescription.latitude, + locationDescription.longitude, + ), allowScrolling = true, onMapLongClick = { false }, onMarkerClick = { false }, // Center on the beacon - mapCenter = LatLng( - locationDescription.latitude, - locationDescription.longitude - ), - userLocation = LatLng( - latitude ?: 0.0, - longitude ?: 0.0 - ), - + mapCenter = + LatLng( + locationDescription.latitude, + locationDescription.longitude, + ), + userLocation = + LatLng( + latitude ?: 0.0, + longitude ?: 0.0, + ), mapViewRotation = 0.0F, userSymbolRotation = heading, - modifier = modifier.fillMaxWidth().aspectRatio(1.0F), - tileGridGeoJson = "" + modifier = + modifier + .fillMaxWidth() + .aspectRatio(1.1F), + tileGridGeoJson = "", ) + } +} - Button( - onClick = { - createBeacon(locationDescription.latitude, locationDescription.longitude) - } +@Composable +private fun LocationDescriptionButtonsSection( + createBeacon: (latitude: Double, longitude: Double) -> Unit, + locationDescription: LocationDescription, + enableStreetPreview: (latitude: Double, longitude: Double) -> Unit, + onNavigateUp: () -> Unit, +) { + Column( + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + IconWithTextButton( + icon = Icons.Filled.LocationOn, + text = stringResource(R.string.create_an_audio_beacon), + ) { + createBeacon(locationDescription.latitude, locationDescription.longitude) + } + + IconWithTextButton( + icon = Icons.Filled.Navigation, + text = stringResource(R.string.enter_street_preview), ) { + enableStreetPreview( + locationDescription.latitude, + locationDescription.longitude, + ) + onNavigateUp() + } + } +} + +@Composable +private fun LocationDescriptionTextsSection(locationDescription: LocationDescription) { + Column( + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + locationDescription.adressName?.let { Text( - text = "Create an audio beacon", - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth(), + text = it, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onBackground, ) } - Button( - onClick = { - enableStreetPreview(locationDescription.latitude, locationDescription.longitude) - onNavigateUp() + locationDescription.distance?.let { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Icon( + imageVector = Icons.Filled.Map, + contentDescription = null, + tint = Foreground2, + ) + Text( + text = it, + style = MaterialTheme.typography.bodyMedium, + color = Foreground2, + ) + } + } + locationDescription.buildAddressFormat()?.let { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Icon( + imageVector = Icons.Filled.LocationOn, + contentDescription = null, + tint = PaleBlue, + ) + Text( + text = it, + style = MaterialTheme.typography.bodyMedium, + color = PaleBlue, + ) } + } + } +} + +@Composable +fun IconWithTextButton( + icon: ImageVector, + text: String, + action: () -> (Unit), +) { + TextButton( + modifier = + Modifier + .fillMaxWidth() + .height(50.dp), + onClick = { + action() + }, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, // Aligns icon and text vertically + modifier = Modifier.fillMaxWidth(), // Ensures the content inside aligns properly ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = IntroPrimary, + ) + Spacer(modifier = Modifier.width(15.dp)) // Space between icon and text Text( - text = "Enter Street Preview", - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth(), + text = text, + textAlign = TextAlign.Start, // Aligns text to start within the Row + color = IntroPrimary, ) } - } + } +} + +@Preview(showBackground = true) +@Composable +fun IconsWithTextActionsPreview() { + IconWithTextButton( + icon = Icons.Filled.LocationOn, + text = "Start Audio Beacon", + action = {}, + ) } @Preview(showBackground = true) @@ -149,15 +269,23 @@ fun LocationDetails( fun LocationDetailsPreview() { SoundscapeTheme { LocationDetails( - LocationDescription("", 0.0, 0.0), - createBeacon = { _,_ -> + LocationDescription( + adressName = "Pizza hut", + distance = "3,5 km", + latitude = 0.0, + longitude = 0.0, + streetNumberAndName = "139 boulevard gambetta", + postcodeAndLocality = "59000 Lille", + country = "France", + ), + createBeacon = { _, _ -> }, - enableStreetPreview = { _,_ -> + enableStreetPreview = { _, _ -> }, onNavigateUp = {}, latitude = null, longitude = null, - heading = 0.0F + heading = 0.0F, ) } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/scottishtecharmy/soundscape/services/SoundscapeService.kt b/app/src/main/java/org/scottishtecharmy/soundscape/services/SoundscapeService.kt index 5d66b497..e86d7479 100644 --- a/app/src/main/java/org/scottishtecharmy/soundscape/services/SoundscapeService.kt +++ b/app/src/main/java/org/scottishtecharmy/soundscape/services/SoundscapeService.kt @@ -37,6 +37,7 @@ import org.scottishtecharmy.soundscape.audio.NativeAudioEngine import org.scottishtecharmy.soundscape.database.local.RealmConfiguration import org.scottishtecharmy.soundscape.geoengine.GeoEngine import org.scottishtecharmy.soundscape.geoengine.PositionedString +import org.scottishtecharmy.soundscape.geojsonparser.geojson.Feature import org.scottishtecharmy.soundscape.geojsonparser.geojson.LngLatAlt import org.scottishtecharmy.soundscape.locationprovider.AndroidDirectionProvider import org.scottishtecharmy.soundscape.locationprovider.AndroidLocationProvider @@ -98,26 +99,26 @@ class SoundscapeService : MediaSessionService() { private var running: Boolean = false - private var binder : SoundscapeBinder? = null + private var binder: SoundscapeBinder? = null + @SuppressLint("MissingSuperCall") override fun onBind(intent: Intent?): IBinder { - if(binder == null) { + if (binder == null) { // Create binder if we don't have one already binder = SoundscapeBinder(this@SoundscapeService) } return binder!! } - fun setStreetPreviewMode(on : Boolean, latitude: Double, longitude: Double) { + fun setStreetPreviewMode(on: Boolean, latitude: Double, longitude: Double) { directionProvider.destroy() locationProvider.destroy() geoEngine.stop() - if(on) { + if (on) { // Use static location, but phone's direction locationProvider = StaticLocationProvider(latitude, longitude) directionProvider = AndroidDirectionProvider(this) - } else - { + } else { // Switch back to phone's location and direction locationProvider = AndroidLocationProvider(this) directionProvider = AndroidDirectionProvider(this) @@ -129,7 +130,8 @@ class SoundscapeService : MediaSessionService() { _streetPreviewFlow.value = on } - override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? = mediaSession + override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? = + mediaSession override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { if (!running) { @@ -335,6 +337,10 @@ class SoundscapeService : MediaSessionService() { } } + suspend fun searchResult(searchString: String): ArrayList? { + return geoEngine.searchResult(searchString)?.features + } + fun whatsAroundMe() { coroutineScope.launch { audioEngine.clearTextToSpeechQueue() @@ -395,11 +401,12 @@ class SoundscapeService : MediaSessionService() { } // Binder to allow local clients to Bind to our service -class SoundscapeBinder(newService : SoundscapeService?) : Binder() { - var service : SoundscapeService? = newService +class SoundscapeBinder(newService: SoundscapeService?) : Binder() { + var service: SoundscapeService? = newService fun getSoundscapeService(): SoundscapeService { return service!! } + fun reset() { service = null } diff --git a/app/src/main/java/org/scottishtecharmy/soundscape/ui/theme/Color.kt b/app/src/main/java/org/scottishtecharmy/soundscape/ui/theme/Color.kt index 12e3ee89..97c9c288 100644 --- a/app/src/main/java/org/scottishtecharmy/soundscape/ui/theme/Color.kt +++ b/app/src/main/java/org/scottishtecharmy/soundscape/ui/theme/Color.kt @@ -17,7 +17,7 @@ val OnSurface = Color.White val OnSurfaceVariant = Color.DarkGray val surfaceBright = Color.Cyan val transparent = Color.Transparent - +val PaleBlue = Color(0xFFB6F2EC) val Foreground1 = Color(0xFF93F7F6) // Light Cyan val Foreground2 = Color(0xFFFFEE59) // Bright Yellow @@ -38,14 +38,16 @@ val IntroPrimary = Color(0xFFFFFFFF) // White val IntroBlue = Color(0xFF196497) // Medium Blue val IntroBlue2 = Color(0xFF083865) // Dark Blue -val gradientBackgroundIntro = Brush.linearGradient( - colors = listOf(PurpleGradientDark, PurpleGradientLight), - start = Offset(0f, 0f), - end = Offset(Float.POSITIVE_INFINITY, Float.POSITIVE_INFINITY) -) - -val blueGradientBackgroundIntro = Brush.linearGradient( - colors = listOf(IntroBlue, IntroBlue2), - start = Offset(0f, 0f), - end = Offset(Float.POSITIVE_INFINITY, Float.POSITIVE_INFINITY) -) \ No newline at end of file +val gradientBackgroundIntro = + Brush.linearGradient( + colors = listOf(PurpleGradientDark, PurpleGradientLight), + start = Offset(0f, 0f), + end = Offset(Float.POSITIVE_INFINITY, Float.POSITIVE_INFINITY), + ) + +val blueGradientBackgroundIntro = + Brush.linearGradient( + colors = listOf(IntroBlue, IntroBlue2), + start = Offset(0f, 0f), + end = Offset(Float.POSITIVE_INFINITY, Float.POSITIVE_INFINITY), + ) diff --git a/app/src/main/java/org/scottishtecharmy/soundscape/utils/LocationExt.kt b/app/src/main/java/org/scottishtecharmy/soundscape/utils/LocationExt.kt new file mode 100644 index 00000000..64666967 --- /dev/null +++ b/app/src/main/java/org/scottishtecharmy/soundscape/utils/LocationExt.kt @@ -0,0 +1,70 @@ +package org.scottishtecharmy.soundscape.utils + +import android.location.Location +import org.scottishtecharmy.soundscape.geojsonparser.geojson.Feature +import org.scottishtecharmy.soundscape.geojsonparser.geojson.Point +import org.scottishtecharmy.soundscape.screens.home.data.LocationDescription +import java.util.Locale + +fun ArrayList.toLocationDescriptions( + currentLocationLatitude: Double, + currentLocationLongitude: Double, +): List = + mapNotNull { feature -> + feature.properties?.let { properties -> + LocationDescription( + adressName = properties["name"]?.toString(), + streetNumberAndName = + listOfNotNull( + properties["housenumber"], + properties["street"], + ).joinToString(" ").nullIfEmpty(), + postcodeAndLocality = + listOfNotNull( + properties["postcode"], + properties["city"], + ).joinToString(" ").nullIfEmpty(), + country = properties["country"]?.toString()?.nullIfEmpty(), + distance = + formatDistance( + calculateDistance( + lat1 = currentLocationLatitude, + lon1 = currentLocationLongitude, + lat2 = (feature.geometry as Point).coordinates.latitude, + lon2 = (feature.geometry as Point).coordinates.longitude, + ), + ), + latitude = (feature.geometry as Point).coordinates.latitude, + longitude = (feature.geometry as Point).coordinates.longitude, + ) + } + } + +fun LocationDescription.buildAddressFormat(): String? { + val addressFormat = + listOfNotNull( + streetNumberAndName, + postcodeAndLocality, + country, + ) + return when { + addressFormat.isEmpty() -> null + else -> addressFormat.joinToString("\n") + } +} + +private fun calculateDistance( + lat1: Double, + lon1: Double, + lat2: Double, + lon2: Double, +): Float { + val results = FloatArray(1) + Location.distanceBetween(lat1, lon1, lat2, lon2, results) + return results[0] +} + +private fun formatDistance(distanceInMeters: Float): String { + val distanceInKm = distanceInMeters / 1000 + return String.format(Locale.getDefault(), "%.1f km", distanceInKm) +} diff --git a/app/src/main/java/org/scottishtecharmy/soundscape/utils/StringExt.kt b/app/src/main/java/org/scottishtecharmy/soundscape/utils/StringExt.kt new file mode 100644 index 00000000..0cd2f6eb --- /dev/null +++ b/app/src/main/java/org/scottishtecharmy/soundscape/utils/StringExt.kt @@ -0,0 +1,4 @@ +package org.scottishtecharmy.soundscape.utils + +fun String.blankOrEmpty() = this.isBlank() || this.isEmpty() +fun String.nullIfEmpty(): String? = ifEmpty { null } diff --git a/app/src/main/java/org/scottishtecharmy/soundscape/viewmodels/HomeViewModel.kt b/app/src/main/java/org/scottishtecharmy/soundscape/viewmodels/HomeViewModel.kt deleted file mode 100644 index 269e6b8c..00000000 --- a/app/src/main/java/org/scottishtecharmy/soundscape/viewmodels/HomeViewModel.kt +++ /dev/null @@ -1,225 +0,0 @@ -// The code for Marker manipulation in maplibre has been moved into an annotations plugin. -// However, this doesn't appear to be supported in Kotlin yet. There's talk of un-deprecating those -// functions in the next release if support isn't added. In the meantime we use the deprecated -// functions and suppress the warnings here. -@file:Suppress("DEPRECATION") - -package org.scottishtecharmy.soundscape.viewmodels - -import android.content.Context -import android.content.Intent -import android.location.Location -import android.util.Log -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import androidx.preference.PreferenceManager -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch -import org.maplibre.android.annotations.Marker -import org.maplibre.android.geometry.LatLng -import org.scottishtecharmy.soundscape.MainActivity.Companion.MAP_DEBUG_KEY -import org.scottishtecharmy.soundscape.SoundscapeServiceConnection -import java.net.URLEncoder -import javax.inject.Inject - -@HiltViewModel -class HomeViewModel @Inject constructor( - private val soundscapeServiceConnection: SoundscapeServiceConnection -) : ViewModel() { - private val _heading: MutableStateFlow = MutableStateFlow(0.0f) - val heading: StateFlow = _heading.asStateFlow() - private val _location: MutableStateFlow = MutableStateFlow(null) - val location: StateFlow = _location.asStateFlow() - private val _beaconLocation: MutableStateFlow = MutableStateFlow(null) // Question, can we have more beacon ? - val beaconLocation: StateFlow = _beaconLocation.asStateFlow() - private val _streetPreviewMode: MutableStateFlow = MutableStateFlow(false) - val streetPreviewMode: StateFlow = _streetPreviewMode.asStateFlow() - private val _tileGridGeoJson: MutableStateFlow = MutableStateFlow("") - val tileGridGeoJson: StateFlow = _tileGridGeoJson.asStateFlow() - - private var job = Job() - private var spJob = Job() - - init { - viewModelScope.launch { - soundscapeServiceConnection.serviceBoundState.collect { - Log.d(TAG, "serviceBoundState $it") - if (it) { - // The service has started, so start monitoring the location and heading - startMonitoringLocation() - // And start monitoring the street preview mode - startMonitoringStreetPreviewMode() - } else { - // The service has gone away so remove the current location marker - _location.value = null - - stopMonitoringStreetPreviewMode() - stopMonitoringLocation() - } - } - } - } - - override fun onCleared() { - super.onCleared() - stopMonitoringStreetPreviewMode() - stopMonitoringLocation() - } - - /** - * startMonitoringLocation launches monitoring of the location and orientation providers. These - * can change e.g. when switching to and from StreetPreview mode, so they are launched in a job. - * That job is cancelled when the StreetPreview mode changes and the monitoring restarted. - */ - private fun startMonitoringLocation() { - Log.d(TAG, "ViewModel startMonitoringLocation") - job = Job() - viewModelScope.launch(job) { - // Observe location updates from the service - soundscapeServiceConnection.getLocationFlow()?.collectLatest { value -> - if (value != null) { - Log.d(TAG, "Location $value") - _location.value = value - } - } - } - viewModelScope.launch(job) { - // Observe orientation updates from the service - soundscapeServiceConnection.getOrientationFlow()?.collectLatest { value -> - if (value != null) { - _heading.value = value.headingDegrees - } - } - } - viewModelScope.launch(job) { - // Observe beacon location update from the service so we can show it on the map - soundscapeServiceConnection.getBeaconFlow()?.collectLatest { value -> - Log.d(TAG, "beacon collected $value") - if (value != null) { - _beaconLocation.value = LatLng(value.latitude, value.longitude) - } else { - _beaconLocation.value = null - } - } - } - - viewModelScope.launch(job) { - // Observe tile grid update from the service so we can show it on the map - soundscapeServiceConnection.getTileGridFlow()?.collectLatest { tileGrid -> - if(tileGrid.tiles.isNotEmpty()) { - Log.d(TAG, "new tile grid") - // Flow out the GeoJSON describing our current grid - _tileGridGeoJson.value = tileGrid.generateGeoJson() - } - else { - _tileGridGeoJson.value = "" - } - } - } - } - - private fun stopMonitoringLocation() { - Log.d(TAG, "stopMonitoringLocation") - job.cancel() - } - - /** - * startMonitoringStreetPreviewMode launches a job to monitor the state of street preview mode. - * When the mode from the service changes then the local flow for the UI is updated and the - * location and orientation monitoring is turned off and on again so as to use the new providers. - */ - private fun startMonitoringStreetPreviewMode() { - Log.d(TAG, "startMonitoringStreetPreviewMode") - spJob = Job() - viewModelScope.launch(spJob) { - // Observe street preview mode from the service so we can update state - soundscapeServiceConnection.getStreetPreviewModeFlow()?.collect { value -> - Log.d(TAG, "Street Preview Mode: $value") - _streetPreviewMode.value = value - - // Restart location monitoring for new provider - stopMonitoringLocation() - startMonitoringLocation() - } - } - } - private fun stopMonitoringStreetPreviewMode() { - Log.d(TAG, "stopMonitoringStreetPreviewMode") - spJob.cancel() - } - - fun createBeacon(latitudeLongitude: LatLng) { - Log.d(TAG, "create beacon") - soundscapeServiceConnection.soundscapeService?.createBeacon( - latitudeLongitude.latitude, - latitudeLongitude.longitude, - ) - } - - fun onMarkerClick(marker: Marker): Boolean { - Log.d(TAG, "marker click") - - if (marker.position == beaconLocation.value) { - soundscapeServiceConnection.soundscapeService?.destroyBeacon() - _beaconLocation.value = null - return true - } - return false - } - - fun myLocation(){ - //Log.d(TAG, "myLocation() triggered") - viewModelScope.launch { - soundscapeServiceConnection.soundscapeService?.myLocation() - } - } - - fun aheadOfMe(){ - //Log.d(TAG, "myLocation() triggered") - viewModelScope.launch { - soundscapeServiceConnection.soundscapeService?.aheadOfMe() - } - } - - fun whatsAroundMe(){ - //Log.d(TAG, "myLocation() triggered") - viewModelScope.launch { - soundscapeServiceConnection.soundscapeService?.whatsAroundMe() - } - } - - - fun shareLocation(context: Context) { - // Share the current location using standard Android sharing mechanism. It's shared as a - // - // soundscape://latitude,longitude - // - // URI, with the , encoded. This shows up in Slack as a clickable link which is the main - // usefulness for now - val location = soundscapeServiceConnection.getLocationFlow()?.value - if(location != null) { - val sendIntent: Intent = Intent().apply { - action = Intent.ACTION_SEND - putExtra(Intent.EXTRA_TITLE, "Problem location") - val latitude = location.latitude - val longitude = location.longitude - val uriData: String = URLEncoder.encode("$latitude,$longitude", Charsets.UTF_8.name()) - putExtra(Intent.EXTRA_TEXT, "soundscape://$uriData") - type = "text/plain" - - } - - val shareIntent = Intent.createChooser(sendIntent, null) - context.startActivity(shareIntent) - } - } - - companion object { - private const val TAG = "HomeViewModel" - } -} diff --git a/app/src/main/java/org/scottishtecharmy/soundscape/viewmodels/home/HomeState.kt b/app/src/main/java/org/scottishtecharmy/soundscape/viewmodels/home/HomeState.kt new file mode 100644 index 00000000..088e48d1 --- /dev/null +++ b/app/src/main/java/org/scottishtecharmy/soundscape/viewmodels/home/HomeState.kt @@ -0,0 +1,15 @@ +package org.scottishtecharmy.soundscape.viewmodels.home + +import android.location.Location +import org.maplibre.android.geometry.LatLng +import org.scottishtecharmy.soundscape.screens.home.data.LocationDescription + +data class HomeState( + var heading: Float = 0.0f, + var location: Location? = null, + var beaconLocation: LatLng? = null, + var streetPreviewMode: Boolean = false, + var tileGridGeoJson: String = "", + var isSearching: Boolean = false, + var searchItems: List? = null, +) diff --git a/app/src/main/java/org/scottishtecharmy/soundscape/viewmodels/home/HomeViewModel.kt b/app/src/main/java/org/scottishtecharmy/soundscape/viewmodels/home/HomeViewModel.kt new file mode 100644 index 00000000..10d7b670 --- /dev/null +++ b/app/src/main/java/org/scottishtecharmy/soundscape/viewmodels/home/HomeViewModel.kt @@ -0,0 +1,278 @@ +// The code for Marker manipulation in maplibre has been moved into an annotations plugin. +// However, this doesn't appear to be supported in Kotlin yet. There's talk of un-deprecating those +// functions in the next release if support isn't added. In the meantime we use the deprecated +// functions and suppress the warnings here. +@file:Suppress("DEPRECATION") + +package org.scottishtecharmy.soundscape.viewmodels.home + +import android.content.Context +import android.content.Intent +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.maplibre.android.annotations.Marker +import org.maplibre.android.geometry.LatLng +import org.scottishtecharmy.soundscape.SoundscapeServiceConnection +import org.scottishtecharmy.soundscape.utils.blankOrEmpty +import org.scottishtecharmy.soundscape.utils.toLocationDescriptions +import java.net.URLEncoder +import javax.inject.Inject + +@HiltViewModel +@OptIn(FlowPreview::class) +class HomeViewModel + @Inject + constructor( + private val soundscapeServiceConnection: SoundscapeServiceConnection, + ) : ViewModel() { + private val _state: MutableStateFlow = MutableStateFlow(HomeState()) + val state: StateFlow = _state.asStateFlow() + private val _searchText: MutableStateFlow = MutableStateFlow("") + val searchText: StateFlow = _searchText.asStateFlow() + + private var job = Job() + private var spJob = Job() + + init { + handleMonitoring() + fetchSearchResult() + } + + private fun handleMonitoring() { + viewModelScope.launch { + soundscapeServiceConnection.serviceBoundState.collect { serviceBoundState -> + Log.d(TAG, "serviceBoundState $serviceBoundState") + if (serviceBoundState) { + // The service has started, so start monitoring the location and heading + startMonitoringLocation() + // And start monitoring the street preview mode + startMonitoringStreetPreviewMode() + } else { + // The service has gone away so remove the current location marker + _state.update { it.copy(location = null) } + stopMonitoringStreetPreviewMode() + stopMonitoringLocation() + } + } + } + } + + override fun onCleared() { + super.onCleared() + stopMonitoringStreetPreviewMode() + stopMonitoringLocation() + } + + /** + * startMonitoringLocation launches monitoring of the location and orientation providers. These + * can change e.g. when switching to and from StreetPreview mode, so they are launched in a job. + * That job is cancelled when the StreetPreview mode changes and the monitoring restarted. + */ + private fun startMonitoringLocation() { + Log.d(TAG, "ViewModel startMonitoringLocation") + job = Job() + viewModelScope.launch(job) { + // Observe location updates from the service + soundscapeServiceConnection.getLocationFlow()?.collectLatest { value -> + if (value != null) { + Log.d(TAG, "Location $value") + _state.update { it.copy(location = value) } + } + } + } + viewModelScope.launch(job) { + // Observe orientation updates from the service + soundscapeServiceConnection.getOrientationFlow()?.collectLatest { value -> + if (value != null) { + _state.update { it.copy(heading = value.headingDegrees) } + } + } + } + viewModelScope.launch(job) { + // Observe beacon location update from the service so we can show it on the map + soundscapeServiceConnection.getBeaconFlow()?.collectLatest { value -> + Log.d(TAG, "beacon collected $value") + if (value != null) { + _state.update { + it.copy( + beaconLocation = + LatLng( + value.latitude, + value.longitude, + ), + ) + } + } else { + _state.update { it.copy(beaconLocation = null) } + } + } + } + + viewModelScope.launch(job) { + // Observe tile grid update from the service so we can show it on the map + soundscapeServiceConnection.getTileGridFlow()?.collectLatest { tileGrid -> + if (tileGrid.tiles.isNotEmpty()) { + Log.d(TAG, "new tile grid") + // Flow out the GeoJSON describing our current grid + _state.update { it.copy(tileGridGeoJson = tileGrid.generateGeoJson()) } + } else { + _state.update { it.copy(tileGridGeoJson = "") } + } + } + } + } + + private fun stopMonitoringLocation() { + Log.d(TAG, "stopMonitoringLocation") + job.cancel() + } + + /** + * startMonitoringStreetPreviewMode launches a job to monitor the state of street preview mode. + * When the mode from the service changes then the local flow for the UI is updated and the + * location and orientation monitoring is turned off and on again so as to use the new providers. + */ + private fun startMonitoringStreetPreviewMode() { + Log.d(TAG, "startMonitoringStreetPreviewMode") + spJob = Job() + viewModelScope.launch(spJob) { + // Observe street preview mode from the service so we can update state + soundscapeServiceConnection.getStreetPreviewModeFlow()?.collect { value -> + Log.d(TAG, "Street Preview Mode: $value") + _state.update { it.copy(streetPreviewMode = value) } + + // Restart location monitoring for new provider + stopMonitoringLocation() + startMonitoringLocation() + } + } + } + + private fun stopMonitoringStreetPreviewMode() { + Log.d(TAG, "stopMonitoringStreetPreviewMode") + spJob.cancel() + } + + fun createBeacon(latitudeLongitude: LatLng) { + Log.d(TAG, "create beacon") + soundscapeServiceConnection.soundscapeService?.createBeacon( + latitudeLongitude.latitude, + latitudeLongitude.longitude, + ) + } + + fun onMarkerClick(marker: Marker): Boolean { + Log.d(TAG, "marker click") + + if (marker.position == _state.value.beaconLocation) { + soundscapeServiceConnection.soundscapeService?.destroyBeacon() + _state.update { it.copy(beaconLocation = null) } + + return true + } + return false + } + + fun myLocation() { + // Log.d(TAG, "myLocation() triggered") + viewModelScope.launch { + soundscapeServiceConnection.soundscapeService?.myLocation() + } + } + + fun aheadOfMe() { + // Log.d(TAG, "myLocation() triggered") + viewModelScope.launch { + soundscapeServiceConnection.soundscapeService?.aheadOfMe() + } + } + + fun whatsAroundMe() { + // Log.d(TAG, "myLocation() triggered") + viewModelScope.launch { + soundscapeServiceConnection.soundscapeService?.whatsAroundMe() + } + } + + fun shareLocation(context: Context) { + // Share the current location using standard Android sharing mechanism. It's shared as a + // + // soundscape://latitude,longitude + // + // URI, with the , encoded. This shows up in Slack as a clickable link which is the main + // usefulness for now + val location = soundscapeServiceConnection.getLocationFlow()?.value + if (location != null) { + val sendIntent: Intent = + Intent().apply { + action = Intent.ACTION_SEND + putExtra(Intent.EXTRA_TITLE, "Problem location") + val latitude = location.latitude + val longitude = location.longitude + val uriData: String = + URLEncoder.encode("$latitude,$longitude", Charsets.UTF_8.name()) + putExtra(Intent.EXTRA_TEXT, "soundscape://$uriData") + type = "text/plain" + } + + val shareIntent = Intent.createChooser(sendIntent, null) + context.startActivity(shareIntent) + } + } + + fun onSearchTextChange(text: String) { + _searchText.value = text + } + + private fun fetchSearchResult() { + viewModelScope.launch { + _searchText + .debounce(500) + .distinctUntilChanged() + .collectLatest { searchText -> + if (searchText.blankOrEmpty()) { + _state.update { it.copy(searchItems = emptyList()) } + } else { + val result = + soundscapeServiceConnection.soundscapeService?.searchResult(searchText) + + _state.update { + it.copy( + searchItems = + result?.toLocationDescriptions( + currentLocationLatitude = state.value.location?.latitude ?: 0.0, + currentLocationLongitude = + state.value.location?.longitude + ?: 0.0, + ), + ) + } + } + } + } + } + + fun onToogleSearch() { + _state.update { it.copy(isSearching = !it.isSearching) } + + if (!state.value.isSearching) { + onSearchTextChange("") + } + } + + companion object { + private const val TAG = "HomeViewModel" + } + } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3971479a..20bb8612 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -3585,5 +3585,9 @@ Alternatively, you may continue previewing the app with a predefined test locati Soundscape uses notifications to let you know if it is currently working. Soundscape will work without this permission. Background Location Permission To allow Soundscape to work in the background, the app needs access to background location services. + Search for an address + Cancel search + Create an audio beacon + Enter Street Preview