Compare commits

..

9 Commits

Author SHA1 Message Date
PoliEcho dbd29bf421 add working speed-o-meter 2026-02-13 16:41:05 +01:00
PoliEcho c3f8dd4066 revert GTFSUtils.kt from 67fea519 2026-02-13 14:37:17 +01:00
PoliEcho 5970dce0d2 works? 2026-02-13 14:20:30 +01:00
PoliEcho 0e2208d104 add more optimalization and speed mareker 2026-02-13 12:28:19 +01:00
PoliEcho 67fea51949 tweaks 2026-01-28 22:34:09 +01:00
PoliEcho 4263a7eff0 add more optimalization 2026-01-28 22:05:30 +01:00
PoliEcho d1314a1b32 fix ANR 2026-01-28 21:55:22 +01:00
PoliEcho 93148fcf36 make sure all stops are requested 2026-01-28 21:55:08 +01:00
PoliEcho ced8e8173d add stops 2026-01-28 19:08:35 +01:00
7 changed files with 332 additions and 111 deletions
+1
View File
@@ -13,3 +13,4 @@
.externalNativeBuild .externalNativeBuild
.cxx .cxx
local.properties local.properties
test-data
+1 -1
View File
@@ -4,7 +4,7 @@
<selectionStates> <selectionStates>
<SelectionState runConfigName="app"> <SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" /> <option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2026-01-19T17:27:16.298528612Z"> <DropdownSelection timestamp="2026-02-13T13:44:07.200825088Z">
<Target type="DEFAULT_BOOT"> <Target type="DEFAULT_BOOT">
<handle> <handle>
<DeviceId pluginId="LocalEmulator" identifier="path=/home/lukas/.config/.android/avd/Medium_Phone_OSS.avd" /> <DeviceId pluginId="LocalEmulator" identifier="path=/home/lukas/.config/.android/avd/Medium_Phone_OSS.avd" />
@@ -14,33 +14,36 @@ import kotlinx.serialization.Serializable
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import ovh.plrapps.mapcompose.api.addMarker import ovh.plrapps.mapcompose.api.addMarker
import ovh.plrapps.mapcompose.api.removeMarker
import ovh.plrapps.mapcompose.ui.state.markers.model.RenderingStrategy
import java.net.URL import java.net.URL
@Serializable @Serializable
data class GTFSRoute( data class GTFSRoute(
val agency_id: String, val agency_id: String?,
val is_night: Boolean, val is_night: Boolean,
val route_color: String, val route_color: String?,
val route_desc: String? = null, val route_desc: String? = null,
val route_id: String, val route_id: String?,
val route_long_name: String, val route_long_name: String?,
val route_short_name: String, val route_short_name: String?,
val route_text_color: String, val route_text_color: String?,
val route_type: Int, val route_type: Int,
val route_url: String, val route_url: String?,
val is_regional: Boolean, val is_regional: Boolean,
val is_substitute_transport: Boolean val is_substitute_transport: Boolean
) )
// Type alias for array of GTFS routes // Type alias for array of GTFS routes
typealias GTFSRoutes = List<GTFSRoute> typealias GTFSRoutes = MutableList<GTFSRoute>
var gtfsRoutes: GTFSRoutes = mutableListOf()
var gtfsRoutes: GTFSRoutes? = null
@Serializable @Serializable
data class GTFSStopGeometry( data class GTFSStopGeometry(
val coordinates: List<Double>, val coordinates: List<Double>,
val type: String val type: String?
) )
@Serializable @Serializable
@@ -49,7 +52,7 @@ data class GTFSStopProperties(
val parent_station: String? = null, val parent_station: String? = null,
val platform_code: String? = null, val platform_code: String? = null,
val stop_id: String, val stop_id: String,
val stop_name: String, val stop_name: String?,
val wheelchair_boarding: Int, val wheelchair_boarding: Int,
val zone_id: String? = null, val zone_id: String? = null,
val level_id: String? = null val level_id: String? = null
@@ -59,16 +62,16 @@ data class GTFSStopProperties(
data class GTFSStop( data class GTFSStop(
val geometry: GTFSStopGeometry, val geometry: GTFSStopGeometry,
val properties: GTFSStopProperties, val properties: GTFSStopProperties,
val type: String val type: String?
) )
@Serializable @Serializable
data class GTFSStops( data class GTFSStops(
val features: List<GTFSStop>, val features: MutableList<GTFSStop>,
val type: String val type: String?
) )
var gtfsStops: GTFSStops? = null var gtfsStops: GTFSStops = GTFSStops(mutableListOf<GTFSStop>(), "FeatureCollection")
// OkHttpClient for making HTTP requests // OkHttpClient for making HTTP requests
@@ -104,34 +107,24 @@ private fun fetchGTFSPosition(gtfsUrl: URL,api_key: String, callee: Activity) {
} }
} }
// Start the timer with a URL: // Start the timer with a URL:
fun startGTFSTracking(url: String, api_key: String, callee: Activity) { fun startGTFSTracking(url: String, api_key: String, callee: Activity) {
if (GTFSIsTracking) return if (GTFSIsTracking) return
GTFSIsTracking = true GTFSIsTracking = true
GTFSTrackingThread = Thread { GTFSTrackingThread = Thread {
var gtfsUrl = URL(url); val gtfsUrl = URL(url);
try { try {
if(gtfsRoutes == null) { if(gtfsRoutes.isEmpty()) {
val request = Request.Builder() GTFSMainHandler.post {
.url("${gtfsUrl.protocol}://${gtfsUrl.host}${callee.getString(R.string.routes_path)}") Toast.makeText(callee, "Fetching routes...", Toast.LENGTH_SHORT).show()}
.header(
"User-Agent",
"${BuildConfig.APPLICATION_ID}/${BuildConfig.VERSION_NAME} (Android)"
)
.header("X-Access-Token", api_key).build();
gtfshttpClient.newCall(request).execute().use { response -> var messageParsed: GTFSRoutes;
var message = if (response.isSuccessful) { var offset = 0;
response.body?.string() ?: "Empty response" do {
} else {
"Error: ${response.code} ${response.message}"
}
gtfsRoutes = json.decodeFromString<GTFSRoutes>(message)
}}
if(gtfsStops == null) {
val request = Request.Builder() val request = Request.Builder()
.url("${gtfsUrl.protocol}://${gtfsUrl.host}${callee.getString(R.string.stops_path)}") .url("${gtfsUrl.protocol}://${gtfsUrl.host}${callee.getString(R.string.routes_path)}?offset=${offset}")
.header( .header(
"User-Agent", "User-Agent",
"${BuildConfig.APPLICATION_ID}/${BuildConfig.VERSION_NAME} (Android)" "${BuildConfig.APPLICATION_ID}/${BuildConfig.VERSION_NAME} (Android)"
@@ -144,27 +137,63 @@ fun startGTFSTracking(url: String, api_key: String, callee: Activity) {
} else { } else {
"Error: ${response.code} ${response.message}" "Error: ${response.code} ${response.message}"
} }
gtfsStops = json.decodeFromString<GTFSStops>(message) messageParsed = json.decodeFromString<GTFSRoutes>(message)
gtfsRoutes.addAll(messageParsed)
offset += messageParsed.size
}} while (messageParsed.isNotEmpty())
}
if(gtfsStops.features.isEmpty()) {
GTFSMainHandler.post {
Toast.makeText(callee, "Fetching stops...", Toast.LENGTH_SHORT).show()}
// put all stops on the map var messageParsed: GTFSStops;
for (stop in gtfsStops!!.features) { var offset = 0;
GTFSMainHandler.post { do {
mapState.addMarker(stop.properties.stop_id, x = longitudeToXNormalized(stop.geometry.coordinates[0]), y = latitudeToYNormalized(stop.geometry.coordinates[0])) { val request = Request.Builder()
Icon( .url("${gtfsUrl.protocol}://${gtfsUrl.host}${callee.getString(R.string.stops_path)}?offset=${offset}")
painter = painterResource(id = R.drawable.outline_directions_bus_24), .header(
contentDescription = null, "User-Agent",
modifier = Modifier.size(17.dp), "${BuildConfig.APPLICATION_ID}/${BuildConfig.VERSION_NAME} (Android)"
tint = Color(0xFF0000FF) )
) .header("X-Access-Token", api_key).build();
}
} gtfshttpClient.newCall(request).execute().use { response ->
var message = if (response.isSuccessful) {
response.body?.string() ?: "Empty response"
} else {
"Error: ${response.code} ${response.message}"
} }
messageParsed = json.decodeFromString<GTFSStops>(message)
gtfsStops.features.addAll(messageParsed.features)
}
offset += messageParsed.features.size;
}while(messageParsed.features.isNotEmpty())
}
// put all stops on the map
GTFSMainHandler.post {
Toast.makeText(callee, "Drawing stops...", Toast.LENGTH_SHORT).show()}
for (stop in gtfsStops!!.features) {
if (stop.properties.parent_station == null) {
mapState.addMarker(stop.properties.stop_id, x = longitudeToXNormalized(stop.geometry.coordinates[0]), y = latitudeToYNormalized(stop.geometry.coordinates[1]), renderingStrategy = RenderingStrategy.LazyLoading("default")) {
Icon(
painter = painterResource(id = R.drawable.outline_directions_bus_24),
contentDescription = null,
modifier = Modifier.size(12.dp),
tint = Color(0xFF0000FF)
)
}} }}
} catch(e: Exception) { }
GTFSMainHandler.post {
Toast.makeText(callee, "Done drawing ${gtfsStops.features.size} stops", Toast.LENGTH_SHORT).show()}
} catch(e: Exception) {
GTFSMainHandler.post {
Toast.makeText(callee, e.message ?: "Unknown error", Toast.LENGTH_SHORT).show() Toast.makeText(callee, e.message ?: "Unknown error", Toast.LENGTH_SHORT).show()
GTFSMainHandler.post { stopGTFSTracking() } }
return@Thread GTFSMainHandler.post { stopGTFSTracking() }
return@Thread
} }
while (GTFSIsTracking) { while (GTFSIsTracking) {
fetchGTFSPosition(gtfsUrl,api_key, callee) fetchGTFSPosition(gtfsUrl,api_key, callee)
@@ -179,6 +208,11 @@ fun startGTFSTracking(url: String, api_key: String, callee: Activity) {
fun stopGTFSTracking() { fun stopGTFSTracking() {
GTFSIsTracking = false GTFSIsTracking = false
for (stop in gtfsStops!!.features) {
if (stop.properties.parent_station == null) {
mapState.removeMarker(stop.properties.stop_id);
}
}
GTFSTrackingThread?.interrupt() GTFSTrackingThread?.interrupt()
GTFSTrackingThread = null GTFSTrackingThread = null
} }
@@ -49,15 +49,38 @@ private fun fetchHaukPosition(haukUrl: String, callee: Activity) {
message = "{" + message.substringAfter("{"); message = "{" + message.substringAfter("{");
val hauk_response = json.decodeFromString<HaukResponse>(message) val hauk_response = json.decodeFromString<HaukResponse>(message)
val lat = hauk_response.points.last().getOrNull(0)
val lat = hauk_response.points.last()?.getOrNull(0) val lon = hauk_response.points.last().getOrNull(1)
val lon = hauk_response.points.last()?.getOrNull(1)
if (lat != null && lon != null) { if (lat != null && lon != null) {
HaukMainHandler.post {
mapState.moveMarker("target_position", x = longitudeToXNormalized(lon), y = latitudeToYNormalized(lat)) val lastlat = hauk_response.points.getOrNull(hauk_response.points.size - 2)?.getOrNull(0)
} val lastlon = hauk_response.points.getOrNull(hauk_response.points.size - 2)?.getOrNull(1)
val newspeed =
if (lastlat != null && lastlon != null) {
var fulldistance: Double = 0.0
for (i in 1 until hauk_response.points.size -1) {
try{
fulldistance += haversineDistance(hauk_response.points[i-1][0]!!, hauk_response.points[i-1][1]!!, hauk_response.points[i][0]!!, hauk_response.points[i][1]!!)
} catch (e: Exception) {
// Ignore
}
}
(fulldistance / (hauk_response.interval * hauk_response.points.size)*3600)
} else {
0.0
}
HaukMainHandler.post {
try {
mapState.moveMarker("target_position", x = longitudeToXNormalized(lon), y = latitudeToYNormalized(lat))
target_speed = "${"%.1f".format(newspeed)}km/h"
} catch (e: Exception) {
// Marker might not exist yet, ignore
}
}
} else { } else {
Toast.makeText(callee, "TARGET LOCATION NOT AVAILABLE", Toast.LENGTH_SHORT).show() HaukMainHandler.post {
Toast.makeText(callee, "TARGET LOCATION NOT AVAILABLE", Toast.LENGTH_SHORT).show()
}
}} }}
} catch (e: Exception) { } catch (e: Exception) {
// Post UI update to main thread // Post UI update to main thread
@@ -4,6 +4,27 @@ import kotlinx.serialization.json.Json
import kotlin.math.PI import kotlin.math.PI
import kotlin.math.ln import kotlin.math.ln
import kotlin.math.tan import kotlin.math.tan
import kotlin.math.*
fun haversineDistance(
lat1: Double,
lon1: Double,
lat2: Double,
lon2: Double
): Double {
val R = 6371.0 // Earth radius in kilometers
val phi1 = lat1 * PI / 180
val phi2 = lat2 * PI / 180
val deltaPhi = (lat2 - lat1) * PI / 180
val deltaLambda = (lon2 - lon1) * PI / 180
val a = sin(deltaPhi / 2).pow(2) +
cos(phi1) * cos(phi2) * sin(deltaLambda / 2).pow(2)
val c = 2 * atan2(sqrt(a), sqrt(1 - a))
return R * c
}
val json = Json { val json = Json {
coerceInputValues = true // Coerce nulls to default values if applicable coerceInputValues = true // Coerce nulls to default values if applicable
@@ -2,7 +2,6 @@ package org.pupes.mhdrunpathfinder
import android.app.Activity import android.app.Activity
import android.content.Context import android.content.Context
import android.content.SharedPreferences
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
@@ -30,8 +29,10 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import kotlinx.coroutines.launch
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.RectangleShape
@@ -40,12 +41,20 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.edit import androidx.core.content.edit
import ovh.plrapps.mapcompose.api.addLayer import ovh.plrapps.mapcompose.api.addLayer
import ovh.plrapps.mapcompose.api.addLazyLoader
import ovh.plrapps.mapcompose.api.addMarker import ovh.plrapps.mapcompose.api.addMarker
import ovh.plrapps.mapcompose.api.scrollTo import ovh.plrapps.mapcompose.api.scrollTo
import ovh.plrapps.mapcompose.core.TileStreamProvider import ovh.plrapps.mapcompose.core.TileStreamProvider
import ovh.plrapps.mapcompose.ui.MapUI import ovh.plrapps.mapcompose.ui.MapUI
import ovh.plrapps.mapcompose.ui.state.MapState import ovh.plrapps.mapcompose.ui.state.MapState
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.net.URL import java.net.URL
import android.os.StrictMode
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.unit.sp
import ovh.plrapps.mapcompose.api.getMarkerInfo
// MapState configuration for OpenStreetMap // MapState configuration for OpenStreetMap
@@ -58,14 +67,84 @@ val mapState = MapState(
fullHeight = 67108864, fullHeight = 67108864,
tileSize = 256 tileSize = 256
) )
var target_speed by mutableStateOf("0.0km/h");
/**
* Creates a cached tile stream provider for OpenStreetMap tiles
* Tiles are cached in the app's cache directory for offline access and faster loading
* Cache expires after 30 days to ensure map updates are fetched
*/
fun createCachedTileStreamProvider(context: Context, cacheExpiryDays: Int = 30): TileStreamProvider {
val cacheDir = File(context.cacheDir, "map_tiles")
if (!cacheDir.exists()) {
cacheDir.mkdirs()
}
val cacheExpiryMillis = cacheExpiryDays * 24 * 60 * 60 * 1000L
return TileStreamProvider { row, col, zoomLvl ->
try {
val tileFile = File(cacheDir, "tile_${zoomLvl}_${col}_${row}.png")
val shouldUseCache = tileFile.exists() &&
(System.currentTimeMillis() - tileFile.lastModified()) < cacheExpiryMillis
if (shouldUseCache) {
return@TileStreamProvider FileInputStream(tileFile)
}
// Since mapcompose runs this on a background thread, we can do networking here.
try {
val url = URL("https://tile.openstreetmap.org/$zoomLvl/$col/$row.png")
val connection = url.openConnection()
// Set User-Agent header as required by OSM tile usage policy
val userAgent = "${BuildConfig.APPLICATION_ID}/${BuildConfig.VERSION_NAME} (Android) (Contact: poliecho@pupes.org)"
connection.setRequestProperty("User-Agent", userAgent)
connection.connectTimeout = 5000
connection.readTimeout = 10000
connection.getInputStream().use { inputStream ->
FileOutputStream(tileFile).use { outputStream ->
inputStream.copyTo(outputStream)
}
}
FileInputStream(tileFile)
} catch (e: Exception) {
e.printStackTrace()
// If download fails, try to use an expired cached tile if it exists
if (tileFile.exists()) {
FileInputStream(tileFile)
} else {
null
}
}
} catch (e: Exception) {
e.printStackTrace()
null
}
}
}
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
// Configure StrictMode to detect threading violations (debug mode only)
if (BuildConfig.DEBUG) {
StrictMode.setThreadPolicy(
StrictMode.ThreadPolicy.Builder()
.detectAll()
.penaltyLog() // Log violations instead of crashing
.build()
)
StrictMode.setVmPolicy(
StrictMode.VmPolicy.Builder()
.detectAll()
.penaltyLog()
.build()
)
}
setContent { setContent {
MaterialTheme { MaterialTheme {
@@ -91,25 +170,55 @@ class MainActivity : ComponentActivity() {
@Composable @Composable
fun MainScreen(callee: Activity) { fun MainScreen(callee: Activity) {
val sharedPreferences = remember { callee.getSharedPreferences("PREFERENCES", Context.MODE_PRIVATE) } val sharedPreferences = remember { callee.getSharedPreferences("PREFERENCES", Context.MODE_PRIVATE) }
val coroutineScope = rememberCoroutineScope()
var showSettings by remember { mutableStateOf(false) } var showSettings by remember { mutableStateOf(false) }
var haukUrl by remember { mutableStateOf("") } var haukUrl by remember { mutableStateOf("") }
var golemioAPIkey by remember { mutableStateOf(sharedPreferences.getString("golemioAPIkey", "")?: "" ) } var golemioAPIkey by remember { mutableStateOf(sharedPreferences.getString("golemioAPIkey", "")?: "" ) }
var haukState by remember { mutableStateOf(false) } var haukState by remember { mutableStateOf(false) }
var golemioState by remember { mutableStateOf(false)} var golemioState by remember { mutableStateOf(golemioAPIkey.isNotEmpty())}
if (golemioAPIkey.isNotEmpty()) { LaunchedEffect(Unit) {
golemioState = true if (golemioState) {
startGTFSTracking(callee.getString(R.string.golemio_base_url), golemioAPIkey, callee); startGTFSTracking(callee.getString(R.string.golemio_base_url), golemioAPIkey, callee)
}
} }
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
OpenStreetMapScreen() OpenStreetMapScreen(callee)
SettingsButton( IconButton( // settings button
onClick = { showSettings = true }, onClick = { showSettings = true },
modifier = Modifier modifier = Modifier
.align(Alignment.TopEnd) .align(Alignment.TopEnd)
.padding(end = 5.dp, bottom = 5.dp) .padding(end = 5.dp, bottom = 5.dp),
IconID = R.drawable.baseline_settings_24
) )
Column(modifier = Modifier.align(Alignment.TopStart)) {
IconButton( // look at target button
onClick = {
coroutineScope.launch {
mapState.getMarkerInfo("target_position")?.let { marker ->
mapState.scrollTo(marker.x, marker.y, destScale = 0.1)
}
}
},
modifier = Modifier
.padding(start = 5.dp, bottom = 5.dp),
IconID = R.drawable.target
)
IconButton( // look at my location button
onClick = {
coroutineScope.launch {
mapState.getMarkerInfo("current_position")?.let { marker ->
mapState.scrollTo(marker.x, marker.y, destScale = 0.1)
}
}
},
modifier = Modifier
.padding(start = 5.dp, bottom = 5.dp),
IconID = R.drawable.user_location
)
}
if (showSettings) { if (showSettings) {
ShowSettingsMenu( ShowSettingsMenu(
onDismiss = { showSettings = false }, onDismiss = { showSettings = false },
@@ -168,7 +277,7 @@ fun MainScreen(callee: Activity) {
@Composable @Composable
// When a composable with parameters is used with @Preview, // When a composable with parameters is used with @Preview,
// a default value must be provided for the preview to render. // a default value must be provided for the preview to render.
fun SettingsButton(modifier: Modifier = Modifier, onClick: () -> Unit = {}) { fun IconButton(modifier: Modifier = Modifier, onClick: () -> Unit = {}, IconID: Int = R.drawable.baseline_settings_24) {
Button( Button(
onClick = { onClick() }, onClick = { onClick() },
colors = ButtonDefaults.buttonColors(containerColor = Color(0xCFFFFFFF)), colors = ButtonDefaults.buttonColors(containerColor = Color(0xCFFFFFFF)),
@@ -176,7 +285,7 @@ fun SettingsButton(modifier: Modifier = Modifier, onClick: () -> Unit = {}) {
) { ) {
Icon( Icon(
painter = painterResource(id = R.drawable.baseline_settings_24), painter = painterResource(id = IconID),
contentDescription = null, contentDescription = null,
modifier = Modifier.size(24.dp), modifier = Modifier.size(24.dp),
tint = Color(0xFF000000) tint = Color(0xFF000000)
@@ -211,6 +320,7 @@ fun ShowSettingsMenu(
value = haukUrl, value = haukUrl,
onValueChange = onHaukUrlChange, onValueChange = onHaukUrlChange,
label = { Text("Target Hauk URL") }, label = { Text("Target Hauk URL") },
singleLine = true,
modifier = Modifier modifier = Modifier
.width(250.dp) .width(250.dp)
) )
@@ -224,6 +334,7 @@ fun ShowSettingsMenu(
onCheckedChange = { onGolemioStateChange(it) } onCheckedChange = { onGolemioStateChange(it) }
) )
TextField(value = golemioAPIkey, onValueChange = onGolemioAPIChange, label = { Text("Golemio API Key") }, TextField(value = golemioAPIkey, onValueChange = onGolemioAPIChange, label = { Text("Golemio API Key") },
singleLine = true,
modifier = Modifier modifier = Modifier
.width(250.dp)); .width(250.dp));
ElevatedButton(onClick = onGolemioApply, modifier = Modifier.padding(start = 5.dp), shape = RectangleShape) { ElevatedButton(onClick = onGolemioApply, modifier = Modifier.padding(start = 5.dp), shape = RectangleShape) {
@@ -235,56 +346,86 @@ fun ShowSettingsMenu(
@Preview @Preview
@Composable @Composable
fun OpenStreetMapScreen() { fun OpenStreetMapScreen(context: Context? = null) {
// Create tile stream provider for OpenStreetMap with User-Agent // Create tile stream provider for OpenStreetMap with caching
val tileStreamProvider = TileStreamProvider { row, col, zoomLvl -> val tileStreamProvider = remember(context) {
try { context?.let {
val url = URL("https://tile.openstreetmap.org/$zoomLvl/$col/$row.png") createCachedTileStreamProvider(it)
val connection = url.openConnection() } ?: TileStreamProvider { row, col, zoomLvl ->
// Set User-Agent header as required by OSM tile usage policy // Fallback for preview mode without context
// Format: AppId/Version (Platform) (Contact: email) try {
val userAgent = "${BuildConfig.APPLICATION_ID}/${BuildConfig.VERSION_NAME} (Android) (Contact: poliecho@pupes.org)" val url = URL("https://tile.openstreetmap.org/$zoomLvl/$col/$row.png")
connection.setRequestProperty("User-Agent", userAgent) val connection = url.openConnection()
connection.getInputStream() val userAgent = "MHDrunPathfinder/Preview (Android)"
} catch (e: Exception) { connection.setRequestProperty("User-Agent", userAgent)
e.printStackTrace() connection.getInputStream()
null } catch (e: Exception) {
e.printStackTrace()
null
}
} }
} }
// Track if map has been initialized to prevent duplicate operations
var mapInitialized by remember { mutableStateOf(false) }
// Add the tile layer, markers, and set initial position - all in LaunchedEffect to avoid blocking main thread
/* Add a marker at the center of the map */
mapState.addMarker("target_position", x = longitudeToXNormalized(14.4058031), y = latitudeToYNormalized(50.0756083)) {
Icon(
painter = painterResource(id = R.drawable.target),
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = Color(0xFFFF0000)
)
}
/* Add a marker for current position */
mapState.addMarker("current_position", x = longitudeToXNormalized(14.4378), y = latitudeToYNormalized(50.0755)) {
Icon(
painter = painterResource(id = R.drawable.user_location),
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = Color(0xFF0000FF) // Blue color for current position
)
}
// Add the tile layer and set initial position
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
mapState.addLayer(tileStreamProvider) if (!mapInitialized) {
// Add lazy loader first
mapState.addLazyLoader("default")
// Scroll to Prague, Czech Republic // Add tile layer
// Prague coordinates: latitude 50.0755, longitude 14.4378 mapState.addLayer(tileStreamProvider)
val normalizedX = longitudeToXNormalized(14.4378)
val normalizedY = latitudeToYNormalized(50.0755)
// Use a higher scale to zoom in more (closer to 1.0 = more zoomed in) // Add a marker for target position
mapState.scrollTo(normalizedX, normalizedY, destScale = 0.8) mapState.addMarker("target_position", x = longitudeToXNormalized(14.0), y = latitudeToYNormalized(50.0), Offset(-0.5f,-0.25f)) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
painter = painterResource(id = R.drawable.target),
contentDescription = null,
modifier = Modifier.size(24.dp),
tint = Color(0xFFFF0000)
)
Surface(
color = Color.Red,
shadowElevation = 4.dp,
shape = MaterialTheme.shapes.small,
modifier = Modifier.padding(1.dp)
) {
Text(
text = target_speed,
modifier = Modifier.padding(1.dp),
style = MaterialTheme.typography.bodyMedium,
fontSize = 8.sp,
color = Color.Blue
)
}
}
}
/* Add a marker for current position */
mapState.addMarker("current_position", x = longitudeToXNormalized(14.0), y = latitudeToYNormalized(50.0),
Offset(-0.5f,-0.5f)
) {
Icon(
painter = painterResource(id = R.drawable.user_location),
contentDescription = null,
modifier = Modifier.size(24.dp),
tint = Color(0xFF004802)
)
}
// Scroll to Prague, Czech Republic
// Prague coordinates: latitude 50.0755, longitude 14.4378
val normalizedX = longitudeToXNormalized(14.4378)
val normalizedY = latitudeToYNormalized(50.0755)
// Use a higher scale to zoom in more (closer to 1.0 = more zoomed in)
mapState.scrollTo(normalizedX, normalizedY, destScale = 0.1)
mapInitialized = true
}
} }
// Display the map // Display the map
+1
View File
@@ -3,4 +3,5 @@
<string name="routes_path">/v2/gtfs/routes</string> <string name="routes_path">/v2/gtfs/routes</string>
<string name="stops_path">/v2/gtfs/stops</string> <string name="stops_path">/v2/gtfs/stops</string>
<string name="golemio_base_url">https://api.golemio.cz</string> <string name="golemio_base_url">https://api.golemio.cz</string>
<string name="golemio_realtime_path"></string>
</resources> </resources>