mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-03-30 11:44:01 -04:00
refactor: maps (#2097)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
@@ -41,7 +41,9 @@ class MeshUtilApplication : GeeksvilleApplication() {
|
||||
crashlytics.setUserId(pref.getInstallId()) // be able to group all bugs per anonymous user
|
||||
|
||||
fun sendCrashReports() {
|
||||
if (isAnalyticsAllowed) crashlytics.sendUnsentReports()
|
||||
if (isAnalyticsAllowed) {
|
||||
crashlytics.sendUnsentReports()
|
||||
}
|
||||
}
|
||||
|
||||
// Send any old reports if user approves
|
||||
|
||||
@@ -21,23 +21,24 @@ import android.content.Context
|
||||
import android.os.Bundle
|
||||
import com.geeksville.mesh.android.AppPrefs
|
||||
import com.geeksville.mesh.android.Logging
|
||||
import com.google.firebase.Firebase
|
||||
import com.google.firebase.analytics.FirebaseAnalytics
|
||||
import com.google.firebase.analytics.analytics
|
||||
import com.google.firebase.analytics.logEvent
|
||||
import com.google.firebase.Firebase
|
||||
|
||||
class DataPair(val name: String, valueIn: Any?) {
|
||||
val value = valueIn ?: "null"
|
||||
|
||||
/// An accumulating firebase event - only one allowed per event
|
||||
// / An accumulating firebase event - only one allowed per event
|
||||
constructor(d: Double) : this(FirebaseAnalytics.Param.VALUE, d)
|
||||
|
||||
constructor(d: Int) : this(FirebaseAnalytics.Param.VALUE, d)
|
||||
}
|
||||
|
||||
/**
|
||||
* Implement our analytics API using Firebase Analytics
|
||||
*/
|
||||
class FirebaseAnalytics(context: Context) : AnalyticsProvider, Logging {
|
||||
/** Implement our analytics API using Firebase Analytics */
|
||||
class FirebaseAnalytics(context: Context) :
|
||||
AnalyticsProvider,
|
||||
Logging {
|
||||
|
||||
val t = Firebase.analytics
|
||||
|
||||
@@ -85,12 +86,10 @@ class FirebaseAnalytics(context: Context) : AnalyticsProvider, Logging {
|
||||
}
|
||||
|
||||
override fun increment(name: String, amount: Double) {
|
||||
//Mint.logEvent("$name increment")
|
||||
// Mint.logEvent("$name increment")
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a google analytics screen view event
|
||||
*/
|
||||
/** Send a google analytics screen view event */
|
||||
override fun sendScreenView(name: String) {
|
||||
debug("Analytics: start screen $name")
|
||||
t.logEvent(FirebaseAnalytics.Event.SCREEN_VIEW) {
|
||||
|
||||
@@ -57,7 +57,6 @@ import com.suddenh4x.ratingdialog.AppRating
|
||||
import io.opentracing.util.GlobalTracer
|
||||
import timber.log.Timber
|
||||
|
||||
/** Created by kevinh on 1/4/15. */
|
||||
open class GeeksvilleApplication :
|
||||
Application(),
|
||||
Logging {
|
||||
@@ -70,7 +69,9 @@ open class GeeksvilleApplication :
|
||||
val isInTestLab: Boolean
|
||||
get() {
|
||||
val testLabSetting = Settings.System.getString(contentResolver, "firebase.test.lab")
|
||||
if (testLabSetting != null) info("Testlab is $testLabSetting")
|
||||
if (testLabSetting != null) {
|
||||
info("Testlab is $testLabSetting")
|
||||
}
|
||||
return "true" == testLabSetting
|
||||
}
|
||||
|
||||
@@ -109,6 +110,7 @@ open class GeeksvilleApplication :
|
||||
fun askToRate(activity: AppCompatActivity) {
|
||||
if (!isGooglePlayAvailable) return
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
exceptionReporter {
|
||||
// we don't want to crash our app because of bugs in this optional feature
|
||||
AppRating.Builder(activity)
|
||||
|
||||
65
app/src/google/java/com/geeksville/mesh/di/MapModule.kt
Normal file
65
app/src/google/java/com/geeksville/mesh/di/MapModule.kt
Normal file
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.di
|
||||
|
||||
import com.geeksville.mesh.repository.map.CustomTileProviderRepository
|
||||
import com.geeksville.mesh.repository.map.SharedPreferencesCustomTileProviderRepository
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.serialization.json.Json
|
||||
import javax.inject.Qualifier
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Qualifier
|
||||
@Retention(AnnotationRetention.BINARY)
|
||||
annotation class IoDispatcher
|
||||
|
||||
@Qualifier
|
||||
@Retention(AnnotationRetention.BINARY)
|
||||
annotation class DefaultDispatcher
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object MapModule {
|
||||
|
||||
@Provides @DefaultDispatcher
|
||||
fun provideDefaultDispatcher(): CoroutineDispatcher = Dispatchers.Default
|
||||
|
||||
@Provides @IoDispatcher
|
||||
fun provideIoDispatcher(): CoroutineDispatcher = Dispatchers.IO
|
||||
|
||||
// Serialization Provider (from original SerializationModule)
|
||||
@Provides @Singleton
|
||||
fun provideJson(): Json = Json { prettyPrint = false }
|
||||
}
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class MapRepositoryModule {
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindCustomTileProviderRepository(
|
||||
impl: SharedPreferencesCustomTileProviderRepository,
|
||||
): CustomTileProviderRepository
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.model.map
|
||||
|
||||
class CustomTileSource {
|
||||
|
||||
companion object {
|
||||
fun getTileSource(index: Int) {
|
||||
index
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.repository.map
|
||||
|
||||
import com.geeksville.mesh.ui.map.CustomTileProviderConfig
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface CustomTileProviderRepository {
|
||||
fun getCustomTileProviders(): Flow<List<CustomTileProviderConfig>>
|
||||
|
||||
suspend fun addCustomTileProvider(config: CustomTileProviderConfig)
|
||||
|
||||
suspend fun updateCustomTileProvider(config: CustomTileProviderConfig)
|
||||
|
||||
suspend fun deleteCustomTileProvider(configId: String)
|
||||
|
||||
suspend fun getCustomTileProviderById(configId: String): CustomTileProviderConfig?
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.repository.map
|
||||
|
||||
import android.content.Context
|
||||
import androidx.core.content.edit
|
||||
import com.geeksville.mesh.di.IoDispatcher
|
||||
import com.geeksville.mesh.ui.map.CustomTileProviderConfig
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.SerializationException
|
||||
import kotlinx.serialization.json.Json
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
private const val KEY_CUSTOM_TILE_PROVIDERS = "custom_tile_providers"
|
||||
private const val PREFS_NAME_TILE = "map_tile_provider_prefs"
|
||||
|
||||
@Singleton
|
||||
class SharedPreferencesCustomTileProviderRepository
|
||||
@Inject
|
||||
constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val json: Json,
|
||||
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
|
||||
) : CustomTileProviderRepository {
|
||||
|
||||
private val sharedPreferences = context.getSharedPreferences(PREFS_NAME_TILE, Context.MODE_PRIVATE)
|
||||
|
||||
private val customTileProvidersStateFlow = MutableStateFlow<List<CustomTileProviderConfig>>(emptyList())
|
||||
|
||||
init {
|
||||
loadDataFromPrefs()
|
||||
}
|
||||
|
||||
private fun loadDataFromPrefs() {
|
||||
val jsonString = sharedPreferences.getString(KEY_CUSTOM_TILE_PROVIDERS, null)
|
||||
if (jsonString != null) {
|
||||
try {
|
||||
customTileProvidersStateFlow.value = json.decodeFromString<List<CustomTileProviderConfig>>(jsonString)
|
||||
} catch (e: SerializationException) {
|
||||
Timber.tag("TileRepo").e(e, "Error deserializing tile providers")
|
||||
customTileProvidersStateFlow.value = emptyList()
|
||||
}
|
||||
} else {
|
||||
customTileProvidersStateFlow.value = emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun saveDataToPrefs(providers: List<CustomTileProviderConfig>) {
|
||||
withContext(ioDispatcher) {
|
||||
try {
|
||||
val jsonString = json.encodeToString(providers)
|
||||
sharedPreferences.edit { putString(KEY_CUSTOM_TILE_PROVIDERS, jsonString) }
|
||||
} catch (e: SerializationException) {
|
||||
Timber.tag("TileRepo").e(e, "Error serializing tile providers")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getCustomTileProviders(): Flow<List<CustomTileProviderConfig>> =
|
||||
customTileProvidersStateFlow.asStateFlow()
|
||||
|
||||
override suspend fun addCustomTileProvider(config: CustomTileProviderConfig) {
|
||||
val newList = customTileProvidersStateFlow.value + config
|
||||
customTileProvidersStateFlow.value = newList
|
||||
saveDataToPrefs(newList)
|
||||
}
|
||||
|
||||
override suspend fun updateCustomTileProvider(config: CustomTileProviderConfig) {
|
||||
val newList = customTileProvidersStateFlow.value.map { if (it.id == config.id) config else it }
|
||||
customTileProvidersStateFlow.value = newList
|
||||
saveDataToPrefs(newList)
|
||||
}
|
||||
|
||||
override suspend fun deleteCustomTileProvider(configId: String) {
|
||||
val newList = customTileProvidersStateFlow.value.filterNot { it.id == configId }
|
||||
customTileProvidersStateFlow.value = newList
|
||||
saveDataToPrefs(newList)
|
||||
}
|
||||
|
||||
override suspend fun getCustomTileProviderById(configId: String): CustomTileProviderConfig? =
|
||||
customTileProvidersStateFlow.value.find { it.id == configId }
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.map
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.IntentSenderRequest
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.geeksville.mesh.android.BuildUtils.debug
|
||||
import com.google.android.gms.common.api.ResolvableApiException
|
||||
import com.google.android.gms.location.LocationRequest
|
||||
import com.google.android.gms.location.LocationServices
|
||||
import com.google.android.gms.location.LocationSettingsRequest
|
||||
import com.google.android.gms.location.Priority
|
||||
|
||||
private const val INTERVAL_MILLIS = 10000L
|
||||
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
fun LocationPermissionsHandler(onPermissionResult: (Boolean) -> Unit) {
|
||||
val context = LocalContext.current
|
||||
var localHasPermission by remember {
|
||||
mutableStateOf(
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ==
|
||||
PackageManager.PERMISSION_GRANTED,
|
||||
)
|
||||
}
|
||||
|
||||
val requestLocationPermissionLauncher =
|
||||
rememberLauncherForActivityResult(contract = ActivityResultContracts.RequestPermission()) { isGranted ->
|
||||
localHasPermission = isGranted
|
||||
// Defer to the LaunchedEffect(localHasPermission) to check settings before confirming via
|
||||
// onPermissionResult
|
||||
// if permission is granted. If not granted, immediately report false.
|
||||
if (!isGranted) {
|
||||
onPermissionResult(false)
|
||||
}
|
||||
}
|
||||
|
||||
val locationSettingsLauncher =
|
||||
rememberLauncherForActivityResult(contract = ActivityResultContracts.StartIntentSenderForResult()) { result ->
|
||||
if (result.resultCode == Activity.RESULT_OK) {
|
||||
debug("Location settings changed by user.")
|
||||
// User has enabled location services or improved accuracy.
|
||||
onPermissionResult(true) // Settings are now adequate, and permission was already granted.
|
||||
} else {
|
||||
debug("Location settings change cancelled by user.")
|
||||
// User chose not to change settings. The permission itself is still granted,
|
||||
// but the experience might be degraded. For the purpose of enabling map features,
|
||||
// we consider this as success if the core permission is there.
|
||||
// If stricter handling is needed (e.g., block feature if settings not optimal),
|
||||
// this logic might change.
|
||||
onPermissionResult(localHasPermission)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
// Initial permission check
|
||||
when (ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION)) {
|
||||
PackageManager.PERMISSION_GRANTED -> {
|
||||
if (!localHasPermission) {
|
||||
localHasPermission = true
|
||||
}
|
||||
// If permission is already granted, proceed to check location settings.
|
||||
// The LaunchedEffect(localHasPermission) will handle this.
|
||||
// No need to call onPermissionResult(true) here yet, let settings check complete.
|
||||
}
|
||||
|
||||
else -> {
|
||||
// Request permission if not granted. The launcher's callback will update localHasPermission.
|
||||
requestLocationPermissionLauncher.launch(Manifest.permission.ACCESS_FINE_LOCATION)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(localHasPermission) {
|
||||
// Handles logic after permission status is known/updated
|
||||
if (localHasPermission) {
|
||||
// Permission is granted, now check location settings
|
||||
val locationRequest = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, INTERVAL_MILLIS).build()
|
||||
|
||||
val builder = LocationSettingsRequest.Builder().addLocationRequest(locationRequest)
|
||||
|
||||
val client = LocationServices.getSettingsClient(context)
|
||||
val task = client.checkLocationSettings(builder.build())
|
||||
|
||||
task.addOnSuccessListener {
|
||||
debug("Location settings are satisfied.")
|
||||
onPermissionResult(true) // Permission granted and settings are good
|
||||
}
|
||||
|
||||
task.addOnFailureListener { exception ->
|
||||
if (exception is ResolvableApiException) {
|
||||
try {
|
||||
val intentSenderRequest = IntentSenderRequest.Builder(exception.resolution).build()
|
||||
locationSettingsLauncher.launch(intentSenderRequest)
|
||||
// Result of this launch will be handled by locationSettingsLauncher's callback
|
||||
} catch (sendEx: ActivityNotFoundException) {
|
||||
debug("Error launching location settings resolution ${sendEx.message}.")
|
||||
onPermissionResult(true) // Permission is granted, but settings dialog failed. Proceed.
|
||||
}
|
||||
} else {
|
||||
debug("Location settings are not satisfiable.${exception.message}")
|
||||
onPermissionResult(true) // Permission is granted, but settings not ideal. Proceed.
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// If permission is not granted, report false.
|
||||
// This case is primarily handled by the requestLocationPermissionLauncher's callback
|
||||
// if the initial state was denied, or if user denies it.
|
||||
onPermissionResult(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
701
app/src/google/java/com/geeksville/mesh/ui/map/MapView.kt
Normal file
701
app/src/google/java/com/geeksville/mesh/ui/map/MapView.kt
Normal file
@@ -0,0 +1,701 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
@file:Suppress("MagicNumber")
|
||||
|
||||
package com.geeksville.mesh.ui.map
|
||||
|
||||
import android.content.Intent
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Paint
|
||||
import android.location.Location
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.compose.animation.core.animate
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.filled.TripOrigin
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.FloatingToolbarDefaults
|
||||
import androidx.compose.material3.FloatingToolbarDefaults.ScreenOffset
|
||||
import androidx.compose.material3.FloatingToolbarExitDirection.Companion.End
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberFloatingToolbarState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.graphics.createBitmap
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.geeksville.mesh.ConfigProtos.Config.DisplayConfig.DisplayUnits
|
||||
import com.geeksville.mesh.MeshProtos
|
||||
import com.geeksville.mesh.MeshProtos.Position
|
||||
import com.geeksville.mesh.MeshProtos.Waypoint
|
||||
import com.geeksville.mesh.R
|
||||
import com.geeksville.mesh.android.BuildUtils.debug
|
||||
import com.geeksville.mesh.android.BuildUtils.warn
|
||||
import com.geeksville.mesh.copy
|
||||
import com.geeksville.mesh.model.Node
|
||||
import com.geeksville.mesh.model.UIViewModel
|
||||
import com.geeksville.mesh.ui.map.components.ClusterItemsListDialog
|
||||
import com.geeksville.mesh.ui.map.components.CustomMapLayersSheet
|
||||
import com.geeksville.mesh.ui.map.components.CustomTileProviderManagerSheet
|
||||
import com.geeksville.mesh.ui.map.components.EditWaypointDialog
|
||||
import com.geeksville.mesh.ui.map.components.MapControlsOverlay
|
||||
import com.geeksville.mesh.ui.map.components.NodeClusterMarkers
|
||||
import com.geeksville.mesh.ui.map.components.WaypointMarkers
|
||||
import com.geeksville.mesh.ui.metrics.HEADING_DEG
|
||||
import com.geeksville.mesh.ui.metrics.formatPositionTime
|
||||
import com.geeksville.mesh.ui.node.DEG_D
|
||||
import com.geeksville.mesh.ui.node.components.NodeChip
|
||||
import com.geeksville.mesh.util.formatAgo
|
||||
import com.geeksville.mesh.util.metersIn
|
||||
import com.geeksville.mesh.util.mpsToKmph
|
||||
import com.geeksville.mesh.util.mpsToMph
|
||||
import com.geeksville.mesh.util.toString
|
||||
import com.geeksville.mesh.waypoint
|
||||
import com.google.android.gms.maps.CameraUpdateFactory
|
||||
import com.google.android.gms.maps.model.BitmapDescriptor
|
||||
import com.google.android.gms.maps.model.BitmapDescriptorFactory
|
||||
import com.google.android.gms.maps.model.CameraPosition
|
||||
import com.google.android.gms.maps.model.JointType
|
||||
import com.google.android.gms.maps.model.LatLng
|
||||
import com.google.android.gms.maps.model.LatLngBounds
|
||||
import com.google.android.gms.maps.model.RoundCap
|
||||
import com.google.maps.android.clustering.ClusterItem
|
||||
import com.google.maps.android.compose.CameraMoveStartedReason
|
||||
import com.google.maps.android.compose.ComposeMapColorScheme
|
||||
import com.google.maps.android.compose.GoogleMap
|
||||
import com.google.maps.android.compose.MapEffect
|
||||
import com.google.maps.android.compose.MapProperties
|
||||
import com.google.maps.android.compose.MapType
|
||||
import com.google.maps.android.compose.MapUiSettings
|
||||
import com.google.maps.android.compose.MapsComposeExperimentalApi
|
||||
import com.google.maps.android.compose.MarkerComposable
|
||||
import com.google.maps.android.compose.MarkerInfoWindowComposable
|
||||
import com.google.maps.android.compose.Polyline
|
||||
import com.google.maps.android.compose.TileOverlay
|
||||
import com.google.maps.android.compose.rememberCameraPositionState
|
||||
import com.google.maps.android.compose.rememberUpdatedMarkerState
|
||||
import com.google.maps.android.compose.widgets.DisappearingScaleBar
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
import java.text.DateFormat
|
||||
|
||||
private const val MIN_TRACK_POINT_DISTANCE_METERS = 20f
|
||||
|
||||
@Suppress("ReturnCount")
|
||||
private fun filterNodeTrack(nodeTrack: List<Position>?): List<Position> {
|
||||
if (nodeTrack.isNullOrEmpty()) return emptyList()
|
||||
|
||||
val sortedTrack = nodeTrack.sortedBy { it.time }
|
||||
if (sortedTrack.size <= 2) return sortedTrack.map { it }
|
||||
|
||||
val filteredPoints = mutableListOf<MeshProtos.Position>()
|
||||
var lastAddedPointProto = sortedTrack.first()
|
||||
filteredPoints.add(lastAddedPointProto)
|
||||
|
||||
for (i in 1 until sortedTrack.size - 1) {
|
||||
val currentPointProto = sortedTrack[i]
|
||||
val currentPoint = currentPointProto.toLatLng()
|
||||
val lastAddedPoint = lastAddedPointProto.toLatLng()
|
||||
val distanceResults = FloatArray(1)
|
||||
Location.distanceBetween(
|
||||
lastAddedPoint.latitude,
|
||||
lastAddedPoint.longitude,
|
||||
currentPoint.latitude,
|
||||
currentPoint.longitude,
|
||||
distanceResults,
|
||||
)
|
||||
if (distanceResults[0] > MIN_TRACK_POINT_DISTANCE_METERS) {
|
||||
filteredPoints.add(currentPointProto)
|
||||
lastAddedPointProto = currentPointProto
|
||||
}
|
||||
}
|
||||
|
||||
val lastOriginalPointProto = sortedTrack.last()
|
||||
if (filteredPoints.last() != lastOriginalPointProto) {
|
||||
val distanceResults = FloatArray(1)
|
||||
val lastAddedPoint = lastAddedPointProto.toLatLng()
|
||||
val lastOriginalPoint = lastOriginalPointProto.toLatLng()
|
||||
Location.distanceBetween(
|
||||
lastAddedPoint.latitude,
|
||||
lastAddedPoint.longitude,
|
||||
lastOriginalPoint.latitude,
|
||||
lastOriginalPoint.longitude,
|
||||
distanceResults,
|
||||
)
|
||||
if (distanceResults[0] > MIN_TRACK_POINT_DISTANCE_METERS || filteredPoints.size == 1) {
|
||||
filteredPoints.add(lastAddedPointProto)
|
||||
}
|
||||
}
|
||||
return filteredPoints
|
||||
}
|
||||
|
||||
@Suppress("CyclomaticComplexMethod", "LongMethod")
|
||||
@OptIn(MapsComposeExperimentalApi::class, ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun MapView(
|
||||
uiViewModel: UIViewModel,
|
||||
mapViewModel: MapViewModel = hiltViewModel(),
|
||||
navigateToNodeDetails: (Int) -> Unit,
|
||||
focusedNodeNum: Int? = null,
|
||||
nodeTrack: List<Position>? = null,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val mapLayers by mapViewModel.mapLayers.collectAsStateWithLifecycle()
|
||||
var hasLocationPermission by remember { mutableStateOf(false) }
|
||||
val displayUnits by mapViewModel.displayUnits.collectAsStateWithLifecycle()
|
||||
|
||||
LocationPermissionsHandler { isGranted -> hasLocationPermission = isGranted }
|
||||
|
||||
val kmlFilePickerLauncher =
|
||||
rememberLauncherForActivityResult(contract = ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
if (result.resultCode == android.app.Activity.RESULT_OK) {
|
||||
result.data?.data?.let { uri ->
|
||||
val fileName = uri.getFileName(context)
|
||||
mapViewModel.addMapLayer(uri, fileName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var mapFilterMenuExpanded by remember { mutableStateOf(false) }
|
||||
val mapFilterState by mapViewModel.mapFilterStateFlow.collectAsStateWithLifecycle()
|
||||
val ourNodeInfo by uiViewModel.ourNodeInfo.collectAsStateWithLifecycle()
|
||||
var editingWaypoint by remember { mutableStateOf<Waypoint?>(null) }
|
||||
val savedCameraPosition by mapViewModel.cameraPosition.collectAsStateWithLifecycle()
|
||||
|
||||
val selectedGoogleMapType by mapViewModel.selectedGoogleMapType.collectAsStateWithLifecycle()
|
||||
val currentCustomTileProviderUrl by mapViewModel.selectedCustomTileProviderUrl.collectAsStateWithLifecycle()
|
||||
|
||||
var mapTypeMenuExpanded by remember { mutableStateOf(false) }
|
||||
var showCustomTileManagerSheet by remember { mutableStateOf(false) }
|
||||
|
||||
val defaultLatLng = LatLng(0.0, 0.0)
|
||||
val cameraPositionState = rememberCameraPositionState {
|
||||
position =
|
||||
savedCameraPosition?.let {
|
||||
CameraPosition(LatLng(it.targetLat, it.targetLng), it.zoom, it.tilt, it.bearing)
|
||||
} ?: CameraPosition.fromLatLngZoom(defaultLatLng, 7f)
|
||||
}
|
||||
|
||||
val floatingToolbarState = rememberFloatingToolbarState()
|
||||
val exitAlwaysScrollBehavior =
|
||||
FloatingToolbarDefaults.exitAlwaysScrollBehavior(exitDirection = End, state = floatingToolbarState)
|
||||
|
||||
LaunchedEffect(cameraPositionState.isMoving, floatingToolbarState.offsetLimit) {
|
||||
val targetOffset =
|
||||
if (cameraPositionState.isMoving) {
|
||||
floatingToolbarState.offsetLimit
|
||||
} else {
|
||||
mapViewModel.onCameraPositionChanged(cameraPositionState.position)
|
||||
0f
|
||||
}
|
||||
if (floatingToolbarState.offset != targetOffset) {
|
||||
if (targetOffset == 0f || floatingToolbarState.offsetLimit != 0f) {
|
||||
launch {
|
||||
animate(initialValue = floatingToolbarState.offset, targetValue = targetOffset) { value, _ ->
|
||||
floatingToolbarState.offset = value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val allNodes by
|
||||
mapViewModel.nodes
|
||||
.map { nodes -> nodes.filter { node -> node.validPosition != null } }
|
||||
.collectAsStateWithLifecycle(listOf())
|
||||
val waypoints by mapViewModel.waypoints.collectAsStateWithLifecycle(emptyMap())
|
||||
val displayableWaypoints = waypoints.values.mapNotNull { it.data.waypoint }
|
||||
|
||||
var hasZoomed by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(allNodes, displayableWaypoints, nodeTrack) {
|
||||
if ((hasZoomed) || cameraPositionState.cameraMoveStartedReason != CameraMoveStartedReason.NO_MOVEMENT_YET) {
|
||||
if (!hasZoomed) hasZoomed = true
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
val pointsToBound: List<LatLng> =
|
||||
when {
|
||||
!nodeTrack.isNullOrEmpty() -> nodeTrack.map { it.toLatLng() }
|
||||
|
||||
allNodes.isNotEmpty() || displayableWaypoints.isNotEmpty() ->
|
||||
allNodes.mapNotNull { it.toLatLng() } + displayableWaypoints.map { it.toLatLng() }
|
||||
|
||||
else -> emptyList()
|
||||
}
|
||||
|
||||
if (pointsToBound.isNotEmpty()) {
|
||||
val bounds = LatLngBounds.builder().apply { pointsToBound.forEach(::include) }.build()
|
||||
|
||||
val padding = if (!pointsToBound.isEmpty()) 100 else 48
|
||||
|
||||
try {
|
||||
cameraPositionState.animate(CameraUpdateFactory.newLatLngBounds(bounds, padding))
|
||||
hasZoomed = true
|
||||
} catch (e: IllegalStateException) {
|
||||
warn("MapView Could not animate to bounds: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
val filteredNodes =
|
||||
if (mapFilterState.onlyFavorites) {
|
||||
allNodes.filter { it.isFavorite || it.num == ourNodeInfo?.num }
|
||||
} else {
|
||||
allNodes
|
||||
}
|
||||
|
||||
val nodeClusterItems =
|
||||
filteredNodes.map { node ->
|
||||
val latLng = LatLng(node.position.latitudeI * DEG_D, node.position.longitudeI * DEG_D)
|
||||
NodeClusterItem(
|
||||
node = node,
|
||||
nodePosition = latLng,
|
||||
nodeTitle = "${node.user.shortName} ${formatAgo(node.position.time)}",
|
||||
nodeSnippet = "${node.user.longName}",
|
||||
)
|
||||
}
|
||||
val isConnected by uiViewModel.isConnectedStateFlow.collectAsStateWithLifecycle()
|
||||
val theme by uiViewModel.theme.collectAsStateWithLifecycle()
|
||||
val dark =
|
||||
when (theme) {
|
||||
AppCompatDelegate.MODE_NIGHT_YES -> true
|
||||
AppCompatDelegate.MODE_NIGHT_NO -> false
|
||||
AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM -> isSystemInDarkTheme()
|
||||
else -> isSystemInDarkTheme()
|
||||
}
|
||||
val mapColorScheme =
|
||||
when (dark) {
|
||||
true -> ComposeMapColorScheme.DARK
|
||||
else -> ComposeMapColorScheme.LIGHT
|
||||
}
|
||||
|
||||
var showLayersBottomSheet by remember { mutableStateOf(false) }
|
||||
|
||||
val onAddLayerClicked = {
|
||||
val intent =
|
||||
Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
|
||||
addCategory(Intent.CATEGORY_OPENABLE)
|
||||
type = "*/*"
|
||||
val mimeTypes = arrayOf("application/vnd.google-earth.kml+xml", "application/vnd.google-earth.kmz")
|
||||
putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes)
|
||||
}
|
||||
kmlFilePickerLauncher.launch(intent)
|
||||
}
|
||||
val onRemoveLayer = { layerId: String -> mapViewModel.removeMapLayer(layerId) }
|
||||
val onToggleVisibility = { layerId: String -> mapViewModel.toggleLayerVisibility(layerId) }
|
||||
|
||||
val effectiveGoogleMapType =
|
||||
if (currentCustomTileProviderUrl != null) {
|
||||
MapType.NONE
|
||||
} else {
|
||||
selectedGoogleMapType
|
||||
}
|
||||
|
||||
var showClusterItemsDialog by remember { mutableStateOf<List<NodeClusterItem>?>(null) }
|
||||
|
||||
Scaffold(modifier = Modifier.nestedScroll(exitAlwaysScrollBehavior)) { paddingValues ->
|
||||
Box(modifier = Modifier.fillMaxSize().padding(paddingValues)) {
|
||||
GoogleMap(
|
||||
mapColorScheme = mapColorScheme,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
cameraPositionState = cameraPositionState,
|
||||
uiSettings =
|
||||
MapUiSettings(
|
||||
zoomControlsEnabled = true,
|
||||
mapToolbarEnabled = true,
|
||||
compassEnabled = true,
|
||||
myLocationButtonEnabled = hasLocationPermission,
|
||||
rotationGesturesEnabled = true,
|
||||
scrollGesturesEnabled = true,
|
||||
tiltGesturesEnabled = true,
|
||||
zoomGesturesEnabled = true,
|
||||
),
|
||||
properties =
|
||||
MapProperties(mapType = effectiveGoogleMapType, isMyLocationEnabled = hasLocationPermission),
|
||||
onMapLongClick = { latLng ->
|
||||
if (isConnected) {
|
||||
val newWaypoint = waypoint {
|
||||
latitudeI = (latLng.latitude / DEG_D).toInt()
|
||||
longitudeI = (latLng.longitude / DEG_D).toInt()
|
||||
}
|
||||
editingWaypoint = newWaypoint
|
||||
}
|
||||
},
|
||||
) {
|
||||
key(currentCustomTileProviderUrl) {
|
||||
currentCustomTileProviderUrl?.let { url ->
|
||||
mapViewModel.createUrlTileProvider(url)?.let { tileProvider ->
|
||||
TileOverlay(tileProvider = tileProvider, fadeIn = true, transparency = 0f, zIndex = -1f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (nodeTrack != null && focusedNodeNum != null) {
|
||||
val originalLatLngs =
|
||||
nodeTrack.sortedBy { it.time }.map { LatLng(it.latitudeI * DEG_D, it.longitudeI * DEG_D) }
|
||||
val filteredLatLngs = filterNodeTrack(nodeTrack)
|
||||
|
||||
val focusedNode = allNodes.find { it.num == focusedNodeNum }
|
||||
val polylineColor = focusedNode?.colors?.let { Color(it.first) } ?: Color.Blue
|
||||
if (originalLatLngs.isNotEmpty()) {
|
||||
focusedNode?.let {
|
||||
MarkerComposable(
|
||||
state = rememberUpdatedMarkerState(position = originalLatLngs.first()),
|
||||
zIndex = 1f,
|
||||
) {
|
||||
NodeChip(node = it, isThisNode = false, isConnected = false, onAction = {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val pointsForMarkers =
|
||||
if (originalLatLngs.isNotEmpty() && focusedNode != null) {
|
||||
filteredLatLngs.drop(1)
|
||||
} else {
|
||||
filteredLatLngs
|
||||
}
|
||||
|
||||
pointsForMarkers.forEachIndexed { index, position ->
|
||||
val markerState = rememberUpdatedMarkerState(position = position.toLatLng())
|
||||
val dateFormat = remember {
|
||||
DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM)
|
||||
}
|
||||
val alpha = 1 - (index.toFloat() / pointsForMarkers.size.toFloat())
|
||||
MarkerInfoWindowComposable(
|
||||
state = markerState,
|
||||
title = stringResource(R.string.position),
|
||||
snippet = formatAgo(position.time),
|
||||
zIndex = alpha,
|
||||
infoContent = {
|
||||
PositionInfoWindowContent(
|
||||
position = position,
|
||||
dateFormat = dateFormat,
|
||||
displayUnits = displayUnits,
|
||||
)
|
||||
},
|
||||
) {
|
||||
Icon(
|
||||
imageVector = androidx.compose.material.icons.Icons.Default.TripOrigin,
|
||||
contentDescription = stringResource(R.string.track_point),
|
||||
modifier = Modifier.padding(8.dp),
|
||||
tint = polylineColor.copy(alpha = alpha),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (filteredLatLngs.size > 1) {
|
||||
Polyline(
|
||||
points = filteredLatLngs.map { it.toLatLng() },
|
||||
jointType = JointType.ROUND,
|
||||
endCap = RoundCap(),
|
||||
startCap = RoundCap(),
|
||||
geodesic = true,
|
||||
color = polylineColor,
|
||||
width = 8f,
|
||||
zIndex = 0f,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
NodeClusterMarkers(
|
||||
nodeClusterItems = nodeClusterItems,
|
||||
mapFilterState = mapFilterState,
|
||||
navigateToNodeDetails = navigateToNodeDetails,
|
||||
onClusterClick = { cluster ->
|
||||
val items = cluster.items.toList()
|
||||
val allSameLocation = items.size > 1 && items.all { it.position == items.first().position }
|
||||
|
||||
if (allSameLocation) {
|
||||
showClusterItemsDialog = items
|
||||
} else {
|
||||
val bounds = LatLngBounds.builder()
|
||||
cluster.items.forEach { bounds.include(it.position) }
|
||||
coroutineScope.launch {
|
||||
cameraPositionState.animate(
|
||||
CameraUpdateFactory.newLatLngBounds(bounds.build(), 100),
|
||||
)
|
||||
}
|
||||
debug("Cluster clicked! $cluster")
|
||||
}
|
||||
true
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
WaypointMarkers(
|
||||
displayableWaypoints = displayableWaypoints,
|
||||
mapFilterState = mapFilterState,
|
||||
myNodeNum = uiViewModel.myNodeNum ?: 0,
|
||||
isConnected = isConnected,
|
||||
unicodeEmojiToBitmapProvider = ::unicodeEmojiToBitmap,
|
||||
onEditWaypointRequest = { waypointToEdit -> editingWaypoint = waypointToEdit },
|
||||
)
|
||||
|
||||
MapEffect(mapLayers) { map ->
|
||||
mapLayers.forEach { layerItem ->
|
||||
mapViewModel.loadKmlLayerIfNeeded(map, layerItem)?.let { kmlLayer ->
|
||||
if (layerItem.isVisible && !kmlLayer.isLayerOnMap) {
|
||||
kmlLayer.addLayerToMap()
|
||||
} else if (!layerItem.isVisible && kmlLayer.isLayerOnMap) {
|
||||
kmlLayer.removeLayerFromMap()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DisappearingScaleBar(cameraPositionState = cameraPositionState)
|
||||
|
||||
editingWaypoint?.let { waypointToEdit ->
|
||||
EditWaypointDialog(
|
||||
waypoint = waypointToEdit,
|
||||
onSendClicked = { updatedWp ->
|
||||
var finalWp = updatedWp
|
||||
if (updatedWp.id == 0) {
|
||||
finalWp = finalWp.copy { id = uiViewModel.generatePacketId() ?: 0 }
|
||||
}
|
||||
if (updatedWp.icon == 0) {
|
||||
finalWp = finalWp.copy { icon = 0x1F4CD }
|
||||
}
|
||||
|
||||
uiViewModel.sendWaypoint(finalWp)
|
||||
editingWaypoint = null
|
||||
},
|
||||
onDeleteClicked = { wpToDelete ->
|
||||
if (wpToDelete.lockedTo == 0 && isConnected && wpToDelete.id != 0) {
|
||||
val deleteMarkerWp = wpToDelete.copy { expire = 1 }
|
||||
uiViewModel.sendWaypoint(deleteMarkerWp)
|
||||
}
|
||||
uiViewModel.deleteWaypoint(wpToDelete.id)
|
||||
editingWaypoint = null
|
||||
},
|
||||
onDismissRequest = { editingWaypoint = null },
|
||||
)
|
||||
}
|
||||
|
||||
MapControlsOverlay(
|
||||
modifier = Modifier.align(Alignment.CenterEnd).offset(x = -ScreenOffset),
|
||||
mapFilterMenuExpanded = mapFilterMenuExpanded,
|
||||
onMapFilterMenuDismissRequest = { mapFilterMenuExpanded = false },
|
||||
onToggleMapFilterMenu = { mapFilterMenuExpanded = true },
|
||||
mapViewModel = mapViewModel,
|
||||
mapTypeMenuExpanded = mapTypeMenuExpanded,
|
||||
onMapTypeMenuDismissRequest = { mapTypeMenuExpanded = false },
|
||||
onToggleMapTypeMenu = { mapTypeMenuExpanded = true },
|
||||
onManageLayersClicked = { showLayersBottomSheet = true },
|
||||
onManageCustomTileProvidersClicked = {
|
||||
mapTypeMenuExpanded = false
|
||||
showCustomTileManagerSheet = true
|
||||
},
|
||||
showFilterButton = focusedNodeNum == null,
|
||||
scrollBehavior = exitAlwaysScrollBehavior,
|
||||
)
|
||||
}
|
||||
if (showLayersBottomSheet) {
|
||||
ModalBottomSheet(onDismissRequest = { showLayersBottomSheet = false }) {
|
||||
CustomMapLayersSheet(mapLayers, onToggleVisibility, onRemoveLayer, onAddLayerClicked)
|
||||
}
|
||||
}
|
||||
showClusterItemsDialog?.let {
|
||||
ClusterItemsListDialog(
|
||||
items = it,
|
||||
onDismiss = { showClusterItemsDialog = null },
|
||||
onItemClick = { item ->
|
||||
navigateToNodeDetails(item.node.num)
|
||||
showClusterItemsDialog = null
|
||||
},
|
||||
)
|
||||
}
|
||||
if (showCustomTileManagerSheet) {
|
||||
ModalBottomSheet(onDismissRequest = { showCustomTileManagerSheet = false }) {
|
||||
CustomTileProviderManagerSheet(mapViewModel = mapViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun convertIntToEmoji(unicodeCodePoint: Int): String = try {
|
||||
String(Character.toChars(unicodeCodePoint))
|
||||
} catch (e: IllegalArgumentException) {
|
||||
Timber.w(e, "Invalid unicode code point: $unicodeCodePoint")
|
||||
"\uD83D\uDCCD"
|
||||
}
|
||||
|
||||
internal fun unicodeEmojiToBitmap(icon: Int): BitmapDescriptor {
|
||||
val unicodeEmoji = convertIntToEmoji(icon)
|
||||
val paint =
|
||||
Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
textSize = 64f
|
||||
color = android.graphics.Color.BLACK
|
||||
textAlign = Paint.Align.CENTER
|
||||
}
|
||||
|
||||
val baseline = -paint.ascent()
|
||||
val width = (paint.measureText(unicodeEmoji) + 0.5f).toInt()
|
||||
val height = (baseline + paint.descent() + 0.5f).toInt()
|
||||
val image = createBitmap(width, height, android.graphics.Bitmap.Config.ARGB_8888)
|
||||
val canvas = Canvas(image)
|
||||
canvas.drawText(unicodeEmoji, width / 2f, baseline, paint)
|
||||
|
||||
return BitmapDescriptorFactory.fromBitmap(image)
|
||||
}
|
||||
|
||||
@Suppress("NestedBlockDepth")
|
||||
fun Uri.getFileName(context: android.content.Context): String {
|
||||
var name = this.lastPathSegment ?: "layer_${System.currentTimeMillis()}"
|
||||
if (this.scheme == "content") {
|
||||
context.contentResolver.query(this, null, null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
val displayNameIndex = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME)
|
||||
if (displayNameIndex != -1) {
|
||||
name = cursor.getString(displayNameIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
data class NodeClusterItem(val node: Node, val nodePosition: LatLng, val nodeTitle: String, val nodeSnippet: String) :
|
||||
ClusterItem {
|
||||
override fun getPosition(): LatLng = nodePosition
|
||||
|
||||
override fun getTitle(): String = nodeTitle
|
||||
|
||||
override fun getSnippet(): String = nodeSnippet
|
||||
|
||||
override fun getZIndex(): Float? = null
|
||||
|
||||
fun getPrecisionMeters(): Double? {
|
||||
val precisionMap =
|
||||
mapOf(
|
||||
10 to 23345.484932,
|
||||
11 to 11672.7369,
|
||||
12 to 5836.36288,
|
||||
13 to 2918.175876,
|
||||
14 to 1459.0823719999053,
|
||||
15 to 729.53562,
|
||||
16 to 364.7622,
|
||||
17 to 182.375556,
|
||||
18 to 91.182212,
|
||||
19 to 45.58554,
|
||||
)
|
||||
return precisionMap[this.node.position.precisionBits]
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
@Suppress("LongMethod")
|
||||
private fun PositionInfoWindowContent(
|
||||
position: Position,
|
||||
dateFormat: DateFormat = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM),
|
||||
displayUnits: DisplayUnits = DisplayUnits.METRIC,
|
||||
) {
|
||||
@Composable
|
||||
fun PositionRow(label: String, value: String) {
|
||||
Row(modifier = Modifier.padding(horizontal = 8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(label, style = MaterialTheme.typography.labelMedium)
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
Text(value, style = MaterialTheme.typography.labelMediumEmphasized)
|
||||
}
|
||||
}
|
||||
|
||||
Card {
|
||||
Column(modifier = Modifier.padding(8.dp)) {
|
||||
PositionRow(
|
||||
label = stringResource(R.string.latitude),
|
||||
value = "%.5f".format(position.latitudeI * com.geeksville.mesh.ui.metrics.DEG_D),
|
||||
)
|
||||
|
||||
PositionRow(
|
||||
label = stringResource(R.string.longitude),
|
||||
value = "%.5f".format(position.longitudeI * com.geeksville.mesh.ui.metrics.DEG_D),
|
||||
)
|
||||
|
||||
PositionRow(label = stringResource(R.string.sats), value = position.satsInView.toString())
|
||||
|
||||
PositionRow(
|
||||
label = stringResource(R.string.alt),
|
||||
value = position.altitude.metersIn(displayUnits).toString(displayUnits),
|
||||
)
|
||||
|
||||
PositionRow(label = stringResource(R.string.speed), value = speedFromPosition(position, displayUnits))
|
||||
|
||||
PositionRow(
|
||||
label = stringResource(R.string.heading),
|
||||
value = "%.0f°".format(position.groundTrack * HEADING_DEG),
|
||||
)
|
||||
|
||||
PositionRow(label = stringResource(R.string.timestamp), value = formatPositionTime(position, dateFormat))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun speedFromPosition(position: Position, displayUnits: DisplayUnits): String {
|
||||
val speedInMps = position.groundSpeed
|
||||
val mpsText = "%d m/s".format(speedInMps)
|
||||
val speedText =
|
||||
if (speedInMps > 10) {
|
||||
when (displayUnits) {
|
||||
DisplayUnits.METRIC -> "%.1f Km/h".format(position.groundSpeed.mpsToKmph())
|
||||
DisplayUnits.IMPERIAL -> "%.1f mph".format(position.groundSpeed.mpsToMph())
|
||||
else -> mpsText // Fallback or handle UNRECOGNIZED
|
||||
}
|
||||
} else {
|
||||
mpsText
|
||||
}
|
||||
return speedText
|
||||
}
|
||||
|
||||
private fun Position.toLatLng(): LatLng = LatLng(this.latitudeI * DEG_D, this.longitudeI * DEG_D)
|
||||
|
||||
private fun Node.toLatLng(): LatLng? = this.position.toLatLng()
|
||||
|
||||
private fun Waypoint.toLatLng(): LatLng = LatLng(this.latitudeI * DEG_D, this.longitudeI * DEG_D)
|
||||
383
app/src/google/java/com/geeksville/mesh/ui/map/MapViewModel.kt
Normal file
383
app/src/google/java/com/geeksville/mesh/ui/map/MapViewModel.kt
Normal file
@@ -0,0 +1,383 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.map
|
||||
|
||||
import android.app.Application
|
||||
import android.content.SharedPreferences
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.geeksville.mesh.ConfigProtos
|
||||
import com.geeksville.mesh.android.BuildUtils.debug
|
||||
import com.geeksville.mesh.database.NodeRepository
|
||||
import com.geeksville.mesh.database.PacketRepository
|
||||
import com.geeksville.mesh.repository.datastore.RadioConfigRepository
|
||||
import com.geeksville.mesh.repository.map.CustomTileProviderRepository
|
||||
import com.google.android.gms.maps.GoogleMap
|
||||
import com.google.android.gms.maps.model.CameraPosition
|
||||
import com.google.android.gms.maps.model.TileProvider
|
||||
import com.google.android.gms.maps.model.UrlTileProvider
|
||||
import com.google.maps.android.compose.MapType
|
||||
import com.google.maps.android.data.kml.KmlLayer
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.mapNotNull
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.net.MalformedURLException
|
||||
import java.net.URL
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val TILE_SIZE = 256
|
||||
|
||||
@Serializable
|
||||
data class MapCameraPosition(
|
||||
val targetLat: Double,
|
||||
val targetLng: Double,
|
||||
val zoom: Float,
|
||||
val tilt: Float,
|
||||
val bearing: Float,
|
||||
)
|
||||
|
||||
@Suppress("TooManyFunctions")
|
||||
@HiltViewModel
|
||||
class MapViewModel
|
||||
@Inject
|
||||
constructor(
|
||||
private val application: Application,
|
||||
preferences: SharedPreferences,
|
||||
nodeRepository: NodeRepository,
|
||||
packetRepository: PacketRepository,
|
||||
radioConfigRepository: RadioConfigRepository,
|
||||
private val customTileProviderRepository: CustomTileProviderRepository,
|
||||
) : BaseMapViewModel(preferences, nodeRepository, packetRepository) {
|
||||
|
||||
private val _errorFlow = MutableSharedFlow<String>()
|
||||
val errorFlow: SharedFlow<String> = _errorFlow.asSharedFlow()
|
||||
|
||||
val customTileProviderConfigs: StateFlow<List<CustomTileProviderConfig>> =
|
||||
customTileProviderRepository
|
||||
.getCustomTileProviders()
|
||||
.stateIn(scope = viewModelScope, started = SharingStarted.WhileSubscribed(5000), initialValue = emptyList())
|
||||
|
||||
private val _selectedCustomTileProviderUrl = MutableStateFlow<String?>(null)
|
||||
val selectedCustomTileProviderUrl: StateFlow<String?> = _selectedCustomTileProviderUrl.asStateFlow()
|
||||
|
||||
private val _selectedGoogleMapType = MutableStateFlow<MapType>(MapType.NORMAL)
|
||||
val selectedGoogleMapType: StateFlow<MapType> = _selectedGoogleMapType.asStateFlow()
|
||||
|
||||
private val _cameraPosition = MutableStateFlow<MapCameraPosition?>(null)
|
||||
|
||||
val cameraPosition: StateFlow<MapCameraPosition?> = _cameraPosition.asStateFlow()
|
||||
|
||||
val displayUnits =
|
||||
radioConfigRepository.deviceProfileFlow
|
||||
.mapNotNull { it.config.display.units }
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5000),
|
||||
initialValue = ConfigProtos.Config.DisplayConfig.DisplayUnits.METRIC,
|
||||
)
|
||||
|
||||
fun onCameraPositionChanged(cameraPosition: CameraPosition) {
|
||||
_cameraPosition.value =
|
||||
MapCameraPosition(
|
||||
targetLat = cameraPosition.target.latitude,
|
||||
targetLng = cameraPosition.target.longitude,
|
||||
zoom = cameraPosition.zoom,
|
||||
tilt = cameraPosition.tilt,
|
||||
bearing = cameraPosition.bearing,
|
||||
)
|
||||
}
|
||||
|
||||
fun addCustomTileProvider(name: String, urlTemplate: String) {
|
||||
viewModelScope.launch {
|
||||
if (name.isBlank() || urlTemplate.isBlank() || !isValidTileUrlTemplate(urlTemplate)) {
|
||||
_errorFlow.emit("Invalid name or URL template for custom tile provider.")
|
||||
return@launch
|
||||
}
|
||||
if (customTileProviderConfigs.value.any { it.name.equals(name, ignoreCase = true) }) {
|
||||
_errorFlow.emit("Custom tile provider with name '$name' already exists.")
|
||||
return@launch
|
||||
}
|
||||
|
||||
val newConfig = CustomTileProviderConfig(name = name, urlTemplate = urlTemplate)
|
||||
customTileProviderRepository.addCustomTileProvider(newConfig)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateCustomTileProvider(configToUpdate: CustomTileProviderConfig) {
|
||||
viewModelScope.launch {
|
||||
if (
|
||||
configToUpdate.name.isBlank() ||
|
||||
configToUpdate.urlTemplate.isBlank() ||
|
||||
!isValidTileUrlTemplate(configToUpdate.urlTemplate)
|
||||
) {
|
||||
_errorFlow.emit("Invalid name or URL template for updating custom tile provider.")
|
||||
return@launch
|
||||
}
|
||||
val existingConfigs = customTileProviderConfigs.value
|
||||
if (
|
||||
existingConfigs.any {
|
||||
it.id != configToUpdate.id && it.name.equals(configToUpdate.name, ignoreCase = true)
|
||||
}
|
||||
) {
|
||||
_errorFlow.emit("Another custom tile provider with name '${configToUpdate.name}' already exists.")
|
||||
return@launch
|
||||
}
|
||||
|
||||
customTileProviderRepository.updateCustomTileProvider(configToUpdate)
|
||||
|
||||
val originalConfig = customTileProviderRepository.getCustomTileProviderById(configToUpdate.id)
|
||||
if (
|
||||
_selectedCustomTileProviderUrl.value != null &&
|
||||
originalConfig?.urlTemplate == _selectedCustomTileProviderUrl.value
|
||||
) {
|
||||
// No change needed if URL didn't change, or handle if it did
|
||||
} else if (originalConfig != null && _selectedCustomTileProviderUrl.value != originalConfig.urlTemplate) {
|
||||
val currentlySelectedConfig =
|
||||
customTileProviderConfigs.value.find { it.urlTemplate == _selectedCustomTileProviderUrl.value }
|
||||
if (currentlySelectedConfig?.id == configToUpdate.id) {
|
||||
_selectedCustomTileProviderUrl.value = configToUpdate.urlTemplate
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun removeCustomTileProvider(configId: String) {
|
||||
viewModelScope.launch {
|
||||
val configToRemove = customTileProviderRepository.getCustomTileProviderById(configId)
|
||||
customTileProviderRepository.deleteCustomTileProvider(configId)
|
||||
|
||||
if (configToRemove != null && _selectedCustomTileProviderUrl.value == configToRemove.urlTemplate) {
|
||||
_selectedCustomTileProviderUrl.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun selectCustomTileProvider(config: CustomTileProviderConfig?) {
|
||||
if (config != null) {
|
||||
if (!isValidTileUrlTemplate(config.urlTemplate)) {
|
||||
Log.w("MapViewModel", "Attempted to select invalid URL template: ${config.urlTemplate}")
|
||||
_selectedCustomTileProviderUrl.value = null
|
||||
return
|
||||
}
|
||||
_selectedCustomTileProviderUrl.value = config.urlTemplate
|
||||
} else {
|
||||
_selectedCustomTileProviderUrl.value = null
|
||||
}
|
||||
}
|
||||
|
||||
fun setSelectedGoogleMapType(mapType: MapType) {
|
||||
_selectedGoogleMapType.value = mapType
|
||||
if (_selectedCustomTileProviderUrl.value != null) {
|
||||
_selectedCustomTileProviderUrl.value = null
|
||||
}
|
||||
}
|
||||
|
||||
fun createUrlTileProvider(urlString: String): TileProvider? {
|
||||
if (!isValidTileUrlTemplate(urlString)) {
|
||||
Log.e("MapViewModel", "Tile URL does not contain valid {x}, {y}, and {z} placeholders: $urlString")
|
||||
return null
|
||||
}
|
||||
return object : UrlTileProvider(TILE_SIZE, TILE_SIZE) {
|
||||
override fun getTileUrl(x: Int, y: Int, zoom: Int): URL? {
|
||||
val formattedUrl =
|
||||
urlString
|
||||
.replace("{z}", zoom.toString(), ignoreCase = true)
|
||||
.replace("{x}", x.toString(), ignoreCase = true)
|
||||
.replace("{y}", y.toString(), ignoreCase = true)
|
||||
return try {
|
||||
URL(formattedUrl)
|
||||
} catch (e: MalformedURLException) {
|
||||
Log.e("MapViewModel", "Malformed URL: $formattedUrl", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun isValidTileUrlTemplate(urlTemplate: String): Boolean = urlTemplate.contains("{z}", ignoreCase = true) &&
|
||||
urlTemplate.contains("{x}", ignoreCase = true) &&
|
||||
urlTemplate.contains("{y}", ignoreCase = true)
|
||||
|
||||
private val _mapLayers = MutableStateFlow<List<MapLayerItem>>(emptyList())
|
||||
val mapLayers: StateFlow<List<MapLayerItem>> = _mapLayers.asStateFlow()
|
||||
|
||||
init {
|
||||
loadPersistedLayers()
|
||||
}
|
||||
|
||||
private fun loadPersistedLayers() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val layersDir = File(application.filesDir, "map_layers")
|
||||
if (layersDir.exists() && layersDir.isDirectory) {
|
||||
val persistedLayerFiles = layersDir.listFiles()
|
||||
|
||||
if (persistedLayerFiles != null) {
|
||||
val loadedItems =
|
||||
persistedLayerFiles.mapNotNull { file ->
|
||||
if (file.isFile) {
|
||||
MapLayerItem(
|
||||
name = file.nameWithoutExtension,
|
||||
uri = Uri.fromFile(file),
|
||||
isVisible = true,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
_mapLayers.value = loadedItems
|
||||
if (loadedItems.isNotEmpty()) {
|
||||
Log.i("MapViewModel", "Loaded ${loadedItems.size} persisted map layers.")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Log.i("MapViewModel", "Map layers directory does not exist. No layers loaded.")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("MapViewModel", "Error loading persisted map layers", e)
|
||||
_mapLayers.value = emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun addMapLayer(uri: Uri, fileName: String?) {
|
||||
viewModelScope.launch {
|
||||
val layerName = fileName ?: "Layer ${mapLayers.value.size + 1}"
|
||||
val localFileUri = copyFileToInternalStorage(uri, fileName ?: "layer_${UUID.randomUUID()}")
|
||||
|
||||
if (localFileUri != null) {
|
||||
val newItem = MapLayerItem(name = layerName, uri = localFileUri)
|
||||
_mapLayers.value = _mapLayers.value + newItem
|
||||
} else {
|
||||
Log.e("MapViewModel", "Failed to copy KML/KMZ file to internal storage.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun copyFileToInternalStorage(uri: Uri, fileName: String): Uri? = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val inputStream = application.contentResolver.openInputStream(uri)
|
||||
val directory = File(application.filesDir, "map_layers")
|
||||
if (!directory.exists()) {
|
||||
directory.mkdirs()
|
||||
}
|
||||
val outputFile = File(directory, fileName)
|
||||
val outputStream = FileOutputStream(outputFile)
|
||||
|
||||
inputStream?.use { input -> outputStream.use { output -> input.copyTo(output) } }
|
||||
Uri.fromFile(outputFile)
|
||||
} catch (e: IOException) {
|
||||
Log.e("MapViewModel", "Error copying file to internal storage", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleLayerVisibility(layerId: String) {
|
||||
_mapLayers.value = _mapLayers.value.map { if (it.id == layerId) it.copy(isVisible = !it.isVisible) else it }
|
||||
}
|
||||
|
||||
fun removeMapLayer(layerId: String) {
|
||||
viewModelScope.launch {
|
||||
val layerToRemove = _mapLayers.value.find { it.id == layerId }
|
||||
layerToRemove?.kmlLayerData?.removeLayerFromMap()
|
||||
layerToRemove?.uri?.let { uri -> deleteFileFromInternalStorage(uri) }
|
||||
_mapLayers.value = _mapLayers.value.filterNot { it.id == layerId }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun deleteFileFromInternalStorage(uri: Uri) {
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val file = File(uri.path ?: return@withContext)
|
||||
if (file.exists()) {
|
||||
file.delete()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("MapViewModel", "Error deleting file from internal storage", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("Recycle")
|
||||
suspend fun getInputStreamFromUri(layerItem: MapLayerItem): InputStream? {
|
||||
val uriToLoad = layerItem.uri ?: return null
|
||||
val stream =
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
application.contentResolver.openInputStream(uriToLoad)
|
||||
} catch (_: Exception) {
|
||||
debug("MapViewModel: Error opening InputStream from URI: $uriToLoad")
|
||||
null
|
||||
}
|
||||
}
|
||||
return stream
|
||||
}
|
||||
|
||||
suspend fun loadKmlLayerIfNeeded(map: GoogleMap, layerItem: MapLayerItem): KmlLayer? {
|
||||
if (layerItem.kmlLayerData != null) {
|
||||
return layerItem.kmlLayerData
|
||||
}
|
||||
|
||||
return try {
|
||||
getInputStreamFromUri(layerItem)?.use { inputStream ->
|
||||
val kmlLayer = KmlLayer(map, inputStream, application.applicationContext)
|
||||
_mapLayers.update { currentLayers ->
|
||||
currentLayers.map { if (it.id == layerItem.id) it.copy(kmlLayerData = kmlLayer) else it }
|
||||
}
|
||||
kmlLayer
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("MapViewModel", "Error loading KML for ${layerItem.uri}", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class MapLayerItem(
|
||||
val id: String = UUID.randomUUID().toString(),
|
||||
val name: String,
|
||||
val uri: Uri? = null,
|
||||
var isVisible: Boolean = true,
|
||||
var kmlLayerData: KmlLayer? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class CustomTileProviderConfig(
|
||||
val id: String = UUID.randomUUID().toString(),
|
||||
val name: String,
|
||||
val urlTemplate: String,
|
||||
)
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.map.components
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.geeksville.mesh.R
|
||||
import com.geeksville.mesh.ui.map.NodeClusterItem
|
||||
import com.geeksville.mesh.ui.node.components.NodeChip
|
||||
|
||||
@Composable
|
||||
fun ClusterItemsListDialog(
|
||||
items: List<NodeClusterItem>,
|
||||
onDismiss: () -> Unit,
|
||||
onItemClick: (NodeClusterItem) -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(text = stringResource(R.string.nodes_at_this_location)) },
|
||||
text = {
|
||||
// Use a LazyColumn for potentially long lists of items
|
||||
LazyColumn(contentPadding = PaddingValues(vertical = 8.dp)) {
|
||||
items(items, key = { it.node.num }) { item ->
|
||||
ClusterDialogListItem(item = item, onClick = { onItemClick(item) })
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.okay)) } },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ClusterDialogListItem(item: NodeClusterItem, onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
ListItem(
|
||||
leadingContent = { NodeChip(node = item.node, enabled = false, isThisNode = false, isConnected = false) {} },
|
||||
headlineContent = { Text(item.nodeTitle) },
|
||||
supportingContent = {
|
||||
if (item.nodeSnippet.isNotBlank()) {
|
||||
Text(item.nodeSnippet)
|
||||
}
|
||||
},
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp), // Add some padding around list items
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.map.components
|
||||
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.geeksville.mesh.R
|
||||
import com.geeksville.mesh.ui.map.MapLayerItem
|
||||
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
fun CustomMapLayersSheet(
|
||||
mapLayers: List<MapLayerItem>,
|
||||
onToggleVisibility: (String) -> Unit,
|
||||
onRemoveLayer: (String) -> Unit,
|
||||
onAddLayerClicked: () -> Unit,
|
||||
) {
|
||||
LazyColumn(contentPadding = PaddingValues(bottom = 16.dp)) {
|
||||
item {
|
||||
Text(
|
||||
modifier = Modifier.Companion.padding(16.dp),
|
||||
text = stringResource(R.string.manage_map_layers),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
|
||||
if (mapLayers.isEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
modifier = Modifier.Companion.padding(16.dp),
|
||||
text = stringResource(R.string.no_map_layers_loaded),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
items(mapLayers, key = { it.id }) { layer ->
|
||||
ListItem(
|
||||
headlineContent = { Text(layer.name) },
|
||||
trailingContent = {
|
||||
Row {
|
||||
IconButton(onClick = { onToggleVisibility(layer.id) }) {
|
||||
Icon(
|
||||
imageVector =
|
||||
if (layer.isVisible) {
|
||||
Icons.Filled.Visibility
|
||||
} else {
|
||||
Icons.Filled.VisibilityOff
|
||||
},
|
||||
contentDescription =
|
||||
stringResource(
|
||||
if (layer.isVisible) {
|
||||
R.string.hide_layer
|
||||
} else {
|
||||
R.string.show_layer
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { onRemoveLayer(layer.id) }) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Delete,
|
||||
contentDescription = stringResource(R.string.remove_layer),
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
item {
|
||||
Button(modifier = Modifier.Companion.fillMaxWidth().padding(16.dp), onClick = onAddLayerClicked) {
|
||||
Text(stringResource(R.string.add_layer))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.map.components
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.geeksville.mesh.R
|
||||
import com.geeksville.mesh.ui.map.CustomTileProviderConfig
|
||||
import com.geeksville.mesh.ui.map.MapViewModel
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
fun CustomTileProviderManagerSheet(mapViewModel: MapViewModel) {
|
||||
val customTileProviders by mapViewModel.customTileProviderConfigs.collectAsStateWithLifecycle()
|
||||
var editingConfig by remember { mutableStateOf<CustomTileProviderConfig?>(null) }
|
||||
var showEditDialog by remember { mutableStateOf(false) }
|
||||
val context = LocalContext.current
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
mapViewModel.errorFlow.collectLatest { errorMessage ->
|
||||
Toast.makeText(context, errorMessage, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
if (showEditDialog) {
|
||||
AddEditCustomTileProviderDialog(
|
||||
config = editingConfig,
|
||||
onDismiss = { showEditDialog = false },
|
||||
onSave = { name, url ->
|
||||
if (editingConfig == null) { // Adding new
|
||||
mapViewModel.addCustomTileProvider(name, url)
|
||||
} else { // Editing existing
|
||||
mapViewModel.updateCustomTileProvider(editingConfig!!.copy(name = name, urlTemplate = url))
|
||||
}
|
||||
showEditDialog = false
|
||||
},
|
||||
mapViewModel = mapViewModel,
|
||||
)
|
||||
}
|
||||
|
||||
LazyColumn(contentPadding = PaddingValues(bottom = 16.dp)) {
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(R.string.manage_custom_tile_sources),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
modifier = Modifier.padding(16.dp),
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
|
||||
if (customTileProviders.isEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(R.string.no_custom_tile_sources_found),
|
||||
modifier = Modifier.padding(16.dp),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
items(customTileProviders, key = { it.id }) { config ->
|
||||
ListItem(
|
||||
headlineContent = { Text(config.name) },
|
||||
supportingContent = { Text(config.urlTemplate, style = MaterialTheme.typography.bodySmall) },
|
||||
trailingContent = {
|
||||
Row {
|
||||
IconButton(
|
||||
onClick = {
|
||||
editingConfig = config
|
||||
showEditDialog = true
|
||||
},
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.Edit,
|
||||
contentDescription = stringResource(R.string.edit_custom_tile_source),
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { mapViewModel.removeCustomTileProvider(config.id) }) {
|
||||
Icon(
|
||||
Icons.Filled.Delete,
|
||||
contentDescription = stringResource(R.string.delete_custom_tile_source),
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Button(
|
||||
onClick = {
|
||||
editingConfig = null
|
||||
showEditDialog = true
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||
) {
|
||||
Text(stringResource(R.string.add_custom_tile_source))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
private fun AddEditCustomTileProviderDialog(
|
||||
config: CustomTileProviderConfig?,
|
||||
onDismiss: () -> Unit,
|
||||
onSave: (String, String) -> Unit,
|
||||
mapViewModel: MapViewModel,
|
||||
) {
|
||||
var name by rememberSaveable { mutableStateOf(config?.name ?: "") }
|
||||
var url by rememberSaveable { mutableStateOf(config?.urlTemplate ?: "") }
|
||||
var nameError by remember { mutableStateOf<String?>(null) }
|
||||
var urlError by remember { mutableStateOf<String?>(null) }
|
||||
val customTileProviders by mapViewModel.customTileProviderConfigs.collectAsStateWithLifecycle()
|
||||
|
||||
val emptyNameError = stringResource(R.string.name_cannot_be_empty)
|
||||
val providerNameExistsError = stringResource(R.string.provider_name_exists)
|
||||
val urlCannotBeEmptyError = stringResource(R.string.url_cannot_be_empty)
|
||||
val urlMustContainPlaceholdersError = stringResource(R.string.url_must_contain_placeholders)
|
||||
|
||||
fun validateAndSave() {
|
||||
val currentNameError =
|
||||
validateName(name, customTileProviders, config?.id, emptyNameError, providerNameExistsError)
|
||||
val currentUrlError = validateUrl(url, urlCannotBeEmptyError, urlMustContainPlaceholdersError)
|
||||
|
||||
nameError = currentNameError
|
||||
urlError = currentUrlError
|
||||
|
||||
if (currentNameError == null && currentUrlError == null) {
|
||||
onSave(name, url)
|
||||
}
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = {
|
||||
Text(
|
||||
if (config == null) {
|
||||
stringResource(R.string.add_custom_tile_source)
|
||||
} else {
|
||||
stringResource(R.string.edit_custom_tile_source)
|
||||
},
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = {
|
||||
name = it
|
||||
nameError = null
|
||||
},
|
||||
label = { Text(stringResource(R.string.name)) },
|
||||
isError = nameError != null,
|
||||
supportingText = { nameError?.let { Text(it) } },
|
||||
singleLine = true,
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = url,
|
||||
onValueChange = {
|
||||
url = it
|
||||
urlError = null
|
||||
},
|
||||
label = { Text(stringResource(R.string.url_template)) },
|
||||
isError = urlError != null,
|
||||
supportingText = {
|
||||
if (urlError != null) {
|
||||
Text(urlError!!)
|
||||
} else {
|
||||
Text(stringResource(R.string.url_template_hint))
|
||||
}
|
||||
},
|
||||
singleLine = false,
|
||||
maxLines = 2,
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = { Button(onClick = { validateAndSave() }) { Text(stringResource(R.string.save)) } },
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } },
|
||||
)
|
||||
}
|
||||
|
||||
private fun validateName(
|
||||
name: String,
|
||||
providers: List<CustomTileProviderConfig>,
|
||||
currentId: String?,
|
||||
emptyNameError: String,
|
||||
nameExistsError: String,
|
||||
): String? = if (name.isBlank()) {
|
||||
emptyNameError
|
||||
} else if (providers.any { it.name.equals(name, ignoreCase = true) && it.id != currentId }) {
|
||||
nameExistsError
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
private fun validateUrl(url: String, emptyUrlError: String, mustContainPlaceholdersError: String): String? =
|
||||
if (url.isBlank()) {
|
||||
emptyUrlError
|
||||
} else if (
|
||||
!url.contains("{z}", ignoreCase = true) ||
|
||||
!url.contains("{x}", ignoreCase = true) ||
|
||||
!url.contains("{y}", ignoreCase = true)
|
||||
) {
|
||||
mustContainPlaceholdersError
|
||||
} else {
|
||||
null
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.map.components
|
||||
|
||||
import android.app.DatePickerDialog
|
||||
import android.app.TimePickerDialog
|
||||
import android.widget.DatePicker
|
||||
import android.widget.TimePicker
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CalendarMonth
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.geeksville.mesh.MeshProtos.Waypoint
|
||||
import com.geeksville.mesh.R
|
||||
import com.geeksville.mesh.copy
|
||||
import com.geeksville.mesh.ui.common.components.EmojiPickerDialog
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Calendar
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Suppress("LongMethod", "CyclomaticComplexMethod", "MagicNumber")
|
||||
@Composable
|
||||
fun EditWaypointDialog(
|
||||
waypoint: Waypoint,
|
||||
onSendClicked: (Waypoint) -> Unit,
|
||||
onDeleteClicked: (Waypoint) -> Unit,
|
||||
onDismissRequest: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var waypointInput by remember { mutableStateOf(waypoint) }
|
||||
val title = if (waypoint.id == 0) R.string.waypoint_new else R.string.waypoint_edit
|
||||
val defaultEmoji = 0x1F4CD // 📍 Round Pushpin
|
||||
val currentEmojiCodepoint = if (waypointInput.icon == 0) defaultEmoji else waypointInput.icon
|
||||
var showEmojiPickerView by remember { mutableStateOf(false) }
|
||||
|
||||
val context = LocalContext.current
|
||||
val calendar = remember { Calendar.getInstance() }
|
||||
|
||||
// Initialize date and time states from waypointInput.expire
|
||||
var selectedDateString by remember { mutableStateOf("") }
|
||||
var selectedTimeString by remember { mutableStateOf("") }
|
||||
var isExpiryEnabled by remember {
|
||||
mutableStateOf(waypointInput.expire != 0 && waypointInput.expire != Int.MAX_VALUE)
|
||||
}
|
||||
|
||||
val locale = Locale.getDefault()
|
||||
val dateFormat = remember {
|
||||
if (locale.country.equals("US", ignoreCase = true)) {
|
||||
SimpleDateFormat("MM/dd/yyyy", locale)
|
||||
} else {
|
||||
SimpleDateFormat("dd/MM/yyyy", locale)
|
||||
}
|
||||
}
|
||||
val timeFormat = remember {
|
||||
val is24Hour = android.text.format.DateFormat.is24HourFormat(context)
|
||||
if (is24Hour) {
|
||||
SimpleDateFormat("HH:mm", locale)
|
||||
} else {
|
||||
SimpleDateFormat("hh:mm a", locale)
|
||||
}
|
||||
}
|
||||
dateFormat.timeZone = TimeZone.getDefault()
|
||||
timeFormat.timeZone = TimeZone.getDefault()
|
||||
|
||||
LaunchedEffect(waypointInput.expire, isExpiryEnabled) {
|
||||
if (isExpiryEnabled) {
|
||||
if (waypointInput.expire != 0 && waypointInput.expire != Int.MAX_VALUE) {
|
||||
calendar.timeInMillis = waypointInput.expire * 1000L
|
||||
selectedDateString = dateFormat.format(calendar.time)
|
||||
selectedTimeString = timeFormat.format(calendar.time)
|
||||
} else { // If enabled but not set, default to 8 hours from now
|
||||
calendar.timeInMillis = System.currentTimeMillis()
|
||||
calendar.add(Calendar.HOUR_OF_DAY, 8)
|
||||
waypointInput = waypointInput.copy { expire = (calendar.timeInMillis / 1000).toInt() }
|
||||
}
|
||||
} else {
|
||||
selectedDateString = ""
|
||||
selectedTimeString = ""
|
||||
}
|
||||
}
|
||||
|
||||
if (!showEmojiPickerView) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
title = {
|
||||
Text(
|
||||
text = stringResource(title),
|
||||
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
OutlinedTextField(
|
||||
value = waypointInput.name,
|
||||
onValueChange = { waypointInput = waypointInput.copy { name = it.take(29) } },
|
||||
label = { Text(stringResource(R.string.name)) },
|
||||
singleLine = true,
|
||||
keyboardOptions =
|
||||
KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Next),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { showEmojiPickerView = true }) {
|
||||
Text(
|
||||
text = String(Character.toChars(currentEmojiCodepoint)),
|
||||
modifier =
|
||||
Modifier.background(MaterialTheme.colorScheme.surfaceVariant, CircleShape)
|
||||
.padding(6.dp),
|
||||
fontSize = 20.sp,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
OutlinedTextField(
|
||||
value = waypointInput.description,
|
||||
onValueChange = { waypointInput = waypointInput.copy { description = it.take(99) } },
|
||||
label = { Text(stringResource(R.string.description)) },
|
||||
keyboardOptions =
|
||||
KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done),
|
||||
keyboardActions = KeyboardActions(onDone = { /* Handle next/done focus */ }),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
minLines = 2,
|
||||
maxLines = 3,
|
||||
)
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Image(
|
||||
imageVector = Icons.Default.Lock,
|
||||
contentDescription = stringResource(R.string.locked),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(stringResource(R.string.locked))
|
||||
}
|
||||
Switch(
|
||||
checked = waypointInput.lockedTo != 0,
|
||||
onCheckedChange = { waypointInput = waypointInput.copy { lockedTo = if (it) 1 else 0 } },
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Image(
|
||||
imageVector = Icons.Default.CalendarMonth,
|
||||
contentDescription = stringResource(R.string.expires),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(stringResource(R.string.expires))
|
||||
}
|
||||
Switch(
|
||||
checked = isExpiryEnabled,
|
||||
onCheckedChange = { checked ->
|
||||
isExpiryEnabled = checked
|
||||
if (checked) {
|
||||
// Default to 8 hours from now if not already set
|
||||
if (waypointInput.expire == 0 || waypointInput.expire == Int.MAX_VALUE) {
|
||||
val cal = Calendar.getInstance()
|
||||
cal.timeInMillis = System.currentTimeMillis()
|
||||
cal.add(Calendar.HOUR_OF_DAY, 8)
|
||||
waypointInput =
|
||||
waypointInput.copy { expire = (cal.timeInMillis / 1000).toInt() }
|
||||
}
|
||||
// LaunchedEffect will update date/time strings
|
||||
} else {
|
||||
waypointInput = waypointInput.copy { expire = Int.MAX_VALUE }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (isExpiryEnabled) {
|
||||
val currentCalendar =
|
||||
Calendar.getInstance().apply {
|
||||
if (waypointInput.expire != 0 && waypointInput.expire != Int.MAX_VALUE) {
|
||||
timeInMillis = waypointInput.expire * 1000L
|
||||
} else {
|
||||
timeInMillis = System.currentTimeMillis()
|
||||
add(Calendar.HOUR_OF_DAY, 8) // Default if re-enabling
|
||||
}
|
||||
}
|
||||
val year = currentCalendar.get(Calendar.YEAR)
|
||||
val month = currentCalendar.get(Calendar.MONTH)
|
||||
val day = currentCalendar.get(Calendar.DAY_OF_MONTH)
|
||||
val hour = currentCalendar.get(Calendar.HOUR_OF_DAY)
|
||||
val minute = currentCalendar.get(Calendar.MINUTE)
|
||||
|
||||
val datePickerDialog =
|
||||
DatePickerDialog(
|
||||
context,
|
||||
{ _: DatePicker, selectedYear: Int, selectedMonth: Int, selectedDay: Int ->
|
||||
calendar.clear()
|
||||
calendar.set(selectedYear, selectedMonth, selectedDay, hour, minute)
|
||||
waypointInput =
|
||||
waypointInput.copy { expire = (calendar.timeInMillis / 1000).toInt() }
|
||||
},
|
||||
year,
|
||||
month,
|
||||
day,
|
||||
)
|
||||
|
||||
val timePickerDialog =
|
||||
TimePickerDialog(
|
||||
context,
|
||||
{ _: TimePicker, selectedHour: Int, selectedMinute: Int ->
|
||||
// Keep the existing date part
|
||||
val tempCal = Calendar.getInstance()
|
||||
tempCal.timeInMillis = waypointInput.expire * 1000L
|
||||
tempCal.set(Calendar.HOUR_OF_DAY, selectedHour)
|
||||
tempCal.set(Calendar.MINUTE, selectedMinute)
|
||||
waypointInput =
|
||||
waypointInput.copy { expire = (tempCal.timeInMillis / 1000).toInt() }
|
||||
},
|
||||
hour,
|
||||
minute,
|
||||
android.text.format.DateFormat.is24HourFormat(context),
|
||||
)
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Button(onClick = { datePickerDialog.show() }) { Text(stringResource(R.string.date)) }
|
||||
Text(
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
text = selectedDateString,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Button(onClick = { timePickerDialog.show() }) { Text(stringResource(R.string.time)) }
|
||||
Text(
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
text = selectedTimeString,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(start = 8.dp, end = 8.dp, bottom = 8.dp),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
if (waypoint.id != 0) {
|
||||
TextButton(
|
||||
onClick = { onDeleteClicked(waypointInput) },
|
||||
modifier = Modifier.padding(end = 8.dp),
|
||||
) {
|
||||
Text(stringResource(R.string.delete), color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.weight(1f)) // Pushes delete to left and cancel/send to right
|
||||
TextButton(onClick = onDismissRequest, modifier = Modifier.padding(end = 8.dp)) {
|
||||
Text(stringResource(R.string.cancel))
|
||||
}
|
||||
Button(onClick = { onSendClicked(waypointInput) }, enabled = waypointInput.name.isNotBlank()) {
|
||||
Text(stringResource(R.string.send))
|
||||
}
|
||||
}
|
||||
},
|
||||
dismissButton = null, // Using custom buttons in confirmButton Row
|
||||
modifier = modifier,
|
||||
)
|
||||
} else {
|
||||
EmojiPickerDialog(onDismiss = { showEmojiPickerView = false }) { selectedEmoji ->
|
||||
showEmojiPickerView = false
|
||||
waypointInput = waypointInput.copy { icon = selectedEmoji.codePointAt(0) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.map.components
|
||||
|
||||
import androidx.compose.material3.FilledIconButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
|
||||
@Composable
|
||||
fun MapButton(icon: ImageVector, contentDescription: String, modifier: Modifier = Modifier, onClick: () -> Unit) {
|
||||
FilledIconButton(onClick = onClick, modifier = modifier) {
|
||||
Icon(imageVector = icon, contentDescription = contentDescription)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.map.components
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Layers
|
||||
import androidx.compose.material.icons.outlined.Map
|
||||
import androidx.compose.material.icons.outlined.Tune
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.FloatingToolbarScrollBehavior
|
||||
import androidx.compose.material3.VerticalFloatingToolbar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.geeksville.mesh.R
|
||||
import com.geeksville.mesh.ui.map.MapViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun MapControlsOverlay(
|
||||
modifier: Modifier = Modifier,
|
||||
mapFilterMenuExpanded: Boolean,
|
||||
onMapFilterMenuDismissRequest: () -> Unit,
|
||||
onToggleMapFilterMenu: () -> Unit,
|
||||
mapViewModel: MapViewModel, // For MapFilterDropdown and MapTypeDropdown
|
||||
mapTypeMenuExpanded: Boolean,
|
||||
onMapTypeMenuDismissRequest: () -> Unit,
|
||||
onToggleMapTypeMenu: () -> Unit,
|
||||
onManageLayersClicked: () -> Unit,
|
||||
onManageCustomTileProvidersClicked: () -> Unit, // New parameter
|
||||
showFilterButton: Boolean,
|
||||
scrollBehavior: FloatingToolbarScrollBehavior,
|
||||
) {
|
||||
VerticalFloatingToolbar(
|
||||
modifier = modifier,
|
||||
expanded = true,
|
||||
leadingContent = {},
|
||||
trailingContent = {},
|
||||
scrollBehavior = scrollBehavior,
|
||||
content = {
|
||||
if (showFilterButton) {
|
||||
Box {
|
||||
MapButton(
|
||||
icon = Icons.Outlined.Tune,
|
||||
contentDescription = stringResource(id = R.string.map_filter),
|
||||
onClick = onToggleMapFilterMenu,
|
||||
)
|
||||
MapFilterDropdown(
|
||||
expanded = mapFilterMenuExpanded,
|
||||
onDismissRequest = onMapFilterMenuDismissRequest,
|
||||
mapViewModel = mapViewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Box {
|
||||
MapButton(
|
||||
icon = Icons.Outlined.Map,
|
||||
contentDescription = stringResource(id = R.string.map_tile_source),
|
||||
onClick = onToggleMapTypeMenu,
|
||||
)
|
||||
MapTypeDropdown(
|
||||
expanded = mapTypeMenuExpanded,
|
||||
onDismissRequest = onMapTypeMenuDismissRequest,
|
||||
mapViewModel = mapViewModel, // Pass mapViewModel
|
||||
onManageCustomTileProvidersClicked = onManageCustomTileProvidersClicked, // Pass new callback
|
||||
)
|
||||
}
|
||||
|
||||
MapButton(
|
||||
icon = Icons.Outlined.Layers,
|
||||
contentDescription = stringResource(id = R.string.manage_map_layers),
|
||||
onClick = onManageLayersClicked,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.map.components
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Favorite
|
||||
import androidx.compose.material.icons.filled.Place
|
||||
import androidx.compose.material.icons.outlined.RadioButtonUnchecked
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.geeksville.mesh.R
|
||||
import com.geeksville.mesh.ui.map.MapViewModel
|
||||
|
||||
@Composable
|
||||
internal fun MapFilterDropdown(expanded: Boolean, onDismissRequest: () -> Unit, mapViewModel: MapViewModel) {
|
||||
val mapFilterState by mapViewModel.mapFilterStateFlow.collectAsStateWithLifecycle()
|
||||
DropdownMenu(expanded = expanded, onDismissRequest = onDismissRequest) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(id = R.string.only_favorites)) },
|
||||
onClick = { mapViewModel.toggleOnlyFavorites() },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Favorite,
|
||||
contentDescription = stringResource(id = R.string.only_favorites),
|
||||
)
|
||||
},
|
||||
trailingIcon = {
|
||||
Checkbox(
|
||||
checked = mapFilterState.onlyFavorites,
|
||||
onCheckedChange = { mapViewModel.toggleOnlyFavorites() },
|
||||
)
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(id = R.string.show_waypoints)) },
|
||||
onClick = { mapViewModel.toggleShowWaypointsOnMap() },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Place,
|
||||
contentDescription = stringResource(id = R.string.show_waypoints),
|
||||
)
|
||||
},
|
||||
trailingIcon = {
|
||||
Checkbox(
|
||||
checked = mapFilterState.showWaypoints,
|
||||
onCheckedChange = { mapViewModel.toggleShowWaypointsOnMap() },
|
||||
)
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(id = R.string.show_precision_circle)) },
|
||||
onClick = { mapViewModel.toggleShowPrecisionCircleOnMap() },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.RadioButtonUnchecked, // Placeholder icon
|
||||
contentDescription = stringResource(id = R.string.show_precision_circle),
|
||||
)
|
||||
},
|
||||
trailingIcon = {
|
||||
Checkbox(
|
||||
checked = mapFilterState.showPrecisionCircle,
|
||||
onCheckedChange = { mapViewModel.toggleShowPrecisionCircleOnMap() },
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.map.components
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.geeksville.mesh.R
|
||||
import com.geeksville.mesh.ui.map.MapViewModel
|
||||
import com.google.maps.android.compose.MapType
|
||||
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
internal fun MapTypeDropdown(
|
||||
expanded: Boolean,
|
||||
onDismissRequest: () -> Unit,
|
||||
mapViewModel: MapViewModel,
|
||||
onManageCustomTileProvidersClicked: () -> Unit,
|
||||
) {
|
||||
val customTileProviders by mapViewModel.customTileProviderConfigs.collectAsStateWithLifecycle()
|
||||
val selectedCustomUrl by mapViewModel.selectedCustomTileProviderUrl.collectAsStateWithLifecycle()
|
||||
val selectedGoogleMapType by mapViewModel.selectedGoogleMapType.collectAsStateWithLifecycle()
|
||||
|
||||
val googleMapTypes =
|
||||
listOf(
|
||||
stringResource(id = R.string.map_type_normal) to MapType.NORMAL,
|
||||
stringResource(id = R.string.map_type_satellite) to MapType.SATELLITE,
|
||||
stringResource(id = R.string.map_type_terrain) to MapType.TERRAIN,
|
||||
stringResource(id = R.string.map_type_hybrid) to MapType.HYBRID,
|
||||
)
|
||||
|
||||
DropdownMenu(expanded = expanded, onDismissRequest = onDismissRequest) {
|
||||
googleMapTypes.forEach { (name, type) ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(name) },
|
||||
onClick = {
|
||||
mapViewModel.setSelectedGoogleMapType(type)
|
||||
onDismissRequest() // Close menu
|
||||
},
|
||||
trailingIcon =
|
||||
if (selectedCustomUrl == null && selectedGoogleMapType == type) {
|
||||
{ Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.selected_map_type)) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (customTileProviders.isNotEmpty()) {
|
||||
HorizontalDivider()
|
||||
customTileProviders.forEach { config ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(config.name) },
|
||||
onClick = {
|
||||
mapViewModel.selectCustomTileProvider(config)
|
||||
onDismissRequest() // Close menu
|
||||
},
|
||||
trailingIcon =
|
||||
if (selectedCustomUrl == config.urlTemplate) {
|
||||
{
|
||||
Icon(
|
||||
Icons.Filled.Check,
|
||||
contentDescription = stringResource(R.string.selected_map_type),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
HorizontalDivider()
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.manage_custom_tile_sources)) },
|
||||
onClick = {
|
||||
onManageCustomTileProvidersClicked()
|
||||
onDismissRequest()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.map.components
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import com.geeksville.mesh.ui.map.BaseMapViewModel
|
||||
import com.geeksville.mesh.ui.map.NodeClusterItem
|
||||
import com.geeksville.mesh.ui.node.components.NodeChip
|
||||
import com.google.maps.android.clustering.Cluster
|
||||
import com.google.maps.android.compose.Circle
|
||||
import com.google.maps.android.compose.MapsComposeExperimentalApi
|
||||
import com.google.maps.android.compose.clustering.Clustering
|
||||
|
||||
@OptIn(MapsComposeExperimentalApi::class)
|
||||
@Suppress("NestedBlockDepth")
|
||||
@Composable
|
||||
fun NodeClusterMarkers(
|
||||
nodeClusterItems: List<NodeClusterItem>,
|
||||
mapFilterState: BaseMapViewModel.MapFilterState,
|
||||
navigateToNodeDetails: (Int) -> Unit,
|
||||
onClusterClick: (Cluster<NodeClusterItem>) -> Boolean,
|
||||
) {
|
||||
if (mapFilterState.showPrecisionCircle) {
|
||||
nodeClusterItems.forEach { clusterItem ->
|
||||
key(clusterItem.node.num) {
|
||||
// Add a stable key for each circle
|
||||
clusterItem.getPrecisionMeters()?.let { precisionMeters ->
|
||||
if (precisionMeters > 0) {
|
||||
Circle(
|
||||
center = clusterItem.position,
|
||||
radius = precisionMeters,
|
||||
fillColor = Color(clusterItem.node.colors.second).copy(alpha = 0.2f),
|
||||
strokeColor = Color(clusterItem.node.colors.second),
|
||||
strokeWidth = 2f,
|
||||
zIndex = 1f, // Ensure circles are drawn above markers
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Clustering(
|
||||
items = nodeClusterItems,
|
||||
onClusterClick = onClusterClick,
|
||||
onClusterItemInfoWindowClick = { item ->
|
||||
navigateToNodeDetails(item.node.num)
|
||||
false
|
||||
},
|
||||
clusterItemContent = { clusterItem ->
|
||||
NodeChip(node = clusterItem.node, enabled = false, isThisNode = false, isConnected = false) {}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.map.components
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import com.geeksville.mesh.MeshProtos
|
||||
import com.geeksville.mesh.R
|
||||
import com.geeksville.mesh.ui.map.BaseMapViewModel
|
||||
import com.geeksville.mesh.ui.node.DEG_D
|
||||
import com.google.android.gms.maps.model.BitmapDescriptor
|
||||
import com.google.android.gms.maps.model.LatLng
|
||||
import com.google.maps.android.compose.Marker
|
||||
import com.google.maps.android.compose.rememberUpdatedMarkerState
|
||||
|
||||
@Composable
|
||||
fun WaypointMarkers(
|
||||
displayableWaypoints: List<MeshProtos.Waypoint>,
|
||||
mapFilterState: BaseMapViewModel.MapFilterState,
|
||||
myNodeNum: Int,
|
||||
isConnected: Boolean,
|
||||
unicodeEmojiToBitmapProvider: (Int) -> BitmapDescriptor,
|
||||
onEditWaypointRequest: (MeshProtos.Waypoint) -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
if (mapFilterState.showWaypoints) {
|
||||
displayableWaypoints.forEach { waypoint ->
|
||||
val markerState =
|
||||
rememberUpdatedMarkerState(position = LatLng(waypoint.latitudeI * DEG_D, waypoint.longitudeI * DEG_D))
|
||||
|
||||
Marker(
|
||||
state = markerState,
|
||||
icon =
|
||||
if (waypoint.icon == 0) {
|
||||
unicodeEmojiToBitmapProvider(PUSHPIN) // Default icon (Round Pushpin)
|
||||
} else {
|
||||
unicodeEmojiToBitmapProvider(waypoint.icon)
|
||||
},
|
||||
title = waypoint.name,
|
||||
snippet = waypoint.description,
|
||||
visible = true,
|
||||
onInfoWindowClick = {
|
||||
if (waypoint.lockedTo == 0 || waypoint.lockedTo == myNodeNum || !isConnected) {
|
||||
onEditWaypointRequest(waypoint)
|
||||
} else {
|
||||
Toast.makeText(context, context.getString(R.string.locked), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val PUSHPIN = 0x1F4CD // Unicode for Round Pushpin
|
||||
36
app/src/google/java/com/geeksville/mesh/ui/node/NodeMap.kt
Normal file
36
app/src/google/java/com/geeksville/mesh/ui/node/NodeMap.kt
Normal file
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.node
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.geeksville.mesh.model.MetricsViewModel
|
||||
import com.geeksville.mesh.model.UIViewModel
|
||||
import com.geeksville.mesh.ui.map.MapView
|
||||
|
||||
const val DEG_D = 1e-7
|
||||
|
||||
@Composable
|
||||
fun NodeMapScreen(uiViewModel: UIViewModel, metricsViewModel: MetricsViewModel = hiltViewModel()) {
|
||||
val state by metricsViewModel.state.collectAsState()
|
||||
val positions = state.positionLogs
|
||||
val destNum = state.node?.num
|
||||
MapView(uiViewModel = uiViewModel, focusedNodeNum = destNum, nodeTrack = positions, navigateToNodeDetails = {})
|
||||
}
|
||||
Reference in New Issue
Block a user