diff --git a/.github/workflows/detekt.yml b/.github/workflows/detekt.yml
new file mode 100644
index 0000000..2658a6c
--- /dev/null
+++ b/.github/workflows/detekt.yml
@@ -0,0 +1,60 @@
+name: Android CI
+
+on:
+ push:
+ branches: [ main ]
+ pull_request:
+ branches: [ main ]
+
+jobs:
+ detekt:
+ runs-on: ubuntu-latest
+ concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}-android-detekt
+ cancel-in-progress: true
+
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ submodules: true
+ lfs: true
+
+ - name: Cache Gradle dependencies
+ uses: actions/cache@v4
+ with:
+ path: |
+ ~/.gradle/caches
+ ~/.gradle/wrapper
+ cardinal-android/.gradle
+ key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
+ restore-keys: |
+ ${{ runner.os }}-gradle-
+
+ - name: Configure Gradle for CI
+ run: |
+ mkdir -p ~/.gradle
+ echo "org.gradle.daemon=false" >> ~/.gradle/gradle.properties
+ echo "org.gradle.parallel=true" >> ~/.gradle/gradle.properties
+ echo "org.gradle.configureondemand=true" >> ~/.gradle/gradle.properties
+ echo "org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g -XX:+HeapDumpOnOutOfMemoryError" >> ~/.gradle/gradle.properties
+
+ - name: Build with Gradle
+ working-directory: cardinal-android
+ run: |
+ touch local.properties
+ ./gradlew detekt
+ env:
+ ANDROID_HOME: ${{ env.ANDROID_HOME }}
+ ANDROID_NDK_HOME: ${{ env.ANDROID_NDK_HOME }}
+ ANDROID_NDK_ROOT: ${{ env.ANDROID_NDK_ROOT }}
+ NDK_HOME: ${{ env.NDK_HOME }}
+
+ - name: Upload build logs on failure
+ if: failure()
+ uses: actions/upload-artifact@v4
+ with:
+ name: build-logs
+ path: |
+ cardinal-android/app/build/reports/
+ ~/.gradle/daemon/*/daemon-*.out.log
+ retention-days: 3
diff --git a/AUTHORS b/AUTHORS
index d25b5b1..879179a 100644
--- a/AUTHORS
+++ b/AUTHORS
@@ -1,3 +1,4 @@
The following individuals and organizations have contributed code to Cardinal Maps (add your name here if you make a PR!):
Ellen Poe
+E Foundation
diff --git a/cardinal-android/app/build.gradle.kts b/cardinal-android/app/build.gradle.kts
index 80deab3..cc5824a 100644
--- a/cardinal-android/app/build.gradle.kts
+++ b/cardinal-android/app/build.gradle.kts
@@ -25,6 +25,7 @@ plugins {
alias(libs.plugins.ksp)
alias(libs.plugins.hilt)
alias(libs.plugins.cargo.ndk)
+ alias(libs.plugins.detekt)
kotlin("plugin.serialization") version "2.2.10"
}
@@ -155,6 +156,12 @@ cargoNdk {
extraCargoBuildArguments = arrayListOf("-p", "cardinal-geocoder")
}
+detekt {
+ parallel = true
+ config.setFrom("detekt.yml")
+ allRules = true
+}
+
dependencies {
implementation(libs.maplibre.compose)
implementation(libs.maplibre.compose.material3)
diff --git a/cardinal-android/app/detekt.yml b/cardinal-android/app/detekt.yml
new file mode 100644
index 0000000..8d33045
--- /dev/null
+++ b/cardinal-android/app/detekt.yml
@@ -0,0 +1,12 @@
+naming:
+ FunctionNaming:
+ ignoreAnnotated: [ 'Composable' ]
+ excludes: [ '**/cardinal/bottomsheet/**' ]
+
+complexity:
+ LongParameterList:
+ active: true
+ ignoreDefaultParameters: true
+ CognitiveComplexMethod:
+ active: true
+ excludes: [ '**/cardinal/bottomsheet/**' ]
diff --git a/cardinal-android/app/license.template b/cardinal-android/app/license.template
new file mode 100644
index 0000000..48be674
--- /dev/null
+++ b/cardinal-android/app/license.template
@@ -0,0 +1,15 @@
+ Cardinal Maps
+ Copyright (C) $originalComment.match("Copyright \(c\) (\d+)", 1, "-", "$today.year")$today.year Cardinal Maps Authors
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
\ No newline at end of file
diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/tileserver/LocalMapServer.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/tileserver/LocalMapServer.kt
index a7b46c4..c14fdcb 100644
--- a/cardinal-android/app/src/main/java/earth/maps/cardinal/tileserver/LocalMapServer.kt
+++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/tileserver/LocalMapServer.kt
@@ -84,249 +84,208 @@ class LocalMapServer(
// Find an available port
val availablePort = findAvailablePort()
- server = embeddedServer(CIO, port = availablePort, host = "127.0.0.1") {
- routing {
- get("/") {
- call.respondText("Tile Server is running!")
- }
-
- get("/style_light.json") {
- try {
- val styleJson = readAssetFile("style_light.json")
- val modifiedStyleJson = styleJson.replace("{port}", port.toString())
- call.respondText(
- modifiedStyleJson, contentType = ContentType.Application.Json
- )
- } catch (e: Exception) {
- Log.e(TAG, "Error reading style_light.json", e)
- call.respondText(
- "Error reading style_light.json",
- status = HttpStatusCode.InternalServerError
- )
- }
- }
-
- get("/style_dark.json") {
- try {
- val styleJson = readAssetFile("style_dark.json")
- val modifiedStyleJson = styleJson.replace("{port}", port.toString())
- call.respondText(
- modifiedStyleJson, contentType = ContentType.Application.Json
- )
- } catch (e: Exception) {
- Log.e(TAG, "Error reading style_dark.json", e)
- call.respondText(
- "Error reading style_dark.json",
- status = HttpStatusCode.InternalServerError
- )
- }
- }
+ server = createEmbeddedServer(availablePort)
+ server?.start(wait = false)
- // Valhalla-compatible routing endpoint
- post("/route") {
- try {
- val requestBody = call.receiveText()
- Log.d(TAG, "Received routing request: $requestBody")
+ port = availablePort
- val routeJson = multiplexedRoutingService.getRoute(requestBody)
+ Log.d(TAG, "Tile server started on port: $port")
+ }
- // Return the route response
- call.respondText(
- routeJson, contentType = ContentType.Application.Json
- )
+ fun stop() {
+ server?.stop(1000, 5000)
+ server = null
+ port = -1
- } catch (e: Exception) {
- Log.e(TAG, "Error processing routing request", e)
- call.respondText(
- "{\"error\":\"${e.message}\"}",
- contentType = ContentType.Application.Json,
- status = HttpStatusCode.InternalServerError
- )
- }
- }
+ // Close the databases.
+ terrainDatabase?.close()
+ terrainDatabase = null
+ landcoverDatabase?.close()
+ landcoverDatabase = null
+ basemapDatabase?.close()
+ basemapDatabase = null
+ offlineAreasDatabase?.close()
+ offlineAreasDatabase = null
- // Serve tiles from terrain.mbtiles.
- get("/terrain/{z}/{x}/{y}.png") {
- val terrainDatabase = terrainDatabase ?: return@get
+ Log.d(TAG, "Tile server stopped")
+ }
- val z = call.parameters["z"]?.toIntOrNull()
- val x = call.parameters["x"]?.toLongOrNull()
- val y = call.parameters["y"]?.toLongOrNull()
+ fun getPort(): Int {
+ return port
+ }
- if (z == null || x == null || y == null) {
- call.respondText(
- "Invalid tile coordinates", status = HttpStatusCode.BadRequest
- )
- return@get
- }
+ private fun createEmbeddedServer(port: Int) =
+ embeddedServer(CIO, port = port, host = "127.0.0.1") {
+ routing {
+ handleRoot()
+ handleStyleLight()
+ handleStyleDark()
+ handleRoute()
+ handleTerrainTile()
+ handleLandcoverTile()
+ handleOpenMapTiles()
+ }
+ }
- // MBTiles uses TMS coordinate system, but most map libraries use XYZ
- // Convert Y coordinate from XYZ to TMS
- val tmsY = (2.0.pow(z.toDouble()) - 1 - y).toLong()
+ private fun io.ktor.server.routing.Routing.handleRoot() = get("/") {
+ call.respondText("Tile Server is running!")
+ }
- val tileData = getTileData(terrainDatabase, z, x, tmsY)
- if (tileData != null) {
- call.respondBytes(tileData, contentType = ContentType.Image.PNG)
- } else {
- call.respondBytes(
- bytes = ByteArray(0),
- contentType = ContentType.Image.PNG,
- status = HttpStatusCode.NotFound
- )
- }
- }
+ private fun io.ktor.server.routing.Routing.handleStyleLight() = get("/style_light.json") {
+ try {
+ val styleJson = readAssetFile("style_light.json")
+ val modifiedStyleJson = styleJson.replace("{port}", port.toString())
+ call.respondText(
+ modifiedStyleJson, contentType = ContentType.Application.Json
+ )
+ } catch (e: Exception) {
+ Log.e(TAG, "Error reading style_light.json", e)
+ call.respondText(
+ "Error reading style_light.json",
+ status = HttpStatusCode.InternalServerError
+ )
+ }
+ }
+ private fun io.ktor.server.routing.Routing.handleStyleDark() = get("/style_dark.json") {
+ try {
+ val styleJson = readAssetFile("style_dark.json")
+ val modifiedStyleJson = styleJson.replace("{port}", port.toString())
+ call.respondText(
+ modifiedStyleJson, contentType = ContentType.Application.Json
+ )
+ } catch (e: Exception) {
+ Log.e(TAG, "Error reading style_dark.json", e)
+ call.respondText(
+ "Error reading style_dark.json",
+ status = HttpStatusCode.InternalServerError
+ )
+ }
+ }
- // Serve tiles from landcover.mbtiles.
- get("/landcover/{z}/{x}/{y}.pbf") {
- val landcoverDatabase = landcoverDatabase ?: return@get
+ private fun io.ktor.server.routing.Routing.handleRoute() = post("/route") {
+ try {
+ val requestBody = call.receiveText()
+ Log.d(TAG, "Received routing request: $requestBody")
- val z = call.parameters["z"]?.toIntOrNull()
- val x = call.parameters["x"]?.toLongOrNull()
- val y = call.parameters["y"]?.toLongOrNull()
+ val routeJson = multiplexedRoutingService.getRoute(requestBody)
- if (z == null || x == null || y == null) {
- call.respondText(
- "Invalid tile coordinates", status = HttpStatusCode.BadRequest
- )
- return@get
- }
+ // Return the route response
+ call.respondText(
+ routeJson, contentType = ContentType.Application.Json
+ )
- // MBTiles uses TMS coordinate system, but most map libraries use XYZ
- // Convert Y coordinate from XYZ to TMS
- val tmsY = (2.0.pow(z.toDouble()) - 1 - y).toLong()
+ } catch (e: Exception) {
+ Log.e(TAG, "Error processing routing request", e)
+ call.respondText(
+ "{\"error\":\"${e.message}\"}",
+ contentType = ContentType.Application.Json,
+ status = HttpStatusCode.InternalServerError
+ )
+ }
+ }
- val tileData = getTileData(landcoverDatabase, z, x, tmsY)
- if (tileData != null) {
- call.response.header("content-encoding", "gzip")
- call.respondBytes(tileData, contentType = ContentType.Application.ProtoBuf)
- } else {
- call.respondBytes(
- bytes = ByteArray(0),
- contentType = ContentType.Application.ProtoBuf,
- status = HttpStatusCode.NotFound
- )
- }
- }
+ private fun io.ktor.server.routing.Routing.handleTerrainTile() =
+ get("/terrain/{z}/{x}/{y}.png") {
+ val terrainDatabase = terrainDatabase ?: return@get
- // Serve tiles from basemap.mbtiles.
- get("/openmaptiles/{z}/{x}/{y}.pbf") {
- val z = call.parameters["z"]?.toIntOrNull()
- val x = call.parameters["x"]?.toLongOrNull()
- val y = call.parameters["y"]?.toLongOrNull()
+ val z = call.parameters["z"]?.toIntOrNull()
+ val x = call.parameters["x"]?.toLongOrNull()
+ val y = call.parameters["y"]?.toLongOrNull()
- if (z == null || x == null || y == null) {
- Log.w(TAG, "Invalid tile coordinates: z=$z, x=$x, y=$y")
- call.respondText(
- "Invalid tile coordinates", status = HttpStatusCode.BadRequest
- )
- return@get
- }
+ if (z == null || x == null || y == null) {
+ call.respondText(
+ "Invalid tile coordinates", status = HttpStatusCode.BadRequest
+ )
+ return@get
+ }
- Log.d(TAG, "Requesting tile: /openmaptiles/$z/$x/$y.pbf")
+ // MBTiles uses TMS coordinate system, but most map libraries use XYZ
+ // Convert Y coordinate from XYZ to TMS
+ val tmsY = (2.0.pow(z.toDouble()) - 1 - y).toLong()
- // MBTiles uses TMS (Tile Map Service) coordinate system where Y=0 is at the bottom
- // Most map libraries use XYZ coordinate system where Y=0 is at the top
- // Convert Y coordinate from XYZ to TMS: TMS_Y = 2^zoom - 1 - XYZ_Y
- val tmsY = (2.0.pow(z.toDouble()) - 1 - y).toLong()
+ val tileData = getTileData(terrainDatabase, z, x, tmsY)
+ if (tileData != null) {
+ call.respondBytes(tileData, contentType = ContentType.Image.PNG)
+ } else {
+ call.respondBytes(
+ bytes = ByteArray(0),
+ contentType = ContentType.Image.PNG,
+ status = HttpStatusCode.NotFound
+ )
+ }
+ }
- var isGzipped = true
+ private fun io.ktor.server.routing.Routing.handleLandcoverTile() =
+ get("/landcover/{z}/{x}/{y}.pbf") {
+ val landcoverDatabase = landcoverDatabase ?: return@get
- val basemapDatabase = basemapDatabase
+ val z = call.parameters["z"]?.toIntOrNull()
+ val x = call.parameters["x"]?.toLongOrNull()
+ val y = call.parameters["y"]?.toLongOrNull()
- // First try to get tile from built-in database
- var tileData = if (basemapDatabase != null) {
- getTileData(basemapDatabase, z, x, tmsY)
- } else {
- Log.w(TAG, "Basemap database is null")
- null
- }
+ if (z == null || x == null || y == null) {
+ call.respondText(
+ "Invalid tile coordinates", status = HttpStatusCode.BadRequest
+ )
+ return@get
+ }
- // If not found, try offline databases
- if (tileData == null) {
- Log.d(TAG, "Tile not found in basemap database, checking offline databases")
- tileData = getTileDataFromOfflineDatabases(z, x, y)
- isGzipped = false
- } else {
- Log.d(TAG, "Tile found in basemap database")
- }
+ // MBTiles uses TMS coordinate system, but most map libraries use XYZ
+ // Convert Y coordinate from XYZ to TMS
+ val tmsY = (2.0.pow(z.toDouble()) - 1 - y).toLong()
- if (tileData != null) {
- Log.d(
- TAG,
- "Serving tile /openmaptiles/$z/$x/$y.pbf, size: ${tileData.size} bytes, gzipped: $isGzipped"
- )
- // Only set gzip header for built-in database tiles
- if (isGzipped) {
- call.response.header("content-encoding", "gzip")
- }
- call.respondBytes(tileData, contentType = ContentType.Application.ProtoBuf)
- } else {
- // Check if we should fetch from the internet (not in offline mode)
- val isOfflineMode = isOfflineMode()
- if (!isOfflineMode) {
- Log.d(
- TAG,
- "Tile not found in local caches, attempting to fetch from internet"
- )
- tileData = CoroutineScope(Dispatchers.IO).async {
- fetchTileFromInternet(
- z, x, y
- )
- }.await()
- if (tileData != null) {
- Log.d(
- TAG,
- "Successfully fetched tile from internet: /openmaptiles/$z/$x/$y.pbf, size: ${tileData.size} bytes"
- )
- call.respondBytes(
- tileData, contentType = ContentType.Application.ProtoBuf
- )
- return@get
- }
- }
-
- Log.d(TAG, "Tile not found: /openmaptiles/$z/$x/$y.pbf")
- // If we respond with NotFound here (as would make sense) it will cause maplibre to cache the
- // fact that this tile doesn't exist in this source, which we don't want because it will never
- // retry, nor will it overzoom the previous tiles.
- call.respondBytes(
- bytes = ByteArray(0),
- contentType = ContentType.Application.ProtoBuf,
- status = HttpStatusCode.BadGateway
- )
- }
- }
+ val tileData = getTileData(landcoverDatabase, z, x, tmsY)
+ if (tileData != null) {
+ call.response.header("content-encoding", "gzip")
+ call.respondBytes(tileData, contentType = ContentType.Application.ProtoBuf)
+ } else {
+ call.respondBytes(
+ bytes = ByteArray(0),
+ contentType = ContentType.Application.ProtoBuf,
+ status = HttpStatusCode.NotFound
+ )
}
}
- server?.start(wait = false)
-
- port = availablePort
- Log.d(TAG, "Tile server started on port: $port")
- }
+ private fun io.ktor.server.routing.Routing.handleOpenMapTiles() =
+ get("/openmaptiles/{z}/{x}/{y}.pbf") {
+ val z = call.parameters["z"]?.toIntOrNull()
+ val x = call.parameters["x"]?.toLongOrNull()
+ val y = call.parameters["y"]?.toLongOrNull()
- fun stop() {
- server?.stop(1000, 5000)
- server = null
- port = -1
-
- // Close the databases.
- terrainDatabase?.close()
- terrainDatabase = null
- landcoverDatabase?.close()
- landcoverDatabase = null
- basemapDatabase?.close()
- basemapDatabase = null
- offlineAreasDatabase?.close()
- offlineAreasDatabase = null
+ if (z == null || x == null || y == null) {
+ Log.w(TAG, "Invalid tile coordinates: z=$z, x=$x, y=$y")
+ call.respondText(
+ "Invalid tile coordinates", status = HttpStatusCode.BadRequest
+ )
+ return@get
+ }
- Log.d(TAG, "Tile server stopped")
- }
+ val (tileData, isGzipped) = findTileData(z, x, y)
- fun getPort(): Int {
- return port
- }
+ if (tileData != null) {
+ Log.d(
+ TAG,
+ "Serving tile /openmaptiles/$z/$x/$y.pbf, size: ${tileData.size} bytes, gzipped: $isGzipped"
+ )
+ // Only set gzip header for built-in database tiles
+ if (isGzipped) {
+ call.response.header("content-encoding", "gzip")
+ }
+ call.respondBytes(tileData, contentType = ContentType.Application.ProtoBuf)
+ } else {
+ Log.d(TAG, "Tile not found: /openmaptiles/$z/$x/$y.pbf")
+ // If we respond with NotFound here (as would make sense) it will cause maplibre to cache the
+ // fact that this tile doesn't exist in this source, which we don't want because it will never
+ // retry, nor will it overzoom the previous tiles.
+ call.respondBytes(
+ bytes = ByteArray(0),
+ contentType = ContentType.Application.ProtoBuf,
+ status = HttpStatusCode.BadGateway
+ )
+ }
+ }
private fun readAssetFile(fileName: String): String {
return context.assets.open(fileName).use { inputStream ->
@@ -552,6 +511,44 @@ class LocalMapServer(
return null
}
+ /**
+ * Find tile data for given coordinates from available sources
+ */
+ private suspend fun findTileData(z: Int, x: Long, y: Long): Pair {
+ val tmsY = (2.0.pow(z.toDouble()) - 1 - y).toLong()
+
+ // First try to get tile from built-in database
+ val tileData = basemapDatabase?.let { getTileData(it, z, x, tmsY) }
+ if (tileData != null) {
+ Log.d(TAG, "Tile found in basemap database")
+ return Pair(tileData, true) // gzipped
+ }
+
+ // If not found, try offline databases
+ Log.d(TAG, "Tile not found in basemap database, checking offline databases")
+ val offlineTileData = getTileDataFromOfflineDatabases(z, x, y)
+ if (offlineTileData != null) {
+ return Pair(offlineTileData, false) // not gzipped
+ }
+
+ // Check if we should fetch from the internet (not in offline mode)
+ if (!isOfflineMode()) {
+ Log.d(TAG, "Tile not found in local caches, attempting to fetch from internet")
+ val internetTileData = CoroutineScope(Dispatchers.IO).async {
+ fetchTileFromInternet(z, x, y)
+ }.await()
+ if (internetTileData != null) {
+ Log.d(
+ TAG,
+ "Successfully fetched tile from internet: size: ${internetTileData.size} bytes"
+ )
+ return Pair(internetTileData, false) // not gzipped
+ }
+ }
+
+ return Pair(null, false) // not found
+ }
+
/**
* Check if the app is in offline mode
*/
diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/tileserver/TileDownloadManager.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/tileserver/TileDownloadManager.kt
index 83b8f7a..ff07d43 100644
--- a/cardinal-android/app/src/main/java/earth/maps/cardinal/tileserver/TileDownloadManager.kt
+++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/tileserver/TileDownloadManager.kt
@@ -74,7 +74,6 @@ class TileDownloadManager(
// Service binding infrastructure
private var serviceBinder: TileDownloadForegroundService.TileDownloadBinder? = null
- private var progressJob: Job? = null
private var isBound = false
private val serviceConnection = object : ServiceConnection {
@@ -123,23 +122,6 @@ class TileDownloadManager(
return expectedTileCount >= totalExpectedBasemapTiles
}
- /**
- * Determines if the Valhalla download phase is complete for the given area
- */
- private suspend fun isValhallaPhaseComplete(areaId: String): Boolean {
- val valhallaTiles = ValhallaTileUtils.tilesForBoundingBox(
- offlineAreaDao.getOfflineAreaById(areaId)?.boundingBox() ?: return false
- )
-
- val expectedValhallaTileCount =
- downloadedTileDao.getDownloadedTileCountForAreaAndType(areaId, TileType.VALHALLA)
- Log.d(
- TAG,
- "Valhalla phase for area $areaId: $expectedValhallaTileCount/${valhallaTiles.size} tiles downloaded"
- )
- return expectedValhallaTileCount >= valhallaTiles.size
- }
-
/**
* Determines which phase the download should resume from based on current progress
*/
@@ -174,62 +156,8 @@ class TileDownloadManager(
try {
Log.d(TAG, "Starting download for area: $name (ID: $areaId)")
- // Check if this area already exists in the database
- val existingArea = offlineAreaDao.getOfflineAreaById(areaId)
- if (existingArea != null) {
- Log.d(
- TAG,
- "Area $areaId already exists in database with status: ${existingArea.status}"
- )
-
- // Determine which phase to resume from based on current progress
- val resumePhase = determineResumePhase(areaId)
- Log.d(TAG, "Determined resume phase for area $areaId: $resumePhase")
-
- // If completed, nothing to do
- if (resumePhase == DownloadStatus.COMPLETED) {
- Log.d(TAG, "Area $areaId is already completed, skipping download")
- return@launch
- }
-
- // Update status to indicate we're resuming
- val updatedArea = existingArea.copy(status = resumePhase)
- offlineAreaDao.updateOfflineArea(updatedArea)
- Log.d(TAG, "Updated area $areaId status to $resumePhase for resume")
-
- } else {
- // Create OfflineArea and insert into database
- val offlineArea = OfflineArea(
- id = areaId,
- name = name,
- north = boundingBox.north,
- south = boundingBox.south,
- east = boundingBox.east,
- west = boundingBox.west,
- minZoom = minZoom,
- maxZoom = maxZoom,
- downloadDate = System.currentTimeMillis(),
- fileSize = 0L,
- status = DownloadStatus.DOWNLOADING_BASEMAP,
- )
-
- offlineAreaDao.insertOfflineArea(offlineArea)
- Log.d(TAG, "Created offline area: $areaId with status ${offlineArea.status}")
- }
-
- // Bind to the service first to ensure we can update progress
- bindToService()
-
- // Wait for service binding with timeout
- val bindTimeout = 3000L // 3 seconds
- val bindStartTime = System.currentTimeMillis()
- while (!isBound && (System.currentTimeMillis() - bindStartTime) < bindTimeout) {
- delay(100)
- }
-
- if (!isBound) {
- Log.w(TAG, "Service binding timeout, starting service anyway")
- }
+ handleExistingArea(areaId, name, boundingBox, minZoom, maxZoom)
+ bindToServiceWithTimeout()
// Start the foreground service
val intent = Intent(context, TileDownloadForegroundService::class.java).apply {
@@ -240,22 +168,104 @@ class TileDownloadManager(
} catch (e: Exception) {
Log.e(TAG, "Error starting download for area $areaId", e)
- // Update the area status to FAILED
- val area = offlineAreaDao.getOfflineAreaById(areaId)
- if (area != null) {
- val failedArea = area.copy(status = DownloadStatus.FAILED)
- offlineAreaDao.updateOfflineArea(failedArea)
- }
+ updateAreaStatus(areaId, DownloadStatus.FAILED)
}
}
}
+ /**
+ * Handle logic for existing areas: check if exists, determine resume phase, skip if completed, or create new area
+ */
+ private suspend fun handleExistingArea(
+ areaId: String, name: String, boundingBox: BoundingBox, minZoom: Int, maxZoom: Int
+ ) {
+ val existingArea = offlineAreaDao.getOfflineAreaById(areaId)
+ if (existingArea != null) {
+ Log.d(
+ TAG,
+ "Area $areaId already exists in database with status: ${existingArea.status}"
+ )
+ handleResumeLogic(existingArea, areaId)
+ } else {
+ createNewOfflineArea(areaId, name, boundingBox, minZoom, maxZoom)
+ }
+ }
+
+ /**
+ * Handle resume logic for existing areas
+ */
+ private suspend fun handleResumeLogic(existingArea: OfflineArea, areaId: String) {
+ val resumePhase = determineResumePhase(areaId)
+ Log.d(TAG, "Determined resume phase for area $areaId: $resumePhase")
+
+ if (resumePhase == DownloadStatus.COMPLETED) {
+ Log.d(TAG, "Area $areaId is already completed, skipping download")
+ throw Exception("Download already completed") // Use exception to exit early
+ }
+
+ val updatedArea = existingArea.copy(status = resumePhase)
+ offlineAreaDao.updateOfflineArea(updatedArea)
+ Log.d(TAG, "Updated area $areaId status to $resumePhase for resume")
+ }
+
+ /**
+ * Create a new offline area
+ */
+ private suspend fun createNewOfflineArea(
+ areaId: String, name: String, boundingBox: BoundingBox, minZoom: Int, maxZoom: Int
+ ) {
+ val offlineArea = OfflineArea(
+ id = areaId,
+ name = name,
+ north = boundingBox.north,
+ south = boundingBox.south,
+ east = boundingBox.east,
+ west = boundingBox.west,
+ minZoom = minZoom,
+ maxZoom = maxZoom,
+ downloadDate = System.currentTimeMillis(),
+ fileSize = 0L,
+ status = DownloadStatus.DOWNLOADING_BASEMAP,
+ )
+
+ offlineAreaDao.insertOfflineArea(offlineArea)
+ Log.d(TAG, "Created offline area: $areaId with status ${offlineArea.status}")
+ }
+
+ /**
+ * Bind to the service with timeout
+ */
+ private suspend fun bindToServiceWithTimeout() {
+ bindToService()
+
+ val bindTimeout = 3000L // 3 seconds
+ val bindStartTime = System.currentTimeMillis()
+ while (!isBound && (System.currentTimeMillis() - bindStartTime) < bindTimeout) {
+ delay(100)
+ }
+
+ if (!isBound) {
+ Log.w(TAG, "Service binding timeout, starting service anyway")
+ }
+ }
+
+ /**
+ * Update area status
+ */
+ private suspend fun updateAreaStatus(areaId: String, status: DownloadStatus) {
+ val area = offlineAreaDao.getOfflineAreaById(areaId)
+ if (area != null) {
+ val updatedArea = area.copy(status = status)
+ offlineAreaDao.updateOfflineArea(updatedArea)
+ }
+ }
+
internal suspend fun downloadTilesInternal(
boundingBox: BoundingBox, minZoom: Int, maxZoom: Int, areaId: String, name: String
) {
var db: SQLiteDatabase? = null
- var basemapResult: Pair = Pair(0, 0)
- var valhallaResult: Pair = Pair(0, 0)
+ var basemapResult: Pair
+ var valhallaResult: Pair
try {
Log.d(TAG, "Starting tile download for area: $name (ID: $areaId)")
@@ -264,260 +274,389 @@ class TileDownloadManager(
"Bounds: N=${boundingBox.north}, S=${boundingBox.south}, E=${boundingBox.east}, W=${boundingBox.west}, Zoom: $minZoom-$maxZoom"
)
- // Determine what phase to resume from
val resumePhase = determineResumePhase(areaId)
Log.d(TAG, "Resuming download from phase: $resumePhase")
- // Check if we've been resumed from paused state - if so, continue existing download
- // Note: Resume from pause should continue with the current phase, not restart determination
-
- // Update service progress - starting download
- // Note: We don't send an initial update with all zeros as this can cause stage jumping
- // Instead, we'll wait until we have actual totals to report
+ db = initializeDatabase()
+ val (totalBasemapTiles, totalValhallaTiles) =
+ calculateTotalDownloadCounts(boundingBox, minZoom, maxZoom)
+ val (downloadedBasemapTiles, downloadedValhallaTiles) =
+ getCurrentProgressCounts(areaId)
- // Use offline database for all downloads
- val outputFile = File(context.filesDir, OFFLINE_DATABASE_NAME)
- val dbExists = outputFile.exists()
- Log.d(TAG, "Using database file: ${outputFile.absolutePath}, exists: $dbExists")
-
- db = SQLiteDatabase.openOrCreateDatabase(outputFile, null)
-
- // Initialize MBTiles schema only if database is new
- if (!dbExists) {
- Log.d(TAG, "Initializing new MBTiles schema")
- initializeMbtilesSchema(db)
- }
-
- // Calculate total tiles to download
- val totalBasemapTiles = calculateTotalTiles(
- boundingBox, minZoom, min(maxZoom, MAX_BASEMAP_ZOOM)
+ initializeProgressReporting(
+ areaId, name, totalBasemapTiles, totalValhallaTiles,
+ downloadedBasemapTiles, downloadedValhallaTiles
)
- val totalValhallaTiles = ValhallaTileUtils.tilesForBoundingBox(boundingBox).size
- Log.d(TAG, "Total basemap tiles to download: $totalBasemapTiles")
- Log.d(TAG, "Total valhalla tiles to download: $totalValhallaTiles")
-
- // Get current progress counts for accurate resume
- val downloadedBasemapTiles =
- downloadedTileDao.getDownloadedTileCountForAreaAndType(areaId, TileType.BASEMAP)
- val downloadedValhallaTiles =
- downloadedTileDao.getDownloadedTileCountForAreaAndType(areaId, TileType.VALHALLA)
+ tileProcessor?.beginTileProcessing()
+ Log.d(TAG, "Tile processor initialized")
- Log.d(
- TAG,
- "Already downloaded: $downloadedBasemapTiles basemap tiles, $downloadedValhallaTiles valhalla tiles"
+ basemapResult = downloadBasemapPhaseIfNeeded(
+ resumePhase, boundingBox, minZoom,
+ maxZoom, areaId, name, totalValhallaTiles
+ )
+ valhallaResult = downloadValhallaPhaseIfNeeded(
+ resumePhase, boundingBox, areaId,
+ db, name
)
- val currentStage = if (downloadedBasemapTiles != totalBasemapTiles) {
- TileDownloadForegroundService.DownloadStage.BASEMAP
- } else if (downloadedValhallaTiles != totalValhallaTiles) {
- TileDownloadForegroundService.DownloadStage.VALHALLA
- } else {
- TileDownloadForegroundService.DownloadStage.PROCESSING
- }
- val stageProgress = when (currentStage) {
- TileDownloadForegroundService.DownloadStage.BASEMAP -> downloadedBasemapTiles
- TileDownloadForegroundService.DownloadStage.VALHALLA -> downloadedValhallaTiles
- TileDownloadForegroundService.DownloadStage.PROCESSING -> 0
- }
- val stageTotal = when (currentStage) {
- TileDownloadForegroundService.DownloadStage.BASEMAP -> totalBasemapTiles
- TileDownloadForegroundService.DownloadStage.VALHALLA -> totalValhallaTiles
- TileDownloadForegroundService.DownloadStage.PROCESSING -> 1
- }
+ finalizeAndStoreMetadata(db, areaId, boundingBox, minZoom, maxZoom, name)
+ db.close()
+ db = null
- // Update service progress with current totals and progress
- serviceBinder?.getService()?.updateProgress(
- areaId = areaId,
- areaName = name,
- currentStage = currentStage,
- stageProgress = stageProgress,
- stageTotal = stageTotal,
- isCompleted = false,
- hasError = false
+ val fileSize = calculateAndLogCompletionStats(
+ boundingBox, minZoom, maxZoom,
+ basemapResult, valhallaResult,
+ downloadedBasemapTiles, downloadedValhallaTiles
)
- // Log the number of tiles already in the database
- val existingTileCount = getTileCount(db)
- Log.d(TAG, "Existing tiles in database: $existingTileCount")
+ processDownloadedTilesAndComplete(areaId, name, fileSize)
- tileProcessor?.beginTileProcessing()
- Log.d(TAG, "Tile processor initialized")
+ } catch (e: Exception) {
+ Log.e(TAG, "Error downloading tiles for area $name (ID: $areaId)", e)
+ handleDownloadError(areaId, name)
+ } finally {
+ tileProcessor?.endTileProcessing()
+ Log.d(TAG, "Tile processing completed")
+ closeDatabaseSafely(db)
+ }
+ }
- // Download basemap tiles if needed (skip if already complete or resuming after)
- if (resumePhase == DownloadStatus.DOWNLOADING_BASEMAP) {
- Log.d(
- TAG, "Starting/continuing basemap tile download for area: $name (ID: $areaId)"
- )
+ /**
+ * Initialize or open the MBTiles database
+ */
+ private suspend fun initializeDatabase(): SQLiteDatabase {
+ val outputFile = File(context.filesDir, OFFLINE_DATABASE_NAME)
+ val dbExists = outputFile.exists()
+ Log.d(TAG, "Using database file: ${outputFile.absolutePath}, exists: $dbExists")
- // Update status to DOWNLOADING_BASEMAP
- val downloadingArea = offlineAreaDao.getOfflineAreaById(areaId)
- if (downloadingArea != null) {
- val updatedArea =
- downloadingArea.copy(status = DownloadStatus.DOWNLOADING_BASEMAP)
- offlineAreaDao.updateOfflineArea(updatedArea)
- }
+ val db = SQLiteDatabase.openOrCreateDatabase(outputFile, null)
- basemapResult = downloadBasemapTiles(
- boundingBox, minZoom, min(maxZoom, MAX_BASEMAP_ZOOM), areaId, name
- )
+ // Initialize MBTiles schema only if database is new
+ if (!dbExists) {
+ Log.d(TAG, "Initializing new MBTiles schema")
+ initializeMbtilesSchema(db)
+ }
- Log.d(
- TAG,
- "Basemap download complete: ${basemapResult.first} new tiles downloaded, ${basemapResult.second} failed"
- )
+ return db
+ }
- // Mark basemap phase as complete
- val basemapCompleteArea = offlineAreaDao.getOfflineAreaById(areaId)
- if (basemapCompleteArea != null) {
- val updatedArea =
- basemapCompleteArea.copy(status = DownloadStatus.DOWNLOADING_VALHALLA)
- offlineAreaDao.updateOfflineArea(updatedArea)
- Log.d(TAG, "Updated area $areaId status to DOWNLOADING_VALHALLA")
- }
+ /**
+ * Calculate total counts for basemap and Valhalla tiles
+ */
+ private suspend fun calculateTotalDownloadCounts(
+ boundingBox: BoundingBox, minZoom: Int, maxZoom: Int
+ ): Pair {
+ val totalBasemapTiles = calculateTotalTiles(
+ boundingBox, minZoom, min(maxZoom, MAX_BASEMAP_ZOOM)
+ )
+ val totalValhallaTiles = ValhallaTileUtils.tilesForBoundingBox(boundingBox).size
- // Update progress to show basemap completion
- // Ensure we maintain consistent progress values to prevent stage jumping
- serviceBinder?.getService()?.updateProgress(
- areaId = areaId,
- areaName = name,
- currentStage = TileDownloadForegroundService.DownloadStage.VALHALLA,
- stageProgress = 0,
- stageTotal = totalValhallaTiles,
- isCompleted = false,
- hasError = false
- )
- } else {
- Log.d(TAG, "Skipping basemap download for area $areaId (already completed)")
- }
+ Log.d(TAG, "Total basemap tiles to download: $totalBasemapTiles")
+ Log.d(TAG, "Total valhalla tiles to download: $totalValhallaTiles")
- // Download Valhalla tiles (always attempted after basemap or when resuming from Valhalla phase)
- if (resumePhase == DownloadStatus.DOWNLOADING_BASEMAP || resumePhase == DownloadStatus.DOWNLOADING_VALHALLA) {
+ return Pair(totalBasemapTiles, totalValhallaTiles)
+ }
- Log.d(
- TAG, "Starting/continuing Valhalla tile download for area: $name (ID: $areaId)"
- )
+ /**
+ * Get current download progress counts
+ */
+ private suspend fun getCurrentProgressCounts(areaId: String): Pair {
+ val downloadedBasemapTiles =
+ downloadedTileDao.getDownloadedTileCountForAreaAndType(areaId, TileType.BASEMAP)
+ val downloadedValhallaTiles =
+ downloadedTileDao.getDownloadedTileCountForAreaAndType(areaId, TileType.VALHALLA)
- // Update status to DOWNLOADING_VALHALLA if not already
- val currentArea = offlineAreaDao.getOfflineAreaById(areaId)
- if (currentArea != null && currentArea.status != DownloadStatus.DOWNLOADING_VALHALLA) {
- val updatedArea = currentArea.copy(status = DownloadStatus.DOWNLOADING_VALHALLA)
- offlineAreaDao.updateOfflineArea(updatedArea)
- }
+ Log.d(
+ TAG,
+ "Already downloaded: $downloadedBasemapTiles basemap tiles, $downloadedValhallaTiles valhalla tiles"
+ )
- valhallaResult = downloadValhallaTiles(
- boundingBox, areaId, db!!, name
- )
+ return Pair(downloadedBasemapTiles, downloadedValhallaTiles)
+ }
- Log.d(
- TAG,
- "Valhalla download complete: ${valhallaResult.first} new tiles downloaded, ${valhallaResult.second} failed"
- )
- } else {
- Log.d(TAG, "Skipping Valhalla download for area $areaId (already completed)")
- }
+ /**
+ * Initialize progress reporting with current state
+ */
+ private suspend fun initializeProgressReporting(
+ areaId: String, name: String, totalBasemapTiles: Int, totalValhallaTiles: Int,
+ downloadedBasemapTiles: Int, downloadedValhallaTiles: Int
+ ) {
+ val currentStage = determineCurrentStage(
+ totalBasemapTiles, totalValhallaTiles,
+ downloadedBasemapTiles, downloadedValhallaTiles
+ )
+ val stageProgress =
+ getStageProgress(currentStage, downloadedBasemapTiles, downloadedValhallaTiles)
+ val stageTotal = getStageTotal(currentStage, totalBasemapTiles, totalValhallaTiles)
+
+ serviceBinder?.getService()?.updateProgress(
+ areaId = areaId,
+ areaName = name,
+ currentStage = currentStage,
+ stageProgress = stageProgress,
+ stageTotal = stageTotal,
+ isCompleted = false,
+ hasError = false
+ )
- // Note: Tiles are already inserted into database during download process
- // No need for additional batch insert here
+ // Log existing tile count
+ val db = SQLiteDatabase.openDatabase(
+ File(context.filesDir, OFFLINE_DATABASE_NAME).absolutePath,
+ null,
+ SQLiteDatabase.OPEN_READONLY
+ )
+ val existingTileCount = getTileCount(db)
+ Log.d(TAG, "Existing tiles in database: $existingTileCount")
+ db.close()
+ }
+
+ /**
+ * Determine current download stage based on progress
+ */
+ private fun determineCurrentStage(
+ totalBasemapTiles: Int, totalValhallaTiles: Int,
+ downloadedBasemapTiles: Int, downloadedValhallaTiles: Int
+ ): TileDownloadForegroundService.DownloadStage {
+ return if (downloadedBasemapTiles != totalBasemapTiles) {
+ TileDownloadForegroundService.DownloadStage.BASEMAP
+ } else if (downloadedValhallaTiles != totalValhallaTiles) {
+ TileDownloadForegroundService.DownloadStage.VALHALLA
+ } else {
+ TileDownloadForegroundService.DownloadStage.PROCESSING
+ }
+ }
- // Log the number of tiles in the database after download
- val finalTileCount = getTileCount(db)
- Log.d(TAG, "Final tiles in database after download: $finalTileCount")
+ /**
+ * Get progress for current stage
+ */
+ private fun getStageProgress(
+ currentStage: TileDownloadForegroundService.DownloadStage,
+ downloadedBasemapTiles: Int, downloadedValhallaTiles: Int
+ ): Int {
+ return when (currentStage) {
+ TileDownloadForegroundService.DownloadStage.BASEMAP -> downloadedBasemapTiles
+ TileDownloadForegroundService.DownloadStage.VALHALLA -> downloadedValhallaTiles
+ TileDownloadForegroundService.DownloadStage.PROCESSING -> 0
+ }
+ }
- // Store area metadata
- Log.d(TAG, "Storing area metadata for $areaId")
- storeAreaMetadata(db, areaId, boundingBox, minZoom, maxZoom, name)
+ /**
+ * Get total for current stage
+ */
+ private fun getStageTotal(
+ currentStage: TileDownloadForegroundService.DownloadStage,
+ totalBasemapTiles: Int, totalValhallaTiles: Int
+ ): Int {
+ return when (currentStage) {
+ TileDownloadForegroundService.DownloadStage.BASEMAP -> totalBasemapTiles
+ TileDownloadForegroundService.DownloadStage.VALHALLA -> totalValhallaTiles
+ TileDownloadForegroundService.DownloadStage.PROCESSING -> 1
+ }
+ }
- db.close()
- db = null
+ /**
+ * Download basemap phase if needed
+ */
+ private suspend fun downloadBasemapPhaseIfNeeded(
+ resumePhase: DownloadStatus, boundingBox: BoundingBox, minZoom: Int, maxZoom: Int,
+ areaId: String, name: String, totalValhallaTiles: Int
+ ): Pair {
+ if (resumePhase == DownloadStatus.DOWNLOADING_BASEMAP) {
+ Log.d(TAG, "Starting/continuing basemap tile download for area: $name (ID: $areaId)")
- // Get the actual file size
- val fileSize = outputFile.length()
+ updateAreaStatus(areaId, DownloadStatus.DOWNLOADING_BASEMAP)
- val totalBasemapDownloaded = basemapResult.first + downloadedBasemapTiles
- val totalValhallaDownloaded = valhallaResult.first + downloadedValhallaTiles
+ val basemapResult = downloadBasemapTiles(
+ boundingBox, minZoom, min(maxZoom, MAX_BASEMAP_ZOOM), areaId, name
+ )
Log.d(
TAG,
- "Tile download completed. $totalBasemapDownloaded/${totalBasemapTiles} basemap tiles, $totalValhallaDownloaded/${totalValhallaTiles} valhalla tiles downloaded. File size: $fileSize bytes"
+ "Basemap download complete: ${basemapResult.first} new tiles downloaded, ${basemapResult.second} failed"
)
- // Update offline area status to PROCESSING
- val area = offlineAreaDao.getOfflineAreaById(areaId)
- if (area != null) {
- val processingArea = area.copy(
- status = DownloadStatus.PROCESSING_GEOCODER, fileSize = fileSize
- )
- offlineAreaDao.updateOfflineArea(processingArea)
- }
+ updateAreaStatus(areaId, DownloadStatus.DOWNLOADING_VALHALLA)
- // Update service progress - downloads completed, now processing
- // Ensure we maintain consistent progress values to prevent stage jumping
+ // Update progress to show basemap completion
serviceBinder?.getService()?.updateProgress(
areaId = areaId,
areaName = name,
- currentStage = TileDownloadForegroundService.DownloadStage.PROCESSING,
+ currentStage = TileDownloadForegroundService.DownloadStage.VALHALLA,
stageProgress = 0,
- stageTotal = 1, // This is a bad estimate obviously but all that matters for now is that we are starting from 0%
+ stageTotal = totalValhallaTiles,
isCompleted = false,
hasError = false
)
- // Start tile processing phase
- Log.d(TAG, "Starting tile processing phase for area $areaId")
- processDownloadedTiles(areaId)
+ return basemapResult
+ } else {
+ Log.d(TAG, "Skipping basemap download for area $areaId (already completed)")
+ return Pair(0, 0)
+ }
+ }
- // Update offline area status to COMPLETED
- val completedArea = offlineAreaDao.getOfflineAreaById(areaId)
- if (completedArea != null) {
- val finalArea = completedArea.copy(
- status = DownloadStatus.COMPLETED, fileSize = fileSize
- )
- offlineAreaDao.updateOfflineArea(finalArea)
- }
+ /**
+ * Download Valhalla phase if needed
+ */
+ private suspend fun downloadValhallaPhaseIfNeeded(
+ resumePhase: DownloadStatus, boundingBox: BoundingBox, areaId: String,
+ db: SQLiteDatabase, name: String
+ ): Pair {
+ if (resumePhase == DownloadStatus.DOWNLOADING_BASEMAP || resumePhase == DownloadStatus.DOWNLOADING_VALHALLA) {
+ Log.d(TAG, "Starting/continuing Valhalla tile download for area: $name (ID: $areaId)")
- // Update service progress - processing completed (unified)
- serviceBinder?.getService()?.updateProgress(
- areaId = areaId,
- areaName = name,
- currentStage = null,
- stageProgress = 0,
- stageTotal = 0,
- isCompleted = true,
- hasError = false
- )
+ ensureValhallaStatus(areaId)
- } catch (e: Exception) {
- Log.e(TAG, "Error downloading tiles for area $name (ID: $areaId)", e)
+ val valhallaResult = downloadValhallaTiles(boundingBox, areaId, db, name)
- // Update service progress - download failed
- serviceBinder?.getService()?.updateProgress(
- areaId = areaId,
- areaName = name,
- currentStage = null,
- stageProgress = 0,
- stageTotal = 0,
- isCompleted = true,
- hasError = true
+ Log.d(
+ TAG,
+ "Valhalla download complete: ${valhallaResult.first} new tiles downloaded, ${valhallaResult.second} failed"
)
- // Update offline area status
- val area = offlineAreaDao.getOfflineAreaById(areaId)
- if (area != null) {
- val updatedArea = area.copy(
- status = DownloadStatus.FAILED, fileSize = 0L
- )
- offlineAreaDao.updateOfflineArea(updatedArea)
- }
- } finally {
- tileProcessor?.endTileProcessing()
- Log.d(TAG, "Tile processing completed")
- // Close database if it's open
- try {
- db?.close()
- } catch (closeException: Exception) {
- Log.e(TAG, "Error closing database", closeException)
- }
+ return valhallaResult
+ } else {
+ Log.d(TAG, "Skipping Valhalla download for area $areaId (already completed)")
+ return Pair(0, 0)
+ }
+ }
+
+ /**
+ * Ensure the area status is set to DOWNLOADING_VALHALLA if not already
+ */
+ private suspend fun ensureValhallaStatus(areaId: String) {
+ val currentArea = offlineAreaDao.getOfflineAreaById(areaId)
+ if (currentArea != null && currentArea.status != DownloadStatus.DOWNLOADING_VALHALLA) {
+ val updatedArea = currentArea.copy(status = DownloadStatus.DOWNLOADING_VALHALLA)
+ offlineAreaDao.updateOfflineArea(updatedArea)
+ }
+ }
+
+ /**
+ * Finalize download and store metadata
+ */
+ private suspend fun finalizeAndStoreMetadata(
+ db: SQLiteDatabase, areaId: String, boundingBox: BoundingBox,
+ minZoom: Int, maxZoom: Int, name: String
+ ) {
+ // Log final tile count
+ val finalTileCount = getTileCount(db)
+ Log.d(TAG, "Final tiles in database after download: $finalTileCount")
+
+ // Store area metadata
+ Log.d(TAG, "Storing area metadata for $areaId")
+ storeAreaMetadata(db, areaId, boundingBox, minZoom, maxZoom, name)
+ }
+
+ /**
+ * Calculate completion stats and log results
+ */
+ private suspend fun calculateAndLogCompletionStats(
+ boundingBox: BoundingBox, minZoom: Int, maxZoom: Int,
+ basemapResult: Pair, valhallaResult: Pair,
+ downloadedBasemapTiles: Int, downloadedValhallaTiles: Int
+ ): Long {
+ val outputFile = File(context.filesDir, OFFLINE_DATABASE_NAME)
+ val fileSize = outputFile.length()
+
+ val totalBasemapTiles =
+ calculateTotalTiles(boundingBox, minZoom, min(maxZoom, MAX_BASEMAP_ZOOM))
+ val totalValhallaTiles = ValhallaTileUtils.tilesForBoundingBox(boundingBox).size
+
+ val totalBasemapDownloaded = basemapResult.first + downloadedBasemapTiles
+ val totalValhallaDownloaded = valhallaResult.first + downloadedValhallaTiles
+
+ Log.d(
+ TAG,
+ "Tile download completed. $totalBasemapDownloaded/${totalBasemapTiles} basemap tiles, $totalValhallaDownloaded/${totalValhallaTiles} valhalla tiles downloaded. File size: $fileSize bytes"
+ )
+
+ return fileSize
+ }
+
+ /**
+ * Process downloaded tiles and mark as completed
+ */
+ private suspend fun processDownloadedTilesAndComplete(
+ areaId: String,
+ name: String,
+ fileSize: Long
+ ) {
+ // Update offline area status to PROCESSING
+ val area = offlineAreaDao.getOfflineAreaById(areaId)
+ if (area != null) {
+ val processingArea =
+ area.copy(status = DownloadStatus.PROCESSING_GEOCODER, fileSize = fileSize)
+ offlineAreaDao.updateOfflineArea(processingArea)
+ }
+
+ // Update service progress - downloads completed, now processing
+ serviceBinder?.getService()?.updateProgress(
+ areaId = areaId,
+ areaName = name,
+ currentStage = TileDownloadForegroundService.DownloadStage.PROCESSING,
+ stageProgress = 0,
+ stageTotal = 1,
+ isCompleted = false,
+ hasError = false
+ )
+
+ // Start tile processing phase
+ Log.d(TAG, "Starting tile processing phase for area $areaId")
+ processDownloadedTiles(areaId)
+
+ // Update offline area status to COMPLETED
+ val completedArea = offlineAreaDao.getOfflineAreaById(areaId)
+ if (completedArea != null) {
+ val finalArea =
+ completedArea.copy(status = DownloadStatus.COMPLETED, fileSize = fileSize)
+ offlineAreaDao.updateOfflineArea(finalArea)
+ }
+
+ // Update service progress - processing completed
+ serviceBinder?.getService()?.updateProgress(
+ areaId = areaId,
+ areaName = name,
+ currentStage = null,
+ stageProgress = 0,
+ stageTotal = 0,
+ isCompleted = true,
+ hasError = false
+ )
+ }
+
+ /**
+ * Handle download error
+ */
+ private suspend fun handleDownloadError(areaId: String, name: String) {
+ // Update service progress - download failed
+ serviceBinder?.getService()?.updateProgress(
+ areaId = areaId,
+ areaName = name,
+ currentStage = null,
+ stageProgress = 0,
+ stageTotal = 0,
+ isCompleted = true,
+ hasError = true
+ )
+
+ // Update offline area status
+ val area = offlineAreaDao.getOfflineAreaById(areaId)
+ if (area != null) {
+ val updatedArea = area.copy(status = DownloadStatus.FAILED, fileSize = 0L)
+ offlineAreaDao.updateOfflineArea(updatedArea)
+ }
+ }
+
+ /**
+ * Close database safely
+ */
+ private fun closeDatabaseSafely(db: SQLiteDatabase?) {
+ try {
+ db?.close()
+ } catch (closeException: Exception) {
+ Log.e(TAG, "Error closing database", closeException)
}
}
@@ -620,6 +759,29 @@ class TileDownloadManager(
Log.d(TAG, "Total Valhalla tiles to download: $totalValhallaTiles")
+ logExistingValhallaTileCount(db, areaId)
+ ensureValhallaTilesDirectory()
+
+ // Process Valhalla tiles sequentially (one at a time to avoid memory issues)
+ for ((hierarchyLevel, tileIndex) in valhallaTiles) {
+ processValhallaTile(
+ hierarchyLevel, tileIndex, areaId, db, areaName,
+ totalValhallaTiles, downloadedCount
+ )?.let {
+ downloadedCount++
+ } ?: run {
+ failedCount++
+ }
+ }
+
+ performFinalValhallaConsistencyCheck(db, areaId)
+ return Pair(downloadedCount, failedCount)
+ }
+
+ /**
+ * Log the count of existing Valhalla tiles for the area
+ */
+ private fun logExistingValhallaTileCount(db: SQLiteDatabase, areaId: String) {
// Validate consistency between expected tiles and existing tiles in database
var cursor: Cursor? = null
try {
@@ -637,82 +799,97 @@ class TileDownloadManager(
} finally {
cursor?.close()
}
+ }
+ /**
+ * Ensure the valhalla tiles directory exists
+ */
+ private fun ensureValhallaTilesDirectory() {
// Create valhalla tiles directory
val valhallaTilesDir = File(context.filesDir, "valhalla_tiles")
if (!valhallaTilesDir.exists()) {
valhallaTilesDir.mkdirs()
}
+ }
- // Process Valhalla tiles sequentially (one at a time to avoid memory issues)
- for ((hierarchyLevel, tileIndex) in valhallaTiles) {
- // Check if this Valhalla tile already exists in the database
- var cursor: Cursor? = null
- var tileExists = false
- try {
- cursor = db.rawQuery(
- "SELECT COUNT(*) FROM valhalla_tiles WHERE hierarchy_level = ? AND tile_index = ? AND area_id = ?",
- arrayOf(hierarchyLevel.toString(), tileIndex.toString(), areaId)
- )
- if (cursor.moveToFirst() && cursor.getInt(0) > 0) {
- tileExists = true
- }
- } catch (e: Exception) {
- Log.w(
- TAG,
- "Error checking existing Valhalla tile $hierarchyLevel/$tileIndex for area $areaId",
- e
- )
- } finally {
- cursor?.close()
- }
+ /**
+ * Process a single Valhalla tile - return true if downloaded successfully, null if failed
+ */
+ private suspend fun processValhallaTile(
+ hierarchyLevel: Int, tileIndex: Int, areaId: String, db: SQLiteDatabase,
+ areaName: String, totalValhallaTiles: Int, downloadedCount: Int
+ ): Boolean? {
+ // Check if this Valhalla tile already exists in the database
+ if (valhallaTileExists(db, hierarchyLevel, tileIndex, areaId)) {
+ Log.v(
+ TAG,
+ "Skipping already downloaded Valhalla tile $hierarchyLevel/$tileIndex for area $areaId"
+ )
+ // Update service progress without incrementing counters
+ serviceBinder?.getService()?.updateProgress(
+ areaId = areaId,
+ areaName = areaName,
+ currentStage = TileDownloadForegroundService.DownloadStage.VALHALLA,
+ stageProgress = downloadedCount,
+ stageTotal = totalValhallaTiles,
+ isCompleted = false,
+ hasError = false
+ )
+ return null // Skip tile (not a failure, just already exists)
+ }
- // If tile already exists, skip downloading
- if (tileExists) {
- Log.v(
- TAG,
- "Skipping already downloaded Valhalla tile $hierarchyLevel/$tileIndex for area $areaId"
- )
- // Update service progress without incrementing counters
- serviceBinder?.getService()?.updateProgress(
- areaId = areaId,
- areaName = areaName,
- currentStage = TileDownloadForegroundService.DownloadStage.VALHALLA,
- stageProgress = downloadedCount,
- stageTotal = totalValhallaTiles,
- isCompleted = false,
- hasError = false
- )
- continue // Skip to next tile
- }
+ val (success, filePath) = downloadValhallaTile(hierarchyLevel, tileIndex)
+ return if (success && filePath != null) {
+ // Store tile reference in database
+ storeValhallaTileReference(db, hierarchyLevel, tileIndex, filePath, areaId)
- val (success, filePath) = downloadValhallaTile(
- hierarchyLevel,
- tileIndex,
+ // Update service progress
+ serviceBinder?.getService()?.updateProgress(
+ areaId = areaId,
+ areaName = areaName,
+ currentStage = TileDownloadForegroundService.DownloadStage.VALHALLA,
+ stageProgress = downloadedCount + 1, // Add 1 since we return before increment
+ stageTotal = totalValhallaTiles,
+ isCompleted = false,
+ hasError = false
)
- if (success && filePath != null) {
- // Store tile reference in database
- storeValhallaTileReference(db, hierarchyLevel, tileIndex, filePath, areaId)
- // Update progress
- downloadedCount++
+ true // Successfully downloaded
+ } else {
+ false // Failed to download
+ }
+ }
- // Update service progress
- serviceBinder?.getService()?.updateProgress(
- areaId = areaId,
- areaName = areaName,
- currentStage = TileDownloadForegroundService.DownloadStage.VALHALLA,
- stageProgress = downloadedCount,
- stageTotal = totalValhallaTiles,
- isCompleted = false,
- hasError = false
- )
- } else {
- failedCount++
- }
+ /**
+ * Check if a Valhalla tile already exists in the database
+ */
+ private fun valhallaTileExists(
+ db: SQLiteDatabase, hierarchyLevel: Int, tileIndex: Int, areaId: String
+ ): Boolean {
+ var cursor: Cursor? = null
+ return try {
+ cursor = db.rawQuery(
+ "SELECT COUNT(*) FROM valhalla_tiles WHERE hierarchy_level = ? AND tile_index = ? AND area_id = ?",
+ arrayOf(hierarchyLevel.toString(), tileIndex.toString(), areaId)
+ )
+ cursor.moveToFirst() && cursor.getInt(0) > 0
+ } catch (e: Exception) {
+ Log.w(
+ TAG,
+ "Error checking existing Valhalla tile $hierarchyLevel/$tileIndex for area $areaId",
+ e
+ )
+ false
+ } finally {
+ cursor?.close()
}
+ }
- // Final consistency check
+ /**
+ * Perform final consistency check for Valhalla tiles
+ */
+ private fun performFinalValhallaConsistencyCheck(db: SQLiteDatabase, areaId: String) {
+ var cursor: Cursor? = null
try {
cursor = db.rawQuery(
"SELECT COUNT(*) FROM valhalla_tiles WHERE area_id = ?", arrayOf(areaId)
@@ -726,8 +903,6 @@ class TileDownloadManager(
} finally {
cursor?.close()
}
-
- return Pair(downloadedCount, failedCount)
}
/**
@@ -1175,7 +1350,6 @@ class TileDownloadManager(
try {
Log.d(TAG, "Starting tile deletion for area ID: $areaId")
- // Open the offline database
val outputFile = File(context.filesDir, OFFLINE_DATABASE_NAME)
if (!outputFile.exists()) {
Log.d(TAG, "Offline database file does not exist, nothing to delete")
@@ -1183,83 +1357,14 @@ class TileDownloadManager(
}
db = SQLiteDatabase.openDatabase(
- outputFile.absolutePath, null, SQLiteDatabase.OPEN_READWRITE
- )
-
- // First, get all tiles for this area
- val tilesToDelete = getTilesForArea(db, areaId)
- Log.d(TAG, "Found ${tilesToDelete.size} tiles for area ID: $areaId")
-
- // For each tile, check if it's shared with other areas
- var actuallyDeletedTiles = 0
- var sharedTiles = 0
- for (tile in tilesToDelete) {
- // Check if this tile is used by other areas
- val isShared = isTileSharedWithOtherAreas(db, tile, areaId)
- if (!isShared) {
- // Tile is not shared with other areas, we can delete it
- val deleted = deleteTile(db, tile, areaId)
- actuallyDeletedTiles += deleted
- Log.v(
- TAG,
- "Deleted tile ${tile.zoomLevel}/${tile.tileColumn}/${tile.tileRow} for area ID: $areaId"
- )
- } else {
- sharedTiles++
- Log.v(
- TAG,
- "Skipping shared tile ${tile.zoomLevel}/${tile.tileColumn}/${tile.tileRow} for area ID: $areaId"
- )
- }
- }
-
- Log.d(
- TAG,
- "Deleted $actuallyDeletedTiles tiles for area ID: $areaId (shared tiles: $sharedTiles, total: ${tilesToDelete.size})"
+ outputFile.absolutePath,
+ null,
+ SQLiteDatabase.OPEN_READWRITE
)
- // Delete Valhalla tiles for this area
- val valhallaTilesToDelete = getValhallaTilesForArea(db, areaId)
- Log.d(TAG, "Found ${valhallaTilesToDelete.size} Valhalla tiles for area ID: $areaId")
-
- var actuallyDeletedValhallaTiles = 0
- var sharedValhallaTiles = 0
- for (valhallaTile in valhallaTilesToDelete) {
- // Check if this Valhalla tile is used by other areas
- val isShared = isValhallaTileSharedWithOtherAreas(db, valhallaTile, areaId)
- if (!isShared) {
- // Delete the physical file
- try {
- val file = File(valhallaTile.filePath)
- if (file.exists() && file.delete()) {
- Log.v(TAG, "Deleted Valhalla tile file: ${valhallaTile.filePath}")
- }
- } catch (e: Exception) {
- Log.w(TAG, "Error deleting Valhalla tile file: ${valhallaTile.filePath}", e)
- }
-
- // Delete from database
- val deleted = deleteValhallaTile(db, valhallaTile, areaId)
- actuallyDeletedValhallaTiles += deleted
- Log.v(
- TAG,
- "Deleted Valhalla tile ${valhallaTile.hierarchyLevel}/${valhallaTile.tileIndex} for area ID: $areaId"
- )
- } else {
- sharedValhallaTiles++
- Log.v(
- TAG,
- "Skipping shared Valhalla tile ${valhallaTile.hierarchyLevel}/${valhallaTile.tileIndex} for area ID: $areaId"
- )
- }
- }
-
- Log.d(
- TAG,
- "Deleted $actuallyDeletedValhallaTiles Valhalla tiles for area ID: $areaId (shared tiles: $sharedValhallaTiles, total: ${valhallaTilesToDelete.size})"
- )
+ deleteUnsharedTiles(db, areaId)
+ deleteUnsharedValhallaTiles(db, areaId)
- // Also delete the area metadata
val deletedMetadata = db.delete("areas", "area_id = ?", arrayOf(areaId))
Log.d(TAG, "Deleted $deletedMetadata area metadata entries for area ID: $areaId")
@@ -1268,12 +1373,97 @@ class TileDownloadManager(
Log.e(TAG, "Error deleting tiles for area ID: $areaId", e)
return false
} finally {
- // Close database if it's open
- try {
- db?.close()
- } catch (closeException: Exception) {
- Log.e(TAG, "Error closing database", closeException)
+ closeDatabaseSafely(db)
+ }
+ }
+
+ /**
+ * Delete tiles that are not shared with other areas
+ */
+ private fun deleteUnsharedTiles(db: SQLiteDatabase, areaId: String): TileDeletionResult {
+ val tilesToDelete = getTilesForArea(db, areaId)
+ Log.d(TAG, "Found ${tilesToDelete.size} tiles for area ID: $areaId")
+
+ var actuallyDeletedTiles = 0
+ var sharedTiles = 0
+
+ for (tile in tilesToDelete) {
+ if (!isTileSharedWithOtherAreas(db, tile, areaId)) {
+ val deleted = deleteTile(db, tile, areaId)
+ actuallyDeletedTiles += deleted
+ Log.v(
+ TAG,
+ "Deleted tile ${tile.zoomLevel}/${tile.tileColumn}/${tile.tileRow} for area ID: $areaId"
+ )
+ } else {
+ sharedTiles++
+ Log.v(
+ TAG,
+ "Skipping shared tile ${tile.zoomLevel}/${tile.tileColumn}/${tile.tileRow} for area ID: $areaId"
+ )
+ }
+ }
+
+ Log.d(
+ TAG,
+ "Deleted $actuallyDeletedTiles tiles for area ID: $areaId (shared tiles: $sharedTiles, total: ${tilesToDelete.size})"
+ )
+ return TileDeletionResult(actuallyDeletedTiles, sharedTiles, tilesToDelete.size)
+ }
+
+ /**
+ * Delete Valhalla tiles that are not shared with other areas
+ */
+ private fun deleteUnsharedValhallaTiles(
+ db: SQLiteDatabase,
+ areaId: String
+ ): TileDeletionResult {
+ val valhallaTilesToDelete = getValhallaTilesForArea(db, areaId)
+ Log.d(TAG, "Found ${valhallaTilesToDelete.size} Valhalla tiles for area ID: $areaId")
+
+ var actuallyDeletedValhallaTiles = 0
+ var sharedValhallaTiles = 0
+
+ for (valhallaTile in valhallaTilesToDelete) {
+ if (!isValhallaTileSharedWithOtherAreas(db, valhallaTile, areaId)) {
+ deleteValhallaPhysicalFile(valhallaTile)
+ val deleted = deleteValhallaTile(db, valhallaTile, areaId)
+ actuallyDeletedValhallaTiles += deleted
+ Log.v(
+ TAG,
+ "Deleted Valhalla tile ${valhallaTile.hierarchyLevel}/${valhallaTile.tileIndex} for area ID: $areaId"
+ )
+ } else {
+ sharedValhallaTiles++
+ Log.v(
+ TAG,
+ "Skipping shared Valhalla tile ${valhallaTile.hierarchyLevel}/${valhallaTile.tileIndex} for area ID: $areaId"
+ )
+ }
+ }
+
+ Log.d(
+ TAG,
+ "Deleted $actuallyDeletedValhallaTiles Valhalla tiles for area ID: $areaId (shared tiles: $sharedValhallaTiles, total: ${valhallaTilesToDelete.size})"
+ )
+ return TileDeletionResult(
+ actuallyDeletedValhallaTiles,
+ sharedValhallaTiles,
+ valhallaTilesToDelete.size
+ )
+ }
+
+ /**
+ * Delete the physical file for a Valhalla tile
+ */
+ private fun deleteValhallaPhysicalFile(valhallaTile: ValhallaTileCoordinates) {
+ try {
+ val file = File(valhallaTile.filePath)
+ if (file.exists() && file.delete()) {
+ Log.v(TAG, "Deleted Valhalla tile file: ${valhallaTile.filePath}")
}
+ } catch (e: Exception) {
+ Log.w(TAG, "Error deleting Valhalla tile file: ${valhallaTile.filePath}", e)
}
}
@@ -1588,6 +1778,12 @@ private data class TileCoordinates(
val zoomLevel: Int, val tileColumn: Int, val tileRow: Int
)
+private data class TileDeletionResult(
+ val deletedCount: Int,
+ val sharedCount: Int,
+ val totalCount: Int
+)
+
/**
* Calculate tile range for a bounding box at a specific zoom level
*/
@@ -1612,4 +1808,4 @@ fun calculateTileRange(
return TileRange(
minX = min(nwX, seX), maxX = max(nwX, seX), minY = min(nwY, seY), maxY = max(nwY, seY)
)
-}
\ No newline at end of file
+}
diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/core/AppContent.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/core/AppContent.kt
index 3f20528..70fa5f4 100644
--- a/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/core/AppContent.kt
+++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/core/AppContent.kt
@@ -63,7 +63,6 @@ import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@@ -105,7 +104,6 @@ import earth.maps.cardinal.data.AppPreferenceRepository
import earth.maps.cardinal.data.Place
import earth.maps.cardinal.data.PolylineUtils
import earth.maps.cardinal.data.RoutingMode
-import earth.maps.cardinal.data.room.OfflineArea
import earth.maps.cardinal.routing.RouteRepository
import earth.maps.cardinal.ui.directions.DirectionsScreen
import earth.maps.cardinal.ui.directions.DirectionsViewModel
@@ -134,8 +132,6 @@ import io.github.dellisd.spatialk.geojson.Position
import kotlinx.coroutines.launch
import org.maplibre.compose.camera.CameraPosition
import org.maplibre.compose.camera.CameraState
-import org.maplibre.compose.camera.rememberCameraState
-import uniffi.ferrostar.Route
val TOOLBAR_HEIGHT_DP = 64.dp
@@ -152,54 +148,36 @@ fun AppContent(
hasNotificationPermission: Boolean,
routeRepository: RouteRepository,
appPreferenceRepository: AppPreferenceRepository,
+ state: AppContentState = rememberAppContentState(),
) {
- val mapPins = remember { mutableStateListOf() }
- val cameraState = rememberCameraState()
- var fabHeight by remember { mutableStateOf(0.dp) }
- val coroutineScope = rememberCoroutineScope()
- val density = LocalDensity.current
- var selectedOfflineArea by remember { mutableStateOf(null) }
-
- var showToolbar by remember { mutableStateOf(true) }
-
- // Route state for displaying on map
- var currentRoute by remember { mutableStateOf(null) }
- var currentTransitItinerary by remember {
- mutableStateOf(
- null
- )
- }
-
- val droppedPinName = stringResource(string.dropped_pin)
- var screenHeightDp by remember { mutableStateOf(0.dp) }
- var screenWidthDp by remember { mutableStateOf(0.dp) }
- var peekHeight by remember { mutableStateOf(0.dp) }
val homeViewModel: HomeViewModel = hiltViewModel()
val transitViewModel: TransitScreenViewModel = hiltViewModel()
val nearbyViewModel: NearbyViewModel = hiltViewModel()
+ val droppedPinName = stringResource(string.dropped_pin)
+
// This is used by nav destinations to determine if it is appropriate of them to update peekHeight.
val topOfBackStack by navController.currentBackStackEntryAsState()
// See comment below in onGloballyPositioned for why this is necessary. I'm not happy about it either.
- LaunchedEffect(peekHeight) {
- mapViewModel.peekHeight = peekHeight
+ LaunchedEffect(state.peekHeight) {
+ mapViewModel.peekHeight = state.peekHeight
}
Box(
modifier = Modifier
.fillMaxSize()
.onGloballyPositioned {
- screenHeightDp = with(density) { it.size.height.toDp() }
- screenWidthDp = with(density) { it.size.width.toDp() }
+ state.screenHeightDp = with(state.density) { it.size.height.toDp() }
+ state.screenWidthDp = with(state.density) { it.size.width.toDp() }
// For very annoying reasons, this ViewModel needs to know the size of the screen.
// Specifically, it is responsible for tracking the state of the "locate me" button across
// a permission request lifecycle. When the permission request is done, it has zero
// business calling back into the view to perform the animateTo operation, and in order
// to perform the animateTo you need to calculate padding based on screen size and peek
// height. :(
- mapViewModel.screenWidth = screenWidthDp
- mapViewModel.screenHeight = screenHeightDp
+ mapViewModel.screenWidth = state.screenWidthDp
+ mapViewModel.screenHeight = state.screenHeightDp
},
) {
@@ -229,18 +207,21 @@ fun AppContent(
onRequestLocationPermission = onRequestLocationPermission,
hasLocationPermission = hasLocationPermission,
fabInsets = PaddingValues(
- start = 0.dp, top = 0.dp, end = 0.dp, bottom = if (screenHeightDp > fabHeight) {
- screenHeightDp - fabHeight
+ start = 0.dp,
+ top = 0.dp,
+ end = 0.dp,
+ bottom = if (state.screenHeightDp > state.fabHeight) {
+ state.screenHeightDp - state.fabHeight
} else {
0.dp
}
),
- cameraState = cameraState,
- mapPins = mapPins,
+ cameraState = state.cameraState,
+ mapPins = state.mapPins,
appPreferences = appPreferenceRepository,
- selectedOfflineArea = selectedOfflineArea,
- currentRoute = currentRoute,
- currentTransitItinerary = currentTransitItinerary
+ selectedOfflineArea = state.selectedOfflineArea,
+ currentRoute = state.currentRoute,
+ currentTransitItinerary = state.currentTransitItinerary
)
} else {
LaunchedEffect(key1 = port) {
@@ -262,240 +243,35 @@ fun AppContent(
Screen.HOME_SEARCH,
enterTransition = { slideInVertically(initialOffsetY = { it }) },
exitTransition = { fadeOut(animationSpec = tween(600)) }) { backStackEntry ->
- showToolbar = true
- HomeScreenComposable(
- viewModel = homeViewModel,
- cameraState = cameraState,
- mapPins = mapPins,
- peekHeight = peekHeight,
- navController = navController,
- onPeekHeightChange = {
- peekHeight = it
- },
- onFabHeightChange = {
- fabHeight = it
- },
- topOfBackStack = topOfBackStack,
- backStackEntry = backStackEntry,
- screenWidthDp = screenWidthDp,
- screenHeightDp = screenHeightDp,
- appPreferenceRepository = appPreferenceRepository
- )
+ HomeRoute(state, homeViewModel, navController, topOfBackStack, appPreferenceRepository, backStackEntry)
}
composable(
Screen.NEARBY_POI,
enterTransition = { slideInVertically(initialOffsetY = { it }) },
exitTransition = { fadeOut(animationSpec = tween(600)) }) { backStackEntry ->
- showToolbar = true
-
- val bottomSheetState = rememberBottomSheetState(
- initialValue = BottomSheetValue.Collapsed
- )
- val scaffoldState =
- rememberBottomSheetScaffoldState(bottomSheetState = bottomSheetState)
-
- CardinalAppScaffold(
- scaffoldState = scaffoldState, peekHeight = screenHeightDp / 3,
- content = {
- NearbyScreenContent(viewModel = nearbyViewModel, onPlaceSelected = {
- NavigationUtils.navigate(navController, Screen.PlaceCard(it))
- })
- },
- fabHeightCallback = {
- if (topOfBackStack == backStackEntry) {
- fabHeight = it
- }
- },
- )
-
+ NearbyPoiRoute(state, nearbyViewModel, navController, topOfBackStack, backStackEntry)
}
composable(
Screen.NEARBY_TRANSIT,
enterTransition = { slideInVertically(initialOffsetY = { it }) },
exitTransition = { fadeOut(animationSpec = tween(600)) }) { backStackEntry ->
- showToolbar = true
-
- val bottomSheetState = rememberBottomSheetState(
- initialValue = BottomSheetValue.Collapsed
- )
- val scaffoldState =
- rememberBottomSheetScaffoldState(bottomSheetState = bottomSheetState)
-
- CardinalAppScaffold(
- scaffoldState = scaffoldState, peekHeight = screenHeightDp / 3,
- content = {
- TransitScreenContent(viewModel = transitViewModel, onRouteClicked = {
- NavigationUtils.navigate(navController, Screen.PlaceCard(it))
- })
- },
- fabHeightCallback = {
- if (topOfBackStack == backStackEntry) {
- fabHeight = it
- }
- },
- )
+ NearbyTransitRoute(state, transitViewModel, navController, topOfBackStack, backStackEntry)
}
composable(
Screen.PLACE_CARD,
enterTransition = { slideInVertically(initialOffsetY = { it }) },
exitTransition = { fadeOut(animationSpec = tween(600)) }) { backStackEntry ->
- showToolbar = false
-
- val bottomSheetState = rememberBottomSheetState(
- initialValue = BottomSheetValue.Collapsed
- )
- val scaffoldState =
- rememberBottomSheetScaffoldState(bottomSheetState = bottomSheetState)
-
- LaunchedEffect(key1 = Unit) {
- // The place card starts partially expanded.
- coroutineScope.launch {
- scaffoldState.bottomSheetState.collapse()
- }
- }
-
- val viewModel: PlaceCardViewModel = hiltViewModel()
- val placeJson = backStackEntry.arguments?.getString("place")
- val place = placeJson?.let { Gson().fromJson(it, Place::class.java) }
- place?.let { place ->
- viewModel.setPlace(place)
- LaunchedEffect(place) {
- // Clear any existing pins and add the new one to ensure only one pin is shown at a time
- mapPins.clear()
- mapPins.add(place)
-
- val previousBackStackEntry = navController.previousBackStackEntry
- val shouldFlyToPoi =
- previousBackStackEntry?.destination?.route?.startsWith(Screen.DIRECTIONS) != true
-
- // Only animate if we're entering from the home screen, as opposed to e.g. popping from the
- // settings screen. This is brittle and may break if we end up with more entry points.
- if (shouldFlyToPoi) {
- coroutineScope.launch {
- cameraState.animateTo(
- CameraPosition(
- target = Position(
- latitude = place.latLng.latitude,
- longitude = place.latLng.longitude
- ), zoom = 15.0, padding = PaddingValues(
- start = screenWidthDp / 8,
- top = screenHeightDp / 8,
- end = screenWidthDp / 8,
- bottom = min(
- 3f * screenHeightDp / 4, peekHeight + screenHeightDp / 8
- )
- )
- ),
- duration = appPreferenceRepository.animationSpeedDurationValue,
- )
- }
- }
- }
-
- CardinalAppScaffold(
- scaffoldState = scaffoldState, peekHeight = peekHeight,
- content = {
- PlaceCardScreen(place = place, viewModel = viewModel, onBack = {
- navController.popBackStack()
- }, onGetDirections = { place ->
- NavigationUtils.navigate(
- navController, Screen.Directions(fromPlace = null, toPlace = place)
- )
- }, onPeekHeightChange = {
- if (topOfBackStack == backStackEntry) {
- peekHeight = it
- }
- })
- },
- showToolbar = false,
- fabHeightCallback = {
- if (topOfBackStack == backStackEntry) {
- fabHeight = it
- }
- },
- )
- }
+ PlaceCardRoute(state, navController, topOfBackStack, appPreferenceRepository, backStackEntry)
}
composable(
Screen.OFFLINE_AREAS,
enterTransition = { slideInVertically(initialOffsetY = { it }) },
exitTransition = { fadeOut(animationSpec = tween(600)) }) { backStackEntry ->
- showToolbar = true
- val bottomSheetState = rememberBottomSheetState(
- initialValue = BottomSheetValue.Collapsed
- )
- val scaffoldState =
- rememberBottomSheetScaffoldState(bottomSheetState = bottomSheetState)
-
- LaunchedEffect(key1 = Unit) {
- mapPins.clear()
- peekHeight = screenHeightDp / 3 // Approx, empirical
- coroutineScope.launch {
- scaffoldState.bottomSheetState.collapse()
- }
- }
- DisposableEffect(key1 = Unit) {
- onDispose {
- selectedOfflineArea = null
- }
- }
- val viewModel: OfflineAreasViewModel = hiltViewModel()
- val snackBarHostState = remember { SnackbarHostState() }
-
- // Track the current viewport reactively
- var currentViewport by remember { mutableStateOf(cameraState.projection?.queryVisibleRegion()) }
-
- // Update viewport when camera state changes
- LaunchedEffect(cameraState.position) {
- currentViewport = cameraState.projection?.queryVisibleRegion()
- }
-
- currentViewport?.let { visibleRegion ->
- CardinalAppScaffold(
- scaffoldState = scaffoldState,
- peekHeight = peekHeight,
- fabHeightCallback = {
- if (topOfBackStack == backStackEntry) {
- fabHeight = it
- }
- },
- content = {
- OfflineAreasScreen(
- currentViewport = visibleRegion,
- currentZoom = cameraState.position.zoom,
- viewModel = viewModel,
- snackBarHostState = snackBarHostState,
- onDismiss = {
- navController.popBackStack()
- },
- onAreaSelected = { area ->
- coroutineScope.launch {
- scaffoldState.bottomSheetState.collapse()
- cameraState.animateTo(
- boundingBox = BoundingBox(
- area.west, area.south, area.east, area.north
- ),
- padding = PaddingValues(
- start = screenWidthDp / 8,
- top = screenHeightDp / 8,
- end = screenWidthDp / 8,
- bottom = min(
- 3f * screenHeightDp / 4,
- peekHeight + screenHeightDp / 8
- )
- ),
- duration = appPreferenceRepository.animationSpeedDurationValue
- )
- }
- selectedOfflineArea = area
- })
- },
- )
- }
+ OfflineAreasRoute(state, navController, topOfBackStack, appPreferenceRepository, backStackEntry)
}
composable(
@@ -505,11 +281,7 @@ fun AppContent(
popEnterTransition = { slideInHorizontally(initialOffsetX = { -it }) },
popExitTransition = { slideOutHorizontally(targetOffsetX = { it }) },
) {
- showToolbar = true
- SettingsScreen(
- navController = navController,
- viewModel = hiltViewModel(),
- )
+ SettingsRoute(state, navController)
}
composable(
@@ -519,17 +291,7 @@ fun AppContent(
popEnterTransition = { slideInHorizontally(initialOffsetX = { -it }) },
popExitTransition = { slideOutHorizontally(targetOffsetX = { it }) },
) {
- showToolbar = true
- val viewModel: SettingsViewModel = hiltViewModel()
- PrivacySettingsScreen(
- viewModel = viewModel,
- onDismiss = { navController.popBackStack() },
- onNavigateToOfflineAreas = {
- NavigationUtils.navigate(
- navController, Screen.OfflineAreas
- )
- },
- )
+ PrivacySettingsRoute(state, navController)
}
composable(
@@ -539,11 +301,7 @@ fun AppContent(
popEnterTransition = { slideInHorizontally(initialOffsetX = { -it }) },
popExitTransition = { slideOutHorizontally(targetOffsetX = { it }) },
) {
- showToolbar = true
- val viewModel: SettingsViewModel = hiltViewModel()
- AccessibilitySettingsScreen(
- viewModel = viewModel, onDismiss = { navController.popBackStack() })
-
+ AccessibilitySettingsRoute(state, navController)
}
composable(
@@ -553,11 +311,7 @@ fun AppContent(
popEnterTransition = { slideInHorizontally(initialOffsetX = { -it }) },
popExitTransition = { slideOutHorizontally(targetOffsetX = { it }) },
) {
- showToolbar = true
- val viewModel: SettingsViewModel = hiltViewModel()
- AdvancedSettingsScreen(
- viewModel = viewModel
- )
+ AdvancedSettingsRoute(state, navController)
}
composable(
@@ -567,10 +321,7 @@ fun AppContent(
popEnterTransition = { slideInHorizontally(initialOffsetX = { -it }) },
popExitTransition = { slideOutHorizontally(targetOffsetX = { it }) },
) {
- showToolbar = true
- RoutingProfilesScreen(
- navController = navController
- )
+ RoutingProfilesRoute(state, navController)
}
composable(
@@ -580,25 +331,7 @@ fun AppContent(
popEnterTransition = { slideInHorizontally(initialOffsetX = { -it }) },
popExitTransition = { slideOutHorizontally(targetOffsetX = { it }) },
) { backStackEntry ->
- LaunchedEffect(key1 = Unit) {
- mapPins.clear()
- }
-
- val snackBarHostState = remember { SnackbarHostState() }
-
- val profileId = backStackEntry.arguments?.getString("profileId")
- Scaffold(
- snackbarHost = { SnackbarHost(snackBarHostState) },
- contentWindowInsets = WindowInsets.safeDrawing,
- content = { padding ->
- Box(modifier = Modifier.padding(padding)) {
- ProfileEditorScreen(
- navController = navController,
- profileId = profileId,
- snackBarHostState = snackBarHostState
- )
- }
- })
+ ProfileEditorRoute(state, navController, backStackEntry)
}
composable(
@@ -608,255 +341,637 @@ fun AppContent(
popEnterTransition = { slideInHorizontally(initialOffsetX = { -it }) },
popExitTransition = { slideOutHorizontally(targetOffsetX = { it }) },
) { backStackEntry ->
- showToolbar = true
-
- val listIdRaw = backStackEntry.arguments?.getString("listId")
- // The screen is set up to take a real value, or null. What we end up with at this point (sometimes?)
- // is an empty string instead of null.
- val listId = if (listIdRaw.isNullOrBlank()) {
- null
- } else {
- listIdRaw
- }
- val parentsGson = backStackEntry.arguments?.getString("parents")?.let { Uri.decode(it) }
- val parents: List =
- parentsGson?.let { Gson().fromJson(it, object : TypeToken>() {}.type) }
- ?: emptyList()
- ManagePlacesScreen(
- navController = navController,
- listId = listId,
- parents = parents,
- )
+ ManagePlacesRoute(state, navController, backStackEntry)
}
composable(
Screen.DIRECTIONS,
enterTransition = { slideInVertically(initialOffsetY = { it }) },
exitTransition = { fadeOut(animationSpec = tween(600)) }) { backStackEntry ->
- showToolbar = false
- val bottomSheetState =
- rememberBottomSheetState(initialValue = BottomSheetValue.Collapsed)
- val scaffoldState =
- rememberBottomSheetScaffoldState(bottomSheetState = bottomSheetState)
-
- val viewModel: DirectionsViewModel = hiltViewModel()
- mapViewModel.locationFlow.collectAsState().value
-
- // Handle initial place setup
- LaunchedEffect(key1 = Unit) {
- val fromPlaceJson = backStackEntry.arguments?.getString("fromPlace")
- val fromPlace =
- fromPlaceJson?.let { Gson().fromJson(Uri.decode(it), Place::class.java) }
- val toPlaceJson = backStackEntry.arguments?.getString("toPlace")
- val toPlace =
- toPlaceJson?.let { Gson().fromJson(Uri.decode(it), Place::class.java) }
-
- if (fromPlace != null) {
- viewModel.updateFromPlace(fromPlace)
- }
- viewModel.updateToPlace(toPlace)
+ DirectionsRoute(state, mapViewModel, navController, topOfBackStack, appPreferenceRepository, hasLocationPermission, onRequestLocationPermission, hasNotificationPermission, onRequestNotificationPermission, backStackEntry)
+ }
+
+ composable(
+ Screen.TRANSIT_ITINERARY_DETAIL,
+ enterTransition = { slideInVertically(initialOffsetY = { it }) },
+ exitTransition = { fadeOut(animationSpec = tween(600)) }) { backStackEntry ->
+ TransitItineraryDetailRoute(state, navController, topOfBackStack, appPreferenceRepository, backStackEntry)
+ }
+
+ composable(Screen.TURN_BY_TURN) { backStackEntry ->
+ TurnByTurnRoute(state, routeRepository, port, backStackEntry)
+ }
+ }
+
+ Box(modifier = Modifier.fillMaxSize()) {
+ // Animated toolbar positioned below the scaffold
+ AnimatedVisibility(
+ modifier = Modifier.align(Alignment.BottomCenter),
+ visible = state.showToolbar,
+ enter = slideInVertically(
+ initialOffsetY = { it }, animationSpec = tween(300)
+ ),
+ exit = slideOutVertically(
+ targetOffsetY = { it }, animationSpec = tween(300)
+ ),
+ ) {
+ CardinalToolbar(navController, onSearchDoublePress = { homeViewModel.expandSearch() })
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun HomeRoute(
+ state: AppContentState,
+ homeViewModel: HomeViewModel,
+ navController: NavHostController,
+ topOfBackStack: NavBackStackEntry?,
+ appPreferenceRepository: AppPreferenceRepository,
+ backStackEntry: NavBackStackEntry
+) {
+ state.showToolbar = true
+ HomeScreenComposable(
+ viewModel = homeViewModel,
+ cameraState = state.cameraState,
+ mapPins = state.mapPins,
+ peekHeight = state.peekHeight,
+ navController = navController,
+ onPeekHeightChange = {
+ state.peekHeight = it
+ },
+ onFabHeightChange = {
+ state.fabHeight = it
+ },
+ topOfBackStack = topOfBackStack,
+ backStackEntry = backStackEntry,
+ screenWidthDp = state.screenWidthDp,
+ screenHeightDp = state.screenHeightDp,
+ appPreferenceRepository = appPreferenceRepository
+ )
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun NearbyPoiRoute(
+ state: AppContentState,
+ nearbyViewModel: NearbyViewModel,
+ navController: NavHostController,
+ topOfBackStack: NavBackStackEntry?,
+ backStackEntry: NavBackStackEntry
+) {
+ state.showToolbar = true
+ val bottomSheetState = rememberBottomSheetState(
+ initialValue = BottomSheetValue.Collapsed
+ )
+ val scaffoldState =
+ rememberBottomSheetScaffoldState(bottomSheetState = bottomSheetState)
+
+ CardinalAppScaffold(
+ scaffoldState = scaffoldState, peekHeight = state.screenHeightDp / 3,
+ content = {
+ NearbyScreenContent(viewModel = nearbyViewModel, onPlaceSelected = {
+ NavigationUtils.navigate(navController, Screen.PlaceCard(it))
+ })
+ },
+ fabHeightCallback = {
+ if (topOfBackStack == backStackEntry) {
+ state.fabHeight = it
+ }
+ },
+ )
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun NearbyTransitRoute(
+ state: AppContentState,
+ transitViewModel: TransitScreenViewModel,
+ navController: NavHostController,
+ topOfBackStack: NavBackStackEntry?,
+ backStackEntry: NavBackStackEntry
+) {
+ state.showToolbar = true
+
+ val bottomSheetState = rememberBottomSheetState(
+ initialValue = BottomSheetValue.Collapsed
+ )
+ val scaffoldState =
+ rememberBottomSheetScaffoldState(bottomSheetState = bottomSheetState)
+
+ CardinalAppScaffold(
+ scaffoldState = scaffoldState, peekHeight = state.screenHeightDp / 3,
+ content = {
+ TransitScreenContent(viewModel = transitViewModel, onRouteClicked = {
+ NavigationUtils.navigate(navController, Screen.PlaceCard(it))
+ })
+ },
+ fabHeightCallback = {
+ if (topOfBackStack == backStackEntry) {
+ state.fabHeight = it
}
+ },
+ )
+}
- val polylinePadding = PaddingValues(
- start = screenWidthDp / 8,
- top = screenHeightDp / 8,
- end = screenWidthDp / 8,
- bottom = min(3f * screenHeightDp / 4, peekHeight + screenHeightDp / 8)
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun SettingsRoute(state: AppContentState, navController: NavHostController) {
+ state.showToolbar = true
+ val viewModel = hiltViewModel()
+ SettingsScreen(navController = navController, viewModel = viewModel)
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun PrivacySettingsRoute(state: AppContentState, navController: NavHostController) {
+ state.showToolbar = true
+ val viewModel: SettingsViewModel = hiltViewModel()
+ PrivacySettingsScreen(
+ viewModel = viewModel,
+ onDismiss = { navController.popBackStack() },
+ onNavigateToOfflineAreas = {
+ NavigationUtils.navigate(
+ navController, Screen.OfflineAreas
)
+ },
+ )
+}
- // Handle route display and camera animation
- RouteDisplayHandler(
- viewModel = viewModel,
- cameraState = cameraState,
- appPreferences = appPreferenceRepository,
- padding = polylinePadding,
- onRouteUpdate = { route -> currentRoute = route })
- DisposableEffect(key1 = Unit) {
- onDispose {
- currentRoute = null
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun AccessibilitySettingsRoute(state: AppContentState, navController: NavHostController) {
+ state.showToolbar = true
+ val viewModel: SettingsViewModel = hiltViewModel()
+ AccessibilitySettingsScreen(viewModel = viewModel, onDismiss = { navController.popBackStack() })
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun AdvancedSettingsRoute(state: AppContentState, navController: NavHostController) {
+ state.showToolbar = true
+ val viewModel: SettingsViewModel = hiltViewModel()
+ AdvancedSettingsScreen(viewModel = viewModel)
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun RoutingProfilesRoute(state: AppContentState, navController: NavHostController) {
+ state.showToolbar = true
+ RoutingProfilesScreen(navController = navController)
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun ProfileEditorRoute(state: AppContentState, navController: NavHostController, backStackEntry: NavBackStackEntry) {
+ LaunchedEffect(key1 = Unit) {
+ state.mapPins.clear()
+ }
+
+ val snackBarHostState = remember { SnackbarHostState() }
+
+ val profileId = backStackEntry.arguments?.getString("profileId")
+ Scaffold(
+ snackbarHost = { SnackbarHost(snackBarHostState) },
+ contentWindowInsets = WindowInsets.safeDrawing,
+ content = { padding ->
+ Box(modifier = Modifier.padding(padding)) {
+ ProfileEditorScreen(
+ navController = navController,
+ profileId = profileId,
+ snackBarHostState = snackBarHostState
+ )
+ }
+ })
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun ManagePlacesRoute(state: AppContentState, navController: NavHostController, backStackEntry: NavBackStackEntry) {
+ state.showToolbar = true
+
+ val listIdRaw = backStackEntry.arguments?.getString("listId")
+ val listId = if (listIdRaw.isNullOrBlank()) {
+ null
+ } else {
+ listIdRaw
+ }
+ val parentsGson = backStackEntry.arguments?.getString("parents")?.let { Uri.decode(it) }
+ val parents: List = parentsGson?.let {
+ Gson().fromJson(it, object : TypeToken>() {}.type)
+ } ?: emptyList()
+ ManagePlacesScreen(
+ navController = navController,
+ listId = listId,
+ parents = parents,
+ )
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun PlaceCardRoute(
+ state: AppContentState,
+ navController: NavHostController,
+ topOfBackStack: NavBackStackEntry?,
+ appPreferenceRepository: AppPreferenceRepository,
+ backStackEntry: NavBackStackEntry
+) {
+ state.showToolbar = false
+
+ val bottomSheetState = rememberBottomSheetState(
+ initialValue = BottomSheetValue.Collapsed
+ )
+ val scaffoldState = rememberBottomSheetScaffoldState(bottomSheetState = bottomSheetState)
+
+ LaunchedEffect(key1 = Unit) {
+ // The place card starts partially expanded.
+ state.coroutineScope.launch {
+ scaffoldState.bottomSheetState.collapse()
+ }
+ }
+
+ val viewModel: PlaceCardViewModel = hiltViewModel()
+ val placeJson = backStackEntry.arguments?.getString("place")
+ val place = placeJson?.let { Gson().fromJson(it, Place::class.java) }
+ place?.let { place ->
+ viewModel.setPlace(place)
+ LaunchedEffect(place) {
+ // Clear any existing pins and add the new one to ensure only one pin is shown at a time
+ state.mapPins.clear()
+ state.mapPins.add(place)
+
+ val previousBackStackEntry = navController.previousBackStackEntry
+ val shouldFlyToPoi =
+ previousBackStackEntry?.destination?.route?.startsWith(Screen.DIRECTIONS) != true
+
+ // Only animate if we're entering from the home screen, as opposed to e.g. popping from the
+ // settings screen. This is brittle and may break if we end up with more entry points.
+ if (shouldFlyToPoi) {
+ state.coroutineScope.launch {
+ state.cameraState.animateTo(
+ CameraPosition(
+ target = Position(
+ latitude = place.latLng.latitude,
+ longitude = place.latLng.longitude
+ ), zoom = 15.0, padding = PaddingValues(
+ start = state.screenWidthDp / 8,
+ top = state.screenHeightDp / 8,
+ end = state.screenWidthDp / 8,
+ bottom = min(
+ 3f * state.screenHeightDp / 4,
+ state.peekHeight + state.screenHeightDp / 8
+ )
+ )
+ ),
+ duration = appPreferenceRepository.animationSpeedDurationValue,
+ )
}
}
+ }
- CardinalAppScaffold(
- scaffoldState = scaffoldState, peekHeight = peekHeight,
- content = {
- DirectionsScreen(
- viewModel = viewModel,
- onPeekHeightChange = {
- if (topOfBackStack == backStackEntry) {
- peekHeight = it
- }
- },
- onBack = { navController.popBackStack() },
- onFullExpansionRequired = {
- coroutineScope.launch {
- scaffoldState.bottomSheetState.expand()
- }
- },
- navController = navController,
- hasLocationPermission = hasLocationPermission,
- onRequestLocationPermission = onRequestLocationPermission,
- hasNotificationPermission = hasNotificationPermission,
- onRequestNotificationPermission = onRequestNotificationPermission,
- appPreferences = appPreferenceRepository
+ CardinalAppScaffold(
+ scaffoldState = scaffoldState, peekHeight = state.peekHeight,
+ content = {
+ PlaceCardScreen(place = place, viewModel = viewModel, onBack = {
+ navController.popBackStack()
+ }, onGetDirections = { place ->
+ NavigationUtils.navigate(
+ navController, Screen.Directions(fromPlace = null, toPlace = place)
)
- },
- showToolbar = false,
- fabHeightCallback = {
+ }, onPeekHeightChange = {
if (topOfBackStack == backStackEntry) {
- fabHeight = it
+ state.peekHeight = it
}
- },
- )
+ })
+ },
+ showToolbar = false,
+ fabHeightCallback = {
+ if (topOfBackStack == backStackEntry) {
+ state.fabHeight = it
+ }
+ },
+ )
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun OfflineAreasRoute(
+ state: AppContentState,
+ navController: NavHostController,
+ topOfBackStack: NavBackStackEntry?,
+ appPreferenceRepository: AppPreferenceRepository,
+ backStackEntry: NavBackStackEntry
+) {
+ state.showToolbar = true
+ val bottomSheetState = rememberBottomSheetState(
+ initialValue = BottomSheetValue.Collapsed
+ )
+ val scaffoldState =
+ rememberBottomSheetScaffoldState(bottomSheetState = bottomSheetState)
+
+ LaunchedEffect(key1 = Unit) {
+ state.mapPins.clear()
+ state.peekHeight = state.screenHeightDp / 3 // Approx, empirical
+ state.coroutineScope.launch {
+ scaffoldState.bottomSheetState.collapse()
}
+ }
+ DisposableEffect(key1 = Unit) {
+ onDispose {
+ state.selectedOfflineArea = null
+ }
+ }
+ val viewModel: OfflineAreasViewModel = hiltViewModel()
+ val snackBarHostState = remember { SnackbarHostState() }
- composable(
- Screen.TRANSIT_ITINERARY_DETAIL,
- enterTransition = { slideInVertically(initialOffsetY = { it }) },
- exitTransition = { fadeOut(animationSpec = tween(600)) }) { backStackEntry ->
- showToolbar = false
+ // Track the current viewport reactively
+ var currentViewport by remember { mutableStateOf(state.cameraState.projection?.queryVisibleRegion()) }
- val bottomSheetState = rememberBottomSheetState(
- initialValue = BottomSheetValue.Collapsed
- )
- val scaffoldState =
- rememberBottomSheetScaffoldState(bottomSheetState = bottomSheetState)
+ // Update viewport when camera state changes
+ LaunchedEffect(state.cameraState.position) {
+ currentViewport = state.cameraState.projection?.queryVisibleRegion()
+ }
- LaunchedEffect(key1 = Unit) {
- coroutineScope.launch {
- scaffoldState.bottomSheetState.collapse()
+ currentViewport?.let { visibleRegion ->
+ CardinalAppScaffold(
+ scaffoldState = scaffoldState,
+ peekHeight = state.peekHeight,
+ fabHeightCallback = {
+ if (topOfBackStack == backStackEntry) {
+ state.fabHeight = it
}
- }
+ },
+ content = {
+ OfflineAreasScreen(
+ currentViewport = visibleRegion,
+ currentZoom = state.cameraState.position.zoom,
+ viewModel = viewModel,
+ snackBarHostState = snackBarHostState,
+ onDismiss = {
+ navController.popBackStack()
+ },
+ onAreaSelected = { area ->
+ state.coroutineScope.launch {
+ scaffoldState.bottomSheetState.collapse()
+ state.cameraState.animateTo(
+ boundingBox = BoundingBox(
+ area.west, area.south, area.east, area.north
+ ),
+ padding = PaddingValues(
+ start = state.screenWidthDp / 8,
+ top = state.screenHeightDp / 8,
+ end = state.screenWidthDp / 8,
+ bottom = min(
+ 3f * state.screenHeightDp / 4,
+ state.peekHeight + state.screenHeightDp / 8
+ )
+ ),
+ duration = appPreferenceRepository.animationSpeedDurationValue
+ )
+ }
+ state.selectedOfflineArea = area
+ })
+ },
+ )
+ }
+}
- val itineraryJson = backStackEntry.arguments?.getString("itinerary")
- val itinerary = itineraryJson?.let {
- Gson().fromJson(Uri.decode(it), earth.maps.cardinal.transit.Itinerary::class.java)
- }
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun DirectionsRoute(
+ state: AppContentState,
+ mapViewModel: MapViewModel,
+ navController: NavHostController,
+ topOfBackStack: NavBackStackEntry?,
+ appPreferenceRepository: AppPreferenceRepository,
+ hasLocationPermission: Boolean,
+ onRequestLocationPermission: () -> Unit,
+ hasNotificationPermission: Boolean,
+ onRequestNotificationPermission: () -> Unit,
+ backStackEntry: NavBackStackEntry
+) {
+ state.showToolbar = false
+ val bottomSheetState =
+ rememberBottomSheetState(initialValue = BottomSheetValue.Collapsed)
+ val scaffoldState =
+ rememberBottomSheetScaffoldState(bottomSheetState = bottomSheetState)
+
+ val viewModel: DirectionsViewModel = hiltViewModel()
+ mapViewModel.locationFlow.collectAsState().value
+
+ // Handle initial place setup
+ LaunchedEffect(key1 = Unit) {
+ val fromPlaceJson = backStackEntry.arguments?.getString("fromPlace")
+ val fromPlace =
+ fromPlaceJson?.let { Gson().fromJson(Uri.decode(it), Place::class.java) }
+ val toPlaceJson = backStackEntry.arguments?.getString("toPlace")
+ val toPlace =
+ toPlaceJson?.let { Gson().fromJson(Uri.decode(it), Place::class.java) }
+
+ if (fromPlace != null) {
+ viewModel.updateFromPlace(fromPlace)
+ }
+ viewModel.updateToPlace(toPlace)
+ }
- itinerary?.let { itinerary ->
- LaunchedEffect(itinerary) {
- // Set current transit itinerary for map display
- currentTransitItinerary = itinerary
+ val polylinePadding = PaddingValues(
+ start = state.screenWidthDp / 8,
+ top = state.screenHeightDp / 8,
+ end = state.screenWidthDp / 8,
+ bottom = min(
+ 3f * state.screenHeightDp / 4,
+ state.peekHeight + state.screenHeightDp / 8
+ )
+ )
- // Extract all leg geometries and calculate bounding box
- val allPositions = mutableListOf()
+ // Handle route display and camera animation
+ RouteDisplayHandler(
+ viewModel = viewModel,
+ cameraState = state.cameraState,
+ appPreferences = appPreferenceRepository,
+ padding = polylinePadding,
+ onRouteUpdate = { route -> state.currentRoute = route })
+ DisposableEffect(key1 = Unit) {
+ onDispose {
+ state.currentRoute = null
+ }
+ }
- itinerary.legs.forEach { leg ->
- leg.legGeometry?.let { geometry ->
- try {
- val positions =
- earth.maps.cardinal.data.PolylineUtils.decodePolyline(
- geometry.points, geometry.precision
- )
- allPositions.addAll(positions)
- } catch (e: Exception) {
- // Ignore decoding errors for individual legs
- }
- }
+ CardinalAppScaffold(
+ scaffoldState = scaffoldState, peekHeight = state.peekHeight,
+ content = {
+ DirectionsScreen(
+ viewModel = viewModel,
+ onPeekHeightChange = {
+ if (topOfBackStack == backStackEntry) {
+ state.peekHeight = it
}
-
- // If we have route geometry, fit camera to the route
- if (allPositions.isNotEmpty()) {
- earth.maps.cardinal.data.PolylineUtils.calculateBoundingBox(allPositions)
- ?.let { boundingBox ->
- coroutineScope.launch {
- cameraState.animateTo(
- boundingBox = BoundingBox(
- west = boundingBox.west,
- south = boundingBox.south,
- east = boundingBox.east,
- north = boundingBox.north
- ),
- padding = PaddingValues(
- start = screenWidthDp / 8,
- top = screenHeightDp / 8,
- end = screenWidthDp / 8,
- bottom = min(
- 3f * screenHeightDp / 4,
- peekHeight + screenHeightDp / 8
- )
- ),
- duration = appPreferenceRepository.animationSpeedDurationValue
- )
- }
- }
+ },
+ onBack = { navController.popBackStack() },
+ onFullExpansionRequired = {
+ state.coroutineScope.launch {
+ scaffoldState.bottomSheetState.expand()
}
+ },
+ navController = navController,
+ hasLocationPermission = hasLocationPermission,
+ onRequestLocationPermission = onRequestLocationPermission,
+ hasNotificationPermission = hasNotificationPermission,
+ onRequestNotificationPermission = onRequestNotificationPermission,
+ appPreferences = appPreferenceRepository
+ )
+ },
+ showToolbar = false,
+ fabHeightCallback = {
+ if (topOfBackStack == backStackEntry) {
+ state.fabHeight = it
+ }
+ },
+ )
+}
- // Clear any existing pins
- mapPins.clear()
- }
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun TransitItineraryDetailRoute(
+ state: AppContentState,
+ navController: NavHostController,
+ topOfBackStack: NavBackStackEntry?,
+ appPreferenceRepository: AppPreferenceRepository,
+ backStackEntry: NavBackStackEntry
+) {
+ state.showToolbar = false
+
+ val bottomSheetState = rememberBottomSheetState(
+ initialValue = BottomSheetValue.Collapsed
+ )
+ val scaffoldState =
+ rememberBottomSheetScaffoldState(bottomSheetState = bottomSheetState)
+
+ LaunchedEffect(key1 = Unit) {
+ state.coroutineScope.launch {
+ scaffoldState.bottomSheetState.collapse()
+ }
+ }
+
+ val itineraryJson = backStackEntry.arguments?.getString("itinerary")
+ val itinerary = itineraryJson?.let {
+ Gson().fromJson(Uri.decode(it), earth.maps.cardinal.transit.Itinerary::class.java)
+ }
- DisposableEffect(key1 = Unit) {
- onDispose {
- currentTransitItinerary = null
+ itinerary?.let { itinerary ->
+ LaunchedEffect(itinerary) {
+ // Set current transit itinerary for map display
+ state.currentTransitItinerary = itinerary
+
+ // Extract all leg geometries and calculate bounding box
+ val allPositions = mutableListOf()
+
+ itinerary.legs.forEach { leg ->
+ leg.legGeometry?.let { geometry ->
+ try {
+ val positions =
+ earth.maps.cardinal.data.PolylineUtils.decodePolyline(
+ geometry.points, geometry.precision
+ )
+ allPositions.addAll(positions)
+ } catch (e: Exception) {
+ // Ignore decoding errors for individual legs
}
}
+ }
- CardinalAppScaffold(
- scaffoldState = scaffoldState,
- peekHeight = peekHeight,
- content = {
- earth.maps.cardinal.ui.directions.TransitItineraryDetailScreen(
- itinerary = itinerary, onBack = {
- navController.popBackStack()
- }, appPreferences = appPreferenceRepository
- )
- },
- showToolbar = false,
- fabHeightCallback = {
- if (topOfBackStack == backStackEntry) {
- fabHeight = it
+ // If we have route geometry, fit camera to the route
+ if (allPositions.isNotEmpty()) {
+ earth.maps.cardinal.data.PolylineUtils.calculateBoundingBox(allPositions)
+ ?.let { boundingBox ->
+ state.coroutineScope.launch {
+ state.cameraState.animateTo(
+ boundingBox = BoundingBox(
+ west = boundingBox.west,
+ south = boundingBox.south,
+ east = boundingBox.east,
+ north = boundingBox.north
+ ),
+ padding = PaddingValues(
+ start = state.screenWidthDp / 8,
+ top = state.screenHeightDp / 8,
+ end = state.screenWidthDp / 8,
+ bottom = min(
+ 3f * state.screenHeightDp / 4,
+ state.peekHeight + state.screenHeightDp / 8
+ )
+ ),
+ duration = appPreferenceRepository.animationSpeedDurationValue
+ )
}
- },
- )
+ }
}
+
+ // Clear any existing pins
+ state.mapPins.clear()
}
- composable(Screen.TURN_BY_TURN) { backStackEntry ->
- showToolbar = false
- val routeId = backStackEntry.arguments?.getString("routeId")
- val routingModeJson = backStackEntry.arguments?.getString("routingMode")
-
- val ferrostarRoute = routeId?.let {
- try {
- routeRepository.getRoute(it)
- } catch (_: Exception) {
- null
- }
+ DisposableEffect(key1 = Unit) {
+ onDispose {
+ state.currentTransitItinerary = null
}
+ }
- val routingMode = routingModeJson?.let {
- try {
- Gson().fromJson(it, RoutingMode::class.java)
- } catch (_: Exception) {
- RoutingMode.AUTO
+ CardinalAppScaffold(
+ scaffoldState = scaffoldState,
+ peekHeight = state.peekHeight,
+ content = {
+ earth.maps.cardinal.ui.directions.TransitItineraryDetailScreen(
+ itinerary = itinerary, onBack = {
+ navController.popBackStack()
+ }, appPreferences = appPreferenceRepository
+ )
+ },
+ showToolbar = false,
+ fabHeightCallback = {
+ if (topOfBackStack == backStackEntry) {
+ state.fabHeight = it
}
- } ?: RoutingMode.AUTO
+ },
+ )
+ }
+}
- port?.let { port ->
- TurnByTurnNavigationScreen(
- port = port,
- mode = routingMode,
- route = ferrostarRoute,
- )
- }
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun TurnByTurnRoute(
+ state: AppContentState,
+ routeRepository: RouteRepository,
+ port: Int?,
+ backStackEntry: NavBackStackEntry
+) {
+ state.showToolbar = false
+ val routeId = backStackEntry.arguments?.getString("routeId")
+ val routingModeJson = backStackEntry.arguments?.getString("routingMode")
+
+ val ferrostarRoute = routeId?.let {
+ try {
+ routeRepository.getRoute(it)
+ } catch (_: Exception) {
+ null
}
}
- Box(modifier = Modifier.fillMaxSize()) {
- // Animated toolbar positioned below the scaffold
- AnimatedVisibility(
- modifier = Modifier.align(Alignment.BottomCenter),
- visible = showToolbar,
- enter = slideInVertically(
- initialOffsetY = { it }, animationSpec = tween(300)
- ),
- exit = slideOutVertically(
- targetOffsetY = { it }, animationSpec = tween(300)
- ),
- ) {
- CardinalToolbar(navController, onSearchDoublePress = { homeViewModel.expandSearch() })
+ val routingMode = routingModeJson?.let {
+ try {
+ Gson().fromJson(it, RoutingMode::class.java)
+ } catch (_: Exception) {
+ RoutingMode.AUTO
}
+ } ?: RoutingMode.AUTO
+
+ port?.let { port ->
+ TurnByTurnNavigationScreen(
+ port = port,
+ mode = routingMode,
+ route = ferrostarRoute,
+ )
}
}
diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/core/AppContentState.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/core/AppContentState.kt
new file mode 100644
index 0000000..4ff03ce
--- /dev/null
+++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/core/AppContentState.kt
@@ -0,0 +1,83 @@
+/*
+ * Cardinal Maps
+ * Copyright (C) 2025 Cardinal Maps Authors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package earth.maps.cardinal.ui.core
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.Stable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.snapshots.SnapshotStateList
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import earth.maps.cardinal.data.Place
+import earth.maps.cardinal.data.room.OfflineArea
+import earth.maps.cardinal.transit.Itinerary
+import kotlinx.coroutines.CoroutineScope
+import org.maplibre.compose.camera.CameraState
+import org.maplibre.compose.camera.rememberCameraState
+import uniffi.ferrostar.Route
+
+@Stable
+class AppContentState(
+ val mapPins: SnapshotStateList,
+ val cameraState: CameraState,
+ val coroutineScope: CoroutineScope,
+ val density: Density,
+ fabHeight: Dp = 0.dp,
+ selectedOfflineArea: OfflineArea? = null,
+ showToolbar: Boolean = true,
+ currentRoute: Route? = null,
+ currentTransitItinerary: Itinerary? = null,
+ screenHeightDp: Dp = 0.dp,
+ screenWidthDp: Dp = 0.dp,
+ peekHeight: Dp = 0.dp,
+) {
+ var fabHeight by mutableStateOf(fabHeight)
+ var selectedOfflineArea by mutableStateOf(selectedOfflineArea)
+ var showToolbar by mutableStateOf(showToolbar)
+ var currentRoute by mutableStateOf(currentRoute)
+ var currentTransitItinerary by mutableStateOf(currentTransitItinerary)
+ var screenHeightDp by mutableStateOf(screenHeightDp)
+ var screenWidthDp by mutableStateOf(screenWidthDp)
+ var peekHeight by mutableStateOf(peekHeight)
+}
+
+@Composable
+fun rememberAppContentState(
+ cameraState: CameraState = rememberCameraState(),
+ coroutineScope: CoroutineScope = rememberCoroutineScope(),
+ density: Density = LocalDensity.current,
+): AppContentState {
+ val mapPins = remember { mutableStateListOf() }
+
+ return remember(cameraState, coroutineScope, density) {
+ AppContentState(
+ mapPins = mapPins,
+ cameraState = cameraState,
+ coroutineScope = coroutineScope,
+ density = density,
+ )
+ }
+}
diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/core/MapView.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/core/MapView.kt
index 8add20b..89855cb 100644
--- a/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/core/MapView.kt
+++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/core/MapView.kt
@@ -167,178 +167,15 @@ fun MapView(
val savedPlaces by mapViewModel.savedPlacesFlow.collectAsState(FeatureCollection())
location?.let { LocationPuck(it) }
- // Show user favorites
- val textColor = MaterialTheme.colorScheme.onSurface
- SymbolLayer(
- id = "user_favorites",
- source = rememberGeoJsonSource(GeoJsonData.Features(savedPlaces)),
- iconImage = image(
- if (isSystemInDarkTheme()) {
- painterResource(drawable.ic_stars_dark)
- } else {
- painterResource(drawable.ic_stars_light)
- }
- ),
- iconSize = const(0.8f),
- textField = org.maplibre.compose.expressions.dsl.Feature["name"].cast(),
- textSize = const(0.8.em),
- textColor = rgbColor(
- const((textColor.red * 255.0f).toInt()),
- const((textColor.green * 255.0f).toInt()),
- const((textColor.blue * 255.0f).toInt()),
- ),
- textAnchor = const(SymbolAnchor.Top),
- textOffset = offset(0.em, 0.8.em),
- textOptional = const(true),
- )
+ FavoritesLayer(savedPlaces, isSystemInDarkTheme())
- // Show offline download bounds if an area is selected
- selectedOfflineArea?.let { area ->
- val boundsPolygon = Polygon(
- listOf(
- listOf(
- Position(area.west, area.north), // Northwest
- Position(area.east, area.north), // Northeast
- Position(area.east, area.south), // Southeast
- Position(area.west, area.south), // Southwest
- Position(area.west, area.north) // Close the polygon
- )
- )
- )
- val boundsFeature = Feature(geometry = boundsPolygon)
- val offlineDownloadBoundsSource = rememberGeoJsonSource(
- GeoJsonData.Features(FeatureCollection(features = listOf(boundsFeature)))
- )
-
- val color = MaterialTheme.colorScheme.onSurface
- LineLayer(
- id = "offline_download_bounds",
- source = offlineDownloadBoundsSource,
- color = rgbColor(
- const((color.red * 255).toInt()),
- const((color.green * 255).toInt()),
- const((color.blue * 255).toInt())
- ),
- width = const(3.dp)
- )
- }
+ OfflineBoundsLayer(selectedOfflineArea)
- // Display route if available
- currentRoute?.let { route ->
- val routePositions = route.geometry.map { coord ->
- Position(coord.lng, coord.lat) // [longitude, latitude]
- }
- val routeLineString = LineString(routePositions)
- val routeFeature = Feature(geometry = routeLineString)
- val routeSource = rememberGeoJsonSource(
- GeoJsonData.Features(FeatureCollection(features = listOf(routeFeature)))
- )
+ RouteLayer(currentRoute)
- val polylineColor = colorResource(R.color.polyline_color)
- val polylineCasingColor =
- colorResource(R.color.polyline_casing_color)
+ TransitLayer(currentTransitItinerary)
- LineLayer(
- id = "route_line_casing", source = routeSource,
- color = rgbColor(
- const((polylineCasingColor.red * 255.0).toInt()), // Blue color
- const((polylineCasingColor.green * 255.0).toInt()),
- const((polylineCasingColor.blue * 255.0).toInt())
- ),
- width = const(8.dp),
- opacity = const(1f),
- cap = const(LineCap.Round),
- join = const(LineJoin.Round),
- )
- LineLayer(
- id = "route_line", source = routeSource,
- color = rgbColor(
- const((polylineColor.red * 255.0).toInt()), // Blue color
- const((polylineColor.green * 255.0).toInt()),
- const((polylineColor.blue * 255.0).toInt())
- ),
- width = const(6.dp),
- opacity = const(1f),
- cap = const(LineCap.Round),
- join = const(LineJoin.Round),
- )
- }
-
- // Display transit itinerary polylines if available
- currentTransitItinerary?.let { itinerary ->
- itinerary.legs.forEachIndexed { legIndex, leg ->
- leg.legGeometry?.let { geometry ->
- val positions = PolylineUtils.decodePolyline(
- encoded = geometry.points,
- precision = geometry.precision
- )
- if (positions.isNotEmpty()) {
- val lineString = LineString(positions)
- val feature = Feature(geometry = lineString)
- val source = rememberGeoJsonSource(
- GeoJsonData.Features(FeatureCollection(features = listOf(feature)))
- )
-
- // Parse route color or use default based on mode
- val routeColor = try {
- leg.routeColor?.let {
- androidx.compose.ui.graphics.Color("#$it".toColorInt())
- } ?: getDefaultModeColor(leg.mode)
- } catch (e: Exception) {
- getDefaultModeColor(leg.mode)
- }
-
- // Different styling for walking vs transit legs
- val (lineWidth, dashArray) = when (leg.mode) {
- Mode.WALK, Mode.BIKE -> Pair(4.dp, null) // Solid line, thinner
- else -> Pair(6.dp, null) // Solid line, thicker for transit
- }
-
- // Line casing for better visibility
- LineLayer(
- id = "transit_leg_${legIndex}_casing",
- source = source,
- color = rgbColor(
- const((routeColor.red * 127).toInt()),
- const((routeColor.green * 127).toInt()),
- const((routeColor.blue * 127).toInt())
- ),
- width = const(lineWidth + 2.dp),
- opacity = const(0.8f),
- cap = const(LineCap.Round),
- join = const(LineJoin.Round),
- )
-
- // Main line
- LineLayer(
- id = "transit_leg_$legIndex",
- source = source,
- color = rgbColor(
- const((routeColor.red * 255).toInt()),
- const((routeColor.green * 255).toInt()),
- const((routeColor.blue * 255).toInt())
- ),
- width = const(lineWidth),
- opacity = const(1f),
- cap = const(LineCap.Round),
- join = const(LineJoin.Round),
- )
- }
- }
- }
- }
-
- SymbolLayer(
- id = "map_pins",
- source = rememberGeoJsonSource(GeoJsonData.Features(FeatureCollection(features = pinFeatures))),
- iconImage = image(
- if (isSystemInDarkTheme()) {
- painterResource(drawable.map_pin_dark)
- } else {
- painterResource(drawable.map_pin_light)
- }
- ),
- )
+ PinsLayer(pinFeatures, isSystemInDarkTheme())
}
} else {
// Handle invalid port - could show an error message
@@ -364,6 +201,190 @@ fun MapView(
}
}
+@Composable
+private fun FavoritesLayer(savedPlaces: FeatureCollection, isSystemInDarkTheme: Boolean) {
+ val textColor = MaterialTheme.colorScheme.onSurface
+ SymbolLayer(
+ id = "user_favorites",
+ source = rememberGeoJsonSource(GeoJsonData.Features(savedPlaces)),
+ iconImage = image(
+ if (isSystemInDarkTheme) {
+ painterResource(drawable.ic_stars_dark)
+ } else {
+ painterResource(drawable.ic_stars_light)
+ }
+ ),
+ iconSize = const(0.8f),
+ textField = org.maplibre.compose.expressions.dsl.Feature["name"].cast(),
+ textSize = const(0.8.em),
+ textColor = rgbColor(
+ const((textColor.red * 255.0f).toInt()),
+ const((textColor.green * 255.0f).toInt()),
+ const((textColor.blue * 255.0f).toInt()),
+ ),
+ textAnchor = const(SymbolAnchor.Top),
+ textOffset = offset(0.em, 0.8.em),
+ textOptional = const(true),
+ )
+}
+
+@Composable
+private fun OfflineBoundsLayer(selectedOfflineArea: OfflineArea?) {
+ selectedOfflineArea?.let { area ->
+ val boundsPolygon = Polygon(
+ listOf(
+ listOf(
+ Position(area.west, area.north), // Northwest
+ Position(area.east, area.north), // Northeast
+ Position(area.east, area.south), // Southeast
+ Position(area.west, area.south), // Southwest
+ Position(area.west, area.north) // Close the polygon
+ )
+ )
+ )
+ val boundsFeature = Feature(geometry = boundsPolygon)
+ val offlineDownloadBoundsSource = rememberGeoJsonSource(
+ GeoJsonData.Features(FeatureCollection(features = listOf(boundsFeature)))
+ )
+
+ val color = MaterialTheme.colorScheme.onSurface
+ LineLayer(
+ id = "offline_download_bounds",
+ source = offlineDownloadBoundsSource,
+ color = rgbColor(
+ const((color.red * 255).toInt()),
+ const((color.green * 255).toInt()),
+ const((color.blue * 255).toInt())
+ ),
+ width = const(3.dp)
+ )
+ }
+}
+
+@Composable
+private fun RouteLayer(currentRoute: Route?) {
+ currentRoute?.let { route ->
+ val routePositions = route.geometry.map { coord ->
+ Position(coord.lng, coord.lat) // [longitude, latitude]
+ }
+ val routeLineString = LineString(routePositions)
+ val routeFeature = Feature(geometry = routeLineString)
+ val routeSource = rememberGeoJsonSource(
+ GeoJsonData.Features(FeatureCollection(features = listOf(routeFeature)))
+ )
+
+ val polylineColor = colorResource(R.color.polyline_color)
+ val polylineCasingColor =
+ colorResource(R.color.polyline_casing_color)
+
+ LineLayer(
+ id = "route_line_casing", source = routeSource,
+ color = rgbColor(
+ const((polylineCasingColor.red * 255.0).toInt()), // Blue color
+ const((polylineCasingColor.green * 255.0).toInt()),
+ const((polylineCasingColor.blue * 255.0).toInt())
+ ),
+ width = const(8.dp),
+ opacity = const(1f),
+ cap = const(LineCap.Round),
+ join = const(LineJoin.Round),
+ )
+ LineLayer(
+ id = "route_line", source = routeSource,
+ color = rgbColor(
+ const((polylineColor.red * 255.0).toInt()), // Blue color
+ const((polylineColor.green * 255.0).toInt()),
+ const((polylineColor.blue * 255.0).toInt())
+ ),
+ width = const(6.dp),
+ opacity = const(1f),
+ cap = const(LineCap.Round),
+ join = const(LineJoin.Round),
+ )
+ }
+}
+
+@Composable
+private fun TransitLayer(currentTransitItinerary: Itinerary?) {
+ currentTransitItinerary?.let { itinerary ->
+ itinerary.legs.forEachIndexed { legIndex, leg ->
+ leg.legGeometry?.let { geometry ->
+ val positions = PolylineUtils.decodePolyline(
+ encoded = geometry.points,
+ precision = geometry.precision
+ )
+ if (positions.isNotEmpty()) {
+ val lineString = LineString(positions)
+ val feature = Feature(geometry = lineString)
+ val source = rememberGeoJsonSource(
+ GeoJsonData.Features(FeatureCollection(features = listOf(feature)))
+ )
+
+ // Parse route color or use default based on mode
+ val routeColor = try {
+ leg.routeColor?.let {
+ androidx.compose.ui.graphics.Color("#$it".toColorInt())
+ } ?: getDefaultModeColor(leg.mode)
+ } catch (e: Exception) {
+ getDefaultModeColor(leg.mode)
+ }
+
+ // Different styling for walking vs transit legs
+ val (lineWidth, dashArray) = when (leg.mode) {
+ Mode.WALK, Mode.BIKE -> Pair(4.dp, null) // Solid line, thinner
+ else -> Pair(6.dp, null) // Solid line, thicker for transit
+ }
+
+ // Line casing for better visibility
+ LineLayer(
+ id = "transit_leg_${legIndex}_casing",
+ source = source,
+ color = rgbColor(
+ const((routeColor.red * 127).toInt()),
+ const((routeColor.green * 127).toInt()),
+ const((routeColor.blue * 127).toInt())
+ ),
+ width = const(lineWidth + 2.dp),
+ opacity = const(0.8f),
+ cap = const(LineCap.Round),
+ join = const(LineJoin.Round),
+ )
+
+ // Main line
+ LineLayer(
+ id = "transit_leg_$legIndex",
+ source = source,
+ color = rgbColor(
+ const((routeColor.red * 255).toInt()),
+ const((routeColor.green * 255).toInt()),
+ const((routeColor.blue * 255).toInt())
+ ),
+ width = const(lineWidth),
+ opacity = const(1f),
+ cap = const(LineCap.Round),
+ join = const(LineJoin.Round),
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun PinsLayer(pinFeatures: List, isSystemInDarkTheme: Boolean) {
+ SymbolLayer(
+ id = "map_pins",
+ source = rememberGeoJsonSource(GeoJsonData.Features(FeatureCollection(features = pinFeatures))),
+ iconImage = image(
+ if (isSystemInDarkTheme) {
+ painterResource(drawable.map_pin_dark)
+ } else {
+ painterResource(drawable.map_pin_light)
+ }
+ ),
+ )
+}
+
/**
* Get default color for transit mode when route color is not available
*/
diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/directions/DirectionsScreen.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/directions/DirectionsScreen.kt
index d205a3c..7e41430 100644
--- a/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/directions/DirectionsScreen.kt
+++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/directions/DirectionsScreen.kt
@@ -82,6 +82,7 @@ import earth.maps.cardinal.ui.core.Screen
import earth.maps.cardinal.ui.place.SearchResults
import earth.maps.cardinal.ui.saved.QuickSuggestions
import io.github.dellisd.spatialk.geojson.BoundingBox
+import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import uniffi.ferrostar.Route
@@ -154,316 +155,451 @@ fun DirectionsScreen(
.fillMaxSize()
.padding(horizontal = dimensionResource(dimen.padding))
) {
- val density = androidx.compose.ui.platform.LocalDensity.current
-
- // Conditionally show UI based on field focus
if (!isAnyFieldFocused) {
- // Show full UI when no field is focused
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .onGloballyPositioned { coordinates ->
- val heightInDp = with(density) { coordinates.size.height.toDp() }
- onPeekHeightChange(heightInDp)
- }) {
- // Header with back button
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(bottom = 8.dp),
- horizontalArrangement = Arrangement.SpaceBetween,
- verticalAlignment = Alignment.CenterVertically
- ) {
- IconButton(onClick = onBack) {
- Icon(
- painter = painterResource(drawable.ic_arrow_back),
- contentDescription = stringResource(string.back)
- )
- }
-
- Text(
- text = stringResource(string.directions),
- style = MaterialTheme.typography.headlineSmall
- )
+ DirectionsScreenFullUI(
+ viewModel = viewModel,
+ onPeekHeightChange = onPeekHeightChange,
+ onBack = onBack,
+ onFullExpansionRequired = onFullExpansionRequired,
+ navController = navController,
+ onFieldFocusStateChange = { fieldFocusState = it },
+ fieldFocusState = fieldFocusState,
+ routeState = routeState,
+ availableProfiles = availableProfiles,
+ appPreferences = appPreferences,
+ hasNotificationPermission = hasNotificationPermission,
+ onRequestNotificationPermission = onRequestNotificationPermission
+ )
+ } else {
+ DirectionsScreenFocusedField(
+ viewModel = viewModel,
+ fieldFocusState = fieldFocusState,
+ savedPlaces = savedPlaces,
+ hasLocationPermission = hasLocationPermission,
+ onRequestLocationPermission = onRequestLocationPermission,
+ pendingLocationRequest = pendingLocationRequest,
+ coroutineScope = coroutineScope
+ )
+ }
+ }
+}
- // Spacer to balance the row
- Box(modifier = Modifier.size(48.dp))
- }
+@Composable
+private fun DirectionsScreenFullUI(
+ viewModel: DirectionsViewModel,
+ onPeekHeightChange: (Dp) -> Unit,
+ onBack: () -> Unit,
+ onFullExpansionRequired: () -> Job,
+ navController: NavController,
+ onFieldFocusStateChange: (FieldFocusState) -> Unit,
+ fieldFocusState: FieldFocusState,
+ routeState: RouteState,
+ availableProfiles: List,
+ appPreferences: AppPreferenceRepository,
+ hasNotificationPermission: Boolean,
+ onRequestNotificationPermission: () -> Unit
+) {
+ val density = androidx.compose.ui.platform.LocalDensity.current
- // From and To fields
- PlaceField(
- label = stringResource(string.from),
- place = viewModel.fromPlace,
- onCleared = {
- viewModel.updateFromPlace(null)
-
- },
- onTextChange = {
- viewModel.updateSearchQuery(it)
- },
- onTextFieldFocusChange = {
- fieldFocusState = if (it) {
- onFullExpansionRequired()
- FieldFocusState.FROM
- } else {
- FieldFocusState.NONE
- }
- },
- isFocused = fieldFocusState == FieldFocusState.FROM,
- showRecalculateButton = viewModel.fromPlace != null && viewModel.toPlace != null,
- onRecalculateClick = { viewModel.recalculateDirections() },
- isRouteLoading = routeState.isLoading,
- modifier = Modifier
- .fillMaxWidth()
- .padding(bottom = 8.dp)
+ // Show full UI when no field is focused
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .onGloballyPositioned { coordinates ->
+ val heightInDp = with(density) { coordinates.size.height.toDp() }
+ onPeekHeightChange(heightInDp)
+ }) {
+ // Header with back button
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(bottom = 8.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ IconButton(onClick = onBack) {
+ Icon(
+ painter = painterResource(drawable.ic_arrow_back),
+ contentDescription = stringResource(string.back)
)
+ }
- PlaceField(
- label = stringResource(string.to),
- place = viewModel.toPlace,
- onCleared = {
- viewModel.updateToPlace(null)
- },
- onTextChange = {
- viewModel.updateSearchQuery(it)
- },
- onTextFieldFocusChange = {
- fieldFocusState = if (it) {
- onFullExpansionRequired()
- FieldFocusState.TO
- } else {
- FieldFocusState.NONE
- }
- },
- isFocused = fieldFocusState == FieldFocusState.TO,
- showFlipButton = viewModel.fromPlace != null && viewModel.toPlace != null,
- onFlipClick = { viewModel.flipDestinations() },
- isRouteLoading = routeState.isLoading,
- modifier = Modifier
- .fillMaxWidth()
- .padding(bottom = dimensionResource(dimen.padding))
- )
+ Text(
+ text = stringResource(string.directions),
+ style = MaterialTheme.typography.headlineSmall
+ )
- val availableRoutingModes by viewModel.getAvailableRoutingModes().collectAsState(
- initial = listOf(
- RoutingMode.PUBLIC_TRANSPORT,
- RoutingMode.BICYCLE,
- RoutingMode.PEDESTRIAN,
- RoutingMode.AUTO,
- )
- )
+ // Spacer to balance the row
+ Box(modifier = Modifier.size(48.dp))
+ }
- RoutingModeSelector(
- availableModes = availableRoutingModes,
- selectedMode = viewModel.selectedRoutingMode,
- onModeSelected = { viewModel.updateRoutingMode(it) },
- modifier = Modifier
- .fillMaxWidth()
- .padding(bottom = dimensionResource(dimen.padding_minor))
- )
+ // From and To fields
+ PlaceField(
+ label = stringResource(string.from),
+ place = viewModel.fromPlace,
+ onCleared = { viewModel.updateFromPlace(null) },
+ onTextChange = { viewModel.updateSearchQuery(it) },
+ onTextFieldFocusChange = {
+ onFieldFocusStateChange(if (it) FieldFocusState.FROM else FieldFocusState.NONE)
+ },
+ isFocused = fieldFocusState == FieldFocusState.FROM,
+ showRecalculateButton = viewModel.fromPlace != null && viewModel.toPlace != null,
+ onRecalculateClick = { viewModel.recalculateDirections() },
+ isRouteLoading = routeState.isLoading,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(bottom = 8.dp)
+ )
- RoutingProfileSelector(
- modifier = Modifier.fillMaxWidth(),
- viewModel = viewModel,
- availableProfiles = availableProfiles
- )
+ PlaceField(
+ label = stringResource(string.to),
+ place = viewModel.toPlace,
+ onCleared = { viewModel.updateToPlace(null) },
+ onTextChange = { viewModel.updateSearchQuery(it) },
+ onTextFieldFocusChange = {
+ onFieldFocusStateChange(if (it) FieldFocusState.TO else FieldFocusState.NONE)
+ },
+ isFocused = fieldFocusState == FieldFocusState.TO,
+ showFlipButton = viewModel.fromPlace != null && viewModel.toPlace != null,
+ onFlipClick = { viewModel.flipDestinations() },
+ isRouteLoading = routeState.isLoading,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(bottom = dimensionResource(dimen.padding))
+ )
- // Inset horizontal divider
- HorizontalDivider(
- modifier = Modifier
- .fillMaxWidth()
- .padding(vertical = dimensionResource(dimen.padding) / 2),
- thickness = DividerDefaults.Thickness,
- color = MaterialTheme.colorScheme.outlineVariant
- )
- }
+ val availableRoutingModes by viewModel.getAvailableRoutingModes().collectAsState(
+ initial = listOf(
+ RoutingMode.PUBLIC_TRANSPORT,
+ RoutingMode.BICYCLE,
+ RoutingMode.PEDESTRIAN,
+ RoutingMode.AUTO,
+ )
+ )
- // Route results
- if (viewModel.selectedRoutingMode == RoutingMode.PUBLIC_TRANSPORT) {
- val planState = viewModel.planState
- when {
- planState.isLoading -> {
- Text(
- text = stringResource(string.calculating_route_in_progress),
- modifier = Modifier
- .fillMaxWidth()
- .padding(dimensionResource(dimen.padding))
- )
- }
+ RoutingModeSelector(
+ availableModes = availableRoutingModes,
+ selectedMode = viewModel.selectedRoutingMode,
+ onModeSelected = { viewModel.updateRoutingMode(it) },
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(bottom = dimensionResource(dimen.padding_minor))
+ )
- planState.error != null -> {
- Text(
- text = stringResource(string.directions_error, planState.error),
- color = MaterialTheme.colorScheme.error,
- modifier = Modifier
- .fillMaxWidth()
- .padding(dimensionResource(dimen.padding))
- )
- }
+ RoutingProfileSelector(
+ modifier = Modifier.fillMaxWidth(),
+ viewModel = viewModel,
+ availableProfiles = availableProfiles
+ )
- planState.planResponse != null -> {
- TransitDirectionsScreen(
- viewModel = viewModel, onItineraryClick = { itinerary ->
- NavigationUtils.navigate(
- navController, Screen.TransitItineraryDetail(itinerary)
- )
- }, appPreferences = appPreferences
- )
- }
+ // Inset horizontal divider
+ HorizontalDivider(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = dimensionResource(dimen.padding) / 2),
+ thickness = DividerDefaults.Thickness,
+ color = MaterialTheme.colorScheme.outlineVariant
+ )
+ }
- else -> {
- // No plan calculated yet
- Text(
- text = stringResource(string.enter_start_and_end_locations_to_get_directions),
- modifier = Modifier
- .fillMaxWidth()
- .padding(dimensionResource(dimen.padding))
- )
- }
- }
- } else {
- when {
- routeState.isLoading -> {
- Text(
- text = stringResource(string.calculating_route_in_progress),
- modifier = Modifier
- .fillMaxWidth()
- .padding(dimensionResource(dimen.padding))
- )
- }
+ // Route results
+ DirectionsRouteResults(
+ viewModel = viewModel,
+ routeState = routeState,
+ navController = navController,
+ appPreferences = appPreferences,
+ hasNotificationPermission = hasNotificationPermission,
+ onRequestNotificationPermission = onRequestNotificationPermission
+ )
+}
- routeState.error != null -> {
- Text(
- text = stringResource(string.directions_error, routeState.error),
- color = MaterialTheme.colorScheme.error,
- modifier = Modifier
- .fillMaxWidth()
- .padding(dimensionResource(dimen.padding))
- )
- }
+@Composable
+private fun DirectionsRouteResults(
+ viewModel: DirectionsViewModel,
+ routeState: RouteState,
+ navController: NavController,
+ appPreferences: AppPreferenceRepository,
+ hasNotificationPermission: Boolean,
+ onRequestNotificationPermission: () -> Unit
+) {
+ val planState = viewModel.planState
+ if (viewModel.selectedRoutingMode == RoutingMode.PUBLIC_TRANSPORT) {
+ TransitRouteResults(planState = planState, navController = navController, viewModel = viewModel, appPreferences = appPreferences)
+ } else {
+ NonTransitRouteResults(
+ routeState = routeState,
+ viewModel = viewModel,
+ navController = navController,
+ appPreferences = appPreferences,
+ hasNotificationPermission = hasNotificationPermission,
+ onRequestNotificationPermission = onRequestNotificationPermission
+ )
+ }
+}
- routeState.route != null -> {
- FerrostarRouteResults(
- ferrostarRoute = routeState.route,
- viewModel = viewModel,
- modifier = Modifier.fillMaxWidth(),
- navController = navController,
- distanceUnit = appPreferences.distanceUnit.collectAsState().value,
- availableProfiles = viewModel.getAvailableProfilesForCurrentMode()
- .collectAsState(initial = emptyList()).value,
- hasNotificationPermission = hasNotificationPermission,
- onRequestNotificationPermission = onRequestNotificationPermission
- )
- }
+@Composable
+private fun TransitRouteResults(
+ planState: TransitPlanState,
+ navController: NavController,
+ viewModel: DirectionsViewModel,
+ appPreferences: AppPreferenceRepository
+) {
+ when {
+ planState.isLoading -> {
+ Text(
+ text = stringResource(string.calculating_route_in_progress),
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(dimensionResource(dimen.padding))
+ )
+ }
- else -> {
- // No route calculated yet
- Text(
- text = stringResource(string.enter_start_and_end_locations_to_get_directions),
- modifier = Modifier
- .fillMaxWidth()
- .padding(dimensionResource(dimen.padding))
- )
- }
- }
- }
- } else {
- // Show only the focused field and search results when a field is focused
- val currentFocusState = fieldFocusState
- PlaceField(
- label = if (currentFocusState == FieldFocusState.FROM) "From" else "To",
- place = if (currentFocusState == FieldFocusState.FROM) viewModel.fromPlace else viewModel.toPlace,
- onCleared = {
- if (currentFocusState == FieldFocusState.FROM) {
- viewModel.updateFromPlace(null)
- } else {
- viewModel.updateToPlace(null)
- }
- },
- onTextChange = {
- viewModel.updateSearchQuery(it)
- },
- onTextFieldFocusChange = { isFocused ->
- fieldFocusState = if (isFocused) {
- currentFocusState
- } else {
- FieldFocusState.NONE
- }
- },
- isFocused = true,
+ planState.error != null -> {
+ Text(
+ text = stringResource(string.directions_error, planState.error),
+ color = MaterialTheme.colorScheme.error,
modifier = Modifier
.fillMaxWidth()
- .padding(bottom = 8.dp)
+ .padding(dimensionResource(dimen.padding))
)
+ }
- // Show search results or quick suggestions based on search query
- if (viewModel.isSearching) {
- Text(
- text = "Searching...",
- modifier = Modifier
- .fillMaxWidth()
- .padding(dimensionResource(dimen.padding))
- )
- } else if (viewModel.searchQuery.isEmpty()) {
- // Show quick suggestions when no search query
- QuickSuggestions(
- onMyLocationSelected = {
- // Check permissions before attempting to get location
- if (hasLocationPermission) {
- // Launch coroutine to get current location
- coroutineScope.launch {
- val myLocationPlace = viewModel.getCurrentLocationAsPlace()
- myLocationPlace?.let { place ->
- // Update the appropriate place based on which field is focused
- if (fieldFocusState == FieldFocusState.FROM) {
- viewModel.updateFromPlace(place)
- } else {
- viewModel.updateToPlace(place)
- }
- // Clear focus state after selection
- fieldFocusState = FieldFocusState.NONE
- }
- }
- } else {
- // Set pending request for auto-retry after permission grant
- pendingLocationRequest = fieldFocusState
- // Request location permission
- onRequestLocationPermission()
- }
- },
- savedPlaces = savedPlaces,
- onSavedPlaceSelected = { place ->
- // Update the appropriate place based on which field is focused
- if (fieldFocusState == FieldFocusState.FROM) {
- viewModel.updateFromPlace(place)
- } else {
- viewModel.updateToPlace(place)
- }
- // Clear focus state after selection
- fieldFocusState = FieldFocusState.NONE
- },
- isGettingLocation = viewModel.isGettingLocation,
- modifier = Modifier.fillMaxWidth()
- )
+ planState.planResponse != null -> {
+ TransitDirectionsScreen(
+ viewModel = viewModel, onItineraryClick = { itinerary ->
+ NavigationUtils.navigate(
+ navController, Screen.TransitItineraryDetail(itinerary)
+ )
+ }, appPreferences = appPreferences
+ )
+ }
+
+ else -> {
+ // No plan calculated yet
+ Text(
+ text = stringResource(string.enter_start_and_end_locations_to_get_directions),
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(dimensionResource(dimen.padding))
+ )
+ }
+ }
+}
+
+@Composable
+private fun NonTransitRouteResults(
+ routeState: RouteState,
+ viewModel: DirectionsViewModel,
+ navController: NavController,
+ appPreferences: AppPreferenceRepository,
+ hasNotificationPermission: Boolean,
+ onRequestNotificationPermission: () -> Unit
+) {
+ when {
+ routeState.isLoading -> {
+ Text(
+ text = stringResource(string.calculating_route_in_progress),
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(dimensionResource(dimen.padding))
+ )
+ }
+
+ routeState.error != null -> {
+ Text(
+ text = stringResource(string.directions_error, routeState.error),
+ color = MaterialTheme.colorScheme.error,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(dimensionResource(dimen.padding))
+ )
+ }
+
+ routeState.route != null -> {
+ FerrostarRouteResults(
+ ferrostarRoute = routeState.route,
+ viewModel = viewModel,
+ modifier = Modifier.fillMaxWidth(),
+ navController = navController,
+ distanceUnit = appPreferences.distanceUnit.collectAsState().value,
+ availableProfiles = viewModel.getAvailableProfilesForCurrentMode()
+ .collectAsState(initial = emptyList()).value,
+ hasNotificationPermission = hasNotificationPermission,
+ onRequestNotificationPermission = onRequestNotificationPermission
+ )
+ }
+
+ else -> {
+ // No route calculated yet
+ Text(
+ text = stringResource(string.enter_start_and_end_locations_to_get_directions),
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(dimensionResource(dimen.padding))
+ )
+ }
+ }
+}
+
+@Composable
+private fun DirectionsScreenFocusedField(
+ viewModel: DirectionsViewModel,
+ fieldFocusState: FieldFocusState,
+ savedPlaces: List,
+ hasLocationPermission: Boolean,
+ onRequestLocationPermission: () -> Unit,
+ pendingLocationRequest: FieldFocusState?,
+ coroutineScope: CoroutineScope
+) {
+ // Show only the focused field and search results when a field is focused
+ PlaceField(
+ label = if (fieldFocusState == FieldFocusState.FROM) "From" else "To",
+ place = if (fieldFocusState == FieldFocusState.FROM) viewModel.fromPlace else viewModel.toPlace,
+ onCleared = {
+ if (fieldFocusState == FieldFocusState.FROM) {
+ viewModel.updateFromPlace(null)
} else {
- // Show search results when there's a query
- SearchResults(
- viewModel = hiltViewModel(),
- geocodeResults = deduplicateSearchResults(viewModel.geocodeResults.value),
- onPlaceSelected = { place ->
- // Update the appropriate place based on which field is focused
- if (fieldFocusState == FieldFocusState.FROM) {
- viewModel.updateFromPlace(place)
- } else {
- viewModel.updateToPlace(place)
- }
- // Clear focus state after selection
- fieldFocusState = FieldFocusState.NONE
- },
- modifier = Modifier.fillMaxWidth()
- )
+ viewModel.updateToPlace(null)
+ }
+ },
+ onTextChange = { viewModel.updateSearchQuery(it) },
+ onTextFieldFocusChange = { isFocused ->
+
+ },
+ isFocused = true,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(bottom = 8.dp)
+ )
+
+ // Show search results or quick suggestions based on search query
+ FocusedFieldContent(
+ viewModel = viewModel,
+ fieldFocusState = fieldFocusState,
+ savedPlaces = savedPlaces,
+ hasLocationPermission = hasLocationPermission,
+ onRequestLocationPermission = onRequestLocationPermission,
+ pendingLocationRequest = pendingLocationRequest,
+ coroutineScope = coroutineScope
+ )
+}
+
+@Composable
+private fun FocusedFieldContent(
+ viewModel: DirectionsViewModel,
+ fieldFocusState: FieldFocusState,
+ savedPlaces: List,
+ hasLocationPermission: Boolean,
+ onRequestLocationPermission: () -> Unit,
+ pendingLocationRequest: FieldFocusState?,
+ coroutineScope: CoroutineScope
+) {
+ when {
+ viewModel.isSearching -> {
+ SearchingIndicator()
+ }
+
+ viewModel.searchQuery.isEmpty() -> {
+ QuickSuggestionsContent(
+ viewModel = viewModel,
+ fieldFocusState = fieldFocusState,
+ savedPlaces = savedPlaces,
+ hasLocationPermission = hasLocationPermission,
+ onRequestLocationPermission = onRequestLocationPermission,
+ pendingLocationRequest = pendingLocationRequest,
+ coroutineScope = coroutineScope
+ )
+ }
+
+ else -> {
+ SearchResultsContent(
+ viewModel = viewModel,
+ fieldFocusState = fieldFocusState
+ )
+ }
+ }
+}
+
+@Composable
+private fun SearchingIndicator() {
+ Text(
+ text = "Searching...",
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(dimensionResource(dimen.padding))
+ )
+}
+
+@Composable
+private fun QuickSuggestionsContent(
+ viewModel: DirectionsViewModel,
+ fieldFocusState: FieldFocusState,
+ savedPlaces: List,
+ hasLocationPermission: Boolean,
+ onRequestLocationPermission: () -> Unit,
+ pendingLocationRequest: FieldFocusState?,
+ coroutineScope: CoroutineScope
+) {
+ QuickSuggestions(
+ onMyLocationSelected = handleMyLocationSelected(
+ viewModel = viewModel,
+ fieldFocusState = fieldFocusState,
+ hasLocationPermission = hasLocationPermission,
+ onRequestLocationPermission = onRequestLocationPermission,
+ coroutineScope = coroutineScope
+ ),
+ savedPlaces = savedPlaces,
+ onSavedPlaceSelected = { place ->
+ updatePlaceForField(viewModel, fieldFocusState, place)
+ },
+ isGettingLocation = viewModel.isGettingLocation,
+ modifier = Modifier.fillMaxWidth()
+ )
+}
+
+@Composable
+private fun SearchResultsContent(
+ viewModel: DirectionsViewModel,
+ fieldFocusState: FieldFocusState
+) {
+ SearchResults(
+ viewModel = hiltViewModel(),
+ geocodeResults = deduplicateSearchResults(viewModel.geocodeResults.value),
+ onPlaceSelected = { place ->
+ updatePlaceForField(viewModel, fieldFocusState, place)
+ },
+ modifier = Modifier.fillMaxWidth()
+ )
+}
+
+private fun updatePlaceForField(
+ viewModel: DirectionsViewModel,
+ fieldFocusState: FieldFocusState,
+ place: Place
+) {
+ if (fieldFocusState == FieldFocusState.FROM) {
+ viewModel.updateFromPlace(place)
+ } else {
+ viewModel.updateToPlace(place)
+ }
+}
+
+private fun handleMyLocationSelected(
+ viewModel: DirectionsViewModel,
+ fieldFocusState: FieldFocusState,
+ hasLocationPermission: Boolean,
+ onRequestLocationPermission: () -> Unit,
+ coroutineScope: CoroutineScope
+): () -> Unit = {
+ if (hasLocationPermission) {
+ coroutineScope.launch {
+ val myLocationPlace = viewModel.getCurrentLocationAsPlace()
+ myLocationPlace?.let { place ->
+ updatePlaceForField(viewModel, fieldFocusState, place)
}
}
+ } else {
+ onRequestLocationPermission()
}
}
@@ -836,19 +972,47 @@ private fun FerrostarRouteResults(
}
}
- // Profile selection dialog
- if (showProfileDialog) {
+ // Dialogs
+ ProfileSelectionDialog(
+ showDialog = showProfileDialog,
+ onDismiss = { showProfileDialog = false },
+ selectedProfile = selectedProfile,
+ availableProfiles = availableProfiles,
+ onProfileSelected = { profile ->
+ viewModel.selectRoutingProfile(profile)
+ showProfileDialog = false
+ }
+ )
+
+ NotificationRequestDialog(
+ showDialog = showNotificationDialog,
+ onDismiss = { showNotificationDialog = false },
+ onConfirm = {
+ showNotificationDialog = false
+ pendingNavigation = true
+ onRequestNotificationPermission()
+ }
+ )
+}
+
+@Composable
+private fun ProfileSelectionDialog(
+ showDialog: Boolean,
+ onDismiss: () -> Unit,
+ selectedProfile: RoutingProfile?,
+ availableProfiles: List,
+ onProfileSelected: (RoutingProfile?) -> Unit
+) {
+ if (showDialog) {
AlertDialog(
- onDismissRequest = { showProfileDialog = false },
+ onDismissRequest = onDismiss,
title = { Text(stringResource(string.select_routing_profile)) },
text = {
Column {
// Default option
TextButton(
- onClick = {
- viewModel.selectRoutingProfile(null)
- showProfileDialog = false
- }, modifier = Modifier.fillMaxWidth()
+ onClick = { onProfileSelected(null) },
+ modifier = Modifier.fillMaxWidth()
) {
Text(
text = stringResource(string.default_profile),
@@ -859,10 +1023,8 @@ private fun FerrostarRouteResults(
// Custom profiles
availableProfiles.forEach { profile ->
TextButton(
- onClick = {
- viewModel.selectRoutingProfile(profile)
- showProfileDialog = false
- }, modifier = Modifier.fillMaxWidth()
+ onClick = { onProfileSelected(profile) },
+ modifier = Modifier.fillMaxWidth()
) {
Text(
text = profile.name,
@@ -873,29 +1035,31 @@ private fun FerrostarRouteResults(
}
},
confirmButton = {
- TextButton(onClick = { showProfileDialog = false }) {
+ TextButton(onClick = onDismiss) {
Text(stringResource(string.cancel_change_routing_profile))
}
})
}
+}
- // Notification permission dialog
- if (showNotificationDialog) {
+@Composable
+private fun NotificationRequestDialog(
+ showDialog: Boolean,
+ onDismiss: () -> Unit,
+ onConfirm: () -> Unit
+) {
+ if (showDialog) {
AlertDialog(
- onDismissRequest = { showNotificationDialog = false },
+ onDismissRequest = onDismiss,
title = { Text(stringResource(string.notification_ask_title)) },
text = { Text(stringResource(string.notification_ask_body)) },
confirmButton = {
- TextButton(onClick = {
- showNotificationDialog = false
- pendingNavigation = true
- onRequestNotificationPermission()
- }) {
+ TextButton(onClick = onConfirm) {
Text(stringResource(string.got_it))
}
},
dismissButton = {
- TextButton(onClick = { showNotificationDialog = false }) {
+ TextButton(onClick = onDismiss) {
Text(stringResource(string.cancel))
}
})
diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/directions/TransitItineraryDetailScreen.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/directions/TransitItineraryDetailScreen.kt
index 2af19ad..bff3f98 100644
--- a/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/directions/TransitItineraryDetailScreen.kt
+++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/directions/TransitItineraryDetailScreen.kt
@@ -217,183 +217,170 @@ private fun DetailedLegCard(
.fillMaxWidth()
.padding(dimensionResource(dimen.padding))
) {
- // Leg header with mode and route info
- Row(
- modifier = Modifier.fillMaxWidth(),
- verticalAlignment = Alignment.CenterVertically
- ) {
- // Mode icon with route color
- Box(
- modifier = Modifier
- .size(48.dp)
- .background(
- color = parseRouteColor(leg.routeColor)
- ?: MaterialTheme.colorScheme.primary,
- shape = CircleShape
- ),
- contentAlignment = Alignment.Center
- ) {
- Icon(
- painter = painterResource(leg.mode.getIcon()),
- contentDescription = null,
- tint = parseRouteColor(leg.routeTextColor)
- ?: MaterialTheme.colorScheme.onPrimary,
- modifier = Modifier.size(24.dp)
- )
- }
+ LegHeader(leg)
- Spacer(modifier = Modifier.width(dimensionResource(dimen.padding)))
+ Spacer(modifier = Modifier.height(dimensionResource(dimen.padding)))
- Column(modifier = Modifier.weight(1f)) {
- // Route name and headsign
- val routeText = leg.routeShortName ?: leg.mode.name.lowercase()
- .replaceFirstChar { it.uppercase() }
- Text(
- text = if (leg.headsign != null) "$routeText to ${leg.headsign}" else routeText,
- style = MaterialTheme.typography.titleMedium,
- fontWeight = FontWeight.Bold
- )
+ JourneyDetails(leg, use24HourFormat)
- // Agency
- leg.agencyName?.let { agency ->
- Text(
- text = agency,
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant
- )
- }
- }
+ LegAdditionalDetails(leg, distanceUnit)
+
+ LegAlerts(leg)
+ }
+ }
+}
+
+@Composable
+private fun LegHeader(leg: Leg) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ // Mode icon with route color
+ Box(
+ modifier = Modifier
+ .size(48.dp)
+ .background(
+ color = parseRouteColor(leg.routeColor)
+ ?: MaterialTheme.colorScheme.primary,
+ shape = CircleShape
+ ),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ painter = painterResource(leg.mode.getIcon()),
+ contentDescription = null,
+ tint = parseRouteColor(leg.routeTextColor)
+ ?: MaterialTheme.colorScheme.onPrimary,
+ modifier = Modifier.size(24.dp)
+ )
+ }
+
+ Spacer(modifier = Modifier.width(dimensionResource(dimen.padding)))
+
+ Column(modifier = Modifier.weight(1f)) {
+ // Route name and headsign
+ val routeText = leg.routeShortName ?: leg.mode.name.lowercase()
+ .replaceFirstChar { it.uppercase() }
+ Text(
+ text = if (leg.headsign != null) "$routeText to ${leg.headsign}" else routeText,
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold
+ )
- // Duration
+ // Agency
+ leg.agencyName?.let { agency ->
Text(
- text = formatDuration(leg.duration),
- style = MaterialTheme.typography.bodyLarge,
- color = MaterialTheme.colorScheme.primary,
- fontWeight = FontWeight.Bold
+ text = agency,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
+ }
- Spacer(modifier = Modifier.height(dimensionResource(dimen.padding)))
+ // Duration
+ Text(
+ text = formatDuration(leg.duration),
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.primary,
+ fontWeight = FontWeight.Bold
+ )
+ }
+}
- // Journey details
- Row(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.SpaceBetween
- ) {
- Column(modifier = Modifier.weight(1f)) {
- Text(
- text = "From",
- style = MaterialTheme.typography.labelMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant
- )
- Text(
- text = leg.fromTransitPlace.name,
- style = MaterialTheme.typography.bodyMedium,
- fontWeight = FontWeight.Medium
- )
- leg.fromTransitPlace.departure?.let { departure ->
- Text(
- text = "Depart: ${departure.formatTime(use24HourFormat)}",
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant
- )
- }
- }
+@Composable
+private fun JourneyDetails(leg: Leg, use24HourFormat: Boolean) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = "From",
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Text(
+ text = leg.fromTransitPlace.name,
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = FontWeight.Medium
+ )
+ leg.fromTransitPlace.departure?.let { departure ->
+ Text(
+ text = "Depart: ${departure.formatTime(use24HourFormat)}",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
- Icon(
- painter = painterResource(drawable.ic_arrow_forward),
- contentDescription = null,
- modifier = Modifier
- .align(Alignment.CenterVertically)
- .padding(horizontal = dimensionResource(dimen.padding_minor)),
- tint = MaterialTheme.colorScheme.onSurfaceVariant
+ Icon(
+ painter = painterResource(drawable.ic_arrow_forward),
+ contentDescription = null,
+ modifier = Modifier
+ .align(Alignment.CenterVertically)
+ .padding(horizontal = dimensionResource(dimen.padding_minor)),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ Column(
+ modifier = Modifier.weight(1f),
+ horizontalAlignment = Alignment.End
+ ) {
+ Text(
+ text = "To",
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Text(
+ text = leg.toTransitPlace.name,
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = FontWeight.Medium
+ )
+ leg.toTransitPlace.arrival?.let { arrival ->
+ Text(
+ text = "Arrive: ${arrival.formatTime(use24HourFormat)}",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun LegAdditionalDetails(leg: Leg, distanceUnit: Int) {
+ when (leg.mode) {
+ Mode.WALK, Mode.BIKE -> {
+ leg.distance?.let { distance ->
+ Spacer(modifier = Modifier.height(dimensionResource(dimen.padding_minor)))
+ HorizontalDivider(
+ thickness = DividerDefaults.Thickness,
+ color = MaterialTheme.colorScheme.outlineVariant
)
+ Spacer(modifier = Modifier.height(dimensionResource(dimen.padding_minor)))
- Column(
- modifier = Modifier.weight(1f),
- horizontalAlignment = Alignment.End
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
- text = "To",
- style = MaterialTheme.typography.labelMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant
+ text = "Distance:",
+ style = MaterialTheme.typography.bodyMedium
)
Text(
- text = leg.toTransitPlace.name,
+ text = GeoUtils.formatDistance(distance, distanceUnit),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium
)
- leg.toTransitPlace.arrival?.let { arrival ->
- Text(
- text = "Arrive: ${arrival.formatTime(use24HourFormat)}",
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant
- )
- }
- }
- }
-
- // Additional details based on mode
- when (leg.mode) {
- Mode.WALK, Mode.BIKE -> {
- leg.distance?.let { distance ->
- Spacer(modifier = Modifier.height(dimensionResource(dimen.padding_minor)))
- HorizontalDivider(
- thickness = DividerDefaults.Thickness,
- color = MaterialTheme.colorScheme.outlineVariant
- )
- Spacer(modifier = Modifier.height(dimensionResource(dimen.padding_minor)))
-
- Row(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.SpaceBetween
- ) {
- Text(
- text = "Distance:",
- style = MaterialTheme.typography.bodyMedium
- )
- Text(
- text = GeoUtils.formatDistance(distance, distanceUnit),
- style = MaterialTheme.typography.bodyMedium,
- fontWeight = FontWeight.Medium
- )
- }
- }
- }
-
- else -> {
- // Transit leg details
- leg.intermediateStops?.let { stops ->
- if (stops.isNotEmpty()) {
- Spacer(modifier = Modifier.height(dimensionResource(dimen.padding_minor)))
- HorizontalDivider(
- thickness = DividerDefaults.Thickness,
- color = MaterialTheme.colorScheme.outlineVariant
- )
- Spacer(modifier = Modifier.height(dimensionResource(dimen.padding_minor)))
-
- Row(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.SpaceBetween
- ) {
- Text(
- text = stringResource(string.stops),
- style = MaterialTheme.typography.bodyMedium
- )
- Text(
- text = stringResource(string.stops_qty, stops.size),
- style = MaterialTheme.typography.bodyMedium,
- fontWeight = FontWeight.Medium
- )
- }
- }
- }
}
}
+ }
- // Show alerts if any
- leg.alerts?.let { alerts ->
- if (alerts.isNotEmpty()) {
+ else -> {
+ // Transit leg details
+ leg.intermediateStops?.let { stops ->
+ if (stops.isNotEmpty()) {
Spacer(modifier = Modifier.height(dimensionResource(dimen.padding_minor)))
HorizontalDivider(
thickness = DividerDefaults.Thickness,
@@ -401,26 +388,55 @@ private fun DetailedLegCard(
)
Spacer(modifier = Modifier.height(dimensionResource(dimen.padding_minor)))
- alerts.forEach { alert ->
- alert.headerText?.let { header ->
- Row(
- modifier = Modifier.fillMaxWidth(),
- verticalAlignment = Alignment.CenterVertically
- ) {
- Icon(
- painter = painterResource(drawable.ic_close), // Using close as warning icon
- contentDescription = null,
- tint = MaterialTheme.colorScheme.error,
- modifier = Modifier.size(16.dp)
- )
- Spacer(modifier = Modifier.width(dimensionResource(dimen.padding_minor)))
- Text(
- text = header,
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.error
- )
- }
- }
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Text(
+ text = stringResource(string.stops),
+ style = MaterialTheme.typography.bodyMedium
+ )
+ Text(
+ text = stringResource(string.stops_qty, stops.size),
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = FontWeight.Medium
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun LegAlerts(leg: Leg) {
+ leg.alerts?.let { alerts ->
+ if (alerts.isNotEmpty()) {
+ Spacer(modifier = Modifier.height(dimensionResource(dimen.padding_minor)))
+ HorizontalDivider(
+ thickness = DividerDefaults.Thickness,
+ color = MaterialTheme.colorScheme.outlineVariant
+ )
+ Spacer(modifier = Modifier.height(dimensionResource(dimen.padding_minor)))
+
+ alerts.forEach { alert ->
+ alert.headerText?.let { header ->
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ painter = painterResource(drawable.ic_close), // Using close as warning icon
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.error,
+ modifier = Modifier.size(16.dp)
+ )
+ Spacer(modifier = Modifier.width(dimensionResource(dimen.padding_minor)))
+ Text(
+ text = header,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.error
+ )
}
}
}
diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/home/HomeScreen.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/home/HomeScreen.kt
index e5170f0..832f59e 100644
--- a/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/home/HomeScreen.kt
+++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/home/HomeScreen.kt
@@ -129,109 +129,180 @@ private fun SearchPanelContent(
.padding(dimensionResource(dimen.padding))
) {
val density = LocalDensity.current
+ val textFieldRequester = remember { FocusRequester() }
// Measure the height of this row for peekHeight
- Column(
+ PeekHeightContent(
+ searchQuery = searchQuery,
+ onSearchQueryChange = onSearchQueryChange,
+ onSearchFocusChange = onSearchFocusChange,
+ onSearchEvent = onSearchEvent,
+ textFieldRequester = textFieldRequester,
+ homeInSearchScreen = homeInSearchScreen,
+ pinnedPlaces = pinnedPlaces,
+ onPlaceSelected = onPlaceSelected,
+ onPeekHeightChange = onPeekHeightChange,
+ density = density
+ )
+
+ ContentBelow(
+ homeInSearchScreen = homeInSearchScreen,
+ geocodePlaces = geocodePlaces,
+ onPlaceSelected = onPlaceSelected,
+ addressFormatter = addressFormatter
+ )
+ }
+}
+
+@Composable
+private fun PeekHeightContent(
+ searchQuery: TextFieldValue,
+ onSearchQueryChange: (TextFieldValue) -> Unit,
+ onSearchFocusChange: (Boolean) -> Unit,
+ onSearchEvent: () -> Unit,
+ textFieldRequester: FocusRequester,
+ homeInSearchScreen: Boolean,
+ pinnedPlaces: List,
+ onPlaceSelected: (Place) -> Unit,
+ onPeekHeightChange: (Dp) -> Unit,
+ density: androidx.compose.ui.unit.Density,
+) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .onGloballyPositioned { coordinates ->
+ val heightInDp = with(density) { coordinates.size.height.toDp() }
+ onPeekHeightChange(heightInDp)
+ }
+ ) {
+ SearchTextField(
+ searchQuery = searchQuery,
+ onSearchQueryChange = onSearchQueryChange,
+ onSearchFocusChange = onSearchFocusChange,
+ onSearchEvent = onSearchEvent,
+ textFieldRequester = textFieldRequester,
+ homeInSearchScreen = homeInSearchScreen
+ )
+
+ PinnedPlacesRow(
+ pinnedPlaces = pinnedPlaces,
+ homeInSearchScreen = homeInSearchScreen,
+ onPlaceSelected = onPlaceSelected
+ )
+
+ HorizontalDivider(
modifier = Modifier
.fillMaxWidth()
- .onGloballyPositioned { coordinates ->
- val heightInDp = with(density) { coordinates.size.height.toDp() }
- onPeekHeightChange(heightInDp)
- }) {
- val textField = remember { FocusRequester() }
- // Search box with "Where to?" placeholder
- TextField(
- value = searchQuery,
- onValueChange = onSearchQueryChange,
- keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
- keyboardActions = KeyboardActions(onSearch = { onSearchEvent() }),
- singleLine = true,
- modifier = Modifier
- .focusRequester(textField)
- .fillMaxWidth()
- .padding(bottom = dimensionResource(dimen.padding))
- .onFocusChanged { focusState ->
- onSearchFocusChange(focusState.isFocused)
- },
- placeholder = { Text(stringResource(string.where_to)) },
- leadingIcon = {
+ .padding(vertical = dimensionResource(dimen.padding) / 2),
+ thickness = DividerDefaults.Thickness,
+ color = MaterialTheme.colorScheme.outlineVariant
+ )
+ }
+}
+
+@Composable
+private fun SearchTextField(
+ searchQuery: TextFieldValue,
+ onSearchQueryChange: (TextFieldValue) -> Unit,
+ onSearchFocusChange: (Boolean) -> Unit,
+ onSearchEvent: () -> Unit,
+ textFieldRequester: FocusRequester,
+ homeInSearchScreen: Boolean,
+) {
+ TextField(
+ value = searchQuery,
+ onValueChange = onSearchQueryChange,
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
+ keyboardActions = KeyboardActions(onSearch = { onSearchEvent() }),
+ singleLine = true,
+ modifier = Modifier
+ .focusRequester(textFieldRequester)
+ .fillMaxWidth()
+ .padding(bottom = dimensionResource(dimen.padding))
+ .onFocusChanged { focusState ->
+ onSearchFocusChange(focusState.isFocused)
+ },
+ placeholder = { Text(stringResource(string.where_to)) },
+ leadingIcon = {
+ Icon(
+ painter = painterResource(drawable.ic_search),
+ contentDescription = stringResource(string.content_description_search)
+ )
+ },
+ trailingIcon = {
+ if (searchQuery.text.isNotEmpty()) {
+ FilledTonalIconButton(
+ onClick = { onSearchQueryChange(TextFieldValue()) },
+ modifier = Modifier.size(36.dp)
+ ) {
Icon(
- painter = painterResource(drawable.ic_search),
- contentDescription = stringResource(string.content_description_search)
+ painter = painterResource(drawable.ic_close),
+ contentDescription = stringResource(string.content_description_clear_search)
)
- },
- trailingIcon = {
- if (searchQuery.text.isNotEmpty()) {
- FilledTonalIconButton(
- onClick = { onSearchQueryChange(TextFieldValue()) },
- modifier = Modifier.size(36.dp)
- ) {
- Icon(
- painter = painterResource(drawable.ic_close),
- contentDescription = stringResource(string.content_description_clear_search)
- )
- }
- }
- },
- colors = TextFieldDefaults.colors(
- focusedIndicatorColor = Color.Transparent,
- unfocusedIndicatorColor = Color.Transparent,
- disabledIndicatorColor = Color.Transparent
- ),
- shape = RoundedCornerShape(dimensionResource(dimen.icon_size))
- )
-
- // This block is responsible for returning focus to the search bar after we pop back to the search panel from a place card.
- LaunchedEffect(homeInSearchScreen) {
- if (homeInSearchScreen) {
- textField.requestFocus()
}
}
+ },
+ colors = TextFieldDefaults.colors(
+ focusedIndicatorColor = Color.Transparent,
+ unfocusedIndicatorColor = Color.Transparent,
+ disabledIndicatorColor = Color.Transparent
+ ),
+ shape = RoundedCornerShape(dimensionResource(dimen.icon_size))
+ )
- AnimatedVisibility(
- visible = pinnedPlaces.isNotEmpty()
- ) {
- // The homeInSearchScreen isn't part of the animated visibility condition because it looks weird.
- if (!homeInSearchScreen) {
- FlowRow(
- modifier = Modifier
- .fillMaxWidth()
- .padding(bottom = dimensionResource(dimen.padding))
- ) {
- for (place in pinnedPlaces)
- NavigationIcon(
- place = place,
- onPlaceSelected = onPlaceSelected
- )
- }
- }
- }
+ LaunchedEffect(homeInSearchScreen) {
+ if (homeInSearchScreen) {
+ textFieldRequester.requestFocus()
+ }
+ }
+}
- // Inset horizontal divider
- HorizontalDivider(
+@Composable
+private fun PinnedPlacesRow(
+ pinnedPlaces: List,
+ homeInSearchScreen: Boolean,
+ onPlaceSelected: (Place) -> Unit,
+) {
+ AnimatedVisibility(
+ visible = pinnedPlaces.isNotEmpty()
+ ) {
+ if (!homeInSearchScreen) {
+ FlowRow(
modifier = Modifier
.fillMaxWidth()
- .padding(vertical = dimensionResource(dimen.padding) / 2),
- thickness = DividerDefaults.Thickness,
- color = MaterialTheme.colorScheme.outlineVariant
- )
+ .padding(bottom = dimensionResource(dimen.padding))
+ ) {
+ for (place in pinnedPlaces)
+ NavigationIcon(
+ place = place,
+ onPlaceSelected = onPlaceSelected
+ )
+ }
}
+ }
+}
- if (homeInSearchScreen) {
- LazyColumn {
- items(geocodePlaces) {
- SearchResultItem(
- addressFormatter = addressFormatter,
- it,
- onPlaceSelected
- )
- }
+@Composable
+private fun ContentBelow(
+ homeInSearchScreen: Boolean,
+ geocodePlaces: List,
+ onPlaceSelected: (Place) -> Unit,
+ addressFormatter: AddressFormatter,
+) {
+ if (homeInSearchScreen) {
+ LazyColumn {
+ items(geocodePlaces) {
+ SearchResultItem(
+ addressFormatter = addressFormatter,
+ it,
+ onPlaceSelected
+ )
}
- Spacer(modifier = Modifier.fillMaxSize())
- } else {
- val savedPlacesViewModel = hiltViewModel()
- SavedPlacesList(savedPlacesViewModel, onPlaceSelected = onPlaceSelected)
}
+ Spacer(modifier = Modifier.fillMaxSize())
+ } else {
+ val savedPlacesViewModel = hiltViewModel()
+ SavedPlacesList(savedPlacesViewModel, onPlaceSelected = onPlaceSelected)
}
}
diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/home/TransitScreen.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/home/TransitScreen.kt
index a8107f3..6311a56 100644
--- a/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/home/TransitScreen.kt
+++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/home/TransitScreen.kt
@@ -30,6 +30,7 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxSize
@@ -54,7 +55,6 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
-import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
@@ -115,70 +115,93 @@ fun TransitScreenContent(
bottom = 36.dp
)
) {
- // Departures section
- Row(
- modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically
- ) {
- Text(
- text = stringResource(string.next_departures),
- style = MaterialTheme.typography.headlineSmall,
- fontWeight = FontWeight.Bold,
- modifier = Modifier
- .padding(bottom = dimensionResource(dimen.padding))
- .weight(1f)
- )
- // Refresh button for departures
- IconButton(onClick = { coroutineScope.launch { viewModel.refreshData() } }) {
- if (isRefreshingDepartures.value) {
- CircularProgressIndicator(
- modifier = Modifier.size(24.dp), strokeWidth = 2.dp
- )
- } else {
- Icon(
- painter = painterResource(R.drawable.ic_refresh),
- contentDescription = stringResource(string.refresh_departures)
- )
- }
- }
- }
+ DeparturesHeader(viewModel, coroutineScope, isRefreshingDepartures.value)
+ DeparturesContent(
+ didLoadingFail.value,
+ isLoading.value,
+ departures,
+ onRouteClicked,
+ use24HourFormat
+ )
+ }
+}
- if (didLoadingFail.value) {
- Text(
- modifier = Modifier
- .fillMaxWidth()
- .padding(16.dp),
- text = stringResource(string.failed_to_load_departures)
- )
- } else if (isLoading.value && departures.isEmpty()) {
- Text(
- modifier = Modifier
- .fillMaxWidth()
- .padding(16.dp),
- text = stringResource(string.loading_departures)
- )
- // Show indeterminate progress bar while loading
- LinearProgressIndicator(
- modifier = Modifier
- .fillMaxWidth()
- .padding(16.dp)
- )
- } else if (departures.isEmpty()) {
- Text(
- text = stringResource(string.no_upcoming_departures),
- style = MaterialTheme.typography.bodyMedium,
- modifier = Modifier.padding(vertical = 8.dp)
- )
- } else {
- // List of departures grouped by route and headsign
- TransitScreenRouteDepartures(
- stopTimes = departures,
- onRouteClicked = onRouteClicked,
- use24HourFormat = use24HourFormat,
- )
+@Composable
+private fun DeparturesHeader(
+ viewModel: TransitScreenViewModel,
+ coroutineScope: kotlinx.coroutines.CoroutineScope,
+ isRefreshing: Boolean,
+) {
+ Row(
+ modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = stringResource(string.next_departures),
+ style = MaterialTheme.typography.headlineSmall,
+ fontWeight = FontWeight.Bold,
+ modifier = Modifier
+ .padding(bottom = dimensionResource(dimen.padding))
+ .weight(1f)
+ )
+ // Refresh button for departures
+ IconButton(onClick = { coroutineScope.launch { viewModel.refreshData() } }) {
+ if (isRefreshing) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(24.dp), strokeWidth = 2.dp
+ )
+ } else {
+ Icon(
+ painter = painterResource(R.drawable.ic_refresh),
+ contentDescription = stringResource(string.refresh_departures)
+ )
+ }
}
}
}
+@Composable
+private fun DeparturesContent(
+ didLoadingFail: Boolean,
+ isLoading: Boolean,
+ departures: List,
+ onRouteClicked: (Place) -> Unit,
+ use24HourFormat: Boolean,
+) {
+ if (didLoadingFail) {
+ Text(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ text = stringResource(string.failed_to_load_departures)
+ )
+ } else if (isLoading && departures.isEmpty()) {
+ Text(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ text = stringResource(string.loading_departures)
+ )
+ // Show indeterminate progress bar while loading
+ LinearProgressIndicator(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp)
+ )
+ } else if (departures.isEmpty()) {
+ Text(
+ text = stringResource(string.no_upcoming_departures),
+ style = MaterialTheme.typography.bodyMedium,
+ modifier = Modifier.padding(vertical = 8.dp)
+ )
+ } else {
+ // List of departures grouped by route and headsign
+ TransitScreenRouteDepartures(
+ stopTimes = departures,
+ onRouteClicked = onRouteClicked,
+ use24HourFormat = use24HourFormat,
+ )
+ }
+}
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class, ExperimentalTime::class)
@Composable
@@ -187,163 +210,198 @@ fun TransitScreenRouteDepartures(
) {
// Group departures by route name
val departuresByRoute = stopTimes.groupBy { it.routeShortName }
+ val transitStopString = stringResource(string.transit_stop)
departuresByRoute.forEach { (routeName, departures) ->
- // Group departures by headsign within each route
- val departuresByHeadsign = departures.groupBy { it.headsign }.map { (key, value) ->
- (key to value.take(1))
- }.toMap()
- val headsigns = departuresByHeadsign.keys.toList().sorted()
+ val (departuresByHeadsign, headsigns, places) = prepareHeadsignData(
+ departures,
+ transitStopString
+ )
+ TransitRouteItem(
+ routeName,
+ departures,
+ departuresByHeadsign,
+ headsigns,
+ places,
+ onRouteClicked,
+ use24HourFormat
+ )
+ }
+}
- val transitStopString = stringResource(string.transit_stop)
- val places = remember(departures) {
- departuresByHeadsign.map { (key, value) ->
- key to value.firstOrNull()?.let { departure ->
- Place(
- name = departure.place.name,
- description = transitStopString,
- latLng = LatLng(departure.place.lat, departure.place.lon),
- isTransitStop = true,
- transitStopId = departure.place.stopId,
- )
- }
- }.toMap()
+private fun prepareHeadsignData(
+ departures: List,
+ transitStopString: String
+): Triple