mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
refactor: maps (#2097)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
parent
c05f434ff2
commit
87e50e03ea
76 changed files with 4188 additions and 1830 deletions
|
|
@ -19,7 +19,6 @@ package com.geeksville.mesh
|
|||
|
||||
import android.graphics.Color
|
||||
import android.os.Parcelable
|
||||
import com.geeksville.mesh.util.GPSFormat
|
||||
import com.geeksville.mesh.util.anonymize
|
||||
import com.geeksville.mesh.util.bearing
|
||||
import com.geeksville.mesh.util.latLongToMeter
|
||||
|
|
@ -115,14 +114,6 @@ data class Position(
|
|||
(latitude >= -90 && latitude <= 90.0) &&
|
||||
(longitude >= -180 && longitude <= 180)
|
||||
|
||||
fun gpsString(gpsFormat: Int): String = when (gpsFormat) {
|
||||
ConfigProtos.Config.DisplayConfig.GpsCoordinateFormat.DEC_VALUE -> GPSFormat.DEC(this)
|
||||
ConfigProtos.Config.DisplayConfig.GpsCoordinateFormat.DMS_VALUE -> GPSFormat.DMS(this)
|
||||
ConfigProtos.Config.DisplayConfig.GpsCoordinateFormat.UTM_VALUE -> GPSFormat.UTM(this)
|
||||
ConfigProtos.Config.DisplayConfig.GpsCoordinateFormat.MGRS_VALUE -> GPSFormat.MGRS(this)
|
||||
else -> GPSFormat.DEC(this)
|
||||
}
|
||||
|
||||
override fun toString(): String =
|
||||
"Position(lat=${latitude.anonymize}, lon=${longitude.anonymize}, alt=${altitude.anonymize}, time=$time)"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,9 +62,14 @@ data class Node(
|
|||
return (if (brightness > 0.5) Color.BLACK else Color.WHITE) to Color.rgb(r, g, b)
|
||||
}
|
||||
|
||||
val isUnknownUser get() = user.hwModel == MeshProtos.HardwareModel.UNSET
|
||||
val hasPKC get() = (publicKey ?: user.publicKey).isNotEmpty()
|
||||
val mismatchKey get() = (publicKey ?: user.publicKey) == NodeEntity.ERROR_BYTE_STRING
|
||||
val isUnknownUser
|
||||
get() = user.hwModel == MeshProtos.HardwareModel.UNSET
|
||||
|
||||
val hasPKC
|
||||
get() = (publicKey ?: user.publicKey).isNotEmpty()
|
||||
|
||||
val mismatchKey
|
||||
get() = (publicKey ?: user.publicKey) == NodeEntity.ERROR_BYTE_STRING
|
||||
|
||||
val hasEnvironmentMetrics: Boolean
|
||||
get() = environmentMetrics != EnvironmentMetrics.getDefaultInstance()
|
||||
|
|
@ -72,20 +77,28 @@ data class Node(
|
|||
val hasPowerMetrics: Boolean
|
||||
get() = powerMetrics != PowerMetrics.getDefaultInstance()
|
||||
|
||||
val batteryLevel get() = deviceMetrics.batteryLevel
|
||||
val voltage get() = deviceMetrics.voltage
|
||||
val batteryStr get() = if (batteryLevel in 1..100) "$batteryLevel%" else ""
|
||||
val batteryLevel
|
||||
get() = deviceMetrics.batteryLevel
|
||||
|
||||
val latitude get() = position.latitudeI * 1e-7
|
||||
val longitude get() = position.longitudeI * 1e-7
|
||||
val voltage
|
||||
get() = deviceMetrics.voltage
|
||||
|
||||
private fun hasValidPosition(): Boolean {
|
||||
return latitude != 0.0 && longitude != 0.0 &&
|
||||
(latitude >= -90 && latitude <= 90.0) &&
|
||||
(longitude >= -180 && longitude <= 180)
|
||||
}
|
||||
val batteryStr
|
||||
get() = if (batteryLevel in 1..100) "$batteryLevel%" else ""
|
||||
|
||||
val validPosition: MeshProtos.Position? get() = position.takeIf { hasValidPosition() }
|
||||
val latitude
|
||||
get() = position.latitudeI * 1e-7
|
||||
|
||||
val longitude
|
||||
get() = position.longitudeI * 1e-7
|
||||
|
||||
private fun hasValidPosition(): Boolean = latitude != 0.0 &&
|
||||
longitude != 0.0 &&
|
||||
(latitude >= -90 && latitude <= 90.0) &&
|
||||
(longitude >= -180 && longitude <= 180)
|
||||
|
||||
val validPosition: MeshProtos.Position?
|
||||
get() = position.takeIf { hasValidPosition() }
|
||||
|
||||
// @return distance in meters to some other node (or null if unknown)
|
||||
fun distance(o: Node): Int? = when {
|
||||
|
|
@ -103,70 +116,58 @@ data class Node(
|
|||
else -> com.geeksville.mesh.util.bearing(latitude, longitude, o.latitude, o.longitude).toInt()
|
||||
}
|
||||
|
||||
fun gpsString(gpsFormat: Int): String = when (gpsFormat) {
|
||||
DisplayConfig.GpsCoordinateFormat.DEC_VALUE -> GPSFormat.toDEC(latitude, longitude)
|
||||
DisplayConfig.GpsCoordinateFormat.DMS_VALUE -> GPSFormat.toDMS(latitude, longitude)
|
||||
DisplayConfig.GpsCoordinateFormat.UTM_VALUE -> GPSFormat.toUTM(latitude, longitude)
|
||||
DisplayConfig.GpsCoordinateFormat.MGRS_VALUE -> GPSFormat.toMGRS(latitude, longitude)
|
||||
else -> GPSFormat.toDEC(latitude, longitude)
|
||||
}
|
||||
fun gpsString(): String = GPSFormat.toDec(latitude, longitude)
|
||||
|
||||
private fun EnvironmentMetrics.getDisplayString(isFahrenheit: Boolean): String {
|
||||
val temp = if (temperature != 0f) {
|
||||
if (isFahrenheit) {
|
||||
"%.1f°F".format(celsiusToFahrenheit(temperature))
|
||||
val temp =
|
||||
if (temperature != 0f) {
|
||||
if (isFahrenheit) {
|
||||
"%.1f°F".format(celsiusToFahrenheit(temperature))
|
||||
} else {
|
||||
"%.1f°C".format(temperature)
|
||||
}
|
||||
} else {
|
||||
"%.1f°C".format(temperature)
|
||||
null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val humidity = if (relativeHumidity != 0f) "%.0f%%".format(relativeHumidity) else null
|
||||
val soilTemperatureStr = if (soilTemperature != 0f) {
|
||||
if (isFahrenheit) {
|
||||
"%.1f°F".format(celsiusToFahrenheit(soilTemperature))
|
||||
val soilTemperatureStr =
|
||||
if (soilTemperature != 0f) {
|
||||
if (isFahrenheit) {
|
||||
"%.1f°F".format(celsiusToFahrenheit(soilTemperature))
|
||||
} else {
|
||||
"%.1f°C".format(soilTemperature)
|
||||
}
|
||||
} else {
|
||||
"%.1f°C".format(soilTemperature)
|
||||
null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val soilMoistureRange = 0..100
|
||||
val soilMoisture =
|
||||
if (soilMoisture in soilMoistureRange && soilTemperature != 0f) {
|
||||
"%d%%".format(soilMoisture)
|
||||
} else { null }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val voltage = if (this.voltage != 0f) "%.2fV".format(this.voltage) else null
|
||||
val current = if (current != 0f) "%.1fmA".format(current) else null
|
||||
val iaq = if (iaq != 0) "IAQ: $iaq" else null
|
||||
|
||||
return listOfNotNull(
|
||||
temp,
|
||||
humidity,
|
||||
soilTemperatureStr,
|
||||
soilMoisture,
|
||||
voltage,
|
||||
current,
|
||||
iaq,
|
||||
).joinToString(" ")
|
||||
return listOfNotNull(temp, humidity, soilTemperatureStr, soilMoisture, voltage, current, iaq).joinToString(" ")
|
||||
}
|
||||
|
||||
private fun PaxcountProtos.Paxcount.getDisplayString() =
|
||||
"PAX: ${ble + wifi} (B:$ble/W:$wifi)".takeIf { ble != 0 || wifi != 0 }
|
||||
|
||||
fun getTelemetryString(isFahrenheit: Boolean = false): String {
|
||||
return listOfNotNull(
|
||||
paxcounter.getDisplayString(),
|
||||
environmentMetrics.getDisplayString(isFahrenheit),
|
||||
).joinToString(" ")
|
||||
}
|
||||
fun getTelemetryString(isFahrenheit: Boolean = false): String =
|
||||
listOfNotNull(paxcounter.getDisplayString(), environmentMetrics.getDisplayString(isFahrenheit))
|
||||
.joinToString(" ")
|
||||
}
|
||||
|
||||
fun ConfigProtos.Config.DeviceConfig.Role?.isUnmessageableRole(): Boolean = this in listOf(
|
||||
ConfigProtos.Config.DeviceConfig.Role.REPEATER,
|
||||
ConfigProtos.Config.DeviceConfig.Role.ROUTER,
|
||||
ConfigProtos.Config.DeviceConfig.Role.ROUTER_LATE,
|
||||
ConfigProtos.Config.DeviceConfig.Role.SENSOR,
|
||||
ConfigProtos.Config.DeviceConfig.Role.TRACKER,
|
||||
ConfigProtos.Config.DeviceConfig.Role.TAK_TRACKER,
|
||||
)
|
||||
fun ConfigProtos.Config.DeviceConfig.Role?.isUnmessageableRole(): Boolean = this in
|
||||
listOf(
|
||||
ConfigProtos.Config.DeviceConfig.Role.REPEATER,
|
||||
ConfigProtos.Config.DeviceConfig.Role.ROUTER,
|
||||
ConfigProtos.Config.DeviceConfig.Role.ROUTER_LATE,
|
||||
ConfigProtos.Config.DeviceConfig.Role.SENSOR,
|
||||
ConfigProtos.Config.DeviceConfig.Role.TRACKER,
|
||||
ConfigProtos.Config.DeviceConfig.Role.TAK_TRACKER,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -64,10 +64,10 @@ import com.geeksville.mesh.repository.radio.MeshActivity
|
|||
import com.geeksville.mesh.repository.radio.RadioInterfaceService
|
||||
import com.geeksville.mesh.service.MeshServiceNotifications
|
||||
import com.geeksville.mesh.service.ServiceAction
|
||||
import com.geeksville.mesh.ui.map.MAP_STYLE_ID
|
||||
import com.geeksville.mesh.ui.node.components.NodeMenuAction
|
||||
import com.geeksville.mesh.util.getShortDate
|
||||
import com.geeksville.mesh.util.positionToMeter
|
||||
import com.geeksville.mesh.util.toggleBooleanPreference
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
|
@ -82,7 +82,6 @@ import kotlinx.coroutines.flow.first
|
|||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.mapLatest
|
||||
import kotlinx.coroutines.flow.mapNotNull
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.shareIn
|
||||
|
|
@ -102,27 +101,27 @@ import kotlin.math.roundToInt
|
|||
// that user, ignoring emojis. If the original name is only one word, strip vowels from the original
|
||||
// name and if the result is 3 or more characters, use the first three characters. If not, just take
|
||||
// the first 3 characters of the original name.
|
||||
fun getInitials(nameIn: String): String {
|
||||
val nchars = 4
|
||||
val minchars = 2
|
||||
val name = nameIn.trim().withoutEmojis()
|
||||
fun getInitials(fullName: String): String {
|
||||
val maxInitialLength = 4
|
||||
val minWordCountForInitials = 2
|
||||
val name = fullName.trim().withoutEmojis()
|
||||
val words = name.split(Regex("\\s+")).filter { it.isNotEmpty() }
|
||||
|
||||
val initials =
|
||||
when (words.size) {
|
||||
in 0 until minchars -> {
|
||||
val nm =
|
||||
in 0 until minWordCountForInitials -> {
|
||||
val nameWithoutVowels =
|
||||
if (name.isNotEmpty()) {
|
||||
name.first() + name.drop(1).filterNot { c -> c.lowercase() in "aeiou" }
|
||||
} else {
|
||||
""
|
||||
}
|
||||
if (nm.length >= nchars) nm else name
|
||||
if (nameWithoutVowels.length >= maxInitialLength) nameWithoutVowels else name
|
||||
}
|
||||
|
||||
else -> words.map { it.first() }.joinToString("")
|
||||
}
|
||||
return initials.take(nchars)
|
||||
return initials.take(maxInitialLength)
|
||||
}
|
||||
|
||||
private fun String.withoutEmojis(): String = filterNot { char -> char.isSurrogate() }
|
||||
|
|
@ -161,7 +160,6 @@ data class NodesUiState(
|
|||
val includeUnknown: Boolean = false,
|
||||
val onlyOnline: Boolean = false,
|
||||
val onlyDirect: Boolean = false,
|
||||
val gpsFormat: Int = 0,
|
||||
val distanceUnits: Int = 0,
|
||||
val tempInFahrenheit: Boolean = false,
|
||||
val showDetails: Boolean = false,
|
||||
|
|
@ -185,7 +183,7 @@ data class Contact(
|
|||
val nodeColors: Pair<Int, Int>? = null,
|
||||
)
|
||||
|
||||
@Suppress("LongParameterList", "LargeClass")
|
||||
@Suppress("LongParameterList", "LargeClass", "UnusedPrivateProperty")
|
||||
@HiltViewModel
|
||||
class UIViewModel
|
||||
@Inject
|
||||
|
|
@ -193,7 +191,7 @@ constructor(
|
|||
private val app: Application,
|
||||
private val nodeDB: NodeRepository,
|
||||
private val radioConfigRepository: RadioConfigRepository,
|
||||
private val radioInterfaceService: RadioInterfaceService,
|
||||
radioInterfaceService: RadioInterfaceService,
|
||||
private val meshLogRepository: MeshLogRepository,
|
||||
private val deviceHardwareRepository: DeviceHardwareRepository,
|
||||
private val packetRepository: PacketRepository,
|
||||
|
|
@ -301,9 +299,6 @@ constructor(
|
|||
val meshService: IMeshService?
|
||||
get() = radioConfigRepository.meshService
|
||||
|
||||
val selectedBluetooth
|
||||
get() = radioInterfaceService.getDeviceAddress()?.getOrNull(0) == 'x'
|
||||
|
||||
private val _localConfig = MutableStateFlow<LocalConfig>(LocalConfig.getDefaultInstance())
|
||||
val localConfig: StateFlow<LocalConfig> = _localConfig
|
||||
val config
|
||||
|
|
@ -338,11 +333,6 @@ constructor(
|
|||
private val onlyOnline = MutableStateFlow(preferences.getBoolean("only-online", false))
|
||||
private val onlyDirect = MutableStateFlow(preferences.getBoolean("only-direct", false))
|
||||
|
||||
private val onlyFavorites = MutableStateFlow(preferences.getBoolean("only-favorites", false))
|
||||
private val showWaypointsOnMap = MutableStateFlow(preferences.getBoolean("show-waypoints-on-map", true))
|
||||
private val showPrecisionCircleOnMap =
|
||||
MutableStateFlow(preferences.getBoolean("show-precision-circle-on-map", true))
|
||||
|
||||
private val _showIgnored = MutableStateFlow(preferences.getBoolean("show-ignored", false))
|
||||
val showIgnored: StateFlow<Boolean> = _showIgnored
|
||||
|
||||
|
|
@ -351,6 +341,7 @@ constructor(
|
|||
|
||||
private val _hasShownNotPairedWarning =
|
||||
MutableStateFlow(preferences.getBoolean(HAS_SHOWN_NOT_PAIRED_WARNING_PREF, false))
|
||||
|
||||
val hasShownNotPairedWarning: StateFlow<Boolean> = _hasShownNotPairedWarning.asStateFlow()
|
||||
|
||||
fun suppressNoPairedWarning() {
|
||||
|
|
@ -358,40 +349,22 @@ constructor(
|
|||
preferences.edit { putBoolean(HAS_SHOWN_NOT_PAIRED_WARNING_PREF, true) }
|
||||
}
|
||||
|
||||
private fun toggleBooleanPreference(
|
||||
state: MutableStateFlow<Boolean>,
|
||||
key: String,
|
||||
onChanged: (Boolean) -> Unit = {},
|
||||
) {
|
||||
val newValue = !state.value
|
||||
state.value = newValue
|
||||
preferences.edit { putBoolean(key, newValue) }
|
||||
onChanged(newValue)
|
||||
}
|
||||
fun toggleShowIgnored() = preferences.toggleBooleanPreference(_showIgnored, "show-ignored")
|
||||
|
||||
fun toggleShowIgnored() = toggleBooleanPreference(_showIgnored, "show-ignored")
|
||||
|
||||
fun toggleShowQuickChat() = toggleBooleanPreference(_showQuickChat, "show-quick-chat")
|
||||
fun toggleShowQuickChat() = preferences.toggleBooleanPreference(_showQuickChat, "show-quick-chat")
|
||||
|
||||
fun setSortOption(sort: NodeSortOption) {
|
||||
nodeSortOption.value = sort
|
||||
preferences.edit { putInt("node-sort-option", sort.ordinal) }
|
||||
}
|
||||
|
||||
fun toggleShowDetails() = toggleBooleanPreference(showDetails, "show-details")
|
||||
fun toggleShowDetails() = preferences.toggleBooleanPreference(showDetails, "show-details")
|
||||
|
||||
fun toggleIncludeUnknown() = toggleBooleanPreference(includeUnknown, "include-unknown")
|
||||
fun toggleIncludeUnknown() = preferences.toggleBooleanPreference(includeUnknown, "include-unknown")
|
||||
|
||||
fun toggleOnlyOnline() = toggleBooleanPreference(onlyOnline, "only-online")
|
||||
fun toggleOnlyOnline() = preferences.toggleBooleanPreference(onlyOnline, "only-online")
|
||||
|
||||
fun toggleOnlyDirect() = toggleBooleanPreference(onlyDirect, "only-direct")
|
||||
|
||||
fun toggleOnlyFavorites() = toggleBooleanPreference(onlyFavorites, "only-favorites")
|
||||
|
||||
fun toggleShowWaypointsOnMap() = toggleBooleanPreference(showWaypointsOnMap, "show-waypoints-on-map")
|
||||
|
||||
fun toggleShowPrecisionCircleOnMap() =
|
||||
toggleBooleanPreference(showPrecisionCircleOnMap, "show-precision-circle-on-map")
|
||||
fun toggleOnlyDirect() = preferences.toggleBooleanPreference(onlyDirect, "only-direct")
|
||||
|
||||
data class NodeFilterState(
|
||||
val filterText: String,
|
||||
|
|
@ -425,7 +398,6 @@ constructor(
|
|||
includeUnknown = filterFlow.includeUnknown,
|
||||
onlyOnline = filterFlow.onlyOnline,
|
||||
onlyDirect = filterFlow.onlyDirect,
|
||||
gpsFormat = profile.config.display.gpsFormat.number,
|
||||
distanceUnits = profile.config.display.units.number,
|
||||
tempInFahrenheit = profile.moduleConfig.telemetry.environmentDisplayFahrenheit,
|
||||
showDetails = showDetails,
|
||||
|
|
@ -474,22 +446,6 @@ constructor(
|
|||
initialValue = 0,
|
||||
)
|
||||
|
||||
data class MapFilterState(val onlyFavorites: Boolean, val showWaypoints: Boolean, val showPrecisionCircle: Boolean)
|
||||
|
||||
val mapFilterStateFlow: StateFlow<MapFilterState> =
|
||||
combine(onlyFavorites, showWaypointsOnMap, showPrecisionCircleOnMap) {
|
||||
favoritesOnly,
|
||||
showWaypoints,
|
||||
showPrecisionCircle,
|
||||
->
|
||||
MapFilterState(favoritesOnly, showWaypoints, showPrecisionCircle)
|
||||
}
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000),
|
||||
initialValue = MapFilterState(false, true, true),
|
||||
)
|
||||
|
||||
// hardware info about our local device (can be null)
|
||||
val myNodeInfo: StateFlow<MyNodeEntity?>
|
||||
get() = nodeDB.myNodeInfo
|
||||
|
|
@ -497,22 +453,15 @@ constructor(
|
|||
val ourNodeInfo: StateFlow<Node?>
|
||||
get() = nodeDB.ourNodeInfo
|
||||
|
||||
val nodesWithPosition
|
||||
get() = nodeDB.nodeDBbyNum.value.values.filter { it.validPosition != null }
|
||||
|
||||
var mapStyleId: Int
|
||||
get() = preferences.getInt(MAP_STYLE_ID, 0)
|
||||
set(value) = preferences.edit { putInt(MAP_STYLE_ID, value) }
|
||||
|
||||
fun getNode(userId: String?) = nodeDB.getNode(userId ?: DataPacket.ID_BROADCAST)
|
||||
|
||||
fun getUser(userId: String?) = nodeDB.getUser(userId ?: DataPacket.ID_BROADCAST)
|
||||
|
||||
val snackbarState = SnackbarHostState()
|
||||
private val snackBarHostState = SnackbarHostState()
|
||||
|
||||
fun showSnackbar(text: Int) = showSnackbar(app.getString(text))
|
||||
fun showSnackBar(text: Int) = showSnackBar(app.getString(text))
|
||||
|
||||
fun showSnackbar(text: String) = viewModelScope.launch { snackbarState.showSnackbar(text) }
|
||||
fun showSnackBar(text: String) = viewModelScope.launch { snackBarHostState.showSnackbar(text) }
|
||||
|
||||
init {
|
||||
radioConfigRepository.errorMessage
|
||||
|
|
@ -615,15 +564,6 @@ constructor(
|
|||
initialValue = emptyList(),
|
||||
)
|
||||
|
||||
val waypoints =
|
||||
packetRepository.getWaypoints().mapLatest { list ->
|
||||
list
|
||||
.associateBy { packet -> packet.data.waypoint!!.id }
|
||||
.filterValues {
|
||||
it.data.waypoint!!.expire == 0 || it.data.waypoint!!.expire > System.currentTimeMillis() / 1000
|
||||
}
|
||||
}
|
||||
|
||||
fun generatePacketId(): Int? {
|
||||
return try {
|
||||
meshService?.packetId
|
||||
|
|
@ -749,8 +689,6 @@ constructor(
|
|||
val connectionState
|
||||
get() = radioConfigRepository.connectionState
|
||||
|
||||
fun isConnected() = isConnectedStateFlow.value
|
||||
|
||||
val isConnectedStateFlow =
|
||||
radioConfigRepository.connectionState
|
||||
.map { it.isConnected() }
|
||||
|
|
@ -763,7 +701,7 @@ constructor(
|
|||
fun requestChannelUrl(url: Uri) = runCatching { _requestChannelSet.value = url.toChannelSet() }
|
||||
.onFailure { ex ->
|
||||
errormsg("Channel url error: ${ex.message}")
|
||||
showSnackbar(R.string.channel_invalid)
|
||||
showSnackBar(R.string.channel_invalid)
|
||||
}
|
||||
|
||||
val latestStableFirmwareRelease = firmwareReleaseRepository.stableRelease.mapNotNull { it?.asDeviceVersion() }
|
||||
|
|
@ -921,6 +859,7 @@ constructor(
|
|||
|
||||
writeToUri(uri) { writer ->
|
||||
val nodePositions = mutableMapOf<Int, MeshProtos.Position?>()
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
writer.appendLine(
|
||||
"\"date\",\"time\",\"from\",\"sender name\",\"sender lat\",\"sender long\",\"rx lat\",\"rx long\",\"rx elevation\",\"rx snr\",\"distance\",\"hop limit\",\"payload\"",
|
||||
|
|
@ -939,7 +878,9 @@ constructor(
|
|||
// If the packet contains position data then use it to update, if valid
|
||||
packet.position?.let { position ->
|
||||
positionToPos.invoke(position)?.let {
|
||||
nodePositions[proto.from.takeIf { it != 0 } ?: myNodeNum] = position
|
||||
nodePositions[
|
||||
proto.from.takeIf { it != 0 } ?: myNodeNum,
|
||||
] = position
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -972,9 +913,9 @@ constructor(
|
|||
""
|
||||
} else {
|
||||
positionToMeter(
|
||||
rxPosition!!, // Use rxPosition but only if rxPos was
|
||||
Position(rxPosition!!), // Use rxPosition but only if rxPos was
|
||||
// valid
|
||||
senderPosition!!, // Use senderPosition but only if
|
||||
Position(senderPosition!!), // Use senderPosition but only if
|
||||
// senderPos was valid
|
||||
)
|
||||
.roundToInt()
|
||||
|
|
@ -998,7 +939,8 @@ constructor(
|
|||
}
|
||||
|
||||
// date,time,from,sender name,sender lat,sender long,rx lat,rx long,rx
|
||||
// elevation,rx snr,distance,hop limit,payload
|
||||
// elevation,rx
|
||||
// snr,distance,hop limit,payload
|
||||
@Suppress("MaxLineLength")
|
||||
writer.appendLine(
|
||||
"$rxDateTime,\"$rxFrom\",\"$senderName\",\"$senderLat\",\"$senderLong\",\"$rxLat\",\"$rxLong\",\"$rxAlt\",\"$rxSnr\",\"$dist\",\"$hopLimit\",\"$payload\"",
|
||||
|
|
|
|||
|
|
@ -1,212 +0,0 @@
|
|||
/*
|
||||
* 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
|
||||
|
||||
import org.osmdroid.tileprovider.tilesource.ITileSource
|
||||
import org.osmdroid.tileprovider.tilesource.OnlineTileSourceBase
|
||||
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
|
||||
import org.osmdroid.tileprovider.tilesource.TileSourcePolicy
|
||||
import org.osmdroid.util.MapTileIndex
|
||||
|
||||
|
||||
class CustomTileSource {
|
||||
|
||||
companion object {
|
||||
val OPENWEATHER_RADAR = OnlineTileSourceAuth(
|
||||
"Open Weather Map", 1, 22, 256, ".png", arrayOf(
|
||||
"https://tile.openweathermap.org/map/"
|
||||
), "Openweathermap",
|
||||
TileSourcePolicy(
|
||||
4,
|
||||
TileSourcePolicy.FLAG_NO_BULK
|
||||
or TileSourcePolicy.FLAG_NO_PREVENTIVE
|
||||
or TileSourcePolicy.FLAG_USER_AGENT_MEANINGFUL
|
||||
or TileSourcePolicy.FLAG_USER_AGENT_NORMALIZED
|
||||
),
|
||||
"precipitation",
|
||||
""
|
||||
)
|
||||
//
|
||||
// val RAIN_VIEWER = object : OnlineTileSourceBase(
|
||||
// "RainViewer", 1, 15, 256, ".png", arrayOf(
|
||||
// "https://tilecache.rainviewer.com/v2/coverage/"
|
||||
// ), "RainViewer",
|
||||
// TileSourcePolicy(
|
||||
// 4,
|
||||
// TileSourcePolicy.FLAG_NO_BULK
|
||||
// or TileSourcePolicy.FLAG_NO_PREVENTIVE
|
||||
// or TileSourcePolicy.FLAG_USER_AGENT_MEANINGFUL
|
||||
// or TileSourcePolicy.FLAG_USER_AGENT_NORMALIZED
|
||||
// )
|
||||
// ) {
|
||||
// override fun getTileURLString(pMapTileIndex: Long): String {
|
||||
// return baseUrl + (MapTileIndex.getZoom(pMapTileIndex)
|
||||
// .toString() + "/" + MapTileIndex.getY(pMapTileIndex)
|
||||
// + "/" + MapTileIndex.getX(pMapTileIndex)
|
||||
// + mImageFilenameEnding)
|
||||
// }
|
||||
// }
|
||||
|
||||
|
||||
private val ESRI_IMAGERY = object : OnlineTileSourceBase(
|
||||
"ESRI World Overview", 1, 20, 256, ".jpg", arrayOf(
|
||||
"https://clarity.maptiles.arcgis.com/arcgis/rest/services/World_Imagery/MapServer/tile/"
|
||||
), "Esri, Maxar, Earthstar Geographics, and the GIS User Community",
|
||||
TileSourcePolicy(
|
||||
4,
|
||||
TileSourcePolicy.FLAG_NO_BULK
|
||||
or TileSourcePolicy.FLAG_NO_PREVENTIVE
|
||||
or TileSourcePolicy.FLAG_USER_AGENT_MEANINGFUL
|
||||
or TileSourcePolicy.FLAG_USER_AGENT_NORMALIZED
|
||||
)
|
||||
) {
|
||||
override fun getTileURLString(pMapTileIndex: Long): String {
|
||||
return baseUrl + (MapTileIndex.getZoom(pMapTileIndex)
|
||||
.toString() + "/" + MapTileIndex.getY(pMapTileIndex)
|
||||
+ "/" + MapTileIndex.getX(pMapTileIndex)
|
||||
+ mImageFilenameEnding)
|
||||
}
|
||||
}
|
||||
|
||||
private val ESRI_WORLD_TOPO = object : OnlineTileSourceBase(
|
||||
"ESRI World TOPO",
|
||||
1,
|
||||
20,
|
||||
256,
|
||||
".jpg",
|
||||
arrayOf(
|
||||
"https://server.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/"
|
||||
),
|
||||
"Esri, HERE, Garmin, FAO, NOAA, USGS, © OpenStreetMap contributors, and the GIS User Community ",
|
||||
TileSourcePolicy(
|
||||
4,
|
||||
TileSourcePolicy.FLAG_NO_BULK
|
||||
or TileSourcePolicy.FLAG_NO_PREVENTIVE
|
||||
or TileSourcePolicy.FLAG_USER_AGENT_MEANINGFUL
|
||||
or TileSourcePolicy.FLAG_USER_AGENT_NORMALIZED
|
||||
)
|
||||
) {
|
||||
override fun getTileURLString(pMapTileIndex: Long): String {
|
||||
return baseUrl + (MapTileIndex.getZoom(pMapTileIndex)
|
||||
.toString() + "/" + MapTileIndex.getY(pMapTileIndex)
|
||||
+ "/" + MapTileIndex.getX(pMapTileIndex)
|
||||
+ mImageFilenameEnding)
|
||||
}
|
||||
}
|
||||
private val USGS_HYDRO_CACHE = object : OnlineTileSourceBase(
|
||||
"USGS Hydro Cache",
|
||||
0,
|
||||
18,
|
||||
256,
|
||||
"",
|
||||
arrayOf(
|
||||
"https://basemap.nationalmap.gov/arcgis/rest/services/USGSHydroCached/MapServer/tile/"
|
||||
),
|
||||
"USGS",
|
||||
TileSourcePolicy(
|
||||
2,
|
||||
TileSourcePolicy.FLAG_NO_PREVENTIVE
|
||||
or TileSourcePolicy.FLAG_USER_AGENT_MEANINGFUL
|
||||
or TileSourcePolicy.FLAG_USER_AGENT_NORMALIZED
|
||||
)
|
||||
) {
|
||||
override fun getTileURLString(pMapTileIndex: Long): String {
|
||||
return baseUrl + (MapTileIndex.getZoom(pMapTileIndex)
|
||||
.toString() + "/" + MapTileIndex.getY(pMapTileIndex)
|
||||
+ "/" + MapTileIndex.getX(pMapTileIndex)
|
||||
+ mImageFilenameEnding)
|
||||
}
|
||||
}
|
||||
private val USGS_SHADED_RELIEF = object : OnlineTileSourceBase(
|
||||
"USGS Shaded Relief Only",
|
||||
0,
|
||||
18,
|
||||
256,
|
||||
"",
|
||||
arrayOf(
|
||||
"https://basemap.nationalmap.gov/arcgis/rest/services/USGSShadedReliefOnly/MapServer/tile/"
|
||||
),
|
||||
"USGS",
|
||||
TileSourcePolicy(
|
||||
2,
|
||||
TileSourcePolicy.FLAG_NO_PREVENTIVE
|
||||
or TileSourcePolicy.FLAG_USER_AGENT_MEANINGFUL
|
||||
or TileSourcePolicy.FLAG_USER_AGENT_NORMALIZED
|
||||
)
|
||||
) {
|
||||
override fun getTileURLString(pMapTileIndex: Long): String {
|
||||
return baseUrl + (MapTileIndex.getZoom(pMapTileIndex)
|
||||
.toString() + "/" + MapTileIndex.getY(pMapTileIndex)
|
||||
+ "/" + MapTileIndex.getX(pMapTileIndex)
|
||||
+ mImageFilenameEnding)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* WMS TILE SERVER
|
||||
* More research is required to get this to function correctly with overlays
|
||||
*/
|
||||
val NOAA_RADAR_WMS = NOAAWmsTileSource(
|
||||
"Recent Weather Radar",
|
||||
arrayOf("https://new.nowcoast.noaa.gov/arcgis/services/nowcoast/radar_meteo_imagery_nexrad_time/MapServer/WmsServer?"),
|
||||
"1",
|
||||
"1.1.0",
|
||||
"",
|
||||
"EPSG%3A3857",
|
||||
"",
|
||||
"image/png"
|
||||
)
|
||||
|
||||
/**
|
||||
* ===============================================================================================
|
||||
*/
|
||||
|
||||
private val MAPNIK: OnlineTileSourceBase = TileSourceFactory.MAPNIK
|
||||
private val USGS_TOPO: OnlineTileSourceBase = TileSourceFactory.USGS_TOPO
|
||||
private val OPEN_TOPO: OnlineTileSourceBase = TileSourceFactory.OpenTopo
|
||||
private val USGS_SAT: OnlineTileSourceBase = TileSourceFactory.USGS_SAT
|
||||
private val SEAMAP: OnlineTileSourceBase = TileSourceFactory.OPEN_SEAMAP
|
||||
val DEFAULT_TILE_SOURCE: OnlineTileSourceBase = TileSourceFactory.DEFAULT_TILE_SOURCE
|
||||
|
||||
/**
|
||||
* Source for each available [ITileSource] and their display names.
|
||||
*/
|
||||
val mTileSources: Map<ITileSource, String> = mapOf(
|
||||
MAPNIK to "OpenStreetMap",
|
||||
USGS_TOPO to "USGS TOPO",
|
||||
OPEN_TOPO to "Open TOPO",
|
||||
ESRI_WORLD_TOPO to "ESRI World TOPO",
|
||||
USGS_SAT to "USGS Satellite",
|
||||
ESRI_IMAGERY to "ESRI World Overview",
|
||||
)
|
||||
|
||||
fun getTileSource(index: Int): ITileSource {
|
||||
return mTileSources.keys.elementAtOrNull(index) ?: DEFAULT_TILE_SOURCE
|
||||
}
|
||||
|
||||
fun getTileSource(aName: String): ITileSource {
|
||||
for (tileSource: ITileSource in mTileSources.keys) {
|
||||
if (tileSource.name().equals(aName)) {
|
||||
return tileSource
|
||||
}
|
||||
}
|
||||
throw IllegalArgumentException("No such tile source: $aName")
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,148 +0,0 @@
|
|||
/*
|
||||
* 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
|
||||
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.graphics.RectF
|
||||
import android.view.MotionEvent
|
||||
import com.geeksville.mesh.android.dpToPx
|
||||
import com.geeksville.mesh.android.spToPx
|
||||
import org.osmdroid.views.MapView
|
||||
import org.osmdroid.views.overlay.Marker
|
||||
import org.osmdroid.views.overlay.Polygon
|
||||
|
||||
class MarkerWithLabel(mapView: MapView?, label: String, emoji: String? = null) : Marker(mapView) {
|
||||
|
||||
companion object {
|
||||
private const val LABEL_CORNER_RADIUS_DP = 4f
|
||||
private const val LABEL_Y_OFFSET_DP = 34f
|
||||
private const val FONT_SIZE_SP = 14f
|
||||
private const val EMOJI_FONT_SIZE_SP = 20f
|
||||
}
|
||||
|
||||
private val labelYOffsetPx by lazy {
|
||||
mapView?.context?.dpToPx(LABEL_Y_OFFSET_DP) ?: 100
|
||||
}
|
||||
|
||||
private val labelCornerRadiusPx by lazy {
|
||||
mapView?.context?.dpToPx(LABEL_CORNER_RADIUS_DP) ?: 12
|
||||
}
|
||||
|
||||
private var nodeColor: Int = Color.GRAY
|
||||
fun setNodeColors(colors: Pair<Int, Int>) {
|
||||
nodeColor = colors.second
|
||||
}
|
||||
|
||||
private var precisionBits: Int? = null
|
||||
fun setPrecisionBits(bits: Int) {
|
||||
precisionBits = bits
|
||||
}
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
private fun getPrecisionMeters(): Double? {
|
||||
return when (precisionBits) {
|
||||
10 -> 23345.484932
|
||||
11 -> 11672.7369
|
||||
12 -> 5836.36288
|
||||
13 -> 2918.175876
|
||||
14 -> 1459.0823719999053
|
||||
15 -> 729.53562
|
||||
16 -> 364.7622
|
||||
17 -> 182.375556
|
||||
18 -> 91.182212
|
||||
19 -> 45.58554
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private var onLongClickListener: (() -> Boolean)? = null
|
||||
|
||||
fun setOnLongClickListener(listener: () -> Boolean) {
|
||||
onLongClickListener = listener
|
||||
}
|
||||
|
||||
private val mLabel = label
|
||||
private val mEmoji = emoji
|
||||
private val textPaint = Paint().apply {
|
||||
textSize = mapView?.context?.spToPx(FONT_SIZE_SP)?.toFloat() ?: 40f
|
||||
color = Color.DKGRAY
|
||||
isAntiAlias = true
|
||||
isFakeBoldText = true
|
||||
textAlign = Paint.Align.CENTER
|
||||
}
|
||||
private val emojiPaint = Paint().apply {
|
||||
textSize = mapView?.context?.spToPx(EMOJI_FONT_SIZE_SP)?.toFloat() ?: 80f
|
||||
isAntiAlias = true
|
||||
textAlign = Paint.Align.CENTER
|
||||
}
|
||||
|
||||
private val bgPaint = Paint().apply { color = Color.WHITE }
|
||||
|
||||
private fun getTextBackgroundSize(text: String, x: Float, y: Float): RectF {
|
||||
val fontMetrics = textPaint.fontMetrics
|
||||
val halfTextLength = textPaint.measureText(text) / 2 + 3
|
||||
return RectF(
|
||||
(x - halfTextLength),
|
||||
(y + fontMetrics.top),
|
||||
(x + halfTextLength),
|
||||
(y + fontMetrics.bottom)
|
||||
)
|
||||
}
|
||||
|
||||
override fun onLongPress(event: MotionEvent?, mapView: MapView?): Boolean {
|
||||
val touched = hitTest(event, mapView)
|
||||
if (touched && this.id != null) {
|
||||
return onLongClickListener?.invoke() ?: super.onLongPress(event, mapView)
|
||||
}
|
||||
return super.onLongPress(event, mapView)
|
||||
}
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
override fun draw(c: Canvas, osmv: MapView?, shadow: Boolean) {
|
||||
super.draw(c, osmv, false)
|
||||
val p = mPositionPixels
|
||||
val bgRect = getTextBackgroundSize(mLabel, p.x.toFloat(), (p.y - labelYOffsetPx.toFloat()))
|
||||
bgRect.inset(-8F, -2F)
|
||||
|
||||
if (mLabel.isNotEmpty()) {
|
||||
c.drawRoundRect(bgRect, labelCornerRadiusPx.toFloat(), labelCornerRadiusPx.toFloat(), bgPaint)
|
||||
c.drawText(mLabel, (p.x - 0F), (p.y - labelYOffsetPx.toFloat()), textPaint)
|
||||
}
|
||||
mEmoji?.let { c.drawText(it, (p.x - 0f), (p.y - 30f), emojiPaint) }
|
||||
|
||||
getPrecisionMeters()?.let { radius ->
|
||||
val polygon = Polygon(osmv).apply {
|
||||
points = Polygon.pointsAsCircle(
|
||||
position,
|
||||
radius
|
||||
)
|
||||
fillPaint.apply {
|
||||
color = nodeColor
|
||||
alpha = 48
|
||||
}
|
||||
outlinePaint.apply {
|
||||
color = nodeColor
|
||||
alpha = 64
|
||||
}
|
||||
}
|
||||
polygon.draw(c, osmv, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,178 +0,0 @@
|
|||
/*
|
||||
* 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
|
||||
|
||||
import android.content.res.Resources
|
||||
import android.util.Log
|
||||
import org.osmdroid.api.IMapView
|
||||
import org.osmdroid.tileprovider.tilesource.OnlineTileSourceBase
|
||||
import org.osmdroid.tileprovider.tilesource.TileSourcePolicy
|
||||
import org.osmdroid.util.MapTileIndex
|
||||
import kotlin.math.atan
|
||||
import kotlin.math.pow
|
||||
import kotlin.math.sinh
|
||||
|
||||
open class NOAAWmsTileSource(
|
||||
aName: String,
|
||||
aBaseUrl: Array<String>,
|
||||
layername: String,
|
||||
version: String,
|
||||
time: String?,
|
||||
srs: String,
|
||||
style: String?,
|
||||
format: String,
|
||||
) : OnlineTileSourceBase(
|
||||
aName, 0, 5, 256, "png", aBaseUrl, "", TileSourcePolicy(
|
||||
2,
|
||||
TileSourcePolicy.FLAG_NO_BULK
|
||||
or TileSourcePolicy.FLAG_NO_PREVENTIVE
|
||||
or TileSourcePolicy.FLAG_USER_AGENT_MEANINGFUL
|
||||
or TileSourcePolicy.FLAG_USER_AGENT_NORMALIZED
|
||||
)
|
||||
) {
|
||||
|
||||
// array indexes for array to hold bounding boxes.
|
||||
private val MINX = 0
|
||||
private val MAXX = 1
|
||||
private val MINY = 2
|
||||
private val MAXY = 3
|
||||
|
||||
// Web Mercator n/w corner of the map.
|
||||
private val TILE_ORIGIN = doubleArrayOf(-20037508.34789244, 20037508.34789244)
|
||||
|
||||
//array indexes for that data
|
||||
private val ORIG_X = 0
|
||||
private val ORIG_Y = 1 // "
|
||||
|
||||
// Size of square world map in meters, using WebMerc projection.
|
||||
private val MAP_SIZE = 20037508.34789244 * 2
|
||||
private var layer = ""
|
||||
private var version = "1.1.0"
|
||||
private var srs = "EPSG%3A3857" //used by geo server
|
||||
private var format = ""
|
||||
private var time = ""
|
||||
private var style: String? = null
|
||||
private var forceHttps = false
|
||||
private var forceHttp = false
|
||||
|
||||
init {
|
||||
Log.i(IMapView.LOGTAG, "WMS support is BETA. Please report any issues")
|
||||
layer = layername
|
||||
this.version = version
|
||||
this.srs = srs
|
||||
this.style = style
|
||||
this.format = format
|
||||
if (time != null) this.time = time
|
||||
}
|
||||
|
||||
// fun createFrom(endpoint: WMSEndpoint, layer: WMSLayer): WMSTileSource? {
|
||||
// var srs: String? = "EPSG:900913"
|
||||
// if (layer.srs.isNotEmpty()) {
|
||||
// srs = layer.srs[0]
|
||||
// }
|
||||
// return if (layer.styles.isEmpty()) {
|
||||
// WMSTileSource(
|
||||
// layer.name, arrayOf(endpoint.baseurl), layer.name,
|
||||
// endpoint.wmsVersion, srs, null, layer.pixelSize
|
||||
// )
|
||||
// } else WMSTileSource(
|
||||
// layer.name, arrayOf(endpoint.baseurl), layer.name,
|
||||
// endpoint.wmsVersion, srs, layer.styles[0], layer.pixelSize
|
||||
// )
|
||||
// }
|
||||
|
||||
|
||||
private fun tile2lon(x: Int, z: Int): Double {
|
||||
return x / 2.0.pow(z.toDouble()) * 360.0 - 180
|
||||
}
|
||||
|
||||
private fun tile2lat(y: Int, z: Int): Double {
|
||||
val n = Math.PI - 2.0 * Math.PI * y / 2.0.pow(z.toDouble())
|
||||
return Math.toDegrees(atan(sinh(n)))
|
||||
}
|
||||
|
||||
// Return a web Mercator bounding box given tile x/y indexes and a zoom
|
||||
// level.
|
||||
private fun getBoundingBox(x: Int, y: Int, zoom: Int): DoubleArray {
|
||||
val tileSize = MAP_SIZE / 2.0.pow(zoom.toDouble())
|
||||
val minx = TILE_ORIGIN[ORIG_X] + x * tileSize
|
||||
val maxx = TILE_ORIGIN[ORIG_X] + (x + 1) * tileSize
|
||||
val miny = TILE_ORIGIN[ORIG_Y] - (y + 1) * tileSize
|
||||
val maxy = TILE_ORIGIN[ORIG_Y] - y * tileSize
|
||||
val bbox = DoubleArray(4)
|
||||
bbox[MINX] = minx
|
||||
bbox[MINY] = miny
|
||||
bbox[MAXX] = maxx
|
||||
bbox[MAXY] = maxy
|
||||
return bbox
|
||||
}
|
||||
|
||||
fun isForceHttps(): Boolean {
|
||||
return forceHttps
|
||||
}
|
||||
|
||||
fun setForceHttps(forceHttps: Boolean) {
|
||||
this.forceHttps = forceHttps
|
||||
}
|
||||
|
||||
fun isForceHttp(): Boolean {
|
||||
return forceHttp
|
||||
}
|
||||
|
||||
fun setForceHttp(forceHttp: Boolean) {
|
||||
this.forceHttp = forceHttp
|
||||
}
|
||||
|
||||
override fun getTileURLString(pMapTileIndex: Long): String? {
|
||||
var baseUrl = baseUrl
|
||||
if (forceHttps) baseUrl = baseUrl.replace("http://", "https://")
|
||||
if (forceHttp) baseUrl = baseUrl.replace("https://", "http://")
|
||||
val sb = StringBuilder(baseUrl)
|
||||
if (!baseUrl.endsWith("&"))
|
||||
sb.append("service=WMS")
|
||||
sb.append("&request=GetMap")
|
||||
sb.append("&version=").append(version)
|
||||
sb.append("&layers=").append(layer)
|
||||
if (style != null) sb.append("&styles=").append(style)
|
||||
sb.append("&format=").append(format)
|
||||
sb.append("&transparent=true")
|
||||
sb.append("&height=").append(Resources.getSystem().displayMetrics.heightPixels)
|
||||
sb.append("&width=").append(Resources.getSystem().displayMetrics.widthPixels)
|
||||
sb.append("&srs=").append(srs)
|
||||
sb.append("&size=").append(getSize())
|
||||
sb.append("&bbox=")
|
||||
val bbox = getBoundingBox(
|
||||
MapTileIndex.getX(pMapTileIndex),
|
||||
MapTileIndex.getY(pMapTileIndex),
|
||||
MapTileIndex.getZoom(pMapTileIndex)
|
||||
)
|
||||
sb.append(bbox[MINX]).append(",")
|
||||
sb.append(bbox[MINY]).append(",")
|
||||
sb.append(bbox[MAXX]).append(",")
|
||||
sb.append(bbox[MAXY])
|
||||
Log.i(IMapView.LOGTAG, sb.toString())
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
private fun getSize(): String {
|
||||
val height = Resources.getSystem().displayMetrics.heightPixels
|
||||
val width = Resources.getSystem().displayMetrics.widthPixels
|
||||
return "$width,$height"
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -1,64 +0,0 @@
|
|||
/*
|
||||
* 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
|
||||
|
||||
import org.osmdroid.tileprovider.tilesource.OnlineTileSourceBase
|
||||
import org.osmdroid.tileprovider.tilesource.TileSourcePolicy
|
||||
import org.osmdroid.util.MapTileIndex
|
||||
|
||||
open class OnlineTileSourceAuth(
|
||||
aName: String,
|
||||
aZoomLevel: Int,
|
||||
aZoomMaxLevel: Int,
|
||||
aTileSizePixels: Int,
|
||||
aImageFileNameEnding: String,
|
||||
aBaseUrl: Array<String>,
|
||||
pCopyright: String,
|
||||
tileSourcePolicy: TileSourcePolicy,
|
||||
layerName: String?,
|
||||
apiKey: String
|
||||
) :
|
||||
OnlineTileSourceBase(
|
||||
aName,
|
||||
aZoomLevel,
|
||||
aZoomMaxLevel,
|
||||
aTileSizePixels,
|
||||
aImageFileNameEnding,
|
||||
aBaseUrl,
|
||||
pCopyright,
|
||||
tileSourcePolicy
|
||||
|
||||
) {
|
||||
private var layerName = ""
|
||||
private var apiKey = ""
|
||||
|
||||
init {
|
||||
if (layerName != null) {
|
||||
this.layerName = layerName
|
||||
}
|
||||
this.apiKey = apiKey
|
||||
|
||||
}
|
||||
|
||||
override fun getTileURLString(pMapTileIndex: Long): String {
|
||||
return "$baseUrl$layerName/" + (MapTileIndex.getZoom(pMapTileIndex)
|
||||
.toString() + "/" + MapTileIndex.getX(pMapTileIndex)
|
||||
.toString() + "/" + MapTileIndex.getY(pMapTileIndex)
|
||||
.toString()) + mImageFilenameEnding + "?appId=$apiKey"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,201 +0,0 @@
|
|||
package com.geeksville.mesh.model.map.clustering;
|
||||
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Point;
|
||||
import android.view.MotionEvent;
|
||||
|
||||
import org.osmdroid.api.IGeoPoint;
|
||||
import org.osmdroid.bonuspack.kml.KmlFeature;
|
||||
import org.osmdroid.util.BoundingBox;
|
||||
import org.osmdroid.util.GeoPoint;
|
||||
import org.osmdroid.views.MapView;
|
||||
import org.osmdroid.views.overlay.Overlay;
|
||||
import com.geeksville.mesh.model.map.MarkerWithLabel;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Iterator;
|
||||
import java.util.ListIterator;
|
||||
|
||||
/**
|
||||
* An overlay allowing to perform markers clustering.
|
||||
* Usage: put your markers inside with add(Marker), and add the MarkerClusterer to the map overlays.
|
||||
* Depending on the zoom level, markers will be displayed separately, or grouped as a single Marker. <br/>
|
||||
*
|
||||
* This abstract class provides the framework. Sub-classes have to implement the clustering algorithm,
|
||||
* and the rendering of a cluster.
|
||||
*
|
||||
* @author M.Kergall
|
||||
*
|
||||
*/
|
||||
public abstract class MarkerClusterer extends Overlay {
|
||||
|
||||
/** impossible value for zoom level, to force clustering */
|
||||
protected static final int FORCE_CLUSTERING = -1;
|
||||
|
||||
protected ArrayList<MarkerWithLabel> mItems = new ArrayList<MarkerWithLabel>();
|
||||
protected Point mPoint = new Point();
|
||||
protected ArrayList<StaticCluster> mClusters = new ArrayList<StaticCluster>();
|
||||
protected int mLastZoomLevel;
|
||||
protected Bitmap mClusterIcon;
|
||||
protected String mName, mDescription;
|
||||
|
||||
// abstract methods:
|
||||
|
||||
/** clustering algorithm */
|
||||
public abstract ArrayList<StaticCluster> clusterer(MapView mapView);
|
||||
/** Build the marker for a cluster. */
|
||||
public abstract MarkerWithLabel buildClusterMarker(StaticCluster cluster, MapView mapView);
|
||||
/** build clusters markers to be used at next draw */
|
||||
public abstract void renderer(ArrayList<StaticCluster> clusters, Canvas canvas, MapView mapView);
|
||||
|
||||
public MarkerClusterer() {
|
||||
super();
|
||||
mLastZoomLevel = FORCE_CLUSTERING;
|
||||
}
|
||||
|
||||
public void setName(String name){
|
||||
mName = name;
|
||||
}
|
||||
|
||||
public String getName(){
|
||||
return mName;
|
||||
}
|
||||
|
||||
public void setDescription(String description){
|
||||
mDescription = description;
|
||||
}
|
||||
|
||||
public String getDescription(){
|
||||
return mDescription;
|
||||
}
|
||||
|
||||
/** Set the cluster icon to be drawn when a cluster contains more than 1 marker.
|
||||
* If not set, default will be the default osmdroid marker icon (which is really inappropriate as a cluster icon). */
|
||||
public void setIcon(Bitmap icon){
|
||||
mClusterIcon = icon;
|
||||
}
|
||||
|
||||
/** Add the Marker.
|
||||
* Important: Markers added in a MarkerClusterer should not be added in the map overlays. */
|
||||
public void add(MarkerWithLabel marker){
|
||||
mItems.add(marker);
|
||||
}
|
||||
|
||||
/** Force a rebuild of clusters at next draw, even without a zooming action.
|
||||
* Should be done when you changed the content of a MarkerClusterer. */
|
||||
public void invalidate(){
|
||||
mLastZoomLevel = FORCE_CLUSTERING;
|
||||
}
|
||||
|
||||
/** @return the Marker at id (starting at 0) */
|
||||
public MarkerWithLabel getItem(int id){
|
||||
return mItems.get(id);
|
||||
}
|
||||
|
||||
/** @return the list of Markers. */
|
||||
public ArrayList<MarkerWithLabel> getItems(){
|
||||
return mItems;
|
||||
}
|
||||
|
||||
protected void hideInfoWindows(){
|
||||
for (MarkerWithLabel m : mItems){
|
||||
if (m.isInfoWindowShown())
|
||||
m.closeInfoWindow();
|
||||
}
|
||||
}
|
||||
|
||||
@Override public void draw(Canvas canvas, MapView mapView, boolean shadow) {
|
||||
if (shadow)
|
||||
return;
|
||||
//if zoom has changed and mapView is now stable, rebuild clusters:
|
||||
int zoomLevel = mapView.getZoomLevel();
|
||||
if (zoomLevel != mLastZoomLevel && !mapView.isAnimating()){
|
||||
hideInfoWindows();
|
||||
mClusters = clusterer(mapView);
|
||||
renderer(mClusters, canvas, mapView);
|
||||
mLastZoomLevel = zoomLevel;
|
||||
}
|
||||
|
||||
for (StaticCluster cluster:mClusters){
|
||||
MarkerWithLabel marker = cluster.getMarker();
|
||||
marker.draw(canvas, mapView, false);
|
||||
}
|
||||
}
|
||||
|
||||
public Iterable<StaticCluster> reversedClusters() {
|
||||
return new Iterable<StaticCluster>() {
|
||||
@Override
|
||||
public Iterator<StaticCluster> iterator() {
|
||||
final ListIterator<StaticCluster> i = mClusters.listIterator(mClusters.size());
|
||||
return new Iterator<StaticCluster>() {
|
||||
@Override
|
||||
public boolean hasNext() {
|
||||
return i.hasPrevious();
|
||||
}
|
||||
|
||||
@Override
|
||||
public StaticCluster next() {
|
||||
return i.previous();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void remove() {
|
||||
i.remove();
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@Override public boolean onSingleTapConfirmed(final MotionEvent event, final MapView mapView){
|
||||
for (final StaticCluster cluster : reversedClusters()) {
|
||||
if (cluster.getMarker().onSingleTapConfirmed(event, mapView))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override public boolean onLongPress(final MotionEvent event, final MapView mapView) {
|
||||
for (final StaticCluster cluster : reversedClusters()) {
|
||||
if (cluster.getMarker().onLongPress(event, mapView))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override public boolean onTouchEvent(final MotionEvent event, final MapView mapView) {
|
||||
for (StaticCluster cluster : reversedClusters()) {
|
||||
if (cluster.getMarker().onTouchEvent(event, mapView))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override public boolean onDoubleTap(final MotionEvent event, final MapView mapView) {
|
||||
for (final StaticCluster cluster : reversedClusters()) {
|
||||
if (cluster.getMarker().onDoubleTap(event, mapView))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override public BoundingBox getBounds(){
|
||||
if (mItems.size() == 0)
|
||||
return null;
|
||||
double minLat = Double.MAX_VALUE;
|
||||
double minLon = Double.MAX_VALUE;
|
||||
double maxLat = -Double.MAX_VALUE;
|
||||
double maxLon = -Double.MAX_VALUE;
|
||||
for (final MarkerWithLabel item : mItems) {
|
||||
final double latitude = item.getPosition().getLatitude();
|
||||
final double longitude = item.getPosition().getLongitude();
|
||||
minLat = Math.min(minLat, latitude);
|
||||
minLon = Math.min(minLon, longitude);
|
||||
maxLat = Math.max(maxLat, latitude);
|
||||
maxLon = Math.max(maxLon, longitude);
|
||||
}
|
||||
return new BoundingBox(maxLat, maxLon, minLat, minLon);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,195 +0,0 @@
|
|||
package com.geeksville.mesh.model.map.clustering;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Color;
|
||||
import android.graphics.Paint;
|
||||
import android.graphics.Rect;
|
||||
import android.graphics.drawable.BitmapDrawable;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.view.MotionEvent;
|
||||
|
||||
import org.osmdroid.bonuspack.R;
|
||||
import org.osmdroid.util.BoundingBox;
|
||||
import org.osmdroid.util.GeoPoint;
|
||||
import org.osmdroid.views.MapView;
|
||||
import com.geeksville.mesh.model.map.MarkerWithLabel;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Iterator;
|
||||
|
||||
/**
|
||||
* Radius-based Clustering algorithm:
|
||||
* create a cluster using the first point from the cloned list.
|
||||
* All points that are found within the neighborhood are added to this cluster.
|
||||
* Then all the neighbors and the main point are removed from the list of points.
|
||||
* It continues until the list is empty.
|
||||
*
|
||||
* Largely inspired from GridMarkerClusterer by M.Kergall
|
||||
*
|
||||
* @author sidorovroman92@gmail.com
|
||||
*/
|
||||
|
||||
public class RadiusMarkerClusterer extends MarkerClusterer {
|
||||
|
||||
protected int mMaxClusteringZoomLevel = 7;
|
||||
protected int mRadiusInPixels = 100;
|
||||
protected double mRadiusInMeters;
|
||||
protected Paint mTextPaint;
|
||||
private ArrayList<MarkerWithLabel> mClonedMarkers;
|
||||
protected boolean mAnimated;
|
||||
int mDensityDpi;
|
||||
|
||||
/** cluster icon anchor */
|
||||
public float mAnchorU = MarkerWithLabel.ANCHOR_CENTER, mAnchorV = MarkerWithLabel.ANCHOR_CENTER;
|
||||
/** anchor point to draw the number of markers inside the cluster icon */
|
||||
public float mTextAnchorU = MarkerWithLabel.ANCHOR_CENTER, mTextAnchorV = MarkerWithLabel.ANCHOR_CENTER;
|
||||
|
||||
public RadiusMarkerClusterer(Context ctx) {
|
||||
super();
|
||||
mTextPaint = new Paint();
|
||||
mTextPaint.setColor(Color.WHITE);
|
||||
mTextPaint.setTextSize(15 * ctx.getResources().getDisplayMetrics().density);
|
||||
mTextPaint.setFakeBoldText(true);
|
||||
mTextPaint.setTextAlign(Paint.Align.CENTER);
|
||||
mTextPaint.setAntiAlias(true);
|
||||
Drawable clusterIconD = ctx.getResources().getDrawable(R.drawable.marker_cluster);
|
||||
Bitmap clusterIcon = ((BitmapDrawable) clusterIconD).getBitmap();
|
||||
setIcon(clusterIcon);
|
||||
mAnimated = true;
|
||||
mDensityDpi = ctx.getResources().getDisplayMetrics().densityDpi;
|
||||
}
|
||||
|
||||
/** If you want to change the default text paint (color, size, font) */
|
||||
public Paint getTextPaint(){
|
||||
return mTextPaint;
|
||||
}
|
||||
|
||||
/** Set the radius of clustering in pixels. Default is 100px. */
|
||||
public void setRadius(int radius){
|
||||
mRadiusInPixels = radius;
|
||||
}
|
||||
|
||||
/** Set max zoom level with clustering. When zoom is higher or equal to this level, clustering is disabled.
|
||||
* You can put a high value to disable this feature. */
|
||||
public void setMaxClusteringZoomLevel(int zoom){
|
||||
mMaxClusteringZoomLevel = zoom;
|
||||
}
|
||||
|
||||
/** Radius-Based clustering algorithm */
|
||||
@Override public ArrayList<StaticCluster> clusterer(MapView mapView) {
|
||||
|
||||
ArrayList<StaticCluster> clusters = new ArrayList<StaticCluster>();
|
||||
convertRadiusToMeters(mapView);
|
||||
|
||||
mClonedMarkers = new ArrayList<MarkerWithLabel>(mItems); //shallow copy
|
||||
while (!mClonedMarkers.isEmpty()) {
|
||||
MarkerWithLabel m = mClonedMarkers.get(0);
|
||||
StaticCluster cluster = createCluster(m, mapView);
|
||||
clusters.add(cluster);
|
||||
}
|
||||
return clusters;
|
||||
}
|
||||
|
||||
private StaticCluster createCluster(MarkerWithLabel m, MapView mapView) {
|
||||
GeoPoint clusterPosition = m.getPosition();
|
||||
|
||||
StaticCluster cluster = new StaticCluster(clusterPosition);
|
||||
cluster.add(m);
|
||||
|
||||
mClonedMarkers.remove(m);
|
||||
|
||||
if (mapView.getZoomLevel() > mMaxClusteringZoomLevel) {
|
||||
//above max level => block clustering:
|
||||
return cluster;
|
||||
}
|
||||
|
||||
Iterator<MarkerWithLabel> it = mClonedMarkers.iterator();
|
||||
while (it.hasNext()) {
|
||||
MarkerWithLabel neighbour = it.next();
|
||||
double distance = clusterPosition.distanceToAsDouble(neighbour.getPosition());
|
||||
if (distance <= mRadiusInMeters) {
|
||||
cluster.add(neighbour);
|
||||
it.remove();
|
||||
}
|
||||
}
|
||||
|
||||
return cluster;
|
||||
}
|
||||
|
||||
@Override public MarkerWithLabel buildClusterMarker(StaticCluster cluster, MapView mapView) {
|
||||
MarkerWithLabel m = new MarkerWithLabel(mapView, "", null);
|
||||
m.setPosition(cluster.getPosition());
|
||||
m.setInfoWindow(null);
|
||||
m.setAnchor(mAnchorU, mAnchorV);
|
||||
|
||||
Bitmap finalIcon = Bitmap.createBitmap(mClusterIcon.getScaledWidth(mDensityDpi),
|
||||
mClusterIcon.getScaledHeight(mDensityDpi), mClusterIcon.getConfig());
|
||||
Canvas iconCanvas = new Canvas(finalIcon);
|
||||
iconCanvas.drawBitmap(mClusterIcon, 0, 0, null);
|
||||
String text = "" + cluster.getSize();
|
||||
int textHeight = (int) (mTextPaint.descent() + mTextPaint.ascent());
|
||||
iconCanvas.drawText(text,
|
||||
mTextAnchorU * finalIcon.getWidth(),
|
||||
mTextAnchorV * finalIcon.getHeight() - textHeight / 2,
|
||||
mTextPaint);
|
||||
m.setIcon(new BitmapDrawable(mapView.getContext().getResources(), finalIcon));
|
||||
|
||||
return m;
|
||||
}
|
||||
|
||||
@Override public void renderer(ArrayList<StaticCluster> clusters, Canvas canvas, MapView mapView) {
|
||||
for (StaticCluster cluster : clusters) {
|
||||
if (cluster.getSize() == 1) {
|
||||
//cluster has only 1 marker => use it as it is:
|
||||
cluster.setMarker(cluster.getItem(0));
|
||||
} else {
|
||||
//only draw 1 Marker at Cluster center, displaying number of Markers contained
|
||||
MarkerWithLabel m = buildClusterMarker(cluster, mapView);
|
||||
cluster.setMarker(m);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void convertRadiusToMeters(MapView mapView) {
|
||||
|
||||
Rect mScreenRect = mapView.getIntrinsicScreenRect(null);
|
||||
|
||||
int screenWidth = mScreenRect.right - mScreenRect.left;
|
||||
int screenHeight = mScreenRect.bottom - mScreenRect.top;
|
||||
|
||||
BoundingBox bb = mapView.getBoundingBox();
|
||||
|
||||
double diagonalInMeters = bb.getDiagonalLengthInMeters();
|
||||
double diagonalInPixels = Math.sqrt(screenWidth * screenWidth + screenHeight * screenHeight);
|
||||
double metersInPixel = diagonalInMeters / diagonalInPixels;
|
||||
|
||||
mRadiusInMeters = mRadiusInPixels * metersInPixel;
|
||||
}
|
||||
|
||||
public void setAnimation(boolean animate){
|
||||
mAnimated = animate;
|
||||
}
|
||||
|
||||
public void zoomOnCluster(MapView mapView, StaticCluster cluster){
|
||||
BoundingBox bb = cluster.getBoundingBox();
|
||||
if (bb.getLatNorth()!=bb.getLatSouth() || bb.getLonEast()!=bb.getLonWest()) {
|
||||
bb = bb.increaseByScale(2.3f);
|
||||
mapView.zoomToBoundingBox(bb, true);
|
||||
} else //all points exactly at the same place:
|
||||
mapView.setExpectedCenter(bb.getCenterWithDateLine());
|
||||
}
|
||||
|
||||
@Override public boolean onSingleTapConfirmed(final MotionEvent event, final MapView mapView){
|
||||
for (final StaticCluster cluster : reversedClusters()) {
|
||||
if (cluster.getMarker().onSingleTapConfirmed(event, mapView)) {
|
||||
if (mAnimated && cluster.getSize() > 1)
|
||||
zoomOnCluster(mapView, cluster);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
package com.geeksville.mesh.model.map.clustering;
|
||||
|
||||
import org.osmdroid.util.BoundingBox;
|
||||
import org.osmdroid.util.GeoPoint;
|
||||
import com.geeksville.mesh.model.map.MarkerWithLabel;
|
||||
|
||||
import java.util.ArrayList;
|
||||
|
||||
/**
|
||||
* Cluster of Markers.
|
||||
* @author M.Kergall
|
||||
*/
|
||||
public class StaticCluster {
|
||||
protected final ArrayList<MarkerWithLabel> mItems = new ArrayList<MarkerWithLabel>();
|
||||
protected GeoPoint mCenter;
|
||||
protected MarkerWithLabel mMarker;
|
||||
|
||||
public StaticCluster(GeoPoint center) {
|
||||
mCenter = center;
|
||||
}
|
||||
|
||||
public void setPosition(GeoPoint center){
|
||||
mCenter = center;
|
||||
}
|
||||
|
||||
public GeoPoint getPosition() {
|
||||
return mCenter;
|
||||
}
|
||||
|
||||
public int getSize() {
|
||||
return mItems.size();
|
||||
}
|
||||
|
||||
public MarkerWithLabel getItem(int index) {
|
||||
return mItems.get(index);
|
||||
}
|
||||
|
||||
public boolean add(MarkerWithLabel t) {
|
||||
return mItems.add(t);
|
||||
}
|
||||
|
||||
/** set the Marker to be displayed for this cluster */
|
||||
public void setMarker(MarkerWithLabel marker){
|
||||
mMarker = marker;
|
||||
}
|
||||
|
||||
/** @return the Marker to be displayed for this cluster */
|
||||
public MarkerWithLabel getMarker(){
|
||||
return mMarker;
|
||||
}
|
||||
|
||||
public BoundingBox getBoundingBox(){
|
||||
if (getSize()==0)
|
||||
return null;
|
||||
GeoPoint p = getItem(0).getPosition();
|
||||
BoundingBox bb = new BoundingBox(p.getLatitude(), p.getLongitude(), p.getLatitude(), p.getLongitude());
|
||||
for (int i=1; i<getSize(); i++) {
|
||||
p = getItem(i).getPosition();
|
||||
double minLat = Math.min(bb.getLatSouth(), p.getLatitude());
|
||||
double minLon = Math.min(bb.getLonWest(), p.getLongitude());
|
||||
double maxLat = Math.max(bb.getLatNorth(), p.getLatitude());
|
||||
double maxLon = Math.max(bb.getLonEast(), p.getLongitude());
|
||||
bb.set(maxLat, maxLon, minLat, minLon);
|
||||
}
|
||||
return bb;
|
||||
}
|
||||
}
|
||||
|
|
@ -22,23 +22,19 @@ import androidx.navigation.NavHostController
|
|||
import androidx.navigation.compose.composable
|
||||
import com.geeksville.mesh.model.UIViewModel
|
||||
import com.geeksville.mesh.ui.map.MapView
|
||||
import com.geeksville.mesh.ui.map.MapViewModel
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
sealed class MapRoutes {
|
||||
@Serializable
|
||||
data object Map : Route
|
||||
@Serializable data object Map : Route
|
||||
}
|
||||
|
||||
fun NavGraphBuilder.mapGraph(
|
||||
navController: NavHostController,
|
||||
uiViewModel: UIViewModel,
|
||||
) {
|
||||
fun NavGraphBuilder.mapGraph(navController: NavHostController, uiViewModel: UIViewModel, mapViewModel: MapViewModel) {
|
||||
composable<MapRoutes.Map> {
|
||||
MapView(
|
||||
model = uiViewModel,
|
||||
navigateToNodeDetails = {
|
||||
navController.navigate(NodesRoutes.NodeDetailGraph(it))
|
||||
},
|
||||
uiViewModel = uiViewModel,
|
||||
mapViewModel = mapViewModel,
|
||||
navigateToNodeDetails = { navController.navigate(NodesRoutes.NodeDetailGraph(it)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import com.geeksville.mesh.model.BluetoothViewModel
|
|||
import com.geeksville.mesh.model.UIViewModel
|
||||
import com.geeksville.mesh.ui.TopLevelDestination.Companion.isTopLevel
|
||||
import com.geeksville.mesh.ui.debug.DebugScreen
|
||||
import com.geeksville.mesh.ui.map.MapViewModel
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
enum class AdminRoute(@StringRes val title: Int) {
|
||||
|
|
@ -71,6 +72,7 @@ fun NavGraph(
|
|||
modifier: Modifier = Modifier,
|
||||
uIViewModel: UIViewModel = hiltViewModel(),
|
||||
bluetoothViewModel: BluetoothViewModel = hiltViewModel(),
|
||||
mapViewModel: MapViewModel = hiltViewModel(),
|
||||
navController: NavHostController = rememberNavController(),
|
||||
) {
|
||||
val isConnected by uIViewModel.isConnectedStateFlow.collectAsStateWithLifecycle(false)
|
||||
|
|
@ -86,7 +88,7 @@ fun NavGraph(
|
|||
) {
|
||||
contactsGraph(navController, uIViewModel)
|
||||
nodesGraph(navController, uIViewModel)
|
||||
mapGraph(navController, uIViewModel)
|
||||
mapGraph(navController, uIViewModel, mapViewModel)
|
||||
channelsGraph(navController, uIViewModel)
|
||||
connectionsGraph(navController, uIViewModel, bluetoothViewModel)
|
||||
composable<Route.DebugPanel> { DebugScreen() }
|
||||
|
|
|
|||
|
|
@ -23,8 +23,8 @@ import androidx.compose.material.icons.filled.CellTower
|
|||
import androidx.compose.material.icons.filled.LightMode
|
||||
import androidx.compose.material.icons.filled.LocationOn
|
||||
import androidx.compose.material.icons.filled.Memory
|
||||
import androidx.compose.material.icons.filled.PermScanWifi
|
||||
import androidx.compose.material.icons.filled.People
|
||||
import androidx.compose.material.icons.filled.PermScanWifi
|
||||
import androidx.compose.material.icons.filled.Power
|
||||
import androidx.compose.material.icons.filled.Router
|
||||
import androidx.compose.runtime.remember
|
||||
|
|
@ -39,130 +39,91 @@ import com.geeksville.mesh.model.UIViewModel
|
|||
import com.geeksville.mesh.ui.metrics.DeviceMetricsScreen
|
||||
import com.geeksville.mesh.ui.metrics.EnvironmentMetricsScreen
|
||||
import com.geeksville.mesh.ui.metrics.HostMetricsLogScreen
|
||||
import com.geeksville.mesh.ui.metrics.PaxMetricsScreen
|
||||
import com.geeksville.mesh.ui.metrics.PositionLogScreen
|
||||
import com.geeksville.mesh.ui.metrics.PowerMetricsScreen
|
||||
import com.geeksville.mesh.ui.metrics.SignalMetricsScreen
|
||||
import com.geeksville.mesh.ui.metrics.TracerouteLogScreen
|
||||
import com.geeksville.mesh.ui.metrics.PaxMetricsScreen
|
||||
import com.geeksville.mesh.ui.node.NodeDetailScreen
|
||||
import com.geeksville.mesh.ui.node.NodeMapScreen
|
||||
import com.geeksville.mesh.ui.node.NodeScreen
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
sealed class NodesRoutes {
|
||||
@Serializable
|
||||
data object Nodes : Route
|
||||
@Serializable data object Nodes : Route
|
||||
|
||||
@Serializable
|
||||
data object NodesGraph : Graph
|
||||
@Serializable data object NodesGraph : Graph
|
||||
|
||||
@Serializable
|
||||
data class NodeDetailGraph(val destNum: Int? = null) : Graph
|
||||
@Serializable data class NodeDetailGraph(val destNum: Int? = null) : Graph
|
||||
|
||||
@Serializable
|
||||
data class NodeDetail(val destNum: Int? = null) : Route
|
||||
@Serializable data class NodeDetail(val destNum: Int? = null) : Route
|
||||
}
|
||||
|
||||
sealed class NodeDetailRoutes {
|
||||
|
||||
@Serializable
|
||||
data object DeviceMetrics : Route
|
||||
@Serializable data object DeviceMetrics : Route
|
||||
|
||||
@Serializable
|
||||
data object NodeMap : Route
|
||||
@Serializable data object NodeMap : Route
|
||||
|
||||
@Serializable
|
||||
data object PositionLog : Route
|
||||
@Serializable data object PositionLog : Route
|
||||
|
||||
@Serializable
|
||||
data object EnvironmentMetrics : Route
|
||||
@Serializable data object EnvironmentMetrics : Route
|
||||
|
||||
@Serializable
|
||||
data object SignalMetrics : Route
|
||||
@Serializable data object SignalMetrics : Route
|
||||
|
||||
@Serializable
|
||||
data object PowerMetrics : Route
|
||||
@Serializable data object PowerMetrics : Route
|
||||
|
||||
@Serializable
|
||||
data object TracerouteLog : Route
|
||||
@Serializable data object TracerouteLog : Route
|
||||
|
||||
@Serializable
|
||||
data object HostMetricsLog : Route
|
||||
@Serializable data object HostMetricsLog : Route
|
||||
|
||||
@Serializable
|
||||
data object PaxMetrics : Route
|
||||
@Serializable data object PaxMetrics : Route
|
||||
}
|
||||
|
||||
fun NavGraphBuilder.nodesGraph(
|
||||
navController: NavHostController,
|
||||
uiViewModel: UIViewModel,
|
||||
) {
|
||||
navigation<NodesRoutes.NodesGraph>(
|
||||
startDestination = NodesRoutes.Nodes,
|
||||
) {
|
||||
fun NavGraphBuilder.nodesGraph(navController: NavHostController, uiViewModel: UIViewModel) {
|
||||
navigation<NodesRoutes.NodesGraph>(startDestination = NodesRoutes.Nodes) {
|
||||
composable<NodesRoutes.Nodes> {
|
||||
NodeScreen(
|
||||
model = uiViewModel,
|
||||
navigateToMessages = {
|
||||
navController.navigate(ContactsRoutes.Messages(it))
|
||||
},
|
||||
navigateToNodeDetails = {
|
||||
navController.navigate(NodesRoutes.NodeDetailGraph(it))
|
||||
},
|
||||
navigateToMessages = { navController.navigate(ContactsRoutes.Messages(it)) },
|
||||
navigateToNodeDetails = { navController.navigate(NodesRoutes.NodeDetailGraph(it)) },
|
||||
)
|
||||
}
|
||||
nodeDetailGraph(navController, uiViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
fun NavGraphBuilder.nodeDetailGraph(
|
||||
navController: NavHostController,
|
||||
uiViewModel: UIViewModel,
|
||||
) {
|
||||
navigation<NodesRoutes.NodeDetailGraph>(
|
||||
startDestination = NodesRoutes.NodeDetail(),
|
||||
) {
|
||||
fun NavGraphBuilder.nodeDetailGraph(navController: NavHostController, uiViewModel: UIViewModel) {
|
||||
navigation<NodesRoutes.NodeDetailGraph>(startDestination = NodesRoutes.NodeDetail()) {
|
||||
composable<NodesRoutes.NodeDetail> { backStackEntry ->
|
||||
val parentEntry = remember(backStackEntry) {
|
||||
val parentRoute = backStackEntry.destination.parent!!.route!!
|
||||
navController.getBackStackEntry(parentRoute)
|
||||
}
|
||||
val parentEntry =
|
||||
remember(backStackEntry) {
|
||||
val parentRoute = backStackEntry.destination.parent!!.route!!
|
||||
navController.getBackStackEntry(parentRoute)
|
||||
}
|
||||
NodeDetailScreen(
|
||||
uiViewModel = uiViewModel,
|
||||
navigateToMessages = {
|
||||
navController.navigate(ContactsRoutes.Messages(it))
|
||||
},
|
||||
onNavigate = {
|
||||
navController.navigate(it)
|
||||
},
|
||||
onNavigateUp = {
|
||||
navController.navigateUp()
|
||||
},
|
||||
navigateToMessages = { navController.navigate(ContactsRoutes.Messages(it)) },
|
||||
onNavigate = { navController.navigate(it) },
|
||||
onNavigateUp = { navController.navigateUp() },
|
||||
viewModel = hiltViewModel(parentEntry),
|
||||
)
|
||||
}
|
||||
NodeDetailRoute.entries.forEach { nodeDetailRoute ->
|
||||
composable(nodeDetailRoute.route::class) { backStackEntry ->
|
||||
val parentEntry = remember(backStackEntry) {
|
||||
val parentRoute = backStackEntry.destination.parent!!.route!!
|
||||
navController.getBackStackEntry(parentRoute)
|
||||
}
|
||||
val parentEntry =
|
||||
remember(backStackEntry) {
|
||||
val parentRoute = backStackEntry.destination.parent!!.route!!
|
||||
navController.getBackStackEntry(parentRoute)
|
||||
}
|
||||
when (nodeDetailRoute) {
|
||||
NodeDetailRoute.DEVICE -> DeviceMetricsScreen(hiltViewModel(parentEntry))
|
||||
NodeDetailRoute.NODE_MAP -> NodeMapScreen(hiltViewModel(parentEntry))
|
||||
NodeDetailRoute.NODE_MAP -> NodeMapScreen(uiViewModel, hiltViewModel(parentEntry))
|
||||
NodeDetailRoute.POSITION_LOG -> PositionLogScreen(hiltViewModel(parentEntry))
|
||||
NodeDetailRoute.ENVIRONMENT -> EnvironmentMetricsScreen(
|
||||
hiltViewModel(
|
||||
parentEntry
|
||||
)
|
||||
)
|
||||
NodeDetailRoute.ENVIRONMENT -> EnvironmentMetricsScreen(hiltViewModel(parentEntry))
|
||||
|
||||
NodeDetailRoute.SIGNAL -> SignalMetricsScreen(hiltViewModel(parentEntry))
|
||||
NodeDetailRoute.TRACEROUTE -> TracerouteLogScreen(
|
||||
viewModel = hiltViewModel(
|
||||
parentEntry
|
||||
)
|
||||
)
|
||||
NodeDetailRoute.TRACEROUTE -> TracerouteLogScreen(viewModel = hiltViewModel(parentEntry))
|
||||
|
||||
NodeDetailRoute.POWER -> PowerMetricsScreen(hiltViewModel(parentEntry))
|
||||
NodeDetailRoute.HOST -> HostMetricsLogScreen(hiltViewModel(parentEntry))
|
||||
|
|
@ -173,11 +134,7 @@ fun NavGraphBuilder.nodeDetailGraph(
|
|||
}
|
||||
}
|
||||
|
||||
enum class NodeDetailRoute(
|
||||
@StringRes val title: Int,
|
||||
val route: Route,
|
||||
val icon: ImageVector?,
|
||||
) {
|
||||
enum class NodeDetailRoute(@StringRes val title: Int, val route: Route, val icon: ImageVector?) {
|
||||
DEVICE(R.string.device, NodeDetailRoutes.DeviceMetrics, Icons.Default.Router),
|
||||
NODE_MAP(R.string.node_map, NodeDetailRoutes.NodeMap, Icons.Default.LocationOn),
|
||||
POSITION_LOG(R.string.position_log, NodeDetailRoutes.PositionLog, Icons.Default.LocationOn),
|
||||
|
|
|
|||
|
|
@ -178,12 +178,12 @@ fun ConnectionsScreen(
|
|||
val isGpsDisabled = context.gpsDisabled()
|
||||
LaunchedEffect(isGpsDisabled) {
|
||||
if (isGpsDisabled) {
|
||||
uiViewModel.showSnackbar(context.getString(R.string.location_disabled))
|
||||
uiViewModel.showSnackBar(context.getString(R.string.location_disabled))
|
||||
}
|
||||
}
|
||||
LaunchedEffect(bluetoothEnabled) {
|
||||
if (!bluetoothEnabled) {
|
||||
uiViewModel.showSnackbar(context.getString(R.string.bluetooth_disabled))
|
||||
uiViewModel.showSnackBar(context.getString(R.string.bluetooth_disabled))
|
||||
}
|
||||
}
|
||||
// when scanning is true - wait 10000ms and then stop scanning
|
||||
|
|
@ -234,7 +234,7 @@ fun ConnectionsScreen(
|
|||
if (!isGpsDisabled) {
|
||||
uiViewModel.meshService?.startProvideLocation()
|
||||
} else {
|
||||
uiViewModel.showSnackbar(context.getString(R.string.location_disabled))
|
||||
uiViewModel.showSnackBar(context.getString(R.string.location_disabled))
|
||||
}
|
||||
} else {
|
||||
// Request permissions if not granted and user wants to provide location
|
||||
|
|
@ -575,7 +575,7 @@ fun ConnectionsScreen(
|
|||
onClick = {
|
||||
showReportBugDialog = false
|
||||
reportError("Clicked Report A Bug")
|
||||
uiViewModel.showSnackbar("Bug report sent!")
|
||||
uiViewModel.showSnackBar("Bug report sent!")
|
||||
},
|
||||
) {
|
||||
Text(stringResource(R.string.report))
|
||||
|
|
@ -619,6 +619,7 @@ private enum class DeviceType {
|
|||
NO_DEVICE_SELECTED -> null
|
||||
else -> null
|
||||
}
|
||||
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
|
|
|||
106
app/src/main/java/com/geeksville/mesh/ui/map/BaseMapViewModel.kt
Normal file
106
app/src/main/java/com/geeksville/mesh/ui/map/BaseMapViewModel.kt
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
/*
|
||||
* 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.content.SharedPreferences
|
||||
import androidx.core.content.edit
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.geeksville.mesh.database.NodeRepository
|
||||
import com.geeksville.mesh.database.PacketRepository
|
||||
import com.geeksville.mesh.database.entity.Packet
|
||||
import com.geeksville.mesh.model.Node
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.mapLatest
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
|
||||
@Suppress("TooManyFunctions")
|
||||
abstract class BaseMapViewModel(
|
||||
protected val preferences: SharedPreferences,
|
||||
nodeRepository: NodeRepository,
|
||||
packetRepository: PacketRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
val nodes: StateFlow<List<Node>> =
|
||||
nodeRepository
|
||||
.getNodes()
|
||||
.map { nodes -> nodes.filterNot { node -> node.isIgnored } }
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000),
|
||||
initialValue = emptyList(),
|
||||
)
|
||||
|
||||
val waypoints: StateFlow<Map<Int, Packet>> =
|
||||
packetRepository
|
||||
.getWaypoints()
|
||||
.mapLatest { list ->
|
||||
list
|
||||
.associateBy { packet -> packet.data.waypoint!!.id }
|
||||
.filterValues {
|
||||
it.data.waypoint!!.expire == 0 || it.data.waypoint!!.expire > System.currentTimeMillis() / 1000
|
||||
}
|
||||
}
|
||||
.stateIn(scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000), initialValue = emptyMap())
|
||||
|
||||
private val showOnlyFavorites = MutableStateFlow(preferences.getBoolean("only-favorites", false))
|
||||
|
||||
private val showWaypointsOnMap = MutableStateFlow(preferences.getBoolean("show-waypoints-on-map", true))
|
||||
|
||||
private val showPrecisionCircleOnMap =
|
||||
MutableStateFlow(preferences.getBoolean("show-precision-circle-on-map", true))
|
||||
|
||||
fun toggleOnlyFavorites() {
|
||||
val current = showOnlyFavorites.value
|
||||
preferences.edit { putBoolean("only-favorites", !current) }
|
||||
showOnlyFavorites.value = !current
|
||||
}
|
||||
|
||||
fun toggleShowWaypointsOnMap() {
|
||||
val current = showWaypointsOnMap.value
|
||||
preferences.edit { putBoolean("show-waypoints-on-map", !current) }
|
||||
showWaypointsOnMap.value = !current
|
||||
}
|
||||
|
||||
fun toggleShowPrecisionCircleOnMap() {
|
||||
val current = showPrecisionCircleOnMap.value
|
||||
preferences.edit { putBoolean("show-precision-circle-on-map", !current) }
|
||||
showPrecisionCircleOnMap.value = !current
|
||||
}
|
||||
|
||||
data class MapFilterState(val onlyFavorites: Boolean, val showWaypoints: Boolean, val showPrecisionCircle: Boolean)
|
||||
|
||||
val mapFilterStateFlow: StateFlow<MapFilterState> =
|
||||
combine(showOnlyFavorites, showWaypointsOnMap, showPrecisionCircleOnMap) {
|
||||
favoritesOnly,
|
||||
showWaypoints,
|
||||
showPrecisionCircle,
|
||||
->
|
||||
MapFilterState(favoritesOnly, showWaypoints, showPrecisionCircle)
|
||||
}
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000),
|
||||
initialValue =
|
||||
MapFilterState(showOnlyFavorites.value, showWaypointsOnMap.value, showPrecisionCircleOnMap.value),
|
||||
)
|
||||
}
|
||||
20
app/src/main/java/com/geeksville/mesh/ui/map/Constants.kt
Normal file
20
app/src/main/java/com/geeksville/mesh/ui/map/Constants.kt
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
/*
|
||||
* 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
|
||||
|
||||
const val MAP_STYLE_ID = "map_style_id"
|
||||
|
|
@ -1,788 +0,0 @@
|
|||
/*
|
||||
* 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 // Added for Accompanist
|
||||
import android.content.Context
|
||||
import androidx.appcompat.content.res.AppCompatResources
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Lens
|
||||
import androidx.compose.material.icons.filled.LocationDisabled
|
||||
import androidx.compose.material.icons.filled.PinDrop
|
||||
import androidx.compose.material.icons.filled.Star
|
||||
import androidx.compose.material.icons.outlined.Layers
|
||||
import androidx.compose.material.icons.outlined.MyLocation
|
||||
import androidx.compose.material.icons.outlined.Tune
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableDoubleStateOf
|
||||
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.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.geeksville.mesh.DataPacket
|
||||
import com.geeksville.mesh.MeshProtos.Waypoint
|
||||
import com.geeksville.mesh.R
|
||||
import com.geeksville.mesh.android.BuildUtils.debug
|
||||
import com.geeksville.mesh.android.gpsDisabled
|
||||
import com.geeksville.mesh.android.hasGps
|
||||
import com.geeksville.mesh.copy
|
||||
import com.geeksville.mesh.database.entity.Packet
|
||||
import com.geeksville.mesh.model.Node
|
||||
import com.geeksville.mesh.model.UIViewModel
|
||||
import com.geeksville.mesh.model.map.CustomTileSource
|
||||
import com.geeksville.mesh.model.map.MarkerWithLabel
|
||||
import com.geeksville.mesh.model.map.clustering.RadiusMarkerClusterer
|
||||
import com.geeksville.mesh.ui.map.components.CacheLayout
|
||||
import com.geeksville.mesh.ui.map.components.DownloadButton
|
||||
import com.geeksville.mesh.ui.map.components.EditWaypointDialog
|
||||
import com.geeksville.mesh.ui.map.components.MapButton
|
||||
import com.geeksville.mesh.util.SqlTileWriterExt
|
||||
import com.geeksville.mesh.util.addCopyright
|
||||
import com.geeksville.mesh.util.addScaleBarOverlay
|
||||
import com.geeksville.mesh.util.createLatLongGrid
|
||||
import com.geeksville.mesh.util.formatAgo
|
||||
import com.geeksville.mesh.util.zoomIn
|
||||
import com.geeksville.mesh.waypoint
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi // Added for Accompanist
|
||||
import com.google.accompanist.permissions.rememberMultiplePermissionsState // Added for Accompanist
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import org.osmdroid.bonuspack.utils.BonusPackHelper.getBitmapFromVectorDrawable
|
||||
import org.osmdroid.config.Configuration
|
||||
import org.osmdroid.events.MapEventsReceiver
|
||||
import org.osmdroid.events.MapListener
|
||||
import org.osmdroid.events.ScrollEvent
|
||||
import org.osmdroid.events.ZoomEvent
|
||||
import org.osmdroid.tileprovider.cachemanager.CacheManager
|
||||
import org.osmdroid.tileprovider.modules.SqliteArchiveTileWriter
|
||||
import org.osmdroid.tileprovider.tilesource.ITileSource
|
||||
import org.osmdroid.tileprovider.tilesource.OnlineTileSourceBase
|
||||
import org.osmdroid.tileprovider.tilesource.TileSourcePolicyException
|
||||
import org.osmdroid.util.BoundingBox
|
||||
import org.osmdroid.util.GeoPoint
|
||||
import org.osmdroid.views.MapView
|
||||
import org.osmdroid.views.overlay.MapEventsOverlay
|
||||
import org.osmdroid.views.overlay.Marker
|
||||
import org.osmdroid.views.overlay.Polygon
|
||||
import org.osmdroid.views.overlay.infowindow.InfoWindow
|
||||
import org.osmdroid.views.overlay.mylocation.MyLocationNewOverlay
|
||||
import java.io.File
|
||||
import java.text.DateFormat
|
||||
|
||||
@Composable
|
||||
private fun MapView.UpdateMarkers(
|
||||
nodeMarkers: List<MarkerWithLabel>,
|
||||
waypointMarkers: List<MarkerWithLabel>,
|
||||
nodeClusterer: RadiusMarkerClusterer,
|
||||
) {
|
||||
debug("Showing on map: ${nodeMarkers.size} nodes ${waypointMarkers.size} waypoints")
|
||||
overlays.removeAll { it is MarkerWithLabel }
|
||||
// overlays.addAll(nodeMarkers + waypointMarkers)
|
||||
overlays.addAll(waypointMarkers)
|
||||
nodeClusterer.items.clear()
|
||||
nodeClusterer.items.addAll(nodeMarkers)
|
||||
nodeClusterer.invalidate()
|
||||
}
|
||||
|
||||
// private fun addWeatherLayer() {
|
||||
// if (map.tileProvider.tileSource.name()
|
||||
// .equals(CustomTileSource.getTileSource("ESRI World TOPO").name())
|
||||
// ) {
|
||||
// val layer = TilesOverlay(
|
||||
// MapTileProviderBasic(
|
||||
// activity,
|
||||
// CustomTileSource.OPENWEATHER_RADAR
|
||||
// ), context
|
||||
// )
|
||||
// layer.loadingBackgroundColor = Color.TRANSPARENT
|
||||
// layer.loadingLineColor = Color.TRANSPARENT
|
||||
// map.overlayManager.add(layer)
|
||||
// }
|
||||
// }
|
||||
|
||||
private fun cacheManagerCallback(onTaskComplete: () -> Unit, onTaskFailed: (Int) -> Unit) =
|
||||
object : CacheManager.CacheManagerCallback {
|
||||
override fun onTaskComplete() {
|
||||
onTaskComplete()
|
||||
}
|
||||
|
||||
override fun onTaskFailed(errors: Int) {
|
||||
onTaskFailed(errors)
|
||||
}
|
||||
|
||||
override fun updateProgress(progress: Int, currentZoomLevel: Int, zoomMin: Int, zoomMax: Int) {
|
||||
// NOOP since we are using the build in UI
|
||||
}
|
||||
|
||||
override fun downloadStarted() {
|
||||
// NOOP since we are using the build in UI
|
||||
}
|
||||
|
||||
override fun setPossibleTilesInArea(total: Int) {
|
||||
// NOOP since we are using the build in UI
|
||||
}
|
||||
}
|
||||
|
||||
private fun Context.purgeTileSource(onResult: (String) -> Unit) {
|
||||
val cache = SqlTileWriterExt()
|
||||
val builder = MaterialAlertDialogBuilder(this)
|
||||
builder.setTitle(R.string.map_tile_source)
|
||||
val sources = cache.sources
|
||||
val sourceList = mutableListOf<String>()
|
||||
for (i in sources.indices) {
|
||||
sourceList.add(sources[i].source as String)
|
||||
}
|
||||
val selected: BooleanArray? = null
|
||||
val selectedList = mutableListOf<Int>()
|
||||
builder.setMultiChoiceItems(sourceList.toTypedArray(), selected) { _, i, b ->
|
||||
if (b) {
|
||||
selectedList.add(i)
|
||||
} else {
|
||||
selectedList.remove(i)
|
||||
}
|
||||
}
|
||||
builder.setPositiveButton(R.string.clear) { _, _ ->
|
||||
for (x in selectedList) {
|
||||
val item = sources[x]
|
||||
val b = cache.purgeCache(item.source)
|
||||
onResult(
|
||||
if (b) {
|
||||
getString(R.string.map_purge_success, item.source)
|
||||
} else {
|
||||
getString(R.string.map_purge_fail)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
builder.setNegativeButton(R.string.cancel) { dialog, _ -> dialog.cancel() }
|
||||
builder.show()
|
||||
}
|
||||
|
||||
/**
|
||||
* Main composable for displaying the map view, including nodes, waypoints, and user location. It handles user
|
||||
* interactions for map manipulation, filtering, and offline caching.
|
||||
*
|
||||
* @param model The [UIViewModel] providing data and state for the map.
|
||||
* @param navigateToNodeDetails Callback to navigate to the details screen of a selected node.
|
||||
*/
|
||||
@OptIn(ExperimentalPermissionsApi::class) // Added for Accompanist
|
||||
@Suppress("CyclomaticComplexMethod", "LongMethod")
|
||||
@Composable
|
||||
fun MapView(model: UIViewModel = viewModel(), navigateToNodeDetails: (Int) -> Unit) {
|
||||
var mapFilterExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
val mapFilterState by model.mapFilterStateFlow.collectAsState()
|
||||
|
||||
var cacheEstimate by remember { mutableStateOf("") }
|
||||
|
||||
var zoomLevelMin by remember { mutableDoubleStateOf(0.0) }
|
||||
var zoomLevelMax by remember { mutableDoubleStateOf(0.0) }
|
||||
|
||||
var downloadRegionBoundingBox: BoundingBox? by remember { mutableStateOf(null) }
|
||||
var myLocationOverlay: MyLocationNewOverlay? by remember { mutableStateOf(null) }
|
||||
|
||||
var showDownloadButton: Boolean by remember { mutableStateOf(false) }
|
||||
var showEditWaypointDialog by remember { mutableStateOf<Waypoint?>(null) }
|
||||
var showCurrentCacheInfo by remember { mutableStateOf(false) }
|
||||
|
||||
val context = LocalContext.current
|
||||
val density = LocalDensity.current
|
||||
|
||||
val haptic = LocalHapticFeedback.current
|
||||
fun performHapticFeedback() = haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
|
||||
val hasGps = remember { context.hasGps() }
|
||||
|
||||
// Accompanist permissions state for location
|
||||
val locationPermissionsState =
|
||||
rememberMultiplePermissionsState(permissions = listOf(Manifest.permission.ACCESS_FINE_LOCATION))
|
||||
var triggerLocationToggleAfterPermission by remember { mutableStateOf(false) }
|
||||
|
||||
fun loadOnlineTileSourceBase(): ITileSource {
|
||||
val id = model.mapStyleId
|
||||
debug("mapStyleId from prefs: $id")
|
||||
return CustomTileSource.getTileSource(id).also {
|
||||
zoomLevelMax = it.maximumZoomLevel.toDouble()
|
||||
showDownloadButton = if (it is OnlineTileSourceBase) it.tileSourcePolicy.acceptsBulkDownload() else false
|
||||
}
|
||||
}
|
||||
|
||||
val initialCameraView = remember {
|
||||
val nodes = model.nodeList.value
|
||||
val nodesWithPosition = nodes.filter { it.validPosition != null }
|
||||
val geoPoints = nodesWithPosition.map { GeoPoint(it.latitude, it.longitude) }
|
||||
BoundingBox.fromGeoPoints(geoPoints)
|
||||
}
|
||||
val map = rememberMapViewWithLifecycle(initialCameraView, loadOnlineTileSourceBase())
|
||||
|
||||
val nodeClusterer = remember { RadiusMarkerClusterer(context) }
|
||||
|
||||
fun MapView.toggleMyLocation() {
|
||||
if (context.gpsDisabled()) {
|
||||
debug("Telling user we need location turned on for MyLocationNewOverlay")
|
||||
model.showSnackbar(R.string.location_disabled)
|
||||
return
|
||||
}
|
||||
debug("user clicked MyLocationNewOverlay ${myLocationOverlay == null}")
|
||||
if (myLocationOverlay == null) {
|
||||
myLocationOverlay =
|
||||
MyLocationNewOverlay(this).apply {
|
||||
enableMyLocation()
|
||||
enableFollowLocation()
|
||||
getBitmapFromVectorDrawable(context, R.drawable.ic_map_location_dot_24)?.let {
|
||||
setPersonIcon(it)
|
||||
setPersonAnchor(0.5f, 0.5f)
|
||||
}
|
||||
getBitmapFromVectorDrawable(context, R.drawable.ic_map_navigation_24)?.let {
|
||||
setDirectionIcon(it)
|
||||
setDirectionAnchor(0.5f, 0.5f)
|
||||
}
|
||||
}
|
||||
overlays.add(myLocationOverlay)
|
||||
} else {
|
||||
myLocationOverlay?.apply {
|
||||
disableMyLocation()
|
||||
disableFollowLocation()
|
||||
}
|
||||
overlays.remove(myLocationOverlay)
|
||||
myLocationOverlay = null
|
||||
}
|
||||
}
|
||||
|
||||
// Effect to toggle MyLocation after permission is granted
|
||||
LaunchedEffect(locationPermissionsState.allPermissionsGranted) {
|
||||
if (locationPermissionsState.allPermissionsGranted && triggerLocationToggleAfterPermission) {
|
||||
map.toggleMyLocation()
|
||||
triggerLocationToggleAfterPermission = false
|
||||
}
|
||||
}
|
||||
|
||||
val nodes by model.nodeList.collectAsStateWithLifecycle()
|
||||
val waypoints by model.waypoints.collectAsStateWithLifecycle(emptyMap())
|
||||
|
||||
val markerIcon = remember { AppCompatResources.getDrawable(context, R.drawable.ic_baseline_location_on_24) }
|
||||
|
||||
fun MapView.onNodesChanged(nodes: Collection<Node>): List<MarkerWithLabel> {
|
||||
val nodesWithPosition = nodes.filter { it.validPosition != null }
|
||||
val ourNode = model.ourNodeInfo.value
|
||||
val gpsFormat = model.config.display.gpsFormat.number
|
||||
val displayUnits = model.config.display.units
|
||||
val mapFilterStateValue = model.mapFilterStateFlow.value // Access mapFilterState directly
|
||||
return nodesWithPosition.mapNotNull { node ->
|
||||
if (mapFilterStateValue.onlyFavorites && !node.isFavorite && !node.equals(ourNode)) {
|
||||
return@mapNotNull null
|
||||
}
|
||||
|
||||
val (p, u) = node.position to node.user
|
||||
val nodePosition = GeoPoint(node.latitude, node.longitude)
|
||||
MarkerWithLabel(mapView = this, label = "${u.shortName} ${formatAgo(p.time)}").apply {
|
||||
id = u.id
|
||||
title = u.longName
|
||||
snippet =
|
||||
context.getString(
|
||||
R.string.map_node_popup_details,
|
||||
node.gpsString(gpsFormat),
|
||||
formatAgo(node.lastHeard),
|
||||
formatAgo(p.time),
|
||||
if (node.batteryStr != "") node.batteryStr else "?",
|
||||
)
|
||||
ourNode?.distanceStr(node, displayUnits)?.let { dist ->
|
||||
subDescription = context.getString(R.string.map_subDescription, ourNode.bearing(node), dist)
|
||||
}
|
||||
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
|
||||
position = nodePosition
|
||||
icon = markerIcon
|
||||
setNodeColors(node.colors)
|
||||
if (!mapFilterStateValue.showPrecisionCircle) {
|
||||
setPrecisionBits(0)
|
||||
} else {
|
||||
setPrecisionBits(p.precisionBits)
|
||||
}
|
||||
setOnLongClickListener {
|
||||
navigateToNodeDetails(node.num)
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun showDeleteMarkerDialog(waypoint: Waypoint) {
|
||||
val builder = MaterialAlertDialogBuilder(context)
|
||||
builder.setTitle(R.string.waypoint_delete)
|
||||
builder.setNeutralButton(R.string.cancel) { _, _ -> debug("User canceled marker delete dialog") }
|
||||
builder.setNegativeButton(R.string.delete_for_me) { _, _ ->
|
||||
debug("User deleted waypoint ${waypoint.id} for me")
|
||||
model.deleteWaypoint(waypoint.id)
|
||||
}
|
||||
if (waypoint.lockedTo in setOf(0, model.myNodeNum ?: 0) && model.isConnected()) {
|
||||
builder.setPositiveButton(R.string.delete_for_everyone) { _, _ ->
|
||||
debug("User deleted waypoint ${waypoint.id} for everyone")
|
||||
model.sendWaypoint(waypoint.copy { expire = 1 })
|
||||
model.deleteWaypoint(waypoint.id)
|
||||
}
|
||||
}
|
||||
val dialog = builder.show()
|
||||
for (
|
||||
button in
|
||||
setOf(
|
||||
androidx.appcompat.app.AlertDialog.BUTTON_NEUTRAL,
|
||||
androidx.appcompat.app.AlertDialog.BUTTON_NEGATIVE,
|
||||
androidx.appcompat.app.AlertDialog.BUTTON_POSITIVE,
|
||||
)
|
||||
) {
|
||||
with(dialog.getButton(button)) {
|
||||
textSize = 12F
|
||||
isAllCaps = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun showMarkerLongPressDialog(id: Int) {
|
||||
performHapticFeedback()
|
||||
debug("marker long pressed id=$id")
|
||||
val waypoint = waypoints[id]?.data?.waypoint ?: return
|
||||
// edit only when unlocked or lockedTo myNodeNum
|
||||
if (waypoint.lockedTo in setOf(0, model.myNodeNum ?: 0) && model.isConnected()) {
|
||||
showEditWaypointDialog = waypoint
|
||||
} else {
|
||||
showDeleteMarkerDialog(waypoint)
|
||||
}
|
||||
}
|
||||
|
||||
fun getUsername(id: String?) = if (id == DataPacket.ID_LOCAL) {
|
||||
context.getString(R.string.you)
|
||||
} else {
|
||||
model.getUser(id).longName
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Suppress("MagicNumber")
|
||||
fun MapView.onWaypointChanged(waypoints: Collection<Packet>): List<MarkerWithLabel> {
|
||||
val dateFormat = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT)
|
||||
return waypoints.mapNotNull { waypoint ->
|
||||
val pt = waypoint.data.waypoint ?: return@mapNotNull null
|
||||
if (!mapFilterState.showWaypoints) return@mapNotNull null // Use collected mapFilterState
|
||||
val lock = if (pt.lockedTo != 0) "\uD83D\uDD12" else ""
|
||||
val time = dateFormat.format(waypoint.received_time)
|
||||
val label = pt.name + " " + formatAgo((waypoint.received_time / 1000).toInt())
|
||||
val emoji = String(Character.toChars(if (pt.icon == 0) 128205 else pt.icon))
|
||||
val timeLeft = pt.expire * 1000L - System.currentTimeMillis()
|
||||
val expireTimeStr =
|
||||
when {
|
||||
pt.expire == 0 || pt.expire == Int.MAX_VALUE -> "Never"
|
||||
timeLeft <= 0 -> "Expired"
|
||||
timeLeft < 60_000 -> "${timeLeft / 1000} seconds"
|
||||
timeLeft < 3_600_000 -> "${timeLeft / 60_000} minute${if (timeLeft / 60_000 != 1L) "s" else ""}"
|
||||
timeLeft < 86_400_000 -> {
|
||||
val hours = (timeLeft / 3_600_000).toInt()
|
||||
val minutes = ((timeLeft % 3_600_000) / 60_000).toInt()
|
||||
if (minutes >= 30) {
|
||||
"${hours + 1} hour${if (hours + 1 != 1) "s" else ""}"
|
||||
} else if (minutes > 0) {
|
||||
"$hours hour${if (hours != 1) "s" else ""}, $minutes minute${if (minutes != 1) "s" else ""}"
|
||||
} else {
|
||||
"$hours hour${if (hours != 1) "s" else ""}"
|
||||
}
|
||||
}
|
||||
else -> "${timeLeft / 86_400_000} day${if (timeLeft / 86_400_000 != 1L) "s" else ""}"
|
||||
}
|
||||
MarkerWithLabel(this, label, emoji).apply {
|
||||
id = "${pt.id}"
|
||||
title = "${pt.name} (${getUsername(waypoint.data.from)}$lock)"
|
||||
snippet = "[$time] ${pt.description} " + stringResource(R.string.expires) + ": $expireTimeStr"
|
||||
position = GeoPoint(pt.latitudeI * 1e-7, pt.longitudeI * 1e-7)
|
||||
setVisible(false) // This seems to be always false, was this intended?
|
||||
setOnLongClickListener {
|
||||
showMarkerLongPressDialog(pt.id)
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val isConnected = model.isConnectedStateFlow.collectAsStateWithLifecycle(false)
|
||||
|
||||
LaunchedEffect(showCurrentCacheInfo) {
|
||||
if (!showCurrentCacheInfo) return@LaunchedEffect
|
||||
model.showSnackbar(R.string.calculating)
|
||||
val cacheManager = CacheManager(map)
|
||||
val cacheCapacity = cacheManager.cacheCapacity()
|
||||
val currentCacheUsage = cacheManager.currentCacheUsage()
|
||||
|
||||
val mapCacheInfoText =
|
||||
context.getString(
|
||||
R.string.map_cache_info,
|
||||
cacheCapacity / (1024.0 * 1024.0),
|
||||
currentCacheUsage / (1024.0 * 1024.0),
|
||||
)
|
||||
|
||||
MaterialAlertDialogBuilder(context)
|
||||
.setTitle(R.string.map_cache_manager)
|
||||
.setMessage(mapCacheInfoText)
|
||||
.setPositiveButton(R.string.close) { dialog, _ ->
|
||||
showCurrentCacheInfo = false
|
||||
dialog.dismiss()
|
||||
}
|
||||
.show()
|
||||
}
|
||||
|
||||
val mapEventsReceiver =
|
||||
object : MapEventsReceiver {
|
||||
override fun singleTapConfirmedHelper(p: GeoPoint): Boolean {
|
||||
InfoWindow.closeAllInfoWindowsOn(map)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun longPressHelper(p: GeoPoint): Boolean {
|
||||
performHapticFeedback()
|
||||
val enabled = isConnected.value && downloadRegionBoundingBox == null
|
||||
|
||||
if (enabled) {
|
||||
showEditWaypointDialog = waypoint {
|
||||
latitudeI = (p.latitude * 1e7).toInt()
|
||||
longitudeI = (p.longitude * 1e7).toInt()
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
fun MapView.drawOverlays() {
|
||||
if (overlays.none { it is MapEventsOverlay }) {
|
||||
overlays.add(0, MapEventsOverlay(mapEventsReceiver))
|
||||
}
|
||||
if (myLocationOverlay != null && overlays.none { it is MyLocationNewOverlay }) {
|
||||
overlays.add(myLocationOverlay)
|
||||
}
|
||||
if (overlays.none { it is RadiusMarkerClusterer }) {
|
||||
overlays.add(nodeClusterer)
|
||||
}
|
||||
|
||||
addCopyright()
|
||||
addScaleBarOverlay(density)
|
||||
createLatLongGrid(false)
|
||||
|
||||
invalidate()
|
||||
}
|
||||
|
||||
with(map) { UpdateMarkers(onNodesChanged(nodes), onWaypointChanged(waypoints.values), nodeClusterer) }
|
||||
|
||||
fun MapView.generateBoxOverlay() {
|
||||
overlays.removeAll { it is Polygon }
|
||||
val zoomFactor = 1.3
|
||||
zoomLevelMin = minOf(zoomLevelDouble, zoomLevelMax)
|
||||
downloadRegionBoundingBox = boundingBox.zoomIn(zoomFactor)
|
||||
val polygon =
|
||||
Polygon().apply {
|
||||
points = Polygon.pointsAsRect(downloadRegionBoundingBox).map { GeoPoint(it.latitude, it.longitude) }
|
||||
}
|
||||
overlays.add(polygon)
|
||||
invalidate()
|
||||
val tileCount: Int =
|
||||
CacheManager(this)
|
||||
.possibleTilesInArea(downloadRegionBoundingBox, zoomLevelMin.toInt(), zoomLevelMax.toInt())
|
||||
cacheEstimate = context.getString(R.string.map_cache_tiles, tileCount)
|
||||
}
|
||||
|
||||
val boxOverlayListener =
|
||||
object : MapListener {
|
||||
override fun onScroll(event: ScrollEvent): Boolean {
|
||||
if (downloadRegionBoundingBox != null) {
|
||||
event.source.generateBoxOverlay()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onZoom(event: ZoomEvent): Boolean = false
|
||||
}
|
||||
|
||||
fun startDownload() {
|
||||
val boundingBox = downloadRegionBoundingBox ?: return
|
||||
try {
|
||||
val outputName = buildString {
|
||||
append(Configuration.getInstance().osmdroidBasePath.absolutePath)
|
||||
append(File.separator)
|
||||
append("mainFile.sqlite")
|
||||
}
|
||||
val writer = SqliteArchiveTileWriter(outputName)
|
||||
val cacheManager = CacheManager(map, writer)
|
||||
cacheManager.downloadAreaAsync(
|
||||
context,
|
||||
boundingBox,
|
||||
zoomLevelMin.toInt(),
|
||||
zoomLevelMax.toInt(),
|
||||
cacheManagerCallback(
|
||||
onTaskComplete = {
|
||||
model.showSnackbar(R.string.map_download_complete)
|
||||
writer.onDetach()
|
||||
},
|
||||
onTaskFailed = { errors ->
|
||||
model.showSnackbar(context.getString(R.string.map_download_errors, errors))
|
||||
writer.onDetach()
|
||||
},
|
||||
),
|
||||
)
|
||||
} catch (ex: TileSourcePolicyException) {
|
||||
debug("Tile source does not allow archiving: ${ex.message}")
|
||||
} catch (ex: Exception) {
|
||||
debug("Tile source exception: ${ex.message}")
|
||||
}
|
||||
}
|
||||
|
||||
fun showMapStyleDialog() {
|
||||
val builder = MaterialAlertDialogBuilder(context)
|
||||
val mapStyles: Array<CharSequence> = CustomTileSource.mTileSources.values.toTypedArray()
|
||||
|
||||
val mapStyleInt = model.mapStyleId
|
||||
builder.setSingleChoiceItems(mapStyles, mapStyleInt) { dialog, which ->
|
||||
debug("Set mapStyleId pref to $which")
|
||||
model.mapStyleId = which
|
||||
dialog.dismiss()
|
||||
map.setTileSource(loadOnlineTileSourceBase())
|
||||
}
|
||||
val dialog = builder.create()
|
||||
dialog.show()
|
||||
}
|
||||
|
||||
fun Context.showCacheManagerDialog() {
|
||||
MaterialAlertDialogBuilder(this)
|
||||
.setTitle(R.string.map_offline_manager)
|
||||
.setItems(
|
||||
arrayOf<CharSequence>(
|
||||
getString(R.string.map_cache_size),
|
||||
getString(R.string.map_download_region),
|
||||
getString(R.string.map_clear_tiles),
|
||||
getString(R.string.cancel),
|
||||
),
|
||||
) { dialog, which ->
|
||||
when (which) {
|
||||
0 -> showCurrentCacheInfo = true
|
||||
1 -> {
|
||||
map.generateBoxOverlay()
|
||||
dialog.dismiss()
|
||||
}
|
||||
2 -> purgeTileSource { model.showSnackbar(it) }
|
||||
else -> dialog.dismiss()
|
||||
}
|
||||
}
|
||||
.show()
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
floatingActionButton = {
|
||||
DownloadButton(showDownloadButton && downloadRegionBoundingBox == null) { context.showCacheManagerDialog() }
|
||||
},
|
||||
) { innerPadding ->
|
||||
Box(modifier = Modifier.fillMaxSize().padding(innerPadding)) {
|
||||
AndroidView(
|
||||
factory = {
|
||||
map.apply {
|
||||
setDestroyMode(false)
|
||||
addMapListener(boxOverlayListener)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
update = { mapView -> mapView.drawOverlays() }, // Renamed map to mapView to avoid conflict
|
||||
)
|
||||
if (downloadRegionBoundingBox != null) {
|
||||
CacheLayout(
|
||||
cacheEstimate = cacheEstimate,
|
||||
onExecuteJob = { startDownload() },
|
||||
onCancelDownload = {
|
||||
downloadRegionBoundingBox = null
|
||||
map.overlays.removeAll { it is Polygon }
|
||||
map.invalidate()
|
||||
},
|
||||
modifier = Modifier.align(Alignment.BottomCenter),
|
||||
)
|
||||
} else {
|
||||
Column(
|
||||
modifier = Modifier.padding(top = 16.dp, end = 16.dp).align(Alignment.TopEnd),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
MapButton(
|
||||
onClick = ::showMapStyleDialog,
|
||||
icon = Icons.Outlined.Layers,
|
||||
contentDescription = R.string.map_style_selection,
|
||||
)
|
||||
Box(modifier = Modifier) {
|
||||
MapButton(
|
||||
onClick = { mapFilterExpanded = true },
|
||||
icon = Icons.Outlined.Tune,
|
||||
contentDescription = R.string.map_filter,
|
||||
)
|
||||
DropdownMenu(
|
||||
expanded = mapFilterExpanded,
|
||||
onDismissRequest = { mapFilterExpanded = false },
|
||||
modifier = Modifier.background(MaterialTheme.colorScheme.surface),
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Star,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(end = 8.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.only_favorites),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Checkbox(
|
||||
checked = mapFilterState.onlyFavorites,
|
||||
onCheckedChange = { model.toggleOnlyFavorites() },
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
)
|
||||
}
|
||||
},
|
||||
onClick = { model.toggleOnlyFavorites() },
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.PinDrop,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(end = 8.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.show_waypoints),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Checkbox(
|
||||
checked = mapFilterState.showWaypoints,
|
||||
onCheckedChange = { model.toggleShowWaypointsOnMap() },
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
)
|
||||
}
|
||||
},
|
||||
onClick = { model.toggleShowWaypointsOnMap() },
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Lens,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(end = 8.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.show_precision_circle),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Checkbox(
|
||||
checked = mapFilterState.showPrecisionCircle,
|
||||
onCheckedChange = { model.toggleShowPrecisionCircleOnMap() },
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
)
|
||||
}
|
||||
},
|
||||
onClick = { model.toggleShowPrecisionCircleOnMap() },
|
||||
)
|
||||
}
|
||||
}
|
||||
if (hasGps) {
|
||||
MapButton(
|
||||
icon =
|
||||
if (myLocationOverlay == null) {
|
||||
Icons.Outlined.MyLocation
|
||||
} else {
|
||||
Icons.Default.LocationDisabled
|
||||
},
|
||||
contentDescription = stringResource(R.string.toggle_my_position),
|
||||
) {
|
||||
if (locationPermissionsState.allPermissionsGranted) {
|
||||
map.toggleMyLocation()
|
||||
} else {
|
||||
triggerLocationToggleAfterPermission = true
|
||||
locationPermissionsState.launchMultiplePermissionRequest()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showEditWaypointDialog != null) {
|
||||
EditWaypointDialog(
|
||||
waypoint = showEditWaypointDialog ?: return, // Safe call
|
||||
onSendClicked = { waypoint ->
|
||||
debug("User clicked send waypoint ${waypoint.id}")
|
||||
showEditWaypointDialog = null
|
||||
model.sendWaypoint(
|
||||
waypoint.copy {
|
||||
if (id == 0) id = model.generatePacketId() ?: return@EditWaypointDialog
|
||||
if (name == "") name = "Dropped Pin"
|
||||
if (expire == 0) expire = Int.MAX_VALUE
|
||||
lockedTo = if (waypoint.lockedTo != 0) model.myNodeNum ?: 0 else 0
|
||||
if (waypoint.icon == 0) icon = 128205
|
||||
},
|
||||
)
|
||||
},
|
||||
onDeleteClicked = { waypoint ->
|
||||
debug("User clicked delete waypoint ${waypoint.id}")
|
||||
showEditWaypointDialog = null
|
||||
showDeleteMarkerDialog(waypoint)
|
||||
},
|
||||
onDismissRequest = {
|
||||
debug("User clicked cancel marker edit dialog")
|
||||
showEditWaypointDialog = null
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,173 +0,0 @@
|
|||
/*
|
||||
* 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.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.os.PowerManager
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableDoubleStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import com.geeksville.mesh.BuildConfig
|
||||
import com.geeksville.mesh.android.BuildUtils.errormsg
|
||||
import com.geeksville.mesh.util.requiredZoomLevel
|
||||
import org.osmdroid.config.Configuration
|
||||
import org.osmdroid.tileprovider.tilesource.ITileSource
|
||||
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
|
||||
import org.osmdroid.util.BoundingBox
|
||||
import org.osmdroid.util.GeoPoint
|
||||
import org.osmdroid.views.CustomZoomButtonsController
|
||||
import org.osmdroid.views.MapView
|
||||
|
||||
@SuppressLint("WakelockTimeout")
|
||||
private fun PowerManager.WakeLock.safeAcquire() {
|
||||
if (!isHeld) {
|
||||
try {
|
||||
acquire()
|
||||
} catch (e: SecurityException) {
|
||||
errormsg("WakeLock permission exception: ${e.message}")
|
||||
} catch (e: IllegalStateException) {
|
||||
errormsg("WakeLock acquire() exception: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun PowerManager.WakeLock.safeRelease() {
|
||||
if (isHeld) {
|
||||
try {
|
||||
release()
|
||||
} catch (e: IllegalStateException) {
|
||||
errormsg("WakeLock release() exception: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const val MAP_STYLE_ID = "map_style_id"
|
||||
|
||||
private const val MIN_ZOOM_LEVEL = 1.5
|
||||
private const val MAX_ZOOM_LEVEL = 20.0
|
||||
private const val DEFAULT_ZOOM_LEVEL = 15.0
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
@Composable
|
||||
internal fun rememberMapViewWithLifecycle(
|
||||
box: BoundingBox,
|
||||
tileSource: ITileSource = TileSourceFactory.DEFAULT_TILE_SOURCE,
|
||||
): MapView {
|
||||
val zoom =
|
||||
if (box.requiredZoomLevel().isFinite()) {
|
||||
(box.requiredZoomLevel() - 0.5).coerceAtLeast(MIN_ZOOM_LEVEL)
|
||||
} else {
|
||||
DEFAULT_ZOOM_LEVEL
|
||||
}
|
||||
val center = GeoPoint(box.centerLatitude, box.centerLongitude)
|
||||
return rememberMapViewWithLifecycle(zoom, center, tileSource)
|
||||
}
|
||||
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
internal fun rememberMapViewWithLifecycle(
|
||||
zoomLevel: Double = MIN_ZOOM_LEVEL,
|
||||
mapCenter: GeoPoint = GeoPoint(0.0, 0.0),
|
||||
tileSource: ITileSource = TileSourceFactory.DEFAULT_TILE_SOURCE,
|
||||
): MapView {
|
||||
var savedZoom by rememberSaveable { mutableDoubleStateOf(zoomLevel) }
|
||||
var savedCenter by
|
||||
rememberSaveable(
|
||||
stateSaver =
|
||||
Saver(
|
||||
save = { mapOf("latitude" to it.latitude, "longitude" to it.longitude) },
|
||||
restore = { GeoPoint(it["latitude"] ?: 0.0, it["longitude"] ?: .0) },
|
||||
),
|
||||
) {
|
||||
mutableStateOf(mapCenter)
|
||||
}
|
||||
|
||||
val context = LocalContext.current
|
||||
val mapView = remember {
|
||||
MapView(context).apply {
|
||||
clipToOutline = true
|
||||
|
||||
// Required to get online tiles
|
||||
Configuration.getInstance().userAgentValue = BuildConfig.APPLICATION_ID
|
||||
setTileSource(tileSource)
|
||||
isVerticalMapRepetitionEnabled = false // disables map repetition
|
||||
setMultiTouchControls(true)
|
||||
val bounds = overlayManager.tilesOverlay.bounds // bounds scrollable map
|
||||
setScrollableAreaLimitLatitude(bounds.actualNorth, bounds.actualSouth, 0)
|
||||
// scales the map tiles to the display density of the screen
|
||||
isTilesScaledToDpi = true
|
||||
// sets the minimum zoom level (the furthest out you can zoom)
|
||||
minZoomLevel = MIN_ZOOM_LEVEL
|
||||
maxZoomLevel = MAX_ZOOM_LEVEL
|
||||
// Disables default +/- button for zooming
|
||||
zoomController.setVisibility(CustomZoomButtonsController.Visibility.SHOW_AND_FADEOUT)
|
||||
|
||||
controller.setZoom(savedZoom)
|
||||
controller.setCenter(savedCenter)
|
||||
}
|
||||
}
|
||||
val lifecycle = LocalLifecycleOwner.current.lifecycle
|
||||
DisposableEffect(lifecycle) {
|
||||
val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
val wakeLock = powerManager.newWakeLock(PowerManager.FULL_WAKE_LOCK, "Meshtastic:MapViewLock")
|
||||
|
||||
wakeLock.safeAcquire()
|
||||
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
when (event) {
|
||||
Lifecycle.Event.ON_PAUSE -> {
|
||||
wakeLock.safeRelease()
|
||||
mapView.onPause()
|
||||
}
|
||||
|
||||
Lifecycle.Event.ON_RESUME -> {
|
||||
wakeLock.safeAcquire()
|
||||
mapView.onResume()
|
||||
}
|
||||
|
||||
Lifecycle.Event.ON_STOP -> {
|
||||
savedCenter = mapView.projection.currentCenter
|
||||
savedZoom = mapView.zoomLevelDouble
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
lifecycle.addObserver(observer)
|
||||
|
||||
onDispose {
|
||||
lifecycle.removeObserver(observer)
|
||||
wakeLock.safeRelease()
|
||||
}
|
||||
}
|
||||
return mapView
|
||||
}
|
||||
|
|
@ -1,108 +0,0 @@
|
|||
/*
|
||||
* 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.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.wrapContentHeight
|
||||
import androidx.compose.material3.Button
|
||||
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.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.geeksville.mesh.R
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
internal fun CacheLayout(
|
||||
cacheEstimate: String,
|
||||
onExecuteJob: () -> Unit,
|
||||
onCancelDownload: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.wrapContentHeight()
|
||||
.background(color = MaterialTheme.colorScheme.background)
|
||||
.padding(8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.map_select_download_region),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.map_tile_download_estimate) + " " + cacheEstimate,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
|
||||
FlowRow(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(space = 8.dp),
|
||||
) {
|
||||
Button(
|
||||
onClick = onCancelDownload,
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.cancel),
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
)
|
||||
}
|
||||
Button(
|
||||
onClick = onExecuteJob,
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.map_start_download),
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun CacheLayoutPreview() {
|
||||
CacheLayout(
|
||||
cacheEstimate = "100 tiles",
|
||||
onExecuteJob = { },
|
||||
onCancelDownload = { },
|
||||
)
|
||||
}
|
||||
|
|
@ -1,69 +0,0 @@
|
|||
/*
|
||||
* 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.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.geeksville.mesh.R
|
||||
|
||||
@Composable
|
||||
internal fun DownloadButton(
|
||||
enabled: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = enabled,
|
||||
enter = slideInHorizontally(
|
||||
initialOffsetX = { it },
|
||||
animationSpec = tween(durationMillis = 600, easing = FastOutSlowInEasing)
|
||||
),
|
||||
exit = slideOutHorizontally(
|
||||
targetOffsetX = { it },
|
||||
animationSpec = tween(durationMillis = 600, easing = FastOutSlowInEasing)
|
||||
)
|
||||
) {
|
||||
FloatingActionButton(
|
||||
onClick = onClick,
|
||||
contentColor = MaterialTheme.colorScheme.primary,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Download,
|
||||
contentDescription = stringResource(R.string.map_download_region),
|
||||
modifier = Modifier.scale(1.25f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//@Preview(showBackground = true)
|
||||
//@Composable
|
||||
//private fun DownloadButtonPreview() {
|
||||
// DownloadButton(true, onClick = {})
|
||||
//}
|
||||
|
|
@ -1,344 +0,0 @@
|
|||
/*
|
||||
* 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.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.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
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.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
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.graphics.Color
|
||||
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.tooling.preview.Preview
|
||||
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.EditTextPreference
|
||||
import com.geeksville.mesh.ui.common.components.EmojiPickerDialog
|
||||
import com.geeksville.mesh.ui.common.theme.AppTheme
|
||||
import com.geeksville.mesh.waypoint
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Calendar
|
||||
import java.util.Locale
|
||||
|
||||
@Suppress("LongMethod")
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
internal 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
|
||||
@Suppress("MagicNumber")
|
||||
val emoji = if (waypointInput.icon == 0) 128205 else waypointInput.icon
|
||||
var showEmojiPickerView by remember { mutableStateOf(false) }
|
||||
|
||||
// Get current context for dialogs
|
||||
val context = LocalContext.current
|
||||
val calendar = Calendar.getInstance()
|
||||
val currentTime = System.currentTimeMillis()
|
||||
calendar.timeInMillis = currentTime
|
||||
@Suppress("MagicNumber")
|
||||
calendar.add(Calendar.HOUR_OF_DAY, 8)
|
||||
|
||||
// Current time for initializing pickers
|
||||
val year = calendar.get(Calendar.YEAR)
|
||||
val month = calendar.get(Calendar.MONTH)
|
||||
val day = calendar.get(Calendar.DAY_OF_MONTH)
|
||||
val hour = calendar.get(Calendar.HOUR_OF_DAY)
|
||||
val minute = calendar.get(Calendar.MINUTE)
|
||||
|
||||
// Determine locale-specific date format
|
||||
val locale = Locale.getDefault()
|
||||
val dateFormat = if (locale.country == "US") {
|
||||
SimpleDateFormat("MM/dd/yyyy", locale)
|
||||
} else {
|
||||
SimpleDateFormat("dd/MM/yyyy", locale)
|
||||
}
|
||||
// Check if 24-hour format is preferred
|
||||
val is24Hour = android.text.format.DateFormat.is24HourFormat(context)
|
||||
val timeFormat = if (is24Hour) {
|
||||
SimpleDateFormat("HH:mm", locale)
|
||||
} else {
|
||||
SimpleDateFormat("hh:mm a", locale)
|
||||
}
|
||||
|
||||
// State to hold selected date and time
|
||||
var selectedDate by remember { mutableStateOf(dateFormat.format(calendar.time)) }
|
||||
var selectedTime by remember { mutableStateOf(timeFormat.format(calendar.time)) }
|
||||
var epochTime by remember { mutableStateOf<Long?>(null) }
|
||||
|
||||
if (!showEmojiPickerView) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
text = {
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = stringResource(title),
|
||||
style = MaterialTheme.typography.titleLarge.copy(
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center,
|
||||
),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 16.dp),
|
||||
)
|
||||
EditTextPreference(
|
||||
title = stringResource(R.string.name),
|
||||
value = waypointInput.name,
|
||||
maxSize = 29,
|
||||
enabled = true,
|
||||
isError = false,
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
keyboardType = KeyboardType.Text, imeAction = ImeAction.Done
|
||||
),
|
||||
keyboardActions = KeyboardActions(onDone = { }),
|
||||
onValueChanged = { waypointInput = waypointInput.copy { name = it } },
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { showEmojiPickerView = true }) {
|
||||
Text(
|
||||
text = String(Character.toChars(emoji)),
|
||||
modifier = Modifier
|
||||
.background(MaterialTheme.colorScheme.background, CircleShape)
|
||||
.padding(4.dp),
|
||||
fontSize = 24.sp,
|
||||
color = Color.Unspecified.copy(alpha = 1f),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
EditTextPreference(
|
||||
title = stringResource(R.string.description),
|
||||
value = waypointInput.description,
|
||||
maxSize = 99,
|
||||
enabled = true,
|
||||
isError = false,
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
keyboardType = KeyboardType.Text, imeAction = ImeAction.Done
|
||||
),
|
||||
keyboardActions = KeyboardActions(onDone = { }),
|
||||
onValueChanged = { waypointInput = waypointInput.copy { description = it } }
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.size(48.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Image(
|
||||
imageVector = Icons.Default.Lock,
|
||||
contentDescription = stringResource(R.string.locked),
|
||||
)
|
||||
Text(stringResource(R.string.locked))
|
||||
Switch(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.wrapContentWidth(Alignment.End),
|
||||
checked = waypointInput.lockedTo != 0,
|
||||
onCheckedChange = {
|
||||
waypointInput = waypointInput.copy { lockedTo = if (it) 1 else 0 }
|
||||
}
|
||||
)
|
||||
}
|
||||
val datePickerDialog = DatePickerDialog(
|
||||
context,
|
||||
{ _: DatePicker, selectedYear: Int, selectedMonth: Int, selectedDay: Int ->
|
||||
selectedDate = "$selectedDay/${selectedMonth + 1}/$selectedYear"
|
||||
calendar.set(selectedYear, selectedMonth, selectedDay)
|
||||
epochTime = calendar.timeInMillis
|
||||
if (epochTime != null) {
|
||||
selectedDate = dateFormat.format(calendar.time)
|
||||
}
|
||||
}, year, month, day
|
||||
)
|
||||
|
||||
val timePickerDialog = android.app.TimePickerDialog(
|
||||
context,
|
||||
{ _: TimePicker, selectedHour: Int, selectedMinute: Int ->
|
||||
selectedTime = String.format(Locale.getDefault(), "%02d:%02d", selectedHour, selectedMinute)
|
||||
calendar.set(Calendar.HOUR_OF_DAY, selectedHour)
|
||||
calendar.set(Calendar.MINUTE, selectedMinute)
|
||||
epochTime = calendar.timeInMillis
|
||||
selectedTime = timeFormat.format(calendar.time)
|
||||
@Suppress("MagicNumber")
|
||||
waypointInput = waypointInput.copy { expire = (epochTime!! / 1000).toInt() }
|
||||
}, hour, minute, is24Hour
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.size(48.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Image(
|
||||
imageVector = Icons.Default.CalendarMonth,
|
||||
contentDescription = stringResource(R.string.expires),
|
||||
)
|
||||
Text(stringResource(R.string.expires))
|
||||
Switch(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.wrapContentWidth(Alignment.End),
|
||||
checked = waypointInput.expire != Int.MAX_VALUE && waypointInput.expire != 0,
|
||||
onCheckedChange = { isChecked ->
|
||||
waypointInput = waypointInput.copy {
|
||||
expire = if (isChecked) {
|
||||
@Suppress("MagicNumber")
|
||||
calendar.timeInMillis / 1000
|
||||
} else {
|
||||
Int.MAX_VALUE
|
||||
}.toInt()
|
||||
}
|
||||
if (isChecked) {
|
||||
selectedDate = dateFormat.format(calendar.time)
|
||||
selectedTime = timeFormat.format(calendar.time)
|
||||
} else {
|
||||
selectedDate = ""
|
||||
selectedTime = ""
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (waypointInput.expire != Int.MAX_VALUE && waypointInput.expire != 0) {
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Button(onClick = { datePickerDialog.show() }) {
|
||||
Text(stringResource(R.string.date))
|
||||
}
|
||||
Text(
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
text = "$selectedDate",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Button(onClick = { timePickerDialog.show() }) {
|
||||
Text(stringResource(R.string.time))
|
||||
}
|
||||
Text(
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
text = "$selectedTime",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} },
|
||||
confirmButton = {
|
||||
FlowRow(
|
||||
modifier = modifier.padding(start = 20.dp, end = 20.dp, bottom = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
TextButton(
|
||||
modifier = modifier.weight(1f),
|
||||
onClick = onDismissRequest
|
||||
) { Text(stringResource(R.string.cancel)) }
|
||||
if (waypoint.id != 0) {
|
||||
Button(
|
||||
modifier = modifier.weight(1f),
|
||||
onClick = { onDeleteClicked(waypointInput) },
|
||||
enabled = waypointInput.name.isNotEmpty(),
|
||||
) { Text(stringResource(R.string.delete)) }
|
||||
}
|
||||
Button(
|
||||
modifier = modifier.weight(1f),
|
||||
onClick = { onSendClicked(waypointInput) },
|
||||
enabled = true,
|
||||
) { Text(stringResource(R.string.send)) }
|
||||
}
|
||||
},
|
||||
)
|
||||
} else {
|
||||
EmojiPickerDialog(onDismiss = { showEmojiPickerView = false }) {
|
||||
showEmojiPickerView = false
|
||||
waypointInput = waypointInput.copy { icon = it.codePointAt(0) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
@Suppress("MagicNumber")
|
||||
private fun EditWaypointFormPreview() {
|
||||
AppTheme {
|
||||
EditWaypointDialog(
|
||||
waypoint = waypoint {
|
||||
id = 123
|
||||
name = "Test 123"
|
||||
description = "This is only a test"
|
||||
icon = 128169
|
||||
expire = (System.currentTimeMillis() / 1000 + 8 * 3600).toInt()
|
||||
},
|
||||
onSendClicked = { },
|
||||
onDeleteClicked = { },
|
||||
onDismissRequest = { },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
/*
|
||||
* 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.annotation.StringRes
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Layers
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.PreviewLightDark
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.geeksville.mesh.R
|
||||
import com.geeksville.mesh.ui.common.theme.AppTheme
|
||||
|
||||
@Composable
|
||||
fun MapButton(
|
||||
icon: ImageVector,
|
||||
@StringRes contentDescription: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: () -> Unit = {}
|
||||
) {
|
||||
MapButton(
|
||||
icon = icon,
|
||||
contentDescription = stringResource(contentDescription),
|
||||
modifier = modifier,
|
||||
onClick = onClick,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MapButton(
|
||||
icon: ImageVector,
|
||||
contentDescription: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: () -> Unit = {}
|
||||
) {
|
||||
FloatingActionButton(
|
||||
onClick = onClick,
|
||||
modifier = modifier,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = contentDescription,
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewLightDark
|
||||
@Composable
|
||||
private fun MapButtonPreview() {
|
||||
AppTheme {
|
||||
MapButton(
|
||||
icon = Icons.Outlined.Layers,
|
||||
contentDescription = R.string.map_style_selection,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -102,17 +102,12 @@ private fun HeaderItem(compactWidth: Boolean) {
|
|||
}
|
||||
}
|
||||
|
||||
private const val DEG_D = 1e-7
|
||||
private const val HEADING_DEG = 1e-5
|
||||
const val DEG_D = 1e-7
|
||||
const val HEADING_DEG = 1e-5
|
||||
private const val SECONDS_TO_MILLIS = 1000L
|
||||
|
||||
@Composable
|
||||
private fun PositionItem(
|
||||
compactWidth: Boolean,
|
||||
position: MeshProtos.Position,
|
||||
dateFormat: DateFormat,
|
||||
system: DisplayUnits,
|
||||
) {
|
||||
fun PositionItem(compactWidth: Boolean, position: MeshProtos.Position, dateFormat: DateFormat, system: DisplayUnits) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
|
|
@ -130,7 +125,7 @@ private fun PositionItem(
|
|||
}
|
||||
|
||||
@Composable
|
||||
private fun formatPositionTime(position: MeshProtos.Position, dateFormat: DateFormat): String {
|
||||
fun formatPositionTime(position: MeshProtos.Position, dateFormat: DateFormat): String {
|
||||
val currentTime = System.currentTimeMillis()
|
||||
val sixMonthsAgo = currentTime - 180.days.inWholeMilliseconds
|
||||
val isOlderThanSixMonths = position.time * SECONDS_TO_MILLIS < sixMonthsAgo
|
||||
|
|
|
|||
|
|
@ -1,62 +0,0 @@
|
|||
/*
|
||||
* 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.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.geeksville.mesh.model.MetricsViewModel
|
||||
import com.geeksville.mesh.ui.map.rememberMapViewWithLifecycle
|
||||
import com.geeksville.mesh.util.addCopyright
|
||||
import com.geeksville.mesh.util.addPolyline
|
||||
import com.geeksville.mesh.util.addPositionMarkers
|
||||
import com.geeksville.mesh.util.addScaleBarOverlay
|
||||
import org.osmdroid.util.BoundingBox
|
||||
import org.osmdroid.util.GeoPoint
|
||||
|
||||
private const val DegD = 1e-7
|
||||
|
||||
@Composable
|
||||
fun NodeMapScreen(
|
||||
viewModel: MetricsViewModel = hiltViewModel(),
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val geoPoints = state.positionLogs.map { GeoPoint(it.latitudeI * DegD, it.longitudeI * DegD) }
|
||||
val cameraView = remember { BoundingBox.fromGeoPoints(geoPoints) }
|
||||
val mapView = rememberMapViewWithLifecycle(cameraView, viewModel.tileSource)
|
||||
|
||||
AndroidView(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
factory = { mapView },
|
||||
update = { map ->
|
||||
map.overlays.clear()
|
||||
map.addCopyright()
|
||||
map.addScaleBarOverlay(density)
|
||||
|
||||
map.addPolyline(density, geoPoints) {}
|
||||
map.addPositionMarkers(state.positionLogs) {}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
@ -115,7 +115,6 @@ fun NodeScreen(
|
|||
modifier = Modifier.animateItem(),
|
||||
thisNode = ourNode,
|
||||
thatNode = node,
|
||||
gpsFormat = state.gpsFormat,
|
||||
distanceUnits = state.distanceUnits,
|
||||
tempInFahrenheit = state.tempInFahrenheit,
|
||||
onAction = { menuItem ->
|
||||
|
|
|
|||
|
|
@ -39,10 +39,7 @@ import androidx.compose.ui.text.buildAnnotatedString
|
|||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.tooling.preview.PreviewLightDark
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import androidx.core.net.toUri
|
||||
import com.geeksville.mesh.ConfigProtos.Config.DisplayConfig.GpsCoordinateFormat
|
||||
import com.geeksville.mesh.android.BuildUtils.debug
|
||||
import com.geeksville.mesh.ui.common.theme.AppTheme
|
||||
import com.geeksville.mesh.ui.common.theme.HyperlinkBlue
|
||||
|
|
@ -52,91 +49,61 @@ import java.net.URLEncoder
|
|||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun LinkedCoordinates(
|
||||
modifier: Modifier = Modifier,
|
||||
latitude: Double,
|
||||
longitude: Double,
|
||||
format: Int,
|
||||
nodeName: String,
|
||||
) {
|
||||
fun LinkedCoordinates(modifier: Modifier = Modifier, latitude: Double, longitude: Double, nodeName: String) {
|
||||
val context = LocalContext.current
|
||||
val clipboard: Clipboard = LocalClipboard.current
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val style = SpanStyle(
|
||||
color = HyperlinkBlue,
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize,
|
||||
textDecoration = TextDecoration.Underline
|
||||
)
|
||||
val style =
|
||||
SpanStyle(
|
||||
color = HyperlinkBlue,
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize,
|
||||
textDecoration = TextDecoration.Underline,
|
||||
)
|
||||
|
||||
val annotatedString = rememberAnnotatedString(latitude, longitude, format, nodeName, style)
|
||||
val annotatedString = rememberAnnotatedString(latitude, longitude, nodeName, style)
|
||||
|
||||
Text(
|
||||
modifier = modifier.combinedClickable(
|
||||
onClick = {
|
||||
handleClick(context, annotatedString)
|
||||
},
|
||||
modifier =
|
||||
modifier.combinedClickable(
|
||||
onClick = { handleClick(context, annotatedString) },
|
||||
onLongClick = {
|
||||
coroutineScope.launch {
|
||||
clipboard.setClipEntry(
|
||||
ClipEntry(
|
||||
ClipData.newPlainText("", annotatedString)
|
||||
)
|
||||
)
|
||||
clipboard.setClipEntry(ClipEntry(ClipData.newPlainText("", annotatedString)))
|
||||
debug("Copied to clipboard")
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
text = annotatedString
|
||||
text = annotatedString,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun rememberAnnotatedString(
|
||||
latitude: Double,
|
||||
longitude: Double,
|
||||
format: Int,
|
||||
nodeName: String,
|
||||
style: SpanStyle
|
||||
) = buildAnnotatedString {
|
||||
pushStringAnnotation(
|
||||
tag = "gps",
|
||||
annotation = "geo:0,0?q=$latitude,$longitude&z=17&label=${
|
||||
URLEncoder.encode(nodeName, "utf-8")
|
||||
}"
|
||||
)
|
||||
withStyle(style = style) {
|
||||
val gpsString = when (format) {
|
||||
GpsCoordinateFormat.DEC_VALUE -> GPSFormat.toDEC(latitude, longitude)
|
||||
GpsCoordinateFormat.DMS_VALUE -> GPSFormat.toDMS(latitude, longitude)
|
||||
GpsCoordinateFormat.UTM_VALUE -> GPSFormat.toUTM(latitude, longitude)
|
||||
GpsCoordinateFormat.MGRS_VALUE -> GPSFormat.toMGRS(latitude, longitude)
|
||||
else -> GPSFormat.toDEC(latitude, longitude)
|
||||
private fun rememberAnnotatedString(latitude: Double, longitude: Double, nodeName: String, style: SpanStyle) =
|
||||
buildAnnotatedString {
|
||||
pushStringAnnotation(
|
||||
tag = "gps",
|
||||
annotation =
|
||||
"geo:0,0?q=$latitude,$longitude&z=17&label=${
|
||||
URLEncoder.encode(nodeName, "utf-8")
|
||||
}",
|
||||
)
|
||||
withStyle(style = style) {
|
||||
val gpsString = GPSFormat.toDec(latitude, longitude)
|
||||
append(gpsString)
|
||||
}
|
||||
append(gpsString)
|
||||
pop()
|
||||
}
|
||||
pop()
|
||||
}
|
||||
|
||||
private fun handleClick(context: Context, annotatedString: AnnotatedString) {
|
||||
annotatedString.getStringAnnotations(
|
||||
tag = "gps",
|
||||
start = 0,
|
||||
end = annotatedString.length
|
||||
).firstOrNull()?.let {
|
||||
annotatedString.getStringAnnotations(tag = "gps", start = 0, end = annotatedString.length).firstOrNull()?.let {
|
||||
val uri = it.item.toUri()
|
||||
val intent = Intent(Intent.ACTION_VIEW, uri).apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
val intent = Intent(Intent.ACTION_VIEW, uri).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) }
|
||||
|
||||
try {
|
||||
if (intent.resolveActivity(context.packageManager) != null) {
|
||||
context.startActivity(intent)
|
||||
} else {
|
||||
Toast.makeText(
|
||||
context,
|
||||
"No application available to open this location!",
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
Toast.makeText(context, "No application available to open this location!", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
} catch (ex: ActivityNotFoundException) {
|
||||
debug("Failed to open geo intent: $ex")
|
||||
|
|
@ -146,20 +113,6 @@ private fun handleClick(context: Context, annotatedString: AnnotatedString) {
|
|||
|
||||
@PreviewLightDark
|
||||
@Composable
|
||||
fun LinkedCoordinatesPreview(
|
||||
@PreviewParameter(GPSFormatPreviewParameterProvider::class) format: Int
|
||||
) {
|
||||
AppTheme {
|
||||
LinkedCoordinates(
|
||||
latitude = 37.7749,
|
||||
longitude = -122.4194,
|
||||
format = format,
|
||||
nodeName = "Test Node Name"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class GPSFormatPreviewParameterProvider : PreviewParameterProvider<Int> {
|
||||
override val values: Sequence<Int>
|
||||
get() = sequenceOf(0, 1, 2)
|
||||
fun LinkedCoordinatesPreview() {
|
||||
AppTheme { LinkedCoordinates(latitude = 37.7749, longitude = -122.4194, nodeName = "Test Node Name") }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ import com.geeksville.mesh.model.Node
|
|||
@Composable
|
||||
fun NodeChip(
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
node: Node,
|
||||
isThisNode: Boolean,
|
||||
isConnected: Boolean,
|
||||
|
|
@ -87,6 +88,7 @@ fun NodeChip(
|
|||
modifier =
|
||||
Modifier.matchParentSize()
|
||||
.combinedClickable(
|
||||
enabled = enabled,
|
||||
onClick = { onAction(NodeMenuAction.MoreDetails(node)) },
|
||||
onLongClick = { menuExpanded = true },
|
||||
interactionSource = inputChipInteractionSource,
|
||||
|
|
|
|||
|
|
@ -65,7 +65,6 @@ import com.geeksville.mesh.util.toDistanceString
|
|||
fun NodeItem(
|
||||
thisNode: Node?,
|
||||
thatNode: Node,
|
||||
gpsFormat: Int,
|
||||
distanceUnits: Int,
|
||||
tempInFahrenheit: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
|
|
@ -79,76 +78,64 @@ fun NodeItem(
|
|||
val longName = thatNode.user.longName.ifEmpty { stringResource(id = R.string.unknown_username) }
|
||||
val isThisNode = remember(thatNode) { thisNode?.num == thatNode.num }
|
||||
val system = remember(distanceUnits) { DisplayConfig.DisplayUnits.forNumber(distanceUnits) }
|
||||
val distance = remember(thisNode, thatNode) {
|
||||
thisNode?.distance(thatNode)?.takeIf { it > 0 }?.toDistanceString(system)
|
||||
}
|
||||
val distance =
|
||||
remember(thisNode, thatNode) { thisNode?.distance(thatNode)?.takeIf { it > 0 }?.toDistanceString(system) }
|
||||
|
||||
val hwInfoString = when (val hwModel = thatNode.user.hwModel) {
|
||||
MeshProtos.HardwareModel.UNSET -> MeshProtos.HardwareModel.UNSET.name
|
||||
else -> hwModel.name.replace('_', '-').replace('p', '.').lowercase()
|
||||
}
|
||||
val roleName = if (thatNode.isUnknownUser) {
|
||||
DeviceConfig.Role.UNRECOGNIZED.name
|
||||
} else {
|
||||
thatNode.user.role.name
|
||||
}
|
||||
val hwInfoString =
|
||||
when (val hwModel = thatNode.user.hwModel) {
|
||||
MeshProtos.HardwareModel.UNSET -> MeshProtos.HardwareModel.UNSET.name
|
||||
else -> hwModel.name.replace('_', '-').replace('p', '.').lowercase()
|
||||
}
|
||||
val roleName =
|
||||
if (thatNode.isUnknownUser) {
|
||||
DeviceConfig.Role.UNRECOGNIZED.name
|
||||
} else {
|
||||
thatNode.user.role.name
|
||||
}
|
||||
|
||||
val style = if (thatNode.isUnknownUser) {
|
||||
LocalTextStyle.current.copy(fontStyle = FontStyle.Italic)
|
||||
} else {
|
||||
LocalTextStyle.current
|
||||
}
|
||||
val style =
|
||||
if (thatNode.isUnknownUser) {
|
||||
LocalTextStyle.current.copy(fontStyle = FontStyle.Italic)
|
||||
} else {
|
||||
LocalTextStyle.current
|
||||
}
|
||||
|
||||
val cardColors = if (isThisNode) {
|
||||
thisNode?.colors?.second
|
||||
} else {
|
||||
thatNode.colors.second
|
||||
}?.let {
|
||||
val containerColor = Color(it).copy(alpha = 0.2f)
|
||||
CardDefaults.cardColors().copy(
|
||||
containerColor = containerColor,
|
||||
contentColor = contentColorFor(containerColor)
|
||||
)
|
||||
} ?: (CardDefaults.cardColors())
|
||||
val cardColors =
|
||||
if (isThisNode) {
|
||||
thisNode?.colors?.second
|
||||
} else {
|
||||
thatNode.colors.second
|
||||
}
|
||||
?.let {
|
||||
val containerColor = Color(it).copy(alpha = 0.2f)
|
||||
CardDefaults.cardColors()
|
||||
.copy(containerColor = containerColor, contentColor = contentColorFor(containerColor))
|
||||
} ?: (CardDefaults.cardColors())
|
||||
|
||||
val (detailsShown, showDetails) = remember { mutableStateOf(expanded) }
|
||||
val unmessageable = remember(thatNode) {
|
||||
when {
|
||||
thatNode.user.hasIsUnmessagable() -> thatNode.user.isUnmessagable
|
||||
else -> thatNode.user.role.isUnmessageableRole()
|
||||
val unmessageable =
|
||||
remember(thatNode) {
|
||||
when {
|
||||
thatNode.user.hasIsUnmessagable() -> thatNode.user.isUnmessagable
|
||||
else -> thatNode.user.role.isUnmessageableRole()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
.defaultMinSize(minHeight = 80.dp),
|
||||
modifier =
|
||||
modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp).defaultMinSize(minHeight = 80.dp),
|
||||
onClick = { showDetails(!detailsShown) },
|
||||
colors = cardColors
|
||||
colors = cardColors,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
NodeChip(
|
||||
node = thatNode,
|
||||
isThisNode = isThisNode,
|
||||
isConnected = isConnected,
|
||||
onAction = onAction,
|
||||
)
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
NodeChip(node = thatNode, isThisNode = isThisNode, isConnected = isConnected, onAction = onAction)
|
||||
|
||||
NodeKeyStatusIcon(
|
||||
hasPKC = thatNode.hasPKC,
|
||||
mismatchKey = thatNode.mismatchKey,
|
||||
publicKey = thatNode.user.publicKey,
|
||||
modifier = Modifier.size(32.dp)
|
||||
modifier = Modifier.size(32.dp),
|
||||
)
|
||||
Text(
|
||||
modifier = Modifier.weight(1f),
|
||||
|
|
@ -157,34 +144,21 @@ fun NodeItem(
|
|||
textDecoration = TextDecoration.LineThrough.takeIf { isIgnored },
|
||||
softWrap = true,
|
||||
)
|
||||
LastHeardInfo(
|
||||
lastHeard = thatNode.lastHeard,
|
||||
currentTimeMillis = currentTimeMillis
|
||||
)
|
||||
LastHeardInfo(lastHeard = thatNode.lastHeard, currentTimeMillis = currentTimeMillis)
|
||||
NodeStatusIcons(
|
||||
isThisNode = isThisNode,
|
||||
isFavorite = isFavorite,
|
||||
isUnmessageable = unmessageable,
|
||||
isConnected = isConnected
|
||||
isConnected = isConnected,
|
||||
)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
if (distance != null) {
|
||||
Text(
|
||||
text = distance,
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize,
|
||||
)
|
||||
Text(text = distance, fontSize = MaterialTheme.typography.labelLarge.fontSize)
|
||||
} else {
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
}
|
||||
BatteryInfo(
|
||||
batteryLevel = thatNode.batteryLevel,
|
||||
voltage = thatNode.voltage
|
||||
)
|
||||
BatteryInfo(batteryLevel = thatNode.batteryLevel, voltage = thatNode.voltage)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Row(
|
||||
|
|
@ -192,10 +166,7 @@ fun NodeItem(
|
|||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
SignalInfo(
|
||||
node = thatNode,
|
||||
isThisNode = isThisNode
|
||||
)
|
||||
SignalInfo(node = thatNode, isThisNode = isThisNode)
|
||||
thatNode.validPosition?.let { position ->
|
||||
val satCount = position.satsInView
|
||||
if (satCount > 0) {
|
||||
|
|
@ -204,10 +175,7 @@ fun NodeItem(
|
|||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
val telemetryString = thatNode.getTelemetryString(tempInFahrenheit)
|
||||
if (telemetryString.isNotEmpty()) {
|
||||
Text(
|
||||
|
|
@ -222,31 +190,24 @@ fun NodeItem(
|
|||
Spacer(modifier = Modifier.height(8.dp))
|
||||
HorizontalDivider()
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
thatNode.validPosition?.let {
|
||||
LinkedCoordinates(
|
||||
latitude = thatNode.latitude,
|
||||
longitude = thatNode.longitude,
|
||||
format = gpsFormat,
|
||||
nodeName = longName
|
||||
nodeName = longName,
|
||||
)
|
||||
}
|
||||
thatNode.validPosition?.let { position ->
|
||||
ElevationInfo(
|
||||
altitude = position.altitude,
|
||||
system = system,
|
||||
suffix = stringResource(id = R.string.elevation_suffix)
|
||||
suffix = stringResource(id = R.string.elevation_suffix),
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
modifier = Modifier.weight(1f),
|
||||
text = hwInfoString,
|
||||
|
|
@ -279,50 +240,29 @@ fun NodeInfoSimplePreview() {
|
|||
AppTheme {
|
||||
val thisNode = NodePreviewParameterProvider().values.first()
|
||||
val thatNode = NodePreviewParameterProvider().values.last()
|
||||
NodeItem(
|
||||
thisNode = thisNode,
|
||||
thatNode = thatNode,
|
||||
1,
|
||||
0,
|
||||
true,
|
||||
currentTimeMillis = System.currentTimeMillis(),
|
||||
)
|
||||
NodeItem(thisNode = thisNode, thatNode = thatNode, 0, true, currentTimeMillis = System.currentTimeMillis())
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview(
|
||||
showBackground = true,
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
)
|
||||
fun NodeInfoPreview(
|
||||
@PreviewParameter(NodePreviewParameterProvider::class)
|
||||
thatNode: Node
|
||||
) {
|
||||
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
fun NodeInfoPreview(@PreviewParameter(NodePreviewParameterProvider::class) thatNode: Node) {
|
||||
AppTheme {
|
||||
val thisNode = NodePreviewParameterProvider().values.first()
|
||||
Column {
|
||||
Text(
|
||||
text = "Details Collapsed",
|
||||
color = MaterialTheme.colorScheme.onBackground
|
||||
)
|
||||
Text(text = "Details Collapsed", color = MaterialTheme.colorScheme.onBackground)
|
||||
NodeItem(
|
||||
thisNode = thisNode,
|
||||
thatNode = thatNode,
|
||||
gpsFormat = 0,
|
||||
distanceUnits = 1,
|
||||
tempInFahrenheit = true,
|
||||
expanded = false,
|
||||
currentTimeMillis = System.currentTimeMillis(),
|
||||
)
|
||||
Text(
|
||||
text = "Details Shown",
|
||||
color = MaterialTheme.colorScheme.onBackground
|
||||
)
|
||||
Text(text = "Details Shown", color = MaterialTheme.colorScheme.onBackground)
|
||||
NodeItem(
|
||||
thisNode = thisNode,
|
||||
thatNode = thatNode,
|
||||
gpsFormat = 0,
|
||||
distanceUnits = 1,
|
||||
tempInFahrenheit = true,
|
||||
expanded = true,
|
||||
|
|
|
|||
|
|
@ -44,16 +44,11 @@ import com.geeksville.mesh.ui.common.components.SwitchPreference
|
|||
import com.geeksville.mesh.ui.radioconfig.RadioConfigViewModel
|
||||
|
||||
@Composable
|
||||
fun DisplayConfigScreen(
|
||||
viewModel: RadioConfigViewModel = hiltViewModel(),
|
||||
) {
|
||||
fun DisplayConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel()) {
|
||||
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
|
||||
|
||||
if (state.responseState.isWaiting()) {
|
||||
PacketResponseStateDialog(
|
||||
state = state.responseState,
|
||||
onDismiss = viewModel::clearPacketResponse,
|
||||
)
|
||||
PacketResponseStateDialog(state = state.responseState, onDismiss = viewModel::clearPacketResponse)
|
||||
}
|
||||
|
||||
DisplayConfigItemList(
|
||||
|
|
@ -62,22 +57,17 @@ fun DisplayConfigScreen(
|
|||
onSaveClicked = { displayInput ->
|
||||
val config = config { display = displayInput }
|
||||
viewModel.setConfig(config)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
fun DisplayConfigItemList(
|
||||
displayConfig: DisplayConfig,
|
||||
enabled: Boolean,
|
||||
onSaveClicked: (DisplayConfig) -> Unit,
|
||||
) {
|
||||
fun DisplayConfigItemList(displayConfig: DisplayConfig, enabled: Boolean, onSaveClicked: (DisplayConfig) -> Unit) {
|
||||
val focusManager = LocalFocusManager.current
|
||||
var displayInput by rememberSaveable { mutableStateOf(displayConfig) }
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
item { PreferenceCategory(text = stringResource(R.string.display_config)) }
|
||||
|
||||
item {
|
||||
|
|
@ -86,21 +76,10 @@ fun DisplayConfigItemList(
|
|||
value = displayInput.screenOnSecs,
|
||||
enabled = enabled,
|
||||
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
|
||||
onValueChanged = { displayInput = displayInput.copy { screenOnSecs = it } }
|
||||
onValueChanged = { displayInput = displayInput.copy { screenOnSecs = it } },
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
DropDownPreference(
|
||||
title = stringResource(R.string.gps_coordinates_format),
|
||||
enabled = enabled,
|
||||
items = DisplayConfig.GpsCoordinateFormat.entries
|
||||
.filter { it != DisplayConfig.GpsCoordinateFormat.UNRECOGNIZED }
|
||||
.map { it to it.name },
|
||||
selectedItem = displayInput.gpsFormat,
|
||||
onItemSelected = { displayInput = displayInput.copy { gpsFormat = it } }
|
||||
)
|
||||
}
|
||||
item { HorizontalDivider() }
|
||||
|
||||
item {
|
||||
|
|
@ -109,9 +88,7 @@ fun DisplayConfigItemList(
|
|||
value = displayInput.autoScreenCarouselSecs,
|
||||
enabled = enabled,
|
||||
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
|
||||
onValueChanged = {
|
||||
displayInput = displayInput.copy { autoScreenCarouselSecs = it }
|
||||
}
|
||||
onValueChanged = { displayInput = displayInput.copy { autoScreenCarouselSecs = it } },
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -120,7 +97,7 @@ fun DisplayConfigItemList(
|
|||
title = stringResource(R.string.compass_north_top),
|
||||
checked = displayInput.compassNorthTop,
|
||||
enabled = enabled,
|
||||
onCheckedChange = { displayInput = displayInput.copy { compassNorthTop = it } }
|
||||
onCheckedChange = { displayInput = displayInput.copy { compassNorthTop = it } },
|
||||
)
|
||||
}
|
||||
item { HorizontalDivider() }
|
||||
|
|
@ -130,7 +107,7 @@ fun DisplayConfigItemList(
|
|||
title = stringResource(R.string.flip_screen),
|
||||
checked = displayInput.flipScreen,
|
||||
enabled = enabled,
|
||||
onCheckedChange = { displayInput = displayInput.copy { flipScreen = it } }
|
||||
onCheckedChange = { displayInput = displayInput.copy { flipScreen = it } },
|
||||
)
|
||||
}
|
||||
item { HorizontalDivider() }
|
||||
|
|
@ -139,11 +116,12 @@ fun DisplayConfigItemList(
|
|||
DropDownPreference(
|
||||
title = stringResource(R.string.display_units),
|
||||
enabled = enabled,
|
||||
items = DisplayConfig.DisplayUnits.entries
|
||||
items =
|
||||
DisplayConfig.DisplayUnits.entries
|
||||
.filter { it != DisplayConfig.DisplayUnits.UNRECOGNIZED }
|
||||
.map { it to it.name },
|
||||
selectedItem = displayInput.units,
|
||||
onItemSelected = { displayInput = displayInput.copy { units = it } }
|
||||
onItemSelected = { displayInput = displayInput.copy { units = it } },
|
||||
)
|
||||
}
|
||||
item { HorizontalDivider() }
|
||||
|
|
@ -152,11 +130,12 @@ fun DisplayConfigItemList(
|
|||
DropDownPreference(
|
||||
title = stringResource(R.string.override_oled_auto_detect),
|
||||
enabled = enabled,
|
||||
items = DisplayConfig.OledType.entries
|
||||
items =
|
||||
DisplayConfig.OledType.entries
|
||||
.filter { it != DisplayConfig.OledType.UNRECOGNIZED }
|
||||
.map { it to it.name },
|
||||
selectedItem = displayInput.oled,
|
||||
onItemSelected = { displayInput = displayInput.copy { oled = it } }
|
||||
onItemSelected = { displayInput = displayInput.copy { oled = it } },
|
||||
)
|
||||
}
|
||||
item { HorizontalDivider() }
|
||||
|
|
@ -165,11 +144,12 @@ fun DisplayConfigItemList(
|
|||
DropDownPreference(
|
||||
title = stringResource(R.string.display_mode),
|
||||
enabled = enabled,
|
||||
items = DisplayConfig.DisplayMode.entries
|
||||
items =
|
||||
DisplayConfig.DisplayMode.entries
|
||||
.filter { it != DisplayConfig.DisplayMode.UNRECOGNIZED }
|
||||
.map { it to it.name },
|
||||
selectedItem = displayInput.displaymode,
|
||||
onItemSelected = { displayInput = displayInput.copy { displaymode = it } }
|
||||
onItemSelected = { displayInput = displayInput.copy { displaymode = it } },
|
||||
)
|
||||
}
|
||||
item { HorizontalDivider() }
|
||||
|
|
@ -179,7 +159,7 @@ fun DisplayConfigItemList(
|
|||
title = stringResource(R.string.heading_bold),
|
||||
checked = displayInput.headingBold,
|
||||
enabled = enabled,
|
||||
onCheckedChange = { displayInput = displayInput.copy { headingBold = it } }
|
||||
onCheckedChange = { displayInput = displayInput.copy { headingBold = it } },
|
||||
)
|
||||
}
|
||||
item { HorizontalDivider() }
|
||||
|
|
@ -189,7 +169,7 @@ fun DisplayConfigItemList(
|
|||
title = stringResource(R.string.wake_screen_on_tap_or_motion),
|
||||
checked = displayInput.wakeOnTapOrMotion,
|
||||
enabled = enabled,
|
||||
onCheckedChange = { displayInput = displayInput.copy { wakeOnTapOrMotion = it } }
|
||||
onCheckedChange = { displayInput = displayInput.copy { wakeOnTapOrMotion = it } },
|
||||
)
|
||||
}
|
||||
item { HorizontalDivider() }
|
||||
|
|
@ -198,11 +178,12 @@ fun DisplayConfigItemList(
|
|||
DropDownPreference(
|
||||
title = stringResource(R.string.compass_orientation),
|
||||
enabled = enabled,
|
||||
items = DisplayConfig.CompassOrientation.entries
|
||||
items =
|
||||
DisplayConfig.CompassOrientation.entries
|
||||
.filter { it != DisplayConfig.CompassOrientation.UNRECOGNIZED }
|
||||
.map { it to it.name },
|
||||
selectedItem = displayInput.compassOrientation,
|
||||
onItemSelected = { displayInput = displayInput.copy { compassOrientation = it } }
|
||||
onItemSelected = { displayInput = displayInput.copy { compassOrientation = it } },
|
||||
)
|
||||
}
|
||||
item { HorizontalDivider() }
|
||||
|
|
@ -213,7 +194,7 @@ fun DisplayConfigItemList(
|
|||
summary = stringResource(R.string.display_time_in_12h_format),
|
||||
enabled = enabled,
|
||||
checked = displayInput.use12HClock,
|
||||
onCheckedChange = { displayInput = displayInput.copy { use12HClock = it } }
|
||||
onCheckedChange = { displayInput = displayInput.copy { use12HClock = it } },
|
||||
)
|
||||
}
|
||||
item { HorizontalDivider() }
|
||||
|
|
@ -228,7 +209,7 @@ fun DisplayConfigItemList(
|
|||
onSaveClicked = {
|
||||
focusManager.clearFocus()
|
||||
onSaveClicked(displayInput)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -237,9 +218,5 @@ fun DisplayConfigItemList(
|
|||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun DisplayConfigPreview() {
|
||||
DisplayConfigItemList(
|
||||
displayConfig = DisplayConfig.getDefaultInstance(),
|
||||
enabled = true,
|
||||
onSaveClicked = { },
|
||||
)
|
||||
DisplayConfigItemList(displayConfig = DisplayConfig.getDefaultInstance(), enabled = true, onSaveClicked = {})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -211,7 +211,7 @@ fun ChannelScreen(
|
|||
channelSet = channels // Throw away user edits
|
||||
|
||||
// Tell the user to try again
|
||||
viewModel.showSnackbar(R.string.cant_change_no_radio)
|
||||
viewModel.showSnackBar(R.string.cant_change_no_radio)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -72,6 +72,21 @@ fun formatAgo(lastSeenUnix: Int, currentTimeMillis: Long = System.currentTimeMil
|
|||
}
|
||||
}
|
||||
|
||||
private const val MPS_TO_KMPH = 3.6f
|
||||
private const val KM_TO_MILES = 0.621371f
|
||||
|
||||
fun Int.mpsToKmph(): Float {
|
||||
// Convert meters per second to kilometers per hour
|
||||
val kmph = this * MPS_TO_KMPH
|
||||
return kmph
|
||||
}
|
||||
|
||||
fun Int.mpsToMph(): Float {
|
||||
// Convert meters per second to miles per hour
|
||||
val mph = this * MPS_TO_KMPH * KM_TO_MILES
|
||||
return mph
|
||||
}
|
||||
|
||||
// Allows usage like email.onEditorAction(EditorInfo.IME_ACTION_NEXT, { confirm() })
|
||||
fun EditText.onEditorAction(actionId: Int, func: () -> Unit) {
|
||||
setOnEditorActionListener { _, receivedActionId, _ ->
|
||||
|
|
|
|||
|
|
@ -17,311 +17,64 @@
|
|||
|
||||
package com.geeksville.mesh.util
|
||||
|
||||
import com.geeksville.mesh.MeshProtos
|
||||
import android.annotation.SuppressLint
|
||||
import com.geeksville.mesh.Position
|
||||
import mil.nga.grid.features.Point
|
||||
import mil.nga.mgrs.MGRS
|
||||
import mil.nga.mgrs.utm.UTM
|
||||
import org.osmdroid.util.BoundingBox
|
||||
import org.osmdroid.util.GeoPoint
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.acos
|
||||
import java.util.Locale
|
||||
import kotlin.math.asin
|
||||
import kotlin.math.atan2
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.log2
|
||||
import kotlin.math.pow
|
||||
import kotlin.math.sin
|
||||
import kotlin.math.PI
|
||||
|
||||
/*******************************************************************************
|
||||
* Revive some of my old Gaggle source code...
|
||||
*
|
||||
* GNU Public License, version 2
|
||||
* All other distribution of Gaggle must conform to the terms of the GNU Public License, version 2. The full
|
||||
* text of this license is included in the Gaggle source, see assets/manual/gpl-2.0.txt.
|
||||
******************************************************************************/
|
||||
import kotlin.math.sqrt
|
||||
|
||||
@SuppressLint("PropertyNaming")
|
||||
object GPSFormat {
|
||||
fun DEC(p: Position): String {
|
||||
return String.format("%.5f %.5f", p.latitude, p.longitude).replace(",", ".")
|
||||
}
|
||||
|
||||
fun DMS(p: Position): String {
|
||||
val lat = degreesToDMS(p.latitude, true)
|
||||
val lon = degreesToDMS(p.longitude, false)
|
||||
fun string(a: Array<String>) = String.format("%s°%s'%.5s\"%s", a[0], a[1], a[2], a[3])
|
||||
return string(lat) + " " + string(lon)
|
||||
}
|
||||
|
||||
fun UTM(p: Position): String {
|
||||
val UTM = UTM.from(Point.point(p.longitude, p.latitude))
|
||||
return String.format(
|
||||
"%s%s %.6s %.7s",
|
||||
UTM.zone,
|
||||
UTM.toMGRS().band,
|
||||
UTM.easting,
|
||||
UTM.northing
|
||||
)
|
||||
}
|
||||
|
||||
fun MGRS(p: Position): String {
|
||||
val MGRS = MGRS.from(Point.point(p.longitude, p.latitude))
|
||||
return String.format(
|
||||
"%s%s %s%s %05d %05d",
|
||||
MGRS.zone,
|
||||
MGRS.band,
|
||||
MGRS.column,
|
||||
MGRS.row,
|
||||
MGRS.easting,
|
||||
MGRS.northing
|
||||
)
|
||||
}
|
||||
|
||||
fun toDEC(latitude: Double, longitude: Double): String {
|
||||
return "%.5f %.5f".format(latitude, longitude).replace(",", ".")
|
||||
}
|
||||
|
||||
fun toDMS(latitude: Double, longitude: Double): String {
|
||||
val lat = degreesToDMS(latitude, true)
|
||||
val lon = degreesToDMS(longitude, false)
|
||||
fun string(a: Array<String>) = "%s°%s'%.5s\"%s".format(a[0], a[1], a[2], a[3])
|
||||
return string(lat) + " " + string(lon)
|
||||
}
|
||||
|
||||
fun toUTM(latitude: Double, longitude: Double): String {
|
||||
val UTM = UTM.from(Point.point(longitude, latitude))
|
||||
return "%s%s %.6s %.7s".format(UTM.zone, UTM.toMGRS().band, UTM.easting, UTM.northing)
|
||||
}
|
||||
|
||||
fun toMGRS(latitude: Double, longitude: Double): String {
|
||||
val MGRS = MGRS.from(Point.point(longitude, latitude))
|
||||
return "%s%s %s%s %05d %05d".format(
|
||||
MGRS.zone,
|
||||
MGRS.band,
|
||||
MGRS.column,
|
||||
MGRS.row,
|
||||
MGRS.easting,
|
||||
MGRS.northing
|
||||
)
|
||||
}
|
||||
fun toDec(latitude: Double, longitude: Double): String =
|
||||
String.format(Locale.getDefault(), "%.5f, %.5f", latitude, longitude)
|
||||
}
|
||||
|
||||
/**
|
||||
* Format as degrees, minutes, secs
|
||||
*
|
||||
* @param degIn
|
||||
* @param isLatitude
|
||||
* @return a string like 120deg
|
||||
*/
|
||||
fun degreesToDMS(
|
||||
_degIn: Double,
|
||||
isLatitude: Boolean
|
||||
): Array<String> {
|
||||
var degIn = _degIn
|
||||
val isPos = degIn >= 0
|
||||
val dirLetter =
|
||||
if (isLatitude) if (isPos) 'N' else 'S' else if (isPos) 'E' else 'W'
|
||||
degIn = abs(degIn)
|
||||
val degOut = degIn.toInt()
|
||||
val minutes = 60 * (degIn - degOut)
|
||||
val minwhole = minutes.toInt()
|
||||
val seconds = (minutes - minwhole) * 60
|
||||
return arrayOf(
|
||||
degOut.toString(), minwhole.toString(),
|
||||
seconds.toString(),
|
||||
dirLetter.toString()
|
||||
)
|
||||
}
|
||||
private const val EARTH_RADIUS_METERS = 6371e3
|
||||
|
||||
fun degreesToDM(_degIn: Double, isLatitude: Boolean): Array<String> {
|
||||
var degIn = _degIn
|
||||
val isPos = degIn >= 0
|
||||
val dirLetter =
|
||||
if (isLatitude) if (isPos) 'N' else 'S' else if (isPos) 'E' else 'W'
|
||||
degIn = abs(degIn)
|
||||
val degOut = degIn.toInt()
|
||||
val minutes = 60 * (degIn - degOut)
|
||||
val seconds = 0
|
||||
return arrayOf(
|
||||
degOut.toString(), minutes.toString(),
|
||||
seconds.toString(),
|
||||
dirLetter.toString()
|
||||
)
|
||||
}
|
||||
/** @return distance in meters along the surface of the earth (ish) */
|
||||
fun latLongToMeter(latitudeA: Double, longitudeA: Double, latitudeB: Double, longitudeB: Double): Double {
|
||||
val lat1 = Math.toRadians(latitudeA)
|
||||
val lon1 = Math.toRadians(longitudeA)
|
||||
val lat2 = Math.toRadians(latitudeB)
|
||||
val lon2 = Math.toRadians(longitudeB)
|
||||
|
||||
fun degreesToD(_degIn: Double, isLatitude: Boolean): Array<String> {
|
||||
var degIn = _degIn
|
||||
val isPos = degIn >= 0
|
||||
val dirLetter =
|
||||
if (isLatitude) if (isPos) 'N' else 'S' else if (isPos) 'E' else 'W'
|
||||
degIn = abs(degIn)
|
||||
val degOut = degIn
|
||||
val minutes = 0
|
||||
val seconds = 0
|
||||
return arrayOf(
|
||||
degOut.toString(), minutes.toString(),
|
||||
seconds.toString(),
|
||||
dirLetter.toString()
|
||||
)
|
||||
}
|
||||
val dLat = lat2 - lat1
|
||||
val dLon = lon2 - lon1
|
||||
|
||||
/**
|
||||
* A not super efficent mapping from a starting lat/long + a distance at a
|
||||
* certain direction
|
||||
*
|
||||
* @param lat
|
||||
* @param longitude
|
||||
* @param distMeters
|
||||
* @param theta
|
||||
* in radians, 0 == north
|
||||
* @return an array with lat and long
|
||||
*/
|
||||
fun addDistance(
|
||||
lat: Double,
|
||||
longitude: Double,
|
||||
distMeters: Double,
|
||||
theta: Double
|
||||
): DoubleArray {
|
||||
val dx = distMeters * sin(theta) // theta measured clockwise
|
||||
// from due north
|
||||
val dy = distMeters * cos(theta) // dx, dy same units as R
|
||||
val dLong = dx / (111320 * cos(lat)) // dx, dy in meters
|
||||
val dLat = dy / 110540 // result in degrees long/lat
|
||||
return doubleArrayOf(lat + dLat, longitude + dLong)
|
||||
}
|
||||
val a = sin(dLat / 2).pow(2) + cos(lat1) * cos(lat2) * sin(dLon / 2).pow(2)
|
||||
val c = 2 * asin(sqrt(a))
|
||||
|
||||
/**
|
||||
* @return distance in meters along the surface of the earth (ish)
|
||||
*/
|
||||
fun latLongToMeter(
|
||||
lat_a: Double,
|
||||
lng_a: Double,
|
||||
lat_b: Double,
|
||||
lng_b: Double
|
||||
): Double {
|
||||
val pk = (180 / PI)
|
||||
val a1 = lat_a / pk
|
||||
val a2 = lng_a / pk
|
||||
val b1 = lat_b / pk
|
||||
val b2 = lng_b / pk
|
||||
val t1 = cos(a1) * cos(a2) * cos(b1) * cos(b2)
|
||||
val t2 = cos(a1) * sin(a2) * cos(b1) * sin(b2)
|
||||
val t3 = sin(a1) * sin(b1)
|
||||
var tt = acos(t1 + t2 + t3)
|
||||
if (java.lang.Double.isNaN(tt)) tt = 0.0 // Must have been the same point?
|
||||
return 6366000 * tt
|
||||
return EARTH_RADIUS_METERS * c
|
||||
}
|
||||
|
||||
// Same as above, but takes Mesh Position proto.
|
||||
fun positionToMeter(a: MeshProtos.Position, b: MeshProtos.Position): Double {
|
||||
return latLongToMeter(
|
||||
a.latitudeI * 1e-7,
|
||||
a.longitudeI * 1e-7,
|
||||
b.latitudeI * 1e-7,
|
||||
b.longitudeI * 1e-7
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert degrees/mins/secs to a single double
|
||||
*
|
||||
* @param degrees
|
||||
* @param minutes
|
||||
* @param seconds
|
||||
* @param isPostive
|
||||
* @return
|
||||
*/
|
||||
fun DMSToDegrees(
|
||||
degrees: Int,
|
||||
minutes: Int,
|
||||
seconds: Float,
|
||||
isPostive: Boolean
|
||||
): Double {
|
||||
return (if (isPostive) 1 else -1) * (degrees + minutes / 60.0 + seconds / 3600.0)
|
||||
}
|
||||
|
||||
fun DMSToDegrees(
|
||||
degrees: Double,
|
||||
minutes: Double,
|
||||
seconds: Double,
|
||||
isPostive: Boolean
|
||||
): Double {
|
||||
return (if (isPostive) 1 else -1) * (degrees + minutes / 60.0 + seconds / 3600.0)
|
||||
}
|
||||
fun positionToMeter(a: Position, b: Position): Double =
|
||||
latLongToMeter(a.latitude * 1e-7, a.longitude * 1e-7, b.latitude * 1e-7, b.longitude * 1e-7)
|
||||
|
||||
/**
|
||||
* Computes the bearing in degrees between two points on Earth.
|
||||
*
|
||||
* @param lat1
|
||||
* Latitude of the first point
|
||||
* @param lon1
|
||||
* Longitude of the first point
|
||||
* @param lat2
|
||||
* Latitude of the second point
|
||||
* @param lon2
|
||||
* Longitude of the second point
|
||||
* @return Bearing between the two points in degrees. A value of 0 means due
|
||||
* north.
|
||||
* @param lat1 Latitude of the first point
|
||||
* @param lon1 Longitude of the first point
|
||||
* @param lat2 Latitude of the second point
|
||||
* @param lon2 Longitude of the second point
|
||||
* @return Bearing between the two points in degrees. A value of 0 means due north.
|
||||
*/
|
||||
fun bearing(
|
||||
lat1: Double,
|
||||
lon1: Double,
|
||||
lat2: Double,
|
||||
lon2: Double
|
||||
): Double {
|
||||
fun bearing(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double {
|
||||
val lat1Rad = Math.toRadians(lat1)
|
||||
val lon1Rad = Math.toRadians(lon1)
|
||||
val lat2Rad = Math.toRadians(lat2)
|
||||
val deltaLonRad = Math.toRadians(lon2 - lon1)
|
||||
val y = sin(deltaLonRad) * cos(lat2Rad)
|
||||
val x = cos(lat1Rad) * sin(lat2Rad) - (sin(lat1Rad) * cos(lat2Rad) * cos(deltaLonRad))
|
||||
return radToBearing(atan2(y, x))
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts an angle in radians to degrees
|
||||
*/
|
||||
fun radToBearing(rad: Double): Double {
|
||||
return (Math.toDegrees(rad) + 360) % 360
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the zoom level required to fit the entire [BoundingBox] inside the map view.
|
||||
* @return The zoom level as a Double value.
|
||||
*/
|
||||
fun BoundingBox.requiredZoomLevel(): Double {
|
||||
val topLeft = GeoPoint(this.latNorth, this.lonWest)
|
||||
val bottomRight = GeoPoint(this.latSouth, this.lonEast)
|
||||
val latLonWidth = topLeft.distanceToAsDouble(GeoPoint(topLeft.latitude, bottomRight.longitude))
|
||||
val latLonHeight = topLeft.distanceToAsDouble(GeoPoint(bottomRight.latitude, topLeft.longitude))
|
||||
val requiredLatZoom = log2(360.0 / (latLonHeight / 111320))
|
||||
val requiredLonZoom = log2(360.0 / (latLonWidth / 111320))
|
||||
return maxOf(requiredLatZoom, requiredLonZoom) * 0.8
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new bounding box with adjusted dimensions based on the provided [zoomFactor].
|
||||
* @return A new [BoundingBox] with added [zoomFactor]. Example:
|
||||
* ```
|
||||
* // Setting the zoom level directly using setZoom()
|
||||
* map.setZoom(14.0)
|
||||
* val boundingBoxZoom14 = map.boundingBox
|
||||
*
|
||||
* // Using zoomIn() results the equivalent BoundingBox with setZoom(15.0)
|
||||
* val boundingBoxZoom15 = boundingBoxZoom14.zoomIn(1.0)
|
||||
* ```
|
||||
*/
|
||||
fun BoundingBox.zoomIn(zoomFactor: Double): BoundingBox {
|
||||
val center = GeoPoint((latNorth + latSouth) / 2, (lonWest + lonEast) / 2)
|
||||
val latDiff = latNorth - latSouth
|
||||
val lonDiff = lonEast - lonWest
|
||||
|
||||
val newLatDiff = latDiff / (2.0.pow(zoomFactor))
|
||||
val newLonDiff = lonDiff / (2.0.pow(zoomFactor))
|
||||
|
||||
return BoundingBox(
|
||||
center.latitude + newLatDiff / 2,
|
||||
center.longitude + newLonDiff / 2,
|
||||
center.latitude - newLatDiff / 2,
|
||||
center.longitude - newLonDiff / 2
|
||||
)
|
||||
val lon2Rad = Math.toRadians(lon2)
|
||||
|
||||
val dLon = lon2Rad - lon1Rad
|
||||
|
||||
val y = sin(dLon) * cos(lat2Rad)
|
||||
val x = cos(lat1Rad) * sin(lat2Rad) - sin(lat1Rad) * cos(lat2Rad) * cos(dLon)
|
||||
val bearing = Math.toDegrees(atan2(y, x))
|
||||
|
||||
return (bearing + 360) % 360
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,148 +0,0 @@
|
|||
/*
|
||||
* 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.util
|
||||
|
||||
import android.graphics.Color
|
||||
import android.graphics.DashPathEffect
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Typeface
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.geeksville.mesh.MeshProtos
|
||||
import com.geeksville.mesh.R
|
||||
import org.osmdroid.util.GeoPoint
|
||||
import org.osmdroid.views.MapView
|
||||
import org.osmdroid.views.overlay.CopyrightOverlay
|
||||
import org.osmdroid.views.overlay.Marker
|
||||
import org.osmdroid.views.overlay.Polyline
|
||||
import org.osmdroid.views.overlay.ScaleBarOverlay
|
||||
import org.osmdroid.views.overlay.advancedpolyline.MonochromaticPaintList
|
||||
import org.osmdroid.views.overlay.gridlines.LatLonGridlineOverlay2
|
||||
|
||||
/**
|
||||
* Adds copyright to map depending on what source is showing
|
||||
*/
|
||||
fun MapView.addCopyright() {
|
||||
if (overlays.none { it is CopyrightOverlay }) {
|
||||
val copyrightNotice: String = tileProvider.tileSource.copyrightNotice ?: return
|
||||
val copyrightOverlay = CopyrightOverlay(context)
|
||||
copyrightOverlay.setCopyrightNotice(copyrightNotice)
|
||||
overlays.add(copyrightOverlay)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create LatLong Grid line overlay
|
||||
* @param enabled: turn on/off gridlines
|
||||
*/
|
||||
fun MapView.createLatLongGrid(enabled: Boolean) {
|
||||
val latLongGridOverlay = LatLonGridlineOverlay2()
|
||||
latLongGridOverlay.isEnabled = enabled
|
||||
if (latLongGridOverlay.isEnabled) {
|
||||
val textPaint = Paint().apply {
|
||||
textSize = 40f
|
||||
color = Color.GRAY
|
||||
isAntiAlias = true
|
||||
isFakeBoldText = true
|
||||
textAlign = Paint.Align.CENTER
|
||||
}
|
||||
latLongGridOverlay.textPaint = textPaint
|
||||
latLongGridOverlay.setBackgroundColor(Color.TRANSPARENT)
|
||||
latLongGridOverlay.setLineWidth(3.0f)
|
||||
latLongGridOverlay.setLineColor(Color.GRAY)
|
||||
overlays.add(latLongGridOverlay)
|
||||
}
|
||||
}
|
||||
|
||||
fun MapView.addScaleBarOverlay(density: Density) {
|
||||
if (overlays.none { it is ScaleBarOverlay }) {
|
||||
val scaleBarOverlay = ScaleBarOverlay(this).apply {
|
||||
setAlignBottom(true)
|
||||
with(density) {
|
||||
setScaleBarOffset(15.dp.toPx().toInt(), 40.dp.toPx().toInt())
|
||||
setTextSize(12.sp.toPx())
|
||||
}
|
||||
textPaint.apply {
|
||||
isAntiAlias = true
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
}
|
||||
}
|
||||
overlays.add(scaleBarOverlay)
|
||||
}
|
||||
}
|
||||
|
||||
fun MapView.addPolyline(
|
||||
density: Density,
|
||||
geoPoints: List<GeoPoint>,
|
||||
onClick: () -> Unit
|
||||
): Polyline {
|
||||
val polyline = Polyline(this).apply {
|
||||
val borderPaint = Paint().apply {
|
||||
color = Color.BLACK
|
||||
isAntiAlias = true
|
||||
strokeWidth = with(density) { 10.dp.toPx() }
|
||||
style = Paint.Style.STROKE
|
||||
strokeJoin = Paint.Join.ROUND
|
||||
strokeCap = Paint.Cap.ROUND
|
||||
pathEffect = DashPathEffect(floatArrayOf(80f, 60f), 0f)
|
||||
}
|
||||
outlinePaintLists.add(MonochromaticPaintList(borderPaint))
|
||||
val fillPaint = Paint().apply {
|
||||
color = Color.WHITE
|
||||
isAntiAlias = true
|
||||
strokeWidth = with(density) { 6.dp.toPx() }
|
||||
style = Paint.Style.FILL_AND_STROKE
|
||||
strokeJoin = Paint.Join.ROUND
|
||||
strokeCap = Paint.Cap.ROUND
|
||||
pathEffect = DashPathEffect(floatArrayOf(80f, 60f), 0f)
|
||||
}
|
||||
outlinePaintLists.add(MonochromaticPaintList(fillPaint))
|
||||
setPoints(geoPoints)
|
||||
setOnClickListener { _, _, _ ->
|
||||
onClick()
|
||||
true
|
||||
}
|
||||
}
|
||||
overlays.add(polyline)
|
||||
|
||||
return polyline
|
||||
}
|
||||
|
||||
fun MapView.addPositionMarkers(
|
||||
positions: List<MeshProtos.Position>,
|
||||
onClick: () -> Unit
|
||||
): List<Marker> {
|
||||
val navIcon = ContextCompat.getDrawable(context, R.drawable.ic_map_navigation_24)
|
||||
val markers = positions.map {
|
||||
Marker(this).apply {
|
||||
icon = navIcon
|
||||
rotation = (it.groundTrack * 1e-5).toFloat()
|
||||
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
|
||||
position = GeoPoint(it.latitudeI * 1e-7, it.longitudeI * 1e-7)
|
||||
setOnMarkerClickListener { _, _ ->
|
||||
onClick()
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
overlays.addAll(markers)
|
||||
|
||||
return markers
|
||||
}
|
||||
|
|
@ -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.util
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import androidx.core.content.edit
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
fun SharedPreferences.toggleBooleanPreference(
|
||||
state: MutableStateFlow<Boolean>,
|
||||
key: String,
|
||||
onChanged: (Boolean) -> Unit = {},
|
||||
) {
|
||||
val newValue = !state.value
|
||||
state.value = newValue
|
||||
this.edit { putBoolean(key, newValue) }
|
||||
onChanged(newValue)
|
||||
}
|
||||
|
|
@ -1,98 +0,0 @@
|
|||
/*
|
||||
* 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.util
|
||||
|
||||
import android.database.Cursor
|
||||
import org.osmdroid.tileprovider.modules.DatabaseFileArchive
|
||||
import org.osmdroid.tileprovider.modules.SqlTileWriter
|
||||
|
||||
|
||||
/**
|
||||
* Extended the sqlite tile writer to have some additional query functions. A this point
|
||||
* it's unclear if there is a need to put these with the osmdroid-android library, thus they were
|
||||
* put here as more of an example.
|
||||
*
|
||||
*
|
||||
* created on 12/21/2016.
|
||||
*
|
||||
* @author Alex O'Ree
|
||||
* @since 5.6.2
|
||||
*/
|
||||
class SqlTileWriterExt() : SqlTileWriter() {
|
||||
fun select(rows: Int, offset: Int): Cursor? {
|
||||
return this.db?.rawQuery(
|
||||
"select " + DatabaseFileArchive.COLUMN_KEY + "," + COLUMN_EXPIRES + "," + DatabaseFileArchive.COLUMN_PROVIDER + " from " + DatabaseFileArchive.TABLE + " limit ? offset ?",
|
||||
arrayOf(rows.toString() + "", offset.toString() + "")
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* gets all the tiles sources that we have tiles for in the cache database and their counts
|
||||
*
|
||||
* @return
|
||||
*/
|
||||
val sources: List<SourceCount>
|
||||
get() {
|
||||
val db = db
|
||||
val ret: MutableList<SourceCount> = ArrayList()
|
||||
if (db == null) {
|
||||
return ret
|
||||
}
|
||||
var cur: Cursor? = null
|
||||
try {
|
||||
cur = db.rawQuery(
|
||||
"select "
|
||||
+ DatabaseFileArchive.COLUMN_PROVIDER
|
||||
+ ",count(*) "
|
||||
+ ",min(length(" + DatabaseFileArchive.COLUMN_TILE + ")) "
|
||||
+ ",max(length(" + DatabaseFileArchive.COLUMN_TILE + ")) "
|
||||
+ ",sum(length(" + DatabaseFileArchive.COLUMN_TILE + ")) "
|
||||
+ "from " + DatabaseFileArchive.TABLE + " "
|
||||
+ "group by " + DatabaseFileArchive.COLUMN_PROVIDER, null
|
||||
)
|
||||
while (cur.moveToNext()) {
|
||||
val c = SourceCount()
|
||||
c.source = cur.getString(0)
|
||||
c.rowCount = cur.getLong(1)
|
||||
c.sizeMin = cur.getLong(2)
|
||||
c.sizeMax = cur.getLong(3)
|
||||
c.sizeTotal = cur.getLong(4)
|
||||
c.sizeAvg = c.sizeTotal / c.rowCount
|
||||
ret.add(c)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
catchException(e)
|
||||
} finally {
|
||||
cur?.close()
|
||||
}
|
||||
return ret
|
||||
}
|
||||
val rowCountExpired: Long
|
||||
get() = getRowCount(
|
||||
"$COLUMN_EXPIRES<?", arrayOf(System.currentTimeMillis().toString())
|
||||
)
|
||||
|
||||
class SourceCount() {
|
||||
var rowCount: Long = 0
|
||||
var source: String? = null
|
||||
var sizeTotal: Long = 0
|
||||
var sizeMin: Long = 0
|
||||
var sizeMax: Long = 0
|
||||
var sizeAvg: Long = 0
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue