feat: location button, and abstracting extInsets to common code

This commit is contained in:
LavaDesu 2025-04-14 23:40:54 +10:00
parent cdbf1970ec
commit e428883d01
Signed by: cilly
GPG key ID: 6500251E087653C9
9 changed files with 178 additions and 38 deletions

View file

@ -35,6 +35,7 @@ kotlin {
implementation(compose.preview) implementation(compose.preview)
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)
implementation(libs.kotlinx.coroutines.android) implementation(libs.kotlinx.coroutines.android)
implementation(libs.play.services.location)
implementation(libs.play.services.maps) implementation(libs.play.services.maps)
implementation(libs.maps.compose) implementation(libs.maps.compose)
} }
@ -49,6 +50,8 @@ kotlin {
implementation(libs.androidx.lifecycle.viewmodel) implementation(libs.androidx.lifecycle.viewmodel)
implementation(libs.androidx.lifecycle.runtime.compose) implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.coroutines.core)
implementation(libs.moko.geo)
implementation(libs.moko.geo.compose)
implementation(projects.shared) implementation(projects.shared)
} }
@ -89,3 +92,8 @@ dependencies {
secrets { secrets {
propertiesFileName = "secrets.properties" propertiesFileName = "secrets.properties"
} }
compose.resources {
publicResClass = true
packageOfResClass = "moe.lava.banksia.resources"
}

View file

@ -2,6 +2,8 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<application <application
android:allowBackup="true" android:allowBackup="true"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"

View file

@ -1,68 +1,73 @@
package moe.lava.banksia.native.maps package moe.lava.banksia.native.maps
import android.app.ActivityManager import android.Manifest
import android.content.Context import android.content.pm.PackageManager
import android.os.Build
import android.util.DisplayMetrics
import android.view.View
import android.view.WindowManager
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.add import androidx.compose.foundation.layout.add
import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SheetState
import androidx.compose.material3.SheetValue
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import com.google.android.gms.location.LocationServices
import com.google.android.gms.maps.model.CameraPosition import com.google.android.gms.maps.model.CameraPosition
import com.google.android.gms.maps.model.LatLng import com.google.android.gms.maps.model.LatLng
import com.google.android.gms.maps.model.MapStyleOptions
import com.google.maps.android.compose.ComposeMapColorScheme import com.google.maps.android.compose.ComposeMapColorScheme
import com.google.maps.android.compose.DefaultMapProperties import com.google.maps.android.compose.DefaultMapProperties
import com.google.maps.android.compose.DefaultMapUiSettings import com.google.maps.android.compose.DefaultMapUiSettings
import com.google.maps.android.compose.GoogleMap import com.google.maps.android.compose.GoogleMap
import com.google.maps.android.compose.Marker
import com.google.maps.android.compose.Polyline import com.google.maps.android.compose.Polyline
import com.google.maps.android.compose.rememberCameraPositionState import com.google.maps.android.compose.rememberCameraPositionState
import kotlin.math.roundToInt import moe.lava.banksia.R
fun Point.toLatLng(): LatLng = LatLng(this.lat, this.lng) fun Point.toLatLng(): LatLng = LatLng(this.lat, this.lng)
@Composable
private fun checkLocationPermission() =
ContextCompat.checkSelfPermission(LocalContext.current, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
@Composable
actual fun getScreenHeight(): Int {
val dp = LocalConfiguration.current.screenHeightDp.dp
return with(LocalDensity.current) {
dp.roundToPx()
}
}
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
actual fun Maps( actual fun Maps(
modifier: Modifier, modifier: Modifier,
markers: List<Marker>, markers: List<Marker>,
polylines: List<Polyline>, polylines: List<Polyline>,
cameraPosition: Point, newCameraPosition: Point?,
sheetState: SheetState, cameraPositionUpdated: () -> Unit,
extInsets: Int,
) { ) {
val extInsets = if (
sheetState.currentValue != SheetValue.Hidden ||
sheetState.targetValue != SheetValue.Hidden
) {
val context = LocalContext.current
val windowManager = remember { context.getSystemService(Context.WINDOW_SERVICE) as WindowManager }
val screenHeight = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R)
windowManager.currentWindowMetrics.bounds.height()
else {
var outMetrics = DisplayMetrics()
@Suppress("DEPRECATION")
windowManager.defaultDisplay.getMetrics(outMetrics)
outMetrics.heightPixels
}
val scaffoldOffset = sheetState.requireOffset().roundToInt()
(screenHeight - scaffoldOffset - WindowInsets.safeDrawing.getBottom(LocalDensity.current)).coerceAtLeast(0)
} else 0
var camPos = rememberCameraPositionState() var camPos = rememberCameraPositionState()
LaunchedEffect(cameraPosition) { val ctx = LocalContext.current
camPos.position = CameraPosition(cameraPosition.toLatLng(), 16.0f, 0.0f, 0.0f) val fusedLocation = remember { LocationServices.getFusedLocationProviderClient(ctx) }
LaunchedEffect(Unit) {
fusedLocation.lastLocation.addOnSuccessListener {
if (it != null)
camPos.position = CameraPosition(LatLng(it.latitude, it.longitude), 16.0f, 0.0f, 0.0f)
}
}
LaunchedEffect(newCameraPosition) {
if (newCameraPosition != null) {
camPos.position = CameraPosition(newCameraPosition.toLatLng(), 16.0f, 0.0f, 0.0f)
cameraPositionUpdated()
}
} }
GoogleMap( GoogleMap(
@ -70,8 +75,8 @@ actual fun Maps(
cameraPositionState = camPos, cameraPositionState = camPos,
mapColorScheme = ComposeMapColorScheme.FOLLOW_SYSTEM, mapColorScheme = ComposeMapColorScheme.FOLLOW_SYSTEM,
properties = DefaultMapProperties.copy( properties = DefaultMapProperties.copy(
//mapStyleOptions = MapStyleOptions.loadRawResourceStyle(LocalContext.current, R.raw.def_mapstyle), mapStyleOptions = MapStyleOptions.loadRawResourceStyle(LocalContext.current, R.raw.def_mapstyle),
//isMyLocationEnabled = checkLocationPermission(), isMyLocationEnabled = checkLocationPermission(),
), ),
uiSettings = DefaultMapUiSettings.copy( uiSettings = DefaultMapUiSettings.copy(
zoomControlsEnabled = false, zoomControlsEnabled = false,

View file

@ -0,0 +1,37 @@
[
{
"featureType": "poi.business",
"stylers": [
{
"visibility": "off"
}
]
},
{
"featureType": "poi.park",
"elementType": "labels.text",
"stylers": [
{
"visibility": "off"
}
]
},
{
"featureType": "road.highway",
"elementType": "labels.icon",
"stylers": [
{
"visibility": "off"
}
]
},
{
"featureType": "transit",
"elementType": "labels.icon",
"stylers": [
{
"visibility": "off"
}
]
}
]

View file

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="#FFFFFFFF" android:pathData="M12,8c-2.21,0 -4,1.79 -4,4s1.79,4 4,4 4,-1.79 4,-4 -1.79,-4 -4,-4zM20.94,11c-0.46,-4.17 -3.77,-7.48 -7.94,-7.94L13,1h-2v2.06C6.83,3.52 3.52,6.83 3.06,11L1,11v2h2.06c0.46,4.17 3.77,7.48 7.94,7.94L11,23h2v-2.06c4.17,-0.46 7.48,-3.77 7.94,-7.94L23,13v-2h-2.06zM12,19c-3.87,0 -7,-3.13 -7,-7s3.13,-7 7,-7 7,3.13 7,7 -3.13,7 -7,7z"/>
</vector>

View file

@ -1,9 +1,18 @@
package moe.lava.banksia package moe.lava.banksia
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.add
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.safeContent
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.LocationOn
import androidx.compose.material3.BottomSheetScaffold import androidx.compose.material3.BottomSheetScaffold
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SheetValue import androidx.compose.material3.SheetValue
import androidx.compose.material3.rememberBottomSheetScaffoldState import androidx.compose.material3.rememberBottomSheetScaffoldState
@ -12,12 +21,26 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import dev.icerock.moko.geo.compose.BindLocationTrackerEffect
import dev.icerock.moko.geo.compose.LocationTrackerAccuracy
import dev.icerock.moko.geo.compose.rememberLocationTrackerFactory
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
import moe.lava.banksia.api.ptv.PtvService import moe.lava.banksia.api.ptv.PtvService
import moe.lava.banksia.native.maps.Maps import moe.lava.banksia.native.maps.Maps
import moe.lava.banksia.native.maps.Point
import moe.lava.banksia.native.maps.getScreenHeight
import moe.lava.banksia.resources.Res
import moe.lava.banksia.resources.my_location_24
import moe.lava.banksia.ui.Searcher import moe.lava.banksia.ui.Searcher
import org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.ui.tooling.preview.Preview import org.jetbrains.compose.ui.tooling.preview.Preview
import kotlin.math.roundToInt
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@ -30,17 +53,43 @@ fun App() {
) )
) )
val locationFactory = rememberLocationTrackerFactory(LocationTrackerAccuracy.Best)
val locationTracker = remember { locationFactory.createLocationTracker() }
BindLocationTrackerEffect(locationTracker)
var lastLocation by remember { mutableStateOf(Point(-37.8136, 144.9631)) }
var newCameraPosition by remember { mutableStateOf<Point?>(Point(-37.8136, 144.9631)) }
var searchTextState by remember { mutableStateOf("") } var searchTextState by remember { mutableStateOf("") }
var searchExpandedState by remember { mutableStateOf(false) } var searchExpandedState by remember { mutableStateOf(false) }
val sheetState = scaffoldState.bottomSheetState
val extInsets = if (
sheetState.currentValue != SheetValue.Hidden ||
sheetState.targetValue != SheetValue.Hidden
) {
val scaffoldOffset = sheetState.requireOffset().roundToInt()
(getScreenHeight() - scaffoldOffset - WindowInsets.safeDrawing.getBottom(LocalDensity.current)).coerceAtLeast(0)
} else 0
var scope = rememberCoroutineScope()
scope.launch {
val flow = locationTracker.getLocationsFlow()
locationTracker.startTracking()
flow.distinctUntilChanged().collect {
lastLocation = Point(it.latitude, it.longitude)
}
}
MaterialTheme { MaterialTheme {
BottomSheetScaffold( BottomSheetScaffold(
scaffoldState = scaffoldState, scaffoldState = scaffoldState,
modifier = Modifier.fillMaxSize(),
sheetContent = { Box(modifier = Modifier) }, sheetContent = { Box(modifier = Modifier) },
) { ) {
Maps( Maps(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
sheetState = scaffoldState.bottomSheetState, newCameraPosition = newCameraPosition,
cameraPositionUpdated = { newCameraPosition = null },
extInsets = extInsets,
) )
Searcher( Searcher(
ptvService = PtvService(), ptvService = PtvService(),
@ -50,6 +99,20 @@ fun App() {
onTextChange = { searchTextState = it }, onTextChange = { searchTextState = it },
onRouteChange = {} onRouteChange = {}
) )
Box(
Modifier.windowInsetsPadding(WindowInsets.safeContent.add(WindowInsets(bottom = extInsets))),
contentAlignment = Alignment.BottomEnd
) {
FloatingActionButton(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
onClick = {
newCameraPosition = lastLocation
},
) {
Icon(painterResource(Res.drawable.my_location_24), "Move to current location")
}
}
} }
} }
} }

View file

@ -5,17 +5,22 @@ import androidx.compose.material3.SheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
data class Marker(val name: String, val onClick: () -> Boolean) data class Marker(val name: String, val onClick: () -> Boolean)
data class Point(val lat: Double, val lng: Double) data class Point(val lat: Double, val lng: Double)
data class Polyline(val points: List<Point>, val colour: Color) data class Polyline(val points: List<Point>, val colour: Color)
@Composable
expect fun getScreenHeight(): Int
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
expect fun Maps( expect fun Maps(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
markers: List<Marker> = listOf(), markers: List<Marker> = listOf(),
polylines: List<Polyline> = listOf(), polylines: List<Polyline> = listOf(),
cameraPosition: Point = Point(-37.8136, 144.9631), newCameraPosition: Point? = Point(-37.8136, 144.9631),
sheetState: SheetState, cameraPositionUpdated: () -> Unit,
extInsets: Int,
) )

View file

@ -3,7 +3,16 @@ package moe.lava.banksia.native.maps
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SheetState import androidx.compose.material3.SheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.unit.Dp
@OptIn(ExperimentalComposeUiApi::class)
@Composable
actual fun getScreenHeight(): Int {
return LocalWindowInfo.current.containerSize.height
}
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@ -11,8 +20,9 @@ actual fun Maps(
modifier: Modifier, modifier: Modifier,
markers: List<Marker>, markers: List<Marker>,
polylines: List<Polyline>, polylines: List<Polyline>,
cameraPosition: Point, newCameraPosition: Point?,
sheetState: SheetState, cameraPositionUpdated: () -> Unit,
extInsets: Int,
) { ) {
TODO("Not yet implemented") TODO("Not yet implemented")
} }

View file

@ -13,6 +13,7 @@ androidx-material = "1.12.0"
androidx-test-junit = "1.2.1" androidx-test-junit = "1.2.1"
compose-multiplatform = "1.7.3" compose-multiplatform = "1.7.3"
coroutines = "1.9.0" coroutines = "1.9.0"
geo = "0.8.0"
junit = "4.13.2" junit = "4.13.2"
kotlin = "2.1.10" kotlin = "2.1.10"
kotlinxSerializationJson = "1.8.1" kotlinxSerializationJson = "1.8.1"
@ -20,10 +21,13 @@ ktor = "3.1.1"
logback = "1.5.17" logback = "1.5.17"
mapsCompose = "6.4.1" mapsCompose = "6.4.1"
okio = "3.11.0" okio = "3.11.0"
playServicesLocation = "21.3.0"
playServicesMaps = "19.1.0" playServicesMaps = "19.1.0"
secretsGradlePlugin = "2.0.1" secretsGradlePlugin = "2.0.1"
[libraries] [libraries]
moko-geo = { module = "dev.icerock.moko:geo", version.ref = "geo" }
moko-geo-compose = { module = "dev.icerock.moko:geo-compose", version.ref = "geo" }
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" } kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" }
junit = { group = "junit", name = "junit", version.ref = "junit" } junit = { group = "junit", name = "junit", version.ref = "junit" }
@ -50,6 +54,7 @@ ktor-server-tests = { module = "io.ktor:ktor-server-test-host", version.ref = "k
logback = { module = "ch.qos.logback:logback-classic", version.ref = "logback" } logback = { module = "ch.qos.logback:logback-classic", version.ref = "logback" }
maps-compose = { module = "com.google.maps.android:maps-compose", version.ref = "mapsCompose" } maps-compose = { module = "com.google.maps.android:maps-compose", version.ref = "mapsCompose" }
okio = { module = "com.squareup.okio:okio", version.ref = "okio" } okio = { module = "com.squareup.okio:okio", version.ref = "okio" }
play-services-location = { module = "com.google.android.gms:play-services-location", version.ref = "playServicesLocation" }
play-services-maps = { module = "com.google.android.gms:play-services-maps", version.ref = "playServicesMaps" } play-services-maps = { module = "com.google.android.gms:play-services-maps", version.ref = "playServicesMaps" }
secrets-gradle-plugin = { module = "com.google.android.libraries.mapsplatform.secrets-gradle-plugin:secrets-gradle-plugin", version.ref = "secretsGradlePlugin" } secrets-gradle-plugin = { module = "com.google.android.libraries.mapsplatform.secrets-gradle-plugin:secrets-gradle-plugin", version.ref = "secretsGradlePlugin" }