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>, List, Map> { + val departuresByHeadsign = departures.groupBy { it.headsign }.mapValues { it.value.take(1) } + val headsigns = departuresByHeadsign.keys.sorted() + val places = departuresByHeadsign.mapValues { (key, value) -> + 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, + ) } + } + return Triple(departuresByHeadsign, headsigns, places) +} - val pagerState = rememberPagerState(pageCount = { headsigns.size }) - Box( - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = { - places[headsigns[pagerState.currentPage]]?.let { place -> - onRouteClicked(place) - } - }), - ) { - Card(modifier = Modifier.padding(bottom = dimensionResource(dimen.padding_minor))) { - Box { - // Route name at top - Row( - modifier = Modifier.padding(dimensionResource(dimen.padding_minor)) - ) { - val routeColor = departures.firstOrNull()?.parseRouteColor() - ?: MaterialTheme.colorScheme.surfaceVariant - Text( - text = stringResource(string.square_char), - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold, - color = routeColor - ) - Spacer(modifier = Modifier.width(dimensionResource(dimen.padding_minor))) - Text( - text = routeName, - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold - ) - } +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class, ExperimentalTime::class) +@Composable +private fun TransitRouteItem( + routeName: String, + departures: List, + departuresByHeadsign: Map>, + headsigns: List, + places: Map, + onRouteClicked: (Place) -> Unit, + use24HourFormat: Boolean, +) { + val pagerState = rememberPagerState(pageCount = { headsigns.size }) - Column(modifier = Modifier.fillMaxWidth()) { - // Page indicator - PageIndicator(headsigns.size, currentPage = pagerState.currentPage) + Box( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = { + places[headsigns[pagerState.currentPage]]?.let { place -> + onRouteClicked(place) + } + }), + ) { + Card(modifier = Modifier.padding(bottom = dimensionResource(dimen.padding_minor))) { + Box { + // Route name at top + RouteNameHeader(routeName, departures) - // Horizontal pager for headsigns - HorizontalPager( - state = pagerState, - modifier = Modifier.fillMaxSize(), - ) { page -> - val selectedHeadsign = headsigns[page] - val departuresForHeadsign = - departuresByHeadsign[selectedHeadsign] ?: emptyList() - val soonestDeparture = departuresForHeadsign.minByOrNull { - val dep = it.place.departure ?: it.place.scheduledDeparture - dep ?: "" - } ?: return@HorizontalPager + Column(modifier = Modifier.fillMaxWidth()) { + // Page indicator + PageIndicator(headsigns.size, currentPage = pagerState.currentPage) - Row( - modifier = Modifier - .fillMaxWidth() - .padding( - start = dimensionResource(dimen.padding), - bottom = dimensionResource(dimen.padding) - ), - ) { - // Left side: headsign and stop - Column( - modifier = Modifier - .weight(1f) - .padding(top = 24.dp) - .align(Alignment.Bottom) - ) { - Spacer(modifier = Modifier.weight(1f)) - Text( - modifier = Modifier.basicMarquee(), - text = selectedHeadsign, - style = MaterialTheme.typography.bodyLarge, - maxLines = 1, - ) - Text( - text = soonestDeparture.place.name, - style = MaterialTheme.typography.bodySmall, - ) - } - // Right side: departure time - val bestDepartureTime = formatDepartureTime( - soonestDeparture, - use24HourFormat = use24HourFormat - ) - val containerContent = @Composable { - Row( - modifier = Modifier.padding( - dimensionResource(dimen.padding), - ), - verticalAlignment = Alignment.CenterVertically - ) { - if (soonestDeparture.realTime) { - val infiniteTransition = - rememberInfiniteTransition(label = "alpha animation") - val animatedAlpha by infiniteTransition.animateFloat( - initialValue = 0.3f, - targetValue = 1f, - animationSpec = infiniteRepeatable( - animation = tween( - durationMillis = 750, easing = LinearEasing - ), repeatMode = RepeatMode.Reverse - ), - label = "alpha" - ) - Text( - modifier = Modifier.padding(end = 4.dp), - text = stringResource(string.live_indicator_short), - color = MaterialTheme.colorScheme.onSurface.copy( - alpha = animatedAlpha - ), - style = MaterialTheme.typography.headlineSmall - ) - } - Text( - text = bestDepartureTime, - style = MaterialTheme.typography.bodyLarge.copy( - fontWeight = if (soonestDeparture.realTime) FontWeight.Bold else FontWeight.Normal - ), - color = MaterialTheme.colorScheme.onSurface.copy(alpha = if (soonestDeparture.realTime) 1f else 0.5f), - textAlign = TextAlign.End - ) - } - } + // Horizontal pager for headsigns + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxSize(), + ) { page -> + val selectedHeadsign = headsigns[page] + val departuresForHeadsign = + departuresByHeadsign[selectedHeadsign] ?: emptyList() + val soonestDeparture = departuresForHeadsign.minByOrNull { + it.place.departure ?: it.place.scheduledDeparture ?: "" + } ?: return@HorizontalPager - Box( - modifier = Modifier - .align(Alignment.CenterVertically) - .padding(end = dimensionResource(dimen.padding)) - .defaultMinSize(minWidth = 50.dp, minHeight = 50.dp), - ) { - containerContent() - } - } - } + DepartureItem(selectedHeadsign, soonestDeparture, use24HourFormat) } } } } } } + +@Composable +private fun RouteNameHeader(routeName: String, departures: List) { + Row(modifier = Modifier.padding(dimensionResource(dimen.padding_minor))) { + val routeColor = departures.firstOrNull()?.parseRouteColor() + ?: MaterialTheme.colorScheme.surfaceVariant + Text( + text = stringResource(string.square_char), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = routeColor + ) + Spacer(modifier = Modifier.width(dimensionResource(dimen.padding_minor))) + Text( + text = routeName, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold + ) + } +} + +@OptIn(ExperimentalTime::class) +@Composable +private fun DepartureItem( + selectedHeadsign: String, + soonestDeparture: StopTime, + use24HourFormat: Boolean +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding( + start = dimensionResource(dimen.padding), + bottom = dimensionResource(dimen.padding) + ), + ) { + // Left side: headsign and stop + Column( + modifier = Modifier + .weight(1f) + .padding(top = 24.dp) + .align(Alignment.Bottom) + ) { + Spacer(modifier = Modifier.weight(1f)) + Text( + modifier = Modifier.basicMarquee(), + text = selectedHeadsign, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + ) + Text( + text = soonestDeparture.place.name, + style = MaterialTheme.typography.bodySmall, + ) + } + // Right side: departure time + DepartureTimeDisplay(soonestDeparture, use24HourFormat) + } +} + +@Composable +private fun RowScope.DepartureTimeDisplay(soonestDeparture: StopTime, use24HourFormat: Boolean) { + val bestDepartureTime = formatDepartureTime(soonestDeparture, use24HourFormat = use24HourFormat) + val containerContent = @Composable { + Row( + modifier = Modifier.padding(dimensionResource(dimen.padding)), + verticalAlignment = Alignment.CenterVertically + ) { + if (soonestDeparture.realTime) { + val infiniteTransition = rememberInfiniteTransition(label = "alpha animation") + val animatedAlpha by infiniteTransition.animateFloat( + initialValue = 0.3f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 750, easing = LinearEasing), + repeatMode = RepeatMode.Reverse + ), + label = "alpha" + ) + Text( + modifier = Modifier.padding(end = 4.dp), + text = stringResource(string.live_indicator_short), + color = MaterialTheme.colorScheme.onSurface.copy(alpha = animatedAlpha), + style = MaterialTheme.typography.headlineSmall + ) + } + Text( + text = bestDepartureTime, + style = MaterialTheme.typography.bodyLarge.copy( + fontWeight = if (soonestDeparture.realTime) FontWeight.Bold else FontWeight.Normal + ), + color = MaterialTheme.colorScheme.onSurface.copy(alpha = if (soonestDeparture.realTime) 1f else 0.5f), + textAlign = TextAlign.End + ) + } + } + + Box( + modifier = Modifier + .align(Alignment.CenterVertically) + .padding(end = dimensionResource(dimen.padding)) + .defaultMinSize(minWidth = 50.dp, minHeight = 50.dp), + ) { + containerContent() + } +} diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/place/PlaceCardScreen.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/place/PlaceCardScreen.kt index b837a4a..0f2bb5a 100644 --- a/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/place/PlaceCardScreen.kt +++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/place/PlaceCardScreen.kt @@ -102,89 +102,9 @@ fun PlaceCardScreen( onPeekHeightChange(heightInDp) }, ) { - // Place name and type - Text( - text = displayedPlace.name, - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold - ) - - Text( - text = displayedPlace.description, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 4.dp) - ) - - // Address information - displayedPlace.address?.let { address -> - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - painter = painterResource(drawable.ic_location_on), - contentDescription = null, - modifier = Modifier.size(dimensionResource(dimen.icon_size)) - ) - Text( - modifier = Modifier - .weight(1f) - .padding(start = dimensionResource(dimen.padding)), - text = displayedPlace.address.format(addressFormatter) - ?: stringResource(string.address_unavailable) - ) - } - } - - // Action buttons - Row( - modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically - ) { - // Get directions button - Button( - onClick = { onGetDirections(displayedPlace) }, modifier = Modifier.padding( - start = dimensionResource(dimen.padding_minor), end = 0.dp - ) - ) { - Text(stringResource(string.get_directions)) - } - - // Save/Unsave button - Button( - onClick = { - if (viewModel.isPlaceSaved.value) { - // Show confirmation dialog for unsaving - showUnsaveConfirmationDialog = true - } else { - viewModel.savePlace(place) - } - }, modifier = Modifier.padding(start = dimensionResource(dimen.padding_minor)) - ) { - Row( - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - painter = if (viewModel.isPlaceSaved.value) - painterResource(drawable.ic_heart_minus) - else - painterResource(drawable.ic_heart), - contentDescription = null, - modifier = Modifier.size(20.dp) - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = if (viewModel.isPlaceSaved.value) { - stringResource(string.unsave_place) - } else { - stringResource(string.save_place) - } - ) - } - } - } + PlaceHeader(displayedPlace) + PlaceAddress(displayedPlace, addressFormatter) + PlaceActions(displayedPlace, viewModel, place, onGetDirections) { showUnsaveConfirmationDialog = true } // Inset horizontal divider HorizontalDivider( modifier = Modifier @@ -201,33 +121,138 @@ fun PlaceCardScreen( }, onRouteClicked = {}) } - // Unsave Confirmation Dialog - if (showUnsaveConfirmationDialog) { - AlertDialog( - onDismissRequest = { showUnsaveConfirmationDialog = false }, - title = { Text(stringResource(string.unsave_place)) }, - text = { - Text( - stringResource( - string.are_you_sure_you_want_to_delete, displayedPlace.name - ) - ) - }, - confirmButton = { - TextButton( - onClick = { - viewModel.unsavePlace(displayedPlace) - showUnsaveConfirmationDialog = false - }) { - Text(stringResource(string.unsave_place)) - } - }, - dismissButton = { - TextButton( - onClick = { showUnsaveConfirmationDialog = false }) { - Text(stringResource(string.cancel_button)) + UnsaveConfirmationDialog(displayedPlace, viewModel, showUnsaveConfirmationDialog) { showUnsaveConfirmationDialog = false } + } +} + +@Composable +private fun PlaceHeader(displayedPlace: Place) { + Text( + text = displayedPlace.name, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold + ) + + Text( + text = displayedPlace.description, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp) + ) +} + +@Composable +private fun PlaceAddress(displayedPlace: Place, addressFormatter: AddressFormatter) { + displayedPlace.address?.let { address -> + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(drawable.ic_location_on), + contentDescription = null, + modifier = Modifier.size(dimensionResource(dimen.icon_size)) + ) + Text( + modifier = Modifier + .weight(1f) + .padding(start = dimensionResource(dimen.padding)), + text = displayedPlace.address.format(addressFormatter) + ?: stringResource(string.address_unavailable) + ) + } + } +} + +@Composable +private fun PlaceActions( + displayedPlace: Place, + viewModel: PlaceCardViewModel, + place: Place, + onGetDirections: (Place) -> Unit, + onShowUnsaveDialog: () -> Unit +) { + Row( + modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically + ) { + // Get directions button + Button( + onClick = { onGetDirections(displayedPlace) }, modifier = Modifier.padding( + start = dimensionResource(dimen.padding_minor), end = 0.dp + ) + ) { + Text(stringResource(string.get_directions)) + } + + // Save/Unsave button + Button( + onClick = { + if (viewModel.isPlaceSaved.value) { + // Show confirmation dialog for unsaving + onShowUnsaveDialog() + } else { + viewModel.savePlace(place) + } + }, modifier = Modifier.padding(start = dimensionResource(dimen.padding_minor)) + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = if (viewModel.isPlaceSaved.value) + painterResource(drawable.ic_heart_minus) + else + painterResource(drawable.ic_heart), + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = if (viewModel.isPlaceSaved.value) { + stringResource(string.unsave_place) + } else { + stringResource(string.save_place) } - }) + ) + } } } } + +@Composable +private fun UnsaveConfirmationDialog( + displayedPlace: Place, + viewModel: PlaceCardViewModel, + show: Boolean, + onDismiss: () -> Unit +) { + if (show) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(string.unsave_place)) }, + text = { + Text( + stringResource( + string.are_you_sure_you_want_to_delete, displayedPlace.name + ) + ) + }, + confirmButton = { + TextButton( + onClick = { + viewModel.unsavePlace(displayedPlace) + onDismiss() + }) { + Text(stringResource(string.unsave_place)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(string.cancel_button)) + } + } + ) + } +} diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/place/TransitStopScreen.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/place/TransitStopScreen.kt index b0578ca..eafa931 100644 --- a/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/place/TransitStopScreen.kt +++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/place/TransitStopScreen.kt @@ -292,19 +292,10 @@ fun PageIndicator(pageCount: Int, currentPage: Int) { fun DepartureRow( stopTime: StopTime, isFirst: Boolean, use24HourFormat: Boolean, ) { - val isoFormatter = DateTimeFormatter.ISO_INSTANT.withZone(ZoneId.systemDefault()) val textColor = MaterialTheme.colorScheme.onSurface - - val bestDepartureInstant = stopTime.place.departure?.let { - try { - Instant.from(isoFormatter.parse(it)) - } catch (_: Exception) { - null - } - } - - val bestDepartureTime: String? = - stopTime.place.departure?.formatTime(use24HourFormat = use24HourFormat) + val stopTimeStyle = getStopTimeStyle(isFirst) + val departureText = getDepartureText(stopTime, use24HourFormat) + val indicatorText = getIndicatorText(stopTime.realTime) Row( modifier = Modifier @@ -312,42 +303,23 @@ fun DepartureRow( .padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically ) { - val stopTimeStyle = if (isFirst) { - MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Bold) - } else { - MaterialTheme.typography.bodyMedium - } Column( modifier = Modifier .padding(horizontal = dimensionResource(dimen.padding)) .fillMaxWidth() ) { // Departure time at the bottom - val timeUntilDeparture = - bestDepartureInstant?.toKotlinInstant()?.minus(Clock.System.now()) Text( modifier = Modifier.align(Alignment.End), - text = if (timeUntilDeparture != null && timeUntilDeparture > 30.minutes) { - bestDepartureTime ?: stringResource(string.unknown_departure) - } else if (timeUntilDeparture != null) { - "${timeUntilDeparture.inWholeMinutes} min" - } else { - bestDepartureTime ?: stringResource(string.unknown_departure) - }, + text = departureText, textAlign = TextAlign.End, style = stopTimeStyle, color = textColor, fontWeight = if (stopTime.realTime) FontWeight.Medium else FontWeight.Normal ) // Real-time or scheduled indicator - val isLiveIndicatorString = if (stopTime.realTime) { - stringResource(string.live_indicator) - } else { - stringResource(string.scheduled_indicator) - } - Text( - text = isLiveIndicatorString, + text = indicatorText, textAlign = TextAlign.End, style = MaterialTheme.typography.bodySmall, color = textColor, @@ -357,6 +329,48 @@ fun DepartureRow( } } +@Composable +private fun getStopTimeStyle(isFirst: Boolean): androidx.compose.ui.text.TextStyle { + return if (isFirst) { + MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Bold) + } else { + MaterialTheme.typography.bodyMedium + } +} + +@OptIn(ExperimentalTime::class) +@Composable +private fun getDepartureText(stopTime: StopTime, use24HourFormat: Boolean): String { + val isoFormatter = DateTimeFormatter.ISO_INSTANT.withZone(ZoneId.systemDefault()) + val departureInstant = stopTime.place.departure?.let { + try { + Instant.from(isoFormatter.parse(it)) + } catch (_: Exception) { + null + } + } + val departureTime: String? = + stopTime.place.departure?.formatTime(use24HourFormat = use24HourFormat) + val timeUntilDeparture = departureInstant?.toKotlinInstant()?.minus(Clock.System.now()) + + return if (timeUntilDeparture != null && timeUntilDeparture > 30.minutes) { + departureTime ?: stringResource(string.unknown_departure) + } else if (timeUntilDeparture != null) { + "${timeUntilDeparture.inWholeMinutes} min" + } else { + departureTime ?: stringResource(string.unknown_departure) + } +} + +@Composable +private fun getIndicatorText(isRealTime: Boolean): String { + return if (isRealTime) { + stringResource(string.live_indicator) + } else { + stringResource(string.scheduled_indicator) + } +} + @OptIn(ExperimentalFoundationApi::class) @Composable fun FixedHeightHorizontalPager( diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/saved/ManagePlacesScreen.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/saved/ManagePlacesScreen.kt index 920a051..380874f 100644 --- a/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/saved/ManagePlacesScreen.kt +++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/saved/ManagePlacesScreen.kt @@ -102,15 +102,10 @@ fun ManagePlacesScreen( val clipboard by viewModel.clipboard.collectAsState(emptySet()) val selectedItems by viewModel.selectedItems.collectAsState() val isAllSelected by viewModel.isAllSelected.collectAsState(initial = false) - val showDeleteConfirmation = remember { mutableStateOf(false) } - val showCreateListDialog = remember { mutableStateOf(false) } - val showEditDialog = remember { mutableStateOf(false) } - val editingItem = remember { mutableStateOf(null) } - var newListName by remember { mutableStateOf("") } - var showEmptyNameWarning by remember { mutableStateOf(false) } - var editName by remember { mutableStateOf("") } - var editDescription by remember { mutableStateOf("") } - var editPinned by remember { mutableStateOf(false) } + var showDeleteConfirmation by remember { mutableStateOf(false) } + var showCreateListDialog by remember { mutableStateOf(false) } + var showEditDialog by remember { mutableStateOf(false) } + var editingItem by remember { mutableStateOf(null) } var fabMenuExpanded by remember { mutableStateOf(false) } // Initialize the view model with the listId if provided @@ -119,8 +114,7 @@ fun ManagePlacesScreen( } Scaffold( - contentWindowInsets = WindowInsets.safeDrawing, - topBar = { + contentWindowInsets = WindowInsets.safeDrawing, topBar = { ManagePlacesTopBar( navController = navController, title = currentListName ?: stringResource(string.saved_places_title_case), @@ -157,8 +151,7 @@ fun ManagePlacesScreen( coroutineScope.launch { val place = viewModel.getSavedPlace(item.id) ?: return@launch NavigationUtils.navigate( - navController, - Screen.PlaceCard(place) + navController, Screen.PlaceCard(place) ) } } @@ -166,300 +159,245 @@ fun ManagePlacesScreen( is ListContentItem -> { currentListId?.let { currentListId -> NavigationUtils.navigate( - navController, - Screen.ManagePlaces( - item.id, - parents = parents.plus(currentListName ?: "") - ), - avoidCycles = false + navController, Screen.ManagePlaces( + item.id, parents = parents.plus(currentListName ?: "") + ), avoidCycles = false ) } } } }, - onEditClick = { editingItem.value = it; showEditDialog.value = true } - ) + onEditClick = { editingItem = it; showEditDialog = true }) } } - val modifier = if (fabMenuExpanded) { - Modifier - .clickable( - indication = null, - interactionSource = null, - onClick = { fabMenuExpanded = false }) - } else { - Modifier - } - Box( - modifier = modifier - .fillMaxSize() - .windowInsetsPadding(WindowInsets.safeDrawing) - .padding(bottom = TOOLBAR_HEIGHT_DP) - ) { - FloatingActionButtonMenu( - modifier = Modifier.align(Alignment.BottomEnd), - expanded = fabMenuExpanded, - button = { - ToggleFloatingActionButton( - checked = fabMenuExpanded, - onCheckedChange = { - fabMenuExpanded = it - }, - content = { - val close = painterResource(drawable.ic_close) - val open = painterResource(drawable.ic_menu) - val painter by remember { - derivedStateOf { - if (checkedProgress > 0.5f) close else open - } - } - Icon( - painter = painter, - contentDescription = null, - modifier = Modifier.animateIcon({ checkedProgress }), - ) - } - ) - } - ) { - FloatingActionButtonMenuItem( - onClick = { - showCreateListDialog.value = true - fabMenuExpanded = false - }, - text = { - Text( - text = stringResource( - string.new_list - ) - ) - }, - icon = { - Icon( - painter = painterResource(drawable.ic_new_list), - contentDescription = null - ) - }) + FloatingActionButtonMenuSection( + fabMenuExpanded = fabMenuExpanded, + onFabMenuExpandedChange = { fabMenuExpanded = it }, + viewModel = viewModel, + onShowDeleteConfirmationChange = { showDeleteConfirmation = it }, + onShowCreateListDialogChange = { showCreateListDialog = it }, + isAllSelected = isAllSelected + ) + } - FloatingActionButtonMenuItem( - onClick = { - viewModel.cutSelected() - fabMenuExpanded = false - }, - text = { - Text(text = stringResource(string.cut)) - }, - icon = { - Icon( - painter = painterResource(drawable.ic_content_cut), - contentDescription = null - ) - } - ) - FloatingActionButtonMenuItem( - onClick = { - viewModel.pasteSelected() - fabMenuExpanded = false - }, - text = { - Text(text = stringResource(string.paste)) - }, - icon = { - Icon( - painter = painterResource(drawable.ic_content_paste), - contentDescription = null - ) - } - ) - FloatingActionButtonMenuItem( - onClick = { - showDeleteConfirmation.value = true - fabMenuExpanded = false - }, - text = { - Text(text = stringResource(string.delete)) - }, - icon = { - Icon( - painter = painterResource(drawable.ic_delete), - contentDescription = null - ) - } - ) - FloatingActionButtonMenuItem( - onClick = { - if (isAllSelected) { - viewModel.clearSelection() - } else { - viewModel.selectAll() - } - }, - text = { - Text(text = stringResource(if (isAllSelected) string.deselect_all else string.select_all)) - }, - icon = { - Icon( - painter = painterResource(if (isAllSelected) drawable.ic_clear_selection else drawable.ic_select_all), - contentDescription = null - ) - } - ) - } - } + if (showDeleteConfirmation) { + DeleteConfirmationDialog( + selectedItemsCount = selectedItems.size, + onDelete = { viewModel.deleteSelected() }, + onDismiss = { + showCreateListDialog = false + }) } - if (showDeleteConfirmation.value) { - AlertDialog( - onDismissRequest = { showDeleteConfirmation.value = false }, - title = { Text(stringResource(string.confirm_delete)) }, - text = { Text(stringResource(string.delete_confirmation_message, selectedItems.size)) }, - confirmButton = { - Button(onClick = { - viewModel.deleteSelected() - showDeleteConfirmation.value = false - }) { - Text(stringResource(string.delete)) - } - }, - dismissButton = { - Button(onClick = { showDeleteConfirmation.value = false }) { - Text(stringResource(string.cancel)) - } - } - ) + if (showCreateListDialog) { + CreateListDialog(onCreate = { + viewModel.createNewListWithSelected(it) + }, onDismiss = { + showCreateListDialog = false + }) } + if (showEditDialog) { + EditDialog(editingItem = editingItem, viewModel = viewModel, onDismiss = { + showEditDialog = false + }) + } +} + +@Composable +private fun DeleteConfirmationDialog( + selectedItemsCount: Int, + onDelete: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = { onDismiss() }, + title = { Text(stringResource(string.confirm_delete)) }, + text = { Text(stringResource(string.delete_confirmation_message, selectedItemsCount)) }, + confirmButton = { + Button(onClick = { + onDelete() + onDismiss() + }) { + Text(stringResource(string.delete)) + } + }, + dismissButton = { + Button(onClick = { onDismiss() }) { + Text(stringResource(string.cancel)) + } + }) +} + +@Composable +private fun CreateListDialog( + onCreate: (String) -> Unit, + onDismiss: () -> Unit, +) { + var newListName by remember { mutableStateOf("") } + var showEmptyNameWarning by remember { mutableStateOf(false) } - if (showCreateListDialog.value) { - AlertDialog( - onDismissRequest = { - showCreateListDialog.value = false + AlertDialog(onDismissRequest = { + onDismiss() + newListName = "" + showEmptyNameWarning = false + }, title = { Text(stringResource(string.add_new_list)) }, text = { + Column { + OutlinedTextField( + value = newListName, + onValueChange = { + newListName = it + if (showEmptyNameWarning) showEmptyNameWarning = false + }, + label = { Text(stringResource(string.list_name)) }, + modifier = Modifier.fillMaxWidth() + ) + if (showEmptyNameWarning) { + Text( + modifier = Modifier.padding(4.dp), + text = stringResource(string.list_name_cannot_be_empty), + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall + ) + } + } + }, confirmButton = { + Button(onClick = { + if (newListName.isBlank()) { + showEmptyNameWarning = true + } else { + onCreate(newListName) + onDismiss() newListName = "" showEmptyNameWarning = false - }, - title = { Text(stringResource(string.add_new_list)) }, - text = { - Column { - OutlinedTextField( - value = newListName, - onValueChange = { - newListName = it - if (showEmptyNameWarning) showEmptyNameWarning = false - }, - label = { Text(stringResource(string.list_name)) }, - modifier = Modifier.fillMaxWidth() - ) - if (showEmptyNameWarning) { - Text( - modifier = Modifier.padding(4.dp), - text = stringResource(string.list_name_cannot_be_empty), - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodySmall - ) - } - } - }, - confirmButton = { - Button(onClick = { - if (newListName.isBlank()) { - showEmptyNameWarning = true - } else { - viewModel.createNewListWithSelected(newListName) - showCreateListDialog.value = false - newListName = "" - showEmptyNameWarning = false - } - }) { - Text(stringResource(string.add_new_list)) + } + }) { + Text(stringResource(string.add_new_list)) + } + }, dismissButton = { + Button(onClick = { + onDismiss() + newListName = "" + showEmptyNameWarning = false + }) { + Text(stringResource(string.cancel)) + } + }) +} + +@Composable +private fun EditDialog( + editingItem: ListContent?, + viewModel: ManagePlacesViewModel, + onDismiss: () -> Unit, +) { + editingItem?.let { item -> + var editName by remember { mutableStateOf("") } + var editDescription by remember { mutableStateOf("") } + var editPinned by remember { mutableStateOf(false) } + + LaunchedEffect(item) { + when (item) { + is PlaceContent -> { + editName = item.name + editDescription = item.customDescription ?: "" + editPinned = item.isPinned } - }, - dismissButton = { - Button(onClick = { - showCreateListDialog.value = false - newListName = "" - showEmptyNameWarning = false - }) { - Text(stringResource(string.cancel)) + + is ListContentItem -> { + editName = item.name + editDescription = item.description ?: "" + editPinned = false } } - ) - } + } - if (showEditDialog.value) { - editingItem.value?.let { item -> - LaunchedEffect(item) { - when (item) { - is PlaceContent -> { - editName = item.name - editDescription = item.customDescription ?: "" - editPinned = item.isPinned - } + val title = when (item) { + is PlaceContent -> stringResource(string.edit_place) + is ListContentItem -> stringResource(string.edit_list) + } - is ListContentItem -> { - editName = item.name - editDescription = item.description ?: "" - editPinned = false + AlertDialog(onDismissRequest = { onDismiss() }, title = { Text(title) }, text = { + Column { + OutlinedTextField( + value = editName, + onValueChange = { editName = it }, + label = { Text(stringResource(string.name)) }, + modifier = Modifier.fillMaxWidth() + ) + OutlinedTextField( + value = editDescription, + onValueChange = { editDescription = it }, + label = { Text(stringResource(string.description)) }, + modifier = Modifier.fillMaxWidth() + ) + if (item is PlaceContent) { + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox( + checked = editPinned, onCheckedChange = { editPinned = it }) + Text(stringResource(string.pin_place)) } } } + }, confirmButton = { + Button(onClick = { + val name = editName.ifBlank { null } + val desc = editDescription + when (item) { + is PlaceContent -> viewModel.updatePlace( + item.id, name, desc, editPinned + ) - val title = when (item) { - is PlaceContent -> stringResource(string.edit_place) - is ListContentItem -> stringResource(string.edit_list) + is ListContentItem -> viewModel.updateList(item.id, name, desc) + } + onDismiss() + }) { + Text(stringResource(string.save)) + } + }, dismissButton = { + Button(onClick = { onDismiss() }) { + Text(stringResource(string.cancel)) } + }) + } +} - AlertDialog( - onDismissRequest = { showEditDialog.value = false }, - title = { Text(title) }, - text = { - Column { - OutlinedTextField( - value = editName, - onValueChange = { editName = it }, - label = { Text(stringResource(string.name)) }, - modifier = Modifier.fillMaxWidth() - ) - OutlinedTextField( - value = editDescription, - onValueChange = { editDescription = it }, - label = { Text(stringResource(string.description)) }, - modifier = Modifier.fillMaxWidth() - ) - if (item is PlaceContent) { - Row(verticalAlignment = Alignment.CenterVertically) { - Checkbox( - checked = editPinned, - onCheckedChange = { editPinned = it } - ) - Text(stringResource(string.pin_place)) - } - } - } +@Composable +private fun Breadcrumbs( + navController: NavController, + breadcrumbNames: List, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = dimensionResource(dimen.padding)), + verticalAlignment = Alignment.CenterVertically + ) { + breadcrumbNames.forEachIndexed { index, name -> + if (index > 0) { + Text( + text = " - ", // Don't replace this with a carat or arrow without dealing with RTL layouts. + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Text( + text = name, + style = MaterialTheme.typography.bodyMedium, + color = if (index == breadcrumbNames.size - 1) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant }, - confirmButton = { - Button(onClick = { - val name = editName.ifBlank { null } - val desc = editDescription // Allow blank descriptions. - when (item) { - is PlaceContent -> viewModel.updatePlace( - item.id, - name, - desc, - editPinned - ) - - is ListContentItem -> viewModel.updateList(item.id, name, desc) + modifier = if (index < breadcrumbNames.size - 1) { + Modifier.clickable { + // Navigate back to this level + repeat(breadcrumbNames.size - 1 - index) { + navController.popBackStack() } - showEditDialog.value = false - }) { - Text(stringResource(string.save)) - } - }, - dismissButton = { - Button(onClick = { showEditDialog.value = false }) { - Text(stringResource(string.cancel)) } - } - ) + } else { + Modifier + }) } } } @@ -482,45 +420,10 @@ private fun ManagePlacesTopBar( ) // Breadcrumbs if (breadcrumbNames.size > 1) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = dimensionResource(dimen.padding)), - verticalAlignment = Alignment.CenterVertically - ) { - breadcrumbNames.forEachIndexed { index, name -> - if (index > 0) { - Text( - text = " - ", // Don't replace this with a carat or arrow without dealing with RTL layouts. - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - Text( - text = name, - style = MaterialTheme.typography.bodyMedium, - color = if (index == breadcrumbNames.size - 1) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - modifier = if (index < breadcrumbNames.size - 1) { - Modifier.clickable { - // Navigate back to this level - repeat(breadcrumbNames.size - 1 - index) { - navController.popBackStack() - } - } - } else { - Modifier - } - ) - } - } + Breadcrumbs(navController, breadcrumbNames) } } - } - ) + }) } @Composable @@ -570,8 +473,7 @@ private fun ListContentGrid( if (shouldShowListsHeader) { item { SectionHeader( - title = stringResource(string.saved_lists), - modifier = Modifier.padding( + title = stringResource(string.saved_lists), modifier = Modifier.padding( vertical = dimensionResource(dimen.padding_minor), horizontal = dimensionResource(dimen.padding) ) @@ -647,8 +549,7 @@ private fun ListContentGrid( @Composable private fun SectionHeader( - title: String, - modifier: Modifier = Modifier + title: String, modifier: Modifier = Modifier ) { Text( text = title, @@ -791,3 +692,115 @@ private fun ListItem( } } } + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Suppress("CognitiveComplexMethod") +@Composable +private fun FloatingActionButtonMenuSection( + fabMenuExpanded: Boolean, + onFabMenuExpandedChange: (Boolean) -> Unit, + viewModel: ManagePlacesViewModel, + onShowDeleteConfirmationChange: (Boolean) -> Unit, + onShowCreateListDialogChange: (Boolean) -> Unit, + isAllSelected: Boolean, +) { + val modifier = if (fabMenuExpanded) { + Modifier.clickable( + indication = null, + interactionSource = null, + onClick = { onFabMenuExpandedChange(false) }) + } else { + Modifier + } + Box( + modifier = modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.safeDrawing) + .padding(bottom = TOOLBAR_HEIGHT_DP) + ) { + FloatingActionButtonMenu( + modifier = Modifier.align(Alignment.BottomEnd), + expanded = fabMenuExpanded, + button = { + ToggleFloatingActionButton( + checked = fabMenuExpanded, + onCheckedChange = onFabMenuExpandedChange, + content = { + val close = painterResource(drawable.ic_close) + val open = painterResource(drawable.ic_menu) + val painter by remember { + derivedStateOf { + if (checkedProgress > 0.5f) close else open + } + } + Icon( + painter = painter, + contentDescription = null, + modifier = Modifier.animateIcon({ checkedProgress }), + ) + }) + }) { + FloatingActionButtonMenuItem(onClick = { + onShowCreateListDialogChange(true) + onFabMenuExpandedChange(false) + }, text = { + Text( + text = stringResource( + string.new_list + ) + ) + }, icon = { + Icon( + painter = painterResource(drawable.ic_new_list), contentDescription = null + ) + }) + + FloatingActionButtonMenuItem(onClick = { + viewModel.cutSelected() + onFabMenuExpandedChange(false) + }, text = { + Text(text = stringResource(string.cut)) + }, icon = { + Icon( + painter = painterResource(drawable.ic_content_cut), + contentDescription = null + ) + }) + FloatingActionButtonMenuItem(onClick = { + viewModel.pasteSelected() + onFabMenuExpandedChange(false) + }, text = { + Text(text = stringResource(string.paste)) + }, icon = { + Icon( + painter = painterResource(drawable.ic_content_paste), + contentDescription = null + ) + }) + FloatingActionButtonMenuItem(onClick = { + onShowDeleteConfirmationChange(true) + onFabMenuExpandedChange(false) + }, text = { + Text(text = stringResource(string.delete)) + }, icon = { + Icon( + painter = painterResource(drawable.ic_delete), contentDescription = null + ) + }) + FloatingActionButtonMenuItem(onClick = { + if (isAllSelected) { + viewModel.clearSelection() + } else { + viewModel.selectAll() + } + }, text = { + Text(text = stringResource(if (isAllSelected) string.deselect_all else string.select_all)) + }, icon = { + Icon( + painter = painterResource(if (isAllSelected) drawable.ic_clear_selection else drawable.ic_select_all), + contentDescription = null + ) + }) + } + } +} diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/settings/AdvancedSettingsScreen.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/settings/AdvancedSettingsScreen.kt index f030fc7..9e754dd 100644 --- a/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/settings/AdvancedSettingsScreen.kt +++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/settings/AdvancedSettingsScreen.kt @@ -62,6 +62,355 @@ import earth.maps.cardinal.R.dimen import earth.maps.cardinal.R.string import earth.maps.cardinal.ui.core.TOOLBAR_HEIGHT_DP +@Composable +private fun ContinuousLocationTrackingSetting(viewModel: SettingsViewModel) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding( + horizontal = dimensionResource(dimen.padding), + vertical = dimensionResource(dimen.padding_minor) + ) + ) { + Text( + text = stringResource(string.continuous_location_tracking_disabled_title), + style = MaterialTheme.typography.titleMedium + ) + Text( + text = stringResource(string.continuous_location_tracking_disabled_help_text), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + val continuousLocationTracking by viewModel.continuousLocationTracking.collectAsState() + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = if (continuousLocationTracking) stringResource(string.enabled) else stringResource( + string.disabled + ), style = MaterialTheme.typography.bodyMedium + ) + Switch( + checked = continuousLocationTracking, + onCheckedChange = { newValue -> + viewModel.setContinuousLocationTrackingEnabled(newValue) + } + ) + } + } +} + +@Composable +private fun ShowZoomFabsSetting(viewModel: SettingsViewModel) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding( + horizontal = dimensionResource(dimen.padding), + vertical = dimensionResource(dimen.padding_minor) + ) + ) { + Text( + text = stringResource(string.show_zoom_fabs_title), + style = MaterialTheme.typography.titleMedium + ) + Text( + text = stringResource(string.show_zoom_fabs_help_text), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + val showZoomFabs by viewModel.showZoomFabs.collectAsState() + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = if (showZoomFabs) stringResource(string.enabled) else stringResource( + string.disabled + ), style = MaterialTheme.typography.bodyMedium + ) + Switch( + checked = showZoomFabs, + onCheckedChange = { newValue -> + viewModel.setShowZoomFabsEnabled(newValue) + } + ) + } + } +} + +@Composable +private fun TimeFormatSetting(viewModel: SettingsViewModel) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding( + horizontal = dimensionResource(dimen.padding), + vertical = dimensionResource(dimen.padding_minor) + ) + ) { + Text( + text = stringResource(string.time_format_title), + style = MaterialTheme.typography.titleMedium + ) + Text( + text = stringResource(string.time_format_help_text), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + val use24HourFormat by viewModel.use24HourFormat.collectAsState() + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + val formatText = if (use24HourFormat) { + "24\u2011hour" + } else { + "12\u2011hour" + } + Text( + text = formatText, + style = MaterialTheme.typography.bodyMedium + ) + Switch( + checked = use24HourFormat, + onCheckedChange = { newValue -> + viewModel.setUse24HourFormat(newValue) + } + ) + } + } +} + +@Composable +private fun DistanceUnitSetting(viewModel: SettingsViewModel) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding( + horizontal = dimensionResource(dimen.padding), + vertical = dimensionResource(dimen.padding_minor) + ) + ) { + + Text( + text = stringResource(string.distance_unit_title), + style = MaterialTheme.typography.titleMedium + ) + Text( + text = stringResource(string.distance_unit_help_text), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + val distanceUnit by viewModel.distanceUnit.collectAsState() + val isMetric = distanceUnit == 0 + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + val unitText = if (isMetric) { + stringResource(string.metric) + } else { + stringResource(string.imperial) + } + Text( + text = unitText, + style = MaterialTheme.typography.bodyMedium + ) + Switch( + checked = isMetric, + onCheckedChange = { newValue -> + val newUnit = if (newValue) 0 else 1 + viewModel.setDistanceUnit(newUnit) + } + ) + } + } +} + +@Composable +private fun PeliasBaseUrlSetting(viewModel: SettingsViewModel) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding( + horizontal = dimensionResource(dimen.padding), + vertical = dimensionResource(dimen.padding_minor) + ) + ) { + Text( + text = stringResource(string.pelias_base_url_title), + style = MaterialTheme.typography.titleMedium + ) + + val currentPeliasConfig by viewModel.peliasApiConfig.collectAsState() + var peliasBaseUrl by remember { mutableStateOf(currentPeliasConfig.baseUrl) } + + // Update state when config changes from outside + LaunchedEffect(currentPeliasConfig) { + peliasBaseUrl = currentPeliasConfig.baseUrl + } + + OutlinedTextField( + value = peliasBaseUrl, + onValueChange = { newValue -> + peliasBaseUrl = newValue + viewModel.setPeliasBaseUrl(newValue) + }, + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri) + ) + } +} + +@Composable +private fun PeliasApiKeySetting(viewModel: SettingsViewModel) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding( + horizontal = dimensionResource(dimen.padding), + vertical = dimensionResource(dimen.padding_minor) + ) + ) { + Text( + text = stringResource(string.pelias_api_key_title), + style = MaterialTheme.typography.titleMedium + ) + + val currentPeliasConfig by viewModel.peliasApiConfig.collectAsState() + var peliasApiKey by remember { + mutableStateOf( + currentPeliasConfig.apiKey ?: "" + ) + } + + // Update state when config changes from outside + LaunchedEffect(currentPeliasConfig) { + peliasApiKey = currentPeliasConfig.apiKey ?: "" + } + + OutlinedTextField( + value = peliasApiKey, + onValueChange = { newValue -> + peliasApiKey = newValue + viewModel.setPeliasApiKey(if (newValue.isNotEmpty()) newValue else null) + }, + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password) + ) + } +} + +@Composable +private fun ValhallaBaseUrlSetting(viewModel: SettingsViewModel) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding( + horizontal = dimensionResource(dimen.padding), + vertical = dimensionResource(dimen.padding_minor) + ) + ) { + Text( + text = stringResource(string.valhalla_base_url_title), + style = MaterialTheme.typography.titleMedium + ) + + val currentValhallaConfig by viewModel.valhallaApiConfig.collectAsState() + var valhallaBaseUrl by remember { mutableStateOf(currentValhallaConfig.baseUrl) } + + // Update state when config changes from outside + LaunchedEffect(currentValhallaConfig) { + valhallaBaseUrl = currentValhallaConfig.baseUrl + } + + OutlinedTextField( + value = valhallaBaseUrl, + onValueChange = { newValue -> + valhallaBaseUrl = newValue + viewModel.setValhallaBaseUrl(newValue) + }, + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri) + ) + } +} + +@Composable +private fun ValhallaApiKeySetting(viewModel: SettingsViewModel) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding( + horizontal = dimensionResource(dimen.padding), + vertical = dimensionResource(dimen.padding_minor) + ) + ) { + Text( + text = stringResource(string.valhalla_api_key_title), + style = MaterialTheme.typography.titleMedium + ) + + val currentValhallaConfig by viewModel.valhallaApiConfig.collectAsState() + var valhallaApiKey by remember { + mutableStateOf( + currentValhallaConfig.apiKey ?: "" + ) + } + + // Update state when config changes from outside + LaunchedEffect(currentValhallaConfig) { + valhallaApiKey = currentValhallaConfig.apiKey ?: "" + } + + OutlinedTextField( + value = valhallaApiKey, + onValueChange = { newValue -> + valhallaApiKey = newValue + viewModel.setValhallaApiKey(if (newValue.isNotEmpty()) newValue else null) + }, + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password) + ) + } +} + @OptIn(ExperimentalMaterial3Api::class) @Composable fun AdvancedSettingsScreen( @@ -96,47 +445,7 @@ fun AdvancedSettingsScreen( color = MaterialTheme.colorScheme.outlineVariant ) - // Continuous Location Tracking - Column( - modifier = Modifier - .fillMaxWidth() - .padding( - horizontal = dimensionResource(dimen.padding), - vertical = dimensionResource(dimen.padding_minor) - ) - ) { - Text( - text = stringResource(string.continuous_location_tracking_disabled_title), - style = MaterialTheme.typography.titleMedium - ) - Text( - text = stringResource(string.continuous_location_tracking_disabled_help_text), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - - val continuousLocationTracking by viewModel.continuousLocationTracking.collectAsState() - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(top = 8.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = if (continuousLocationTracking) stringResource(string.enabled) else stringResource( - string.disabled - ), style = MaterialTheme.typography.bodyMedium - ) - Switch( - checked = continuousLocationTracking, - onCheckedChange = { newValue -> - viewModel.setContinuousLocationTrackingEnabled(newValue) - } - ) - } - } + ContinuousLocationTrackingSetting(viewModel) HorizontalDivider( modifier = Modifier.padding(vertical = 8.dp), @@ -144,47 +453,7 @@ fun AdvancedSettingsScreen( color = MaterialTheme.colorScheme.outlineVariant ) - // Show Zoom FABs - Column( - modifier = Modifier - .fillMaxWidth() - .padding( - horizontal = dimensionResource(dimen.padding), - vertical = dimensionResource(dimen.padding_minor) - ) - ) { - Text( - text = stringResource(string.show_zoom_fabs_title), - style = MaterialTheme.typography.titleMedium - ) - Text( - text = stringResource(string.show_zoom_fabs_help_text), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - - val showZoomFabs by viewModel.showZoomFabs.collectAsState() - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(top = 8.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = if (showZoomFabs) stringResource(string.enabled) else stringResource( - string.disabled - ), style = MaterialTheme.typography.bodyMedium - ) - Switch( - checked = showZoomFabs, - onCheckedChange = { newValue -> - viewModel.setShowZoomFabsEnabled(newValue) - } - ) - } - } + ShowZoomFabsSetting(viewModel) HorizontalDivider( modifier = Modifier.padding(vertical = 8.dp), @@ -192,51 +461,7 @@ fun AdvancedSettingsScreen( color = MaterialTheme.colorScheme.outlineVariant ) - // Time Format - Column( - modifier = Modifier - .fillMaxWidth() - .padding( - horizontal = dimensionResource(dimen.padding), - vertical = dimensionResource(dimen.padding_minor) - ) - ) { - Text( - text = stringResource(string.time_format_title), - style = MaterialTheme.typography.titleMedium - ) - Text( - text = stringResource(string.time_format_help_text), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - - val use24HourFormat by viewModel.use24HourFormat.collectAsState() - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(top = 8.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - val formatText = if (use24HourFormat) { - "24\u2011hour" - } else { - "12\u2011hour" - } - Text( - text = formatText, - style = MaterialTheme.typography.bodyMedium - ) - Switch( - checked = use24HourFormat, - onCheckedChange = { newValue -> - viewModel.setUse24HourFormat(newValue) - } - ) - } - } + TimeFormatSetting(viewModel) HorizontalDivider( modifier = Modifier.padding(vertical = 8.dp), @@ -244,54 +469,7 @@ fun AdvancedSettingsScreen( color = MaterialTheme.colorScheme.outlineVariant ) - // Distance Unit - Column( - modifier = Modifier - .fillMaxWidth() - .padding( - horizontal = dimensionResource(dimen.padding), - vertical = dimensionResource(dimen.padding_minor) - ) - ) { - - Text( - text = stringResource(string.distance_unit_title), - style = MaterialTheme.typography.titleMedium - ) - Text( - text = stringResource(string.distance_unit_help_text), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - - val distanceUnit by viewModel.distanceUnit.collectAsState() - val isMetric = distanceUnit == 0 - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(top = 8.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - val unitText = if (isMetric) { - stringResource(string.metric) - } else { - stringResource(string.imperial) - } - Text( - text = unitText, - style = MaterialTheme.typography.bodyMedium - ) - Switch( - checked = isMetric, - onCheckedChange = { newValue -> - val newUnit = if (newValue) 0 else 1 - viewModel.setDistanceUnit(newUnit) - } - ) - } - } + DistanceUnitSetting(viewModel) HorizontalDivider( modifier = Modifier.padding(vertical = 8.dp), @@ -301,41 +479,7 @@ fun AdvancedSettingsScreen( // NEW SETTINGS GO HERE. - // Pelias Base URL - Column( - modifier = Modifier - .fillMaxWidth() - .padding( - horizontal = dimensionResource(dimen.padding), - vertical = dimensionResource(dimen.padding_minor) - ) - ) { - Text( - text = stringResource(string.pelias_base_url_title), - style = MaterialTheme.typography.titleMedium - ) - - val currentPeliasConfig by viewModel.peliasApiConfig.collectAsState() - var peliasBaseUrl by remember { mutableStateOf(currentPeliasConfig.baseUrl) } - - // Update state when config changes from outside - LaunchedEffect(currentPeliasConfig) { - peliasBaseUrl = currentPeliasConfig.baseUrl - } - - OutlinedTextField( - value = peliasBaseUrl, - onValueChange = { newValue -> - peliasBaseUrl = newValue - viewModel.setPeliasBaseUrl(newValue) - }, - modifier = Modifier - .fillMaxWidth() - .padding(top = 8.dp), - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri) - ) - } + PeliasBaseUrlSetting(viewModel) HorizontalDivider( modifier = Modifier.padding(vertical = 8.dp), @@ -343,46 +487,7 @@ fun AdvancedSettingsScreen( color = MaterialTheme.colorScheme.outlineVariant ) - // Pelias API Key - Column( - modifier = Modifier - .fillMaxWidth() - .padding( - horizontal = dimensionResource(dimen.padding), - vertical = dimensionResource(dimen.padding_minor) - ) - ) { - Text( - text = stringResource(string.pelias_api_key_title), - style = MaterialTheme.typography.titleMedium - ) - - val currentPeliasConfig by viewModel.peliasApiConfig.collectAsState() - var peliasApiKey by remember { - mutableStateOf( - currentPeliasConfig.apiKey ?: "" - ) - } - - // Update state when config changes from outside - LaunchedEffect(currentPeliasConfig) { - peliasApiKey = currentPeliasConfig.apiKey ?: "" - } - - OutlinedTextField( - value = peliasApiKey, - onValueChange = { newValue -> - peliasApiKey = newValue - viewModel.setPeliasApiKey(if (newValue.isNotEmpty()) newValue else null) - }, - modifier = Modifier - .fillMaxWidth() - .padding(top = 8.dp), - singleLine = true, - visualTransformation = PasswordVisualTransformation(), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password) - ) - } + PeliasApiKeySetting(viewModel) HorizontalDivider( modifier = Modifier.padding(vertical = 8.dp), @@ -390,41 +495,7 @@ fun AdvancedSettingsScreen( color = MaterialTheme.colorScheme.outlineVariant ) - // Valhalla Base URL - Column( - modifier = Modifier - .fillMaxWidth() - .padding( - horizontal = dimensionResource(dimen.padding), - vertical = dimensionResource(dimen.padding_minor) - ) - ) { - Text( - text = stringResource(string.valhalla_base_url_title), - style = MaterialTheme.typography.titleMedium - ) - - val currentValhallaConfig by viewModel.valhallaApiConfig.collectAsState() - var valhallaBaseUrl by remember { mutableStateOf(currentValhallaConfig.baseUrl) } - - // Update state when config changes from outside - LaunchedEffect(currentValhallaConfig) { - valhallaBaseUrl = currentValhallaConfig.baseUrl - } - - OutlinedTextField( - value = valhallaBaseUrl, - onValueChange = { newValue -> - valhallaBaseUrl = newValue - viewModel.setValhallaBaseUrl(newValue) - }, - modifier = Modifier - .fillMaxWidth() - .padding(top = 8.dp), - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri) - ) - } + ValhallaBaseUrlSetting(viewModel) HorizontalDivider( modifier = Modifier.padding(vertical = 8.dp), @@ -432,46 +503,7 @@ fun AdvancedSettingsScreen( color = MaterialTheme.colorScheme.outlineVariant ) - // Valhalla API Key - Column( - modifier = Modifier - .fillMaxWidth() - .padding( - horizontal = dimensionResource(dimen.padding), - vertical = dimensionResource(dimen.padding_minor) - ) - ) { - Text( - text = stringResource(string.valhalla_api_key_title), - style = MaterialTheme.typography.titleMedium - ) - - val currentValhallaConfig by viewModel.valhallaApiConfig.collectAsState() - var valhallaApiKey by remember { - mutableStateOf( - currentValhallaConfig.apiKey ?: "" - ) - } - - // Update state when config changes from outside - LaunchedEffect(currentValhallaConfig) { - valhallaApiKey = currentValhallaConfig.apiKey ?: "" - } - - OutlinedTextField( - value = valhallaApiKey, - onValueChange = { newValue -> - valhallaApiKey = newValue - viewModel.setValhallaApiKey(if (newValue.isNotEmpty()) newValue else null) - }, - modifier = Modifier - .fillMaxWidth() - .padding(top = 8.dp), - singleLine = true, - visualTransformation = PasswordVisualTransformation(), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password) - ) - } + ValhallaApiKeySetting(viewModel) Spacer( modifier = Modifier diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/settings/ProfileEditorScreen.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/settings/ProfileEditorScreen.kt index 973d371..f183377 100644 --- a/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/settings/ProfileEditorScreen.kt +++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/settings/ProfileEditorScreen.kt @@ -128,125 +128,28 @@ fun ProfileEditorScreen( } Column(modifier = Modifier.fillMaxSize()) { - // Custom app bar using Row - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = dimensionResource(dimen.padding), vertical = 8.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - IconButton(onClick = { handleBackNavigation() }) { - Icon( - painter = painterResource(drawable.ic_arrow_back), - contentDescription = stringResource( - string.content_description_back - ) - ) - } - - Text( - text = if (isNewProfile) "Create Profile" else "Edit Profile", - style = MaterialTheme.typography.titleLarge - ) - - if (!isLoading) { - IconButton( - onClick = { - viewModel.saveProfile { - navController.popBackStack() - } - } - ) { - Icon(painter = painterResource(drawable.ic_save), contentDescription = "Save") - } - } else { - // Placeholder to maintain layout - IconButton(onClick = {}, enabled = false) { - Icon( - painter = painterResource(drawable.ic_add), - contentDescription = null, - tint = Color.Transparent - ) + ProfileEditorAppBar( + onBack = { handleBackNavigation() }, + title = if (isNewProfile) "Create Profile" else "Edit Profile", + isLoading = isLoading, + onSave = { + viewModel.saveProfile { + navController.popBackStack() } } - } + ) if (isLoading) { - Column( - modifier = Modifier - .fillMaxSize() - .padding(top = dimensionResource(dimen.padding)), // Reduced padding since we removed TopAppBar - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - CircularProgressIndicator() - } + LoadingView() } else { - Column( - modifier = Modifier - .fillMaxSize() - .padding(top = dimensionResource(dimen.padding)) // Reduced padding since we removed TopAppBar - .verticalScroll(rememberScrollState()) - .padding(dimensionResource(dimen.padding)), - verticalArrangement = Arrangement.spacedBy(dimensionResource(dimen.padding)) - ) { - // Profile Name - OutlinedTextField( - value = profileName, - onValueChange = { viewModel.updateProfileName(it) }, - label = { Text("Profile Name") }, - modifier = Modifier.fillMaxWidth(), - singleLine = true - ) - - // Routing Mode Selector - RoutingModeSelector( - selectedMode = selectedMode, - onModeSelected = { viewModel.updateRoutingMode(it) } - ) - - // Options Editor based on mode - when (selectedMode) { - RoutingMode.AUTO -> AutoOptionsEditor(routingOptions as AutoRoutingOptions) { - viewModel.updateRoutingOptions( - it - ) - } - - RoutingMode.TRUCK -> TruckOptionsEditor(routingOptions as TruckRoutingOptions) { - viewModel.updateRoutingOptions( - it - ) - } - - RoutingMode.MOTOR_SCOOTER -> MotorScooterOptionsEditor(routingOptions as MotorScooterRoutingOptions) { - viewModel.updateRoutingOptions( - it - ) - } - - RoutingMode.MOTORCYCLE -> MotorcycleOptionsEditor(routingOptions as MotorcycleRoutingOptions) { - viewModel.updateRoutingOptions( - it - ) - } - - RoutingMode.BICYCLE -> CyclingOptionsEditor(routingOptions as CyclingRoutingOptions) { - viewModel.updateRoutingOptions( - it - ) - } - - RoutingMode.PEDESTRIAN -> PedestrianOptionsEditor(routingOptions as PedestrianRoutingOptions) { - viewModel.updateRoutingOptions( - it - ) - } - - RoutingMode.PUBLIC_TRANSPORT -> {} - } - } + ProfileEditorContent( + profileName = profileName, + onProfileNameChange = { viewModel.updateProfileName(it) }, + selectedMode = selectedMode, + onModeSelected = { viewModel.updateRoutingMode(it) }, + routingOptions = routingOptions, + onRoutingOptionsChange = { viewModel.updateRoutingOptions(it) } + ) } } @@ -969,4 +872,105 @@ private fun > EnumDropdownOption( } } +@Composable +private fun ProfileEditorAppBar( + onBack: () -> Unit, + title: String, + isLoading: Boolean, + onSave: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = dimensionResource(dimen.padding), vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + IconButton(onClick = onBack) { + Icon( + painter = painterResource(drawable.ic_arrow_back), + contentDescription = stringResource(string.content_description_back) + ) + } + + Text( + text = title, + style = MaterialTheme.typography.titleLarge + ) + + if (!isLoading) { + IconButton(onClick = onSave) { + Icon(painter = painterResource(drawable.ic_save), contentDescription = "Save") + } + } else { + // Placeholder to maintain layout + IconButton(onClick = {}, enabled = false) { + Icon( + painter = painterResource(drawable.ic_add), + contentDescription = null, + tint = Color.Transparent + ) + } + } + } +} + +@Composable +private fun LoadingView() { + Column( + modifier = Modifier + .fillMaxSize() + .padding(top = dimensionResource(dimen.padding)), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + CircularProgressIndicator() + } +} + +@Composable +private fun ProfileEditorContent( + profileName: String, + onProfileNameChange: (String) -> Unit, + selectedMode: RoutingMode, + onModeSelected: (RoutingMode) -> Unit, + routingOptions: RoutingOptions, + onRoutingOptionsChange: (RoutingOptions) -> Unit +) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(top = dimensionResource(dimen.padding)) + .verticalScroll(rememberScrollState()) + .padding(dimensionResource(dimen.padding)), + verticalArrangement = Arrangement.spacedBy(dimensionResource(dimen.padding)) + ) { + // Profile Name + OutlinedTextField( + value = profileName, + onValueChange = onProfileNameChange, + label = { Text("Profile Name") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + + // Routing Mode Selector + RoutingModeSelector( + selectedMode = selectedMode, + onModeSelected = onModeSelected + ) + + // Options Editor based on mode + when (selectedMode) { + RoutingMode.AUTO -> AutoOptionsEditor(routingOptions as AutoRoutingOptions, onRoutingOptionsChange) + RoutingMode.TRUCK -> TruckOptionsEditor(routingOptions as TruckRoutingOptions, onRoutingOptionsChange) + RoutingMode.MOTOR_SCOOTER -> MotorScooterOptionsEditor(routingOptions as MotorScooterRoutingOptions, onRoutingOptionsChange) + RoutingMode.MOTORCYCLE -> MotorcycleOptionsEditor(routingOptions as MotorcycleRoutingOptions, onRoutingOptionsChange) + RoutingMode.BICYCLE -> CyclingOptionsEditor(routingOptions as CyclingRoutingOptions, onRoutingOptionsChange) + RoutingMode.PEDESTRIAN -> PedestrianOptionsEditor(routingOptions as PedestrianRoutingOptions, onRoutingOptionsChange) + RoutingMode.PUBLIC_TRANSPORT -> {} + } + } +} + private fun Double.format(digits: Int) = "%.${digits}f".format(this) diff --git a/cardinal-android/app/src/test/java/earth/maps/cardinal/ExampleUnitTest.kt b/cardinal-android/app/src/test/java/earth/maps/cardinal/ExampleUnitTest.kt index 5e69330..816a895 100644 --- a/cardinal-android/app/src/test/java/earth/maps/cardinal/ExampleUnitTest.kt +++ b/cardinal-android/app/src/test/java/earth/maps/cardinal/ExampleUnitTest.kt @@ -28,7 +28,7 @@ import org.junit.Test */ class ExampleUnitTest { @Test - fun addition_isCorrect() { + fun additionIsCorrect() { assertEquals(4, 2 + 2) } } \ No newline at end of file diff --git a/cardinal-android/gradle/libs.versions.toml b/cardinal-android/gradle/libs.versions.toml index cb149dc..b79a900 100644 --- a/cardinal-android/gradle/libs.versions.toml +++ b/cardinal-android/gradle/libs.versions.toml @@ -27,6 +27,7 @@ valhallaMobileConfig = "0.0.9" ferrostar = "0.41.0" okhttp3 = "5.1.0" material3 = "1.5.0-alpha04" +detekt = "2.0.0-alpha.0" [libraries] androidaddressformatter = { module = "com.github.woheller69:AndroidAddressFormatter", version.ref = "androidaddressformatter" } @@ -81,3 +82,4 @@ kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "ko ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } hilt = { id = "com.google.dagger.hilt.android", version.ref = "hiltPlugin" } cargo-ndk = { id = "com.github.willir.rust.cargo-ndk-android", version.ref = "cargo-ndk" } +detekt = { id = "dev.detekt", version.ref = "detekt" } \ No newline at end of file