kopia lustrzana https://github.com/meshtastic/Meshtastic-Android
refactor: migrate MapFragment to Composable (#647)
rodzic
e15cdc42f1
commit
d4879ceea9
|
@ -126,6 +126,7 @@ dependencies {
|
|||
implementation "androidx.appcompat:appcompat:$appcompat_version"
|
||||
// For loading and tinting drawables on older versions of the platform
|
||||
implementation "androidx.appcompat:appcompat-resources:$appcompat_version"
|
||||
implementation "androidx.emoji2:emoji2-emojipicker:1.4.0-beta05"
|
||||
|
||||
implementation 'androidx.core:core-ktx:1.10.1'
|
||||
implementation 'androidx.fragment:fragment-ktx:1.6.0'
|
||||
|
@ -188,7 +189,10 @@ dependencies {
|
|||
def osmdroid_version = '6.1.14'
|
||||
implementation "org.osmdroid:osmdroid-android:$osmdroid_version"
|
||||
implementation "org.osmdroid:osmdroid-wms:$osmdroid_version"
|
||||
implementation "org.osmdroid:osmdroid-geopackage:$osmdroid_version"
|
||||
implementation ("org.osmdroid:osmdroid-geopackage:$osmdroid_version") {
|
||||
exclude module: 'ormlite-core'
|
||||
exclude group: 'com.j256.ormlite'
|
||||
}
|
||||
implementation 'com.github.MKergall:osmbonuspack:6.9.0'
|
||||
implementation('mil.nga.mgrs:mgrs-android:2.2.2') { exclude group: 'com.google.android.gms' }
|
||||
|
||||
|
@ -204,8 +208,8 @@ dependencies {
|
|||
|
||||
// Coroutines
|
||||
def coroutines_version = '1.7.1'
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version"
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version"
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-guava:$coroutines_version"
|
||||
|
||||
// For now I'm not using javalite, because I want JSON printing
|
||||
implementation "com.google.protobuf:protobuf-kotlin:$protobuf_version"
|
||||
|
|
|
@ -40,6 +40,7 @@ import com.geeksville.mesh.repository.radio.BluetoothInterface
|
|||
import com.geeksville.mesh.repository.radio.SerialInterface
|
||||
import com.geeksville.mesh.service.*
|
||||
import com.geeksville.mesh.ui.*
|
||||
import com.geeksville.mesh.ui.map.MapFragment
|
||||
import com.geeksville.mesh.util.Exceptions
|
||||
import com.geeksville.mesh.util.getParcelableExtraCompat
|
||||
import com.geeksville.mesh.util.LanguageUtils
|
||||
|
|
|
@ -1,18 +1,14 @@
|
|||
package com.geeksville.mesh.model.map
|
||||
|
||||
import android.content.Context
|
||||
import android.view.MotionEvent
|
||||
import org.osmdroid.tileprovider.MapTileProviderBase
|
||||
import org.osmdroid.views.MapView
|
||||
import org.osmdroid.views.overlay.DefaultOverlayManager
|
||||
import org.osmdroid.views.overlay.TilesOverlay
|
||||
|
||||
|
||||
class CustomOverlayManager
|
||||
/**
|
||||
* Default constructor
|
||||
* CustomOverlayManager with disabled double taps events
|
||||
*/
|
||||
(tilesOverlay: TilesOverlay?) : DefaultOverlayManager(tilesOverlay) {
|
||||
class CustomOverlayManager(tilesOverlay: TilesOverlay?) : DefaultOverlayManager(tilesOverlay) {
|
||||
/**
|
||||
* Override event & do nothing
|
||||
*/
|
||||
|
@ -26,19 +22,4 @@ class CustomOverlayManager
|
|||
override fun onDoubleTapEvent(e: MotionEvent?, pMapView: MapView?): Boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Create MyOverlayManager
|
||||
*/
|
||||
fun create(mapView: MapView, context: Context?): CustomOverlayManager {
|
||||
val mTileProvider: MapTileProviderBase = mapView.tileProvider
|
||||
val tilesOverlay = TilesOverlay(mTileProvider, context)
|
||||
mapView.tileProvider
|
||||
mapView.overlayManager = CustomOverlayManager(tilesOverlay)
|
||||
//mapView.overlayManager.overlays().add(overlay)
|
||||
mapView.invalidate()
|
||||
return CustomOverlayManager(tilesOverlay)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -167,23 +167,25 @@ class CustomTileSource {
|
|||
val DEFAULT_TILE_SOURCE: OnlineTileSourceBase = TileSourceFactory.DEFAULT_TILE_SOURCE
|
||||
|
||||
/**
|
||||
* The order in this list must match that in the arrays.xml under map_styles
|
||||
* Source for each available [ITileSource] and their display names.
|
||||
*/
|
||||
val mTileSources: List<ITileSource> =
|
||||
listOf(
|
||||
MAPNIK,
|
||||
USGS_TOPO,
|
||||
OPEN_TOPO,
|
||||
ESRI_WORLD_TOPO,
|
||||
USGS_SAT,
|
||||
ESRI_IMAGERY,
|
||||
)
|
||||
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) {
|
||||
for (tileSource: ITileSource in mTileSources.keys) {
|
||||
if (tileSource.name().equals(aName)) {
|
||||
return tileSource;
|
||||
return tileSource
|
||||
}
|
||||
}
|
||||
throw IllegalArgumentException("No such tile source: $aName")
|
||||
|
|
|
@ -0,0 +1,63 @@
|
|||
package com.geeksville.mesh.model.map
|
||||
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Rect
|
||||
import android.view.MotionEvent
|
||||
import org.osmdroid.views.MapView
|
||||
import org.osmdroid.views.overlay.Marker
|
||||
|
||||
class MarkerWithLabel(mapView: MapView?, label: String, emoji: String? = null) : Marker(mapView) {
|
||||
|
||||
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 = 40f
|
||||
color = Color.DKGRAY
|
||||
isAntiAlias = true
|
||||
isFakeBoldText = true
|
||||
textAlign = Paint.Align.CENTER
|
||||
}
|
||||
private val emojiPaint = Paint().apply {
|
||||
textSize = 80f
|
||||
isAntiAlias = true
|
||||
textAlign = Paint.Align.CENTER
|
||||
}
|
||||
|
||||
private val bgPaint = Paint().apply { color = Color.WHITE }
|
||||
|
||||
private fun getTextBackgroundSize(text: String, x: Float, y: Float): Rect {
|
||||
val fontMetrics = textPaint.fontMetrics
|
||||
val halfTextLength = textPaint.measureText(text) / 2 + 3
|
||||
return Rect(
|
||||
(x - halfTextLength).toInt(),
|
||||
(y + fontMetrics.top).toInt(),
|
||||
(x + halfTextLength).toInt(),
|
||||
(y + fontMetrics.bottom).toInt()
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
override fun draw(c: Canvas, osmv: MapView?, shadow: Boolean) {
|
||||
super.draw(c, osmv, false)
|
||||
val p = mPositionPixels
|
||||
val bgRect = getTextBackgroundSize(mLabel, (p.x - 0f), (p.y - 110f))
|
||||
c.drawRect(bgRect, bgPaint)
|
||||
c.drawText(mLabel, (p.x - 0f), (p.y - 110f), textPaint)
|
||||
mEmoji?.let { c.drawText(it, (p.x - 0f), (p.y - 30f), emojiPaint) }
|
||||
}
|
||||
}
|
|
@ -1,788 +0,0 @@
|
|||
package com.geeksville.mesh.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Rect
|
||||
import android.os.Bundle
|
||||
import android.view.HapticFeedbackConstants
|
||||
import android.view.LayoutInflater
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.EditText
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import com.geeksville.mesh.BuildConfig
|
||||
import com.geeksville.mesh.DataPacket
|
||||
import com.geeksville.mesh.MeshProtos.Waypoint
|
||||
import com.geeksville.mesh.NodeInfo
|
||||
import com.geeksville.mesh.R
|
||||
import com.geeksville.mesh.android.Logging
|
||||
import com.geeksville.mesh.copy
|
||||
import com.geeksville.mesh.database.entity.Packet
|
||||
import com.geeksville.mesh.databinding.MapViewBinding
|
||||
import com.geeksville.mesh.model.UIViewModel
|
||||
import com.geeksville.mesh.model.map.CustomOverlayManager
|
||||
import com.geeksville.mesh.model.map.CustomTileSource
|
||||
import com.geeksville.mesh.util.SqlTileWriterExt
|
||||
import com.geeksville.mesh.util.formatAgo
|
||||
import com.geeksville.mesh.waypoint
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.google.android.material.switchmaterial.SwitchMaterial
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.osmdroid.api.IMapController
|
||||
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.cachemanager.CacheManager.CacheManagerCallback
|
||||
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.CustomZoomButtonsController
|
||||
import org.osmdroid.views.MapView
|
||||
import org.osmdroid.views.overlay.CopyrightOverlay
|
||||
import org.osmdroid.views.overlay.MapEventsOverlay
|
||||
import org.osmdroid.views.overlay.Marker
|
||||
import org.osmdroid.views.overlay.Polygon
|
||||
import org.osmdroid.views.overlay.gridlines.LatLonGridlineOverlay2
|
||||
import org.osmdroid.views.overlay.infowindow.InfoWindow
|
||||
import java.io.File
|
||||
import java.text.DateFormat
|
||||
import kotlin.math.log2
|
||||
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MapFragment : ScreenFragment("Map Fragment"), Logging {
|
||||
|
||||
// UI Elements
|
||||
private lateinit var binding: MapViewBinding
|
||||
private lateinit var map: MapView
|
||||
private lateinit var cacheEstimate: TextView
|
||||
private var cache: SqlTileWriterExt? = null
|
||||
|
||||
// constants
|
||||
private val defaultMinZoom = 1.5
|
||||
private val defaultMaxZoom = 18.0
|
||||
private val defaultZoomSpeed = 3000L
|
||||
private val prefsName = "org.geeksville.osm.prefs"
|
||||
private val mapStyleId = "map_style_id"
|
||||
private var nodePositions = listOf<MarkerWithLabel>()
|
||||
private var waypoints = mapOf<Int, Waypoint?>()
|
||||
private var waypointMarkers = listOf<MarkerWithLabel>()
|
||||
private val nodeLayer = 1
|
||||
|
||||
// Distance of bottom corner to top corner of bounding box
|
||||
private val zoomLevelLowest = 13.0 // approx 5 miles long
|
||||
private val zoomLevelMiddle = 12.25 // approx 10 miles long
|
||||
private val zoomLevelHighest = 11.5 // approx 15 miles long
|
||||
|
||||
private var zoomLevelMin = 0.0
|
||||
private var zoomLevelMax = 0.0
|
||||
|
||||
// Map Elements
|
||||
private lateinit var mapController: IMapController
|
||||
private lateinit var mPrefs: SharedPreferences
|
||||
private lateinit var writer: SqliteArchiveTileWriter
|
||||
private val model: UIViewModel by activityViewModels()
|
||||
private lateinit var cacheManager: CacheManager
|
||||
private lateinit var downloadRegionBoundingBox: BoundingBox
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
|
||||
): View {
|
||||
binding = MapViewBinding.inflate(inflater)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(viewIn: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(viewIn, savedInstanceState)
|
||||
Configuration.getInstance().userAgentValue =
|
||||
BuildConfig.APPLICATION_ID // Required to get online tiles
|
||||
map = viewIn.findViewById(R.id.map)
|
||||
mPrefs = requireContext().getSharedPreferences(prefsName, Context.MODE_PRIVATE)
|
||||
|
||||
setupMapProperties()
|
||||
map.setTileSource(loadOnlineTileSourceBase())
|
||||
renderDownloadButton()
|
||||
map.let {
|
||||
if (view != null) {
|
||||
mapController = map.controller
|
||||
binding.mapStyleButton.setOnClickListener {
|
||||
chooseMapStyle()
|
||||
}
|
||||
if (binding.cacheLayout.visibility == View.GONE) {
|
||||
model.nodeDB.nodes.value?.let { nodes ->
|
||||
onNodesChanged(nodes.values)
|
||||
drawOverlays()
|
||||
}
|
||||
}
|
||||
}
|
||||
if (binding.cacheLayout.visibility == View.GONE) {
|
||||
// Any times nodes change update our map
|
||||
model.nodeDB.nodes.observe(viewLifecycleOwner) { nodes ->
|
||||
onNodesChanged(nodes.values)
|
||||
drawOverlays()
|
||||
}
|
||||
model.waypoints.observe(viewLifecycleOwner) {
|
||||
debug("New waypoints received: ${it.size}")
|
||||
waypoints = it.mapValues { p -> p.value.data.waypoint }
|
||||
onWaypointChanged(it.values)
|
||||
drawOverlays()
|
||||
}
|
||||
}
|
||||
zoomToNodes(mapController)
|
||||
}
|
||||
binding.downloadButton.setOnClickListener { showCacheManagerDialog() }
|
||||
}
|
||||
|
||||
private fun performHapticFeedback() = requireView().performHapticFeedback(
|
||||
HapticFeedbackConstants.LONG_PRESS,
|
||||
HapticFeedbackConstants.FLAG_IGNORE_VIEW_SETTING
|
||||
)
|
||||
|
||||
private fun showCacheManagerDialog() {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.map_offline_manager)
|
||||
.setItems(
|
||||
arrayOf<CharSequence>(
|
||||
resources.getString(R.string.map_cache_size),
|
||||
resources.getString(R.string.map_download_region),
|
||||
resources.getString(R.string.map_clear_tiles),
|
||||
resources.getString(R.string.cancel)
|
||||
)
|
||||
) { dialog, which ->
|
||||
when (which) {
|
||||
0 -> showCurrentCacheInfo()
|
||||
1 -> {
|
||||
downloadJobAlert()
|
||||
dialog.dismiss()
|
||||
}
|
||||
2 -> purgeTileSource()
|
||||
else -> dialog.dismiss()
|
||||
}
|
||||
}
|
||||
.show()
|
||||
|
||||
}
|
||||
|
||||
private fun purgeTileSource() {
|
||||
cache = SqlTileWriterExt()
|
||||
val builder = MaterialAlertDialogBuilder(requireContext())
|
||||
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)
|
||||
if (b) Toast.makeText(
|
||||
context,
|
||||
getString(R.string.map_purge_success).format(item.source),
|
||||
Toast.LENGTH_SHORT
|
||||
)
|
||||
.show() else Toast.makeText(
|
||||
context,
|
||||
R.string.map_purge_fail,
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
}
|
||||
builder.setNegativeButton(
|
||||
R.string.cancel
|
||||
) { dialog, _ -> dialog.cancel() }
|
||||
builder.show()
|
||||
}
|
||||
|
||||
|
||||
private fun showCurrentCacheInfo() {
|
||||
Toast.makeText(activity, R.string.calculating, Toast.LENGTH_SHORT).show()
|
||||
cacheManager = CacheManager(map) // Make sure CacheManager has latest from map
|
||||
Thread {
|
||||
val alertDialogBuilder = MaterialAlertDialogBuilder(requireContext())
|
||||
// set title
|
||||
alertDialogBuilder.setTitle(R.string.map_cache_manager)
|
||||
.setMessage(
|
||||
getString(R.string.map_cache_info).format(
|
||||
cacheManager.cacheCapacity() / (1024.0 * 1024.0),
|
||||
cacheManager.currentCacheUsage() / (1024.0 * 1024.0)
|
||||
)
|
||||
)
|
||||
// set dialog message
|
||||
alertDialogBuilder.setItems(
|
||||
arrayOf<CharSequence>(
|
||||
resources.getString(R.string.cancel)
|
||||
)
|
||||
) { dialog, _ -> dialog.dismiss() }
|
||||
requireActivity().runOnUiThread { // show it
|
||||
// create alert dialog
|
||||
val alertDialog = alertDialogBuilder.create()
|
||||
alertDialog.show()
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
|
||||
private data class DialogBuilder(
|
||||
val builder: MaterialAlertDialogBuilder,
|
||||
val nameInput: EditText,
|
||||
val descInput: EditText,
|
||||
val lockedSwitch: SwitchMaterial,
|
||||
) {
|
||||
val name get() = nameInput.text.toString().trim()
|
||||
val description get() = descInput.text.toString().trim()
|
||||
}
|
||||
|
||||
private fun createEditDialog(context: Context, title: String): DialogBuilder {
|
||||
val builder = MaterialAlertDialogBuilder(context)
|
||||
val layout = LayoutInflater.from(context).inflate(R.layout.dialog_add_waypoint, null)
|
||||
|
||||
val nameInput: EditText = layout.findViewById(R.id.waypointName)
|
||||
val descInput: EditText= layout.findViewById(R.id.waypointDescription)
|
||||
val lockedSwitch: SwitchMaterial = layout.findViewById(R.id.waypointLocked)
|
||||
|
||||
builder.setTitle(title)
|
||||
builder.setView(layout)
|
||||
|
||||
return DialogBuilder(builder, nameInput, descInput, lockedSwitch)
|
||||
}
|
||||
|
||||
private fun showDeleteMarkerDialog(waypoint: Waypoint) {
|
||||
val builder = MaterialAlertDialogBuilder(requireContext())
|
||||
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 $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 $id for everyone")
|
||||
model.sendWaypoint(waypoint.copy { expire = 1 })
|
||||
model.deleteWaypoint(waypoint.id)
|
||||
}
|
||||
val dialog = builder.show()
|
||||
for (button in setOf(
|
||||
AlertDialog.BUTTON_NEUTRAL,
|
||||
AlertDialog.BUTTON_NEGATIVE,
|
||||
AlertDialog.BUTTON_POSITIVE
|
||||
)) with(dialog.getButton(button)) { textSize = 12F; isAllCaps = false }
|
||||
}
|
||||
|
||||
private fun showEditMarkerDialog(waypoint: Waypoint) {
|
||||
val dialog = createEditDialog(requireContext(), getString(R.string.waypoint_edit))
|
||||
dialog.nameInput.setText(waypoint.name)
|
||||
dialog.descInput.setText(waypoint.description)
|
||||
dialog.lockedSwitch.isEnabled = false
|
||||
dialog.lockedSwitch.isChecked = waypoint.lockedTo != 0
|
||||
dialog.builder.setNeutralButton(R.string.cancel) { _, _ ->
|
||||
debug("User canceled marker edit dialog")
|
||||
}
|
||||
dialog.builder.setNegativeButton(R.string.delete) { _, _ ->
|
||||
debug("User clicked delete waypoint ${waypoint.id}")
|
||||
showDeleteMarkerDialog(waypoint)
|
||||
}
|
||||
dialog.builder.setPositiveButton(getString(R.string.send)) { _, _ ->
|
||||
debug("User edited waypoint ${waypoint.id}")
|
||||
model.sendWaypoint(waypoint.copy {
|
||||
name = dialog.name.ifEmpty { return@setPositiveButton }
|
||||
description = dialog.description
|
||||
expire = Int.MAX_VALUE // TODO add expire picker
|
||||
icon = 0 // TODO add emoji picker
|
||||
})
|
||||
}
|
||||
dialog.builder.show()
|
||||
}
|
||||
|
||||
fun showMarkerLongPressDialog(id: Int) {
|
||||
debug("marker long pressed id=${id}")
|
||||
val waypoint = waypoints[id] ?: return
|
||||
// edit only when unlocked or lockedTo myNodeNum
|
||||
if (waypoint.lockedTo in setOf(0, model.myNodeNum ?: 0) && model.isConnected())
|
||||
showEditMarkerDialog(waypoint)
|
||||
else
|
||||
showDeleteMarkerDialog(waypoint)
|
||||
}
|
||||
|
||||
private fun downloadJobAlert() {
|
||||
//prompt for input params .
|
||||
binding.downloadButton.hide()
|
||||
binding.mapStyleButton.visibility = View.GONE
|
||||
binding.cacheLayout.visibility = View.VISIBLE
|
||||
val builder = MaterialAlertDialogBuilder(requireContext())
|
||||
binding.box5miles.setOnClickListener{ generateBoxOverlay(zoomLevelLowest) }
|
||||
binding.box10miles.setOnClickListener { generateBoxOverlay(zoomLevelMiddle) }
|
||||
binding.box15miles.setOnClickListener { generateBoxOverlay(zoomLevelHighest) }
|
||||
cacheEstimate = binding.cacheEstimate
|
||||
generateBoxOverlay(zoomLevelLowest)
|
||||
binding.executeJob.setOnClickListener { updateEstimate() }
|
||||
binding.cancelDownload.setOnClickListener {
|
||||
cacheEstimate.text = ""
|
||||
defaultMapSettings()
|
||||
|
||||
}
|
||||
builder.setCancelable(true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset map to default settings & visible buttons
|
||||
*/
|
||||
private fun defaultMapSettings() {
|
||||
binding.downloadButton.show()
|
||||
binding.mapStyleButton.visibility = View.VISIBLE
|
||||
binding.cacheLayout.visibility = View.GONE
|
||||
setupMapProperties()
|
||||
drawOverlays()
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates Box overlay showing what area can be downloaded
|
||||
*/
|
||||
private fun generateBoxOverlay(zoomLevel: Double) {
|
||||
drawOverlays()
|
||||
map.setMultiTouchControls(false)
|
||||
zoomLevelMax = zoomLevel // furthest back
|
||||
zoomLevelMin =
|
||||
map.tileProvider.tileSource.maximumZoomLevel.toDouble() // furthest in min should be > than max
|
||||
mapController.setZoom(zoomLevel)
|
||||
downloadRegionBoundingBox = map.boundingBox
|
||||
val polygon = Polygon().apply {
|
||||
points = Polygon.pointsAsRect(downloadRegionBoundingBox)
|
||||
.map { GeoPoint(it.latitude, it.longitude) }
|
||||
}
|
||||
map.overlayManager.add(polygon)
|
||||
mapController.setZoom(zoomLevel - 1.0)
|
||||
cacheManager = CacheManager(map)
|
||||
val tileCount: Int =
|
||||
cacheManager.possibleTilesInArea(
|
||||
downloadRegionBoundingBox,
|
||||
zoomLevelMax.toInt(),
|
||||
zoomLevelMin.toInt()
|
||||
)
|
||||
cacheEstimate.text = getString(R.string.map_cache_tiles).format(tileCount)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* if true, start the job
|
||||
* if false, just update the dialog box
|
||||
*/
|
||||
private fun updateEstimate() {
|
||||
try {
|
||||
if (this::downloadRegionBoundingBox.isInitialized) {
|
||||
val outputName =
|
||||
Configuration.getInstance().osmdroidBasePath.absolutePath + File.separator + "mainFile.sqlite" // TODO: Accept filename input param from user
|
||||
writer = SqliteArchiveTileWriter(outputName)
|
||||
//nesw
|
||||
try {
|
||||
cacheManager =
|
||||
CacheManager(map, writer) // Make sure cacheManager has latest from map
|
||||
} catch (ex: TileSourcePolicyException) {
|
||||
debug("Tile source does not allow archiving: ${ex.message}")
|
||||
return
|
||||
}
|
||||
//this triggers the download
|
||||
downloadRegion(
|
||||
downloadRegionBoundingBox,
|
||||
zoomLevelMax.toInt(),
|
||||
zoomLevelMin.toInt(),
|
||||
)
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
ex.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
private fun downloadRegion(bb: BoundingBox, zoommin: Int, zoommax: Int) {
|
||||
cacheManager.downloadAreaAsync(
|
||||
activity,
|
||||
bb,
|
||||
zoommin,
|
||||
zoommax,
|
||||
object : CacheManagerCallback {
|
||||
override fun onTaskComplete() {
|
||||
Toast.makeText(activity, R.string.map_download_complete, Toast.LENGTH_LONG)
|
||||
.show()
|
||||
writer.onDetach()
|
||||
defaultMapSettings()
|
||||
}
|
||||
|
||||
override fun onTaskFailed(errors: Int) {
|
||||
Toast.makeText(
|
||||
activity,
|
||||
getString(R.string.map_download_errors).format(errors),
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
writer.onDetach()
|
||||
defaultMapSettings()
|
||||
}
|
||||
|
||||
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 chooseMapStyle() {
|
||||
/// Prepare dialog and its items
|
||||
val builder = MaterialAlertDialogBuilder(requireContext())
|
||||
val mapStyles = arrayOf<CharSequence>(
|
||||
"OpenStreetMap",
|
||||
"USGS TOPO",
|
||||
"Open TOPO",
|
||||
"ESRI World TOPO",
|
||||
"USGS Satellite",
|
||||
"ESRI World Overview",
|
||||
)
|
||||
|
||||
/// Load preferences and its value
|
||||
val mapStyleInt = mPrefs.getInt(mapStyleId, 1)
|
||||
builder.setSingleChoiceItems(mapStyles, mapStyleInt) { dialog, which ->
|
||||
debug("Set mapStyleId pref to $which")
|
||||
val editor: SharedPreferences.Editor = mPrefs.edit()
|
||||
editor.putInt(mapStyleId, which)
|
||||
editor.apply()
|
||||
dialog.dismiss()
|
||||
map.setTileSource(loadOnlineTileSourceBase())
|
||||
renderDownloadButton()
|
||||
drawOverlays()
|
||||
}
|
||||
val dialog = builder.create()
|
||||
dialog.show()
|
||||
}
|
||||
|
||||
private fun renderDownloadButton() {
|
||||
if (!(map.tileProvider.tileSource as OnlineTileSourceBase).tileSourcePolicy.acceptsBulkDownload()) {
|
||||
binding.downloadButton.hide()
|
||||
} else {
|
||||
binding.downloadButton.show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun getUsername(id: String?) = if (id == DataPacket.ID_LOCAL) getString(R.string.you)
|
||||
else model.nodeDB.nodes.value?.get(id)?.user?.longName ?: getString(R.string.unknown_username)
|
||||
|
||||
private fun onWaypointChanged(wayPt: Collection<Packet>) {
|
||||
|
||||
/**
|
||||
* Using the latest waypoint, generate GeoPoint
|
||||
*/
|
||||
// Find all waypoints
|
||||
fun getCurrentWayPoints(): List<MarkerWithLabel> {
|
||||
debug("Showing on map: ${wayPt.size} waypoints")
|
||||
val wayPoint = wayPt.map { pt ->
|
||||
lateinit var marker: MarkerWithLabel
|
||||
pt.data.waypoint?.let {
|
||||
val lock = if (it.lockedTo != 0) "\uD83D\uDD12" else ""
|
||||
val time = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT)
|
||||
.format(pt.received_time)
|
||||
val label = it.name + " " + formatAgo((pt.received_time / 1000).toInt())
|
||||
val emoji = String(Character.toChars(if (it.icon == 0) 128205 else it.icon))
|
||||
marker = MarkerWithLabel(map, label, emoji)
|
||||
marker.id = "${it.id}"
|
||||
marker.title = "${it.name} (${getUsername(pt.data.from)}$lock)"
|
||||
marker.snippet = "[$time] " + it.description
|
||||
marker.position = GeoPoint(it.latitudeI * 1e-7, it.longitudeI * 1e-7)
|
||||
marker.setVisible(false)
|
||||
}
|
||||
marker
|
||||
}
|
||||
return wayPoint
|
||||
}
|
||||
waypointMarkers = getCurrentWayPoints()
|
||||
}
|
||||
|
||||
private fun onNodesChanged(nodes: Collection<NodeInfo>) {
|
||||
val nodesWithPosition = nodes.filter { it.validPosition != null }
|
||||
val ic = ContextCompat.getDrawable(requireActivity(), R.drawable.ic_baseline_location_on_24)
|
||||
debug("Showing on map: ${nodesWithPosition.size} nodes")
|
||||
nodePositions = nodesWithPosition.map { node ->
|
||||
val (p, u) = Pair(node.position!!, node.user!!)
|
||||
val marker = MarkerWithLabel(map, "${u.longName} ${formatAgo(p.time)}")
|
||||
marker.title = "${u.longName} ${node.batteryStr}"
|
||||
marker.snippet = model.gpsString(p)
|
||||
model.ourNodeInfo.value?.let { our ->
|
||||
our.distanceStr(node)?.let { dist ->
|
||||
marker.subDescription = getString(R.string.map_subDescription)
|
||||
.format(our.bearing(node), dist)
|
||||
}
|
||||
}
|
||||
marker.setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
|
||||
marker.position = GeoPoint(p.latitude, p.longitude)
|
||||
marker.icon = ic
|
||||
marker
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Create LatLong Grid line overlay
|
||||
* @param enabled: turn on/off gridlines
|
||||
*/
|
||||
private fun createLatLongGrid(enabled: Boolean) {
|
||||
val latLongGridOverlay = LatLonGridlineOverlay2()
|
||||
latLongGridOverlay.isEnabled = enabled
|
||||
if (latLongGridOverlay.isEnabled) {
|
||||
val textPaint = Paint()
|
||||
textPaint.textSize = 40f
|
||||
textPaint.color = Color.GRAY
|
||||
textPaint.isAntiAlias = true
|
||||
textPaint.isFakeBoldText = true
|
||||
textPaint.textAlign = Paint.Align.CENTER
|
||||
latLongGridOverlay.textPaint = textPaint
|
||||
latLongGridOverlay.setBackgroundColor(Color.TRANSPARENT)
|
||||
latLongGridOverlay.setLineWidth(3.0f)
|
||||
latLongGridOverlay.setLineColor(Color.GRAY)
|
||||
map.overlayManager.add(latLongGridOverlay)
|
||||
}
|
||||
}
|
||||
|
||||
private fun drawOverlays() {
|
||||
map.overlayManager.overlays().clear()
|
||||
addCopyright() // Copyright is required for certain map sources
|
||||
createLatLongGrid(false)
|
||||
map.overlayManager.addAll(nodeLayer, nodePositions)
|
||||
map.overlayManager.addAll(nodeLayer, waypointMarkers)
|
||||
map.overlayManager.add(nodeLayer, MapEventsOverlay(object : MapEventsReceiver {
|
||||
override fun singleTapConfirmedHelper(p: GeoPoint): Boolean {
|
||||
InfoWindow.closeAllInfoWindowsOn(map)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun longPressHelper(p: GeoPoint): Boolean {
|
||||
performHapticFeedback()
|
||||
if (!model.isConnected()) return true
|
||||
|
||||
val dialog = createEditDialog(requireContext(), getString(R.string.waypoint_new))
|
||||
dialog.builder.setNeutralButton(R.string.cancel) { _, _ ->
|
||||
debug("User canceled marker create dialog")
|
||||
}
|
||||
dialog.builder.setPositiveButton(getString(R.string.send)) { _, _ ->
|
||||
debug("User created waypoint")
|
||||
model.sendWaypoint(waypoint {
|
||||
name = dialog.name.ifEmpty { return@setPositiveButton }
|
||||
description = dialog.description
|
||||
id = model.generatePacketId() ?: return@setPositiveButton
|
||||
latitudeI = (p.latitude * 1e7).toInt()
|
||||
longitudeI = (p.longitude * 1e7).toInt()
|
||||
expire = Int.MAX_VALUE // TODO add expire picker
|
||||
icon = 0 // TODO add emoji picker
|
||||
lockedTo = if (!dialog.lockedSwitch.isChecked) 0 else model.myNodeNum ?: 0
|
||||
})
|
||||
}
|
||||
dialog.builder.show()
|
||||
return true
|
||||
}
|
||||
}))
|
||||
map.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)
|
||||
// }
|
||||
// }
|
||||
|
||||
/**
|
||||
* Adds copyright to map depending on what source is showing
|
||||
*/
|
||||
private fun addCopyright() {
|
||||
if (map.tileProvider.tileSource.copyrightNotice != null) {
|
||||
val copyrightNotice: String = map.tileProvider.tileSource.copyrightNotice
|
||||
val copyrightOverlay = CopyrightOverlay(context)
|
||||
copyrightOverlay.setCopyrightNotice(copyrightNotice)
|
||||
map.overlays.add(copyrightOverlay)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupMapProperties() {
|
||||
if (this::map.isInitialized) {
|
||||
map.setDestroyMode(false) // keeps map instance alive when in the background.
|
||||
map.isVerticalMapRepetitionEnabled = false // disables map repetition
|
||||
map.overlayManager = CustomOverlayManager.create(map, context)
|
||||
map.setScrollableAreaLimitLatitude(
|
||||
map.overlayManager.tilesOverlay.bounds.actualNorth,
|
||||
map.overlayManager.tilesOverlay.bounds.actualSouth,
|
||||
0
|
||||
) // bounds scrollable map
|
||||
map.isTilesScaledToDpi =
|
||||
true // scales the map tiles to the display density of the screen
|
||||
map.minZoomLevel =
|
||||
defaultMinZoom // sets the minimum zoom level (the furthest out you can zoom)
|
||||
map.maxZoomLevel = defaultMaxZoom
|
||||
map.setMultiTouchControls(true) // Sets gesture controls to true.
|
||||
map.zoomController.setVisibility(CustomZoomButtonsController.Visibility.NEVER) // Disables default +/- button for zooming
|
||||
map.addMapListener(object : MapListener {
|
||||
override fun onScroll(event: ScrollEvent): Boolean {
|
||||
if (binding.cacheLayout.visibility == View.VISIBLE) {
|
||||
generateBoxOverlay(zoomLevelMax)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onZoom(event: ZoomEvent): Boolean {
|
||||
return false
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private fun zoomToNodes(controller: IMapController) {
|
||||
val points: MutableList<GeoPoint> = mutableListOf()
|
||||
val nodesWithPosition =
|
||||
model.nodeDB.nodes.value?.values?.filter { it.validPosition != null }
|
||||
if ((nodesWithPosition != null) && nodesWithPosition.isNotEmpty()) {
|
||||
val maximumZoomLevel = map.tileProvider.tileSource.maximumZoomLevel.toDouble()
|
||||
if (nodesWithPosition.size >= 2) {
|
||||
// Multiple nodes, make them all fit on the map view
|
||||
nodesWithPosition.forEach {
|
||||
points.add(
|
||||
GeoPoint(
|
||||
it.position!!.latitude, it.position!!.longitude
|
||||
)
|
||||
)
|
||||
}
|
||||
val box = BoundingBox.fromGeoPoints(points)
|
||||
val point = GeoPoint(box.centerLatitude, box.centerLongitude)
|
||||
val topLeft = GeoPoint(box.latNorth, box.lonWest)
|
||||
val bottomRight = GeoPoint(box.latSouth, box.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))
|
||||
val requiredZoom = requiredLatZoom.coerceAtLeast(requiredLonZoom)
|
||||
val finalZoomLevel = (requiredZoom * 0.8).coerceAtMost(maximumZoomLevel)
|
||||
controller.animateTo(point, finalZoomLevel, defaultZoomSpeed)
|
||||
} else {
|
||||
// Only one node, just zoom in on it
|
||||
val it = nodesWithPosition[0].position!!
|
||||
points.add(GeoPoint(it.latitude, it.longitude))
|
||||
controller.animateTo(points[0], maximumZoomLevel, defaultZoomSpeed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadOnlineTileSourceBase(): ITileSource {
|
||||
val id = mPrefs.getInt(mapStyleId, 1)
|
||||
debug("mapStyleId from prefs: $id")
|
||||
return CustomTileSource.mTileSources.getOrNull(id) ?: CustomTileSource.DEFAULT_TILE_SOURCE
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
map.onPause()
|
||||
super.onPause()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
map.onResume()
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
map.onDetach()
|
||||
}
|
||||
|
||||
private inner class MarkerWithLabel(mapView: MapView?, label: String, emoji: String? = null) :
|
||||
Marker(mapView) {
|
||||
private val mLabel = label
|
||||
private val mEmoji = emoji
|
||||
private val textPaint = Paint().apply {
|
||||
textSize = 40f
|
||||
color = Color.DKGRAY
|
||||
isAntiAlias = true
|
||||
isFakeBoldText = true
|
||||
textAlign = Paint.Align.CENTER
|
||||
}
|
||||
private val emojiPaint = Paint().apply {
|
||||
textSize = 80f
|
||||
isAntiAlias = true
|
||||
textAlign = Paint.Align.CENTER
|
||||
}
|
||||
|
||||
private val bgPaint = Paint().apply { color = Color.WHITE }
|
||||
|
||||
private fun getTextBackgroundSize(text: String, x: Float, y: Float): Rect {
|
||||
val fontMetrics = textPaint.fontMetrics
|
||||
val halfTextLength = textPaint.measureText(text) / 2 + 3
|
||||
return Rect(
|
||||
(x - halfTextLength).toInt(),
|
||||
(y + fontMetrics.top).toInt(),
|
||||
(x + halfTextLength).toInt(),
|
||||
(y + fontMetrics.bottom).toInt()
|
||||
)
|
||||
}
|
||||
|
||||
override fun onLongPress(event: MotionEvent?, mapView: MapView?): Boolean {
|
||||
val touched = hitTest(event, mapView)
|
||||
if (touched && this.id != null) {
|
||||
performHapticFeedback()
|
||||
this.id.toIntOrNull()?.run(::showMarkerLongPressDialog)
|
||||
}
|
||||
return super.onLongPress(event, mapView)
|
||||
}
|
||||
|
||||
override fun draw(c: Canvas, osmv: MapView?, shadow: Boolean) {
|
||||
super.draw(c, osmv, false)
|
||||
val p = mPositionPixels
|
||||
val bgRect = getTextBackgroundSize(mLabel, (p.x - 0f), (p.y - 110f))
|
||||
c.drawRect(bgRect, bgPaint)
|
||||
c.drawText(mLabel, (p.x - 0f), (p.y - 110f), textPaint)
|
||||
mEmoji?.let { c.drawText(it, (p.x - 0f), (p.y - 30f), emojiPaint) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,630 @@
|
|||
package com.geeksville.mesh.ui.map
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.Scaffold
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
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.ComposeView
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.platform.ViewCompositionStrategy
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.geeksville.mesh.BuildConfig
|
||||
import com.geeksville.mesh.DataPacket
|
||||
import com.geeksville.mesh.MeshProtos.Waypoint
|
||||
import com.geeksville.mesh.NodeInfo
|
||||
import com.geeksville.mesh.R
|
||||
import com.geeksville.mesh.android.BuildUtils.debug
|
||||
import com.geeksville.mesh.android.Logging
|
||||
import com.geeksville.mesh.copy
|
||||
import com.geeksville.mesh.database.entity.Packet
|
||||
import com.geeksville.mesh.model.UIViewModel
|
||||
import com.geeksville.mesh.model.map.CustomOverlayManager
|
||||
import com.geeksville.mesh.model.map.CustomTileSource
|
||||
import com.geeksville.mesh.model.map.MarkerWithLabel
|
||||
import com.geeksville.mesh.ui.ScreenFragment
|
||||
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.MapStyleButton
|
||||
import com.geeksville.mesh.util.SqlTileWriterExt
|
||||
import com.geeksville.mesh.util.formatAgo
|
||||
import com.geeksville.mesh.waypoint
|
||||
import com.google.accompanist.themeadapter.appcompat.AppCompatTheme
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
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.CustomZoomButtonsController
|
||||
import org.osmdroid.views.MapView
|
||||
import org.osmdroid.views.overlay.CopyrightOverlay
|
||||
import org.osmdroid.views.overlay.DefaultOverlayManager
|
||||
import org.osmdroid.views.overlay.MapEventsOverlay
|
||||
import org.osmdroid.views.overlay.Marker
|
||||
import org.osmdroid.views.overlay.Polygon
|
||||
import org.osmdroid.views.overlay.TilesOverlay
|
||||
import org.osmdroid.views.overlay.gridlines.LatLonGridlineOverlay2
|
||||
import org.osmdroid.views.overlay.infowindow.InfoWindow
|
||||
import java.io.File
|
||||
import java.text.DateFormat
|
||||
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MapFragment : ScreenFragment("Map Fragment"), Logging {
|
||||
|
||||
private val model: UIViewModel by activityViewModels()
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
return ComposeView(requireContext()).apply {
|
||||
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
|
||||
setContent {
|
||||
AppCompatTheme {
|
||||
MapView(model)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MapView(model: UIViewModel = viewModel()) {
|
||||
|
||||
// UI Elements
|
||||
var cacheEstimate by remember { mutableStateOf("") }
|
||||
|
||||
// constants
|
||||
val defaultMinZoom = 1.5
|
||||
val defaultMaxZoom = 18.0
|
||||
val prefsName = "org.geeksville.osm.prefs"
|
||||
val mapStyleId = "map_style_id"
|
||||
val nodeLayer = 1
|
||||
|
||||
// Distance of bottom corner to top corner of bounding box
|
||||
val zoomLevelLowest = 13.0 // approx 5 miles long
|
||||
val zoomLevelMiddle = 12.25 // approx 10 miles long
|
||||
val zoomLevelHighest = 11.5 // approx 15 miles long
|
||||
|
||||
var zoomLevelMin = 0.0
|
||||
var zoomLevelMax = 0.0
|
||||
|
||||
// Map Elements
|
||||
var writer: SqliteArchiveTileWriter
|
||||
var downloadRegionBoundingBox: BoundingBox? by remember { mutableStateOf(null) }
|
||||
|
||||
val context = LocalContext.current
|
||||
val mPrefs = context.getSharedPreferences(prefsName, Context.MODE_PRIVATE)
|
||||
|
||||
val haptic = LocalHapticFeedback.current
|
||||
fun performHapticFeedback() = haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
|
||||
val map = remember {
|
||||
MapView(context).apply {
|
||||
clipToOutline = true
|
||||
}
|
||||
}
|
||||
var showDownloadButton: Boolean by remember { mutableStateOf(false) }
|
||||
var showEditWaypointDialog by remember { mutableStateOf<Waypoint?>(null) }
|
||||
var showCurrentCacheInfo by remember { mutableStateOf(false) }
|
||||
|
||||
fun onNodesChanged(nodes: Collection<NodeInfo>): List<MarkerWithLabel> {
|
||||
val nodesWithPosition = nodes.filter { it.validPosition != null }
|
||||
val ic = ContextCompat.getDrawable(context, R.drawable.ic_baseline_location_on_24)
|
||||
val ourNode = model.ourNodeInfo.value
|
||||
debug("Showing on map: ${nodesWithPosition.size} nodes")
|
||||
return nodesWithPosition.map { node ->
|
||||
val (p, u) = node.position!! to node.user!!
|
||||
MarkerWithLabel(map, "${u.longName} ${formatAgo(p.time)}").apply {
|
||||
title = "${u.longName} ${node.batteryStr}"
|
||||
snippet = model.gpsString(p)
|
||||
ourNode?.distanceStr(node)?.let { dist ->
|
||||
val string = context.getString(R.string.map_subDescription)
|
||||
subDescription = string.format(ourNode.bearing(node), dist)
|
||||
}
|
||||
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
|
||||
position = GeoPoint(p.latitude, p.longitude)
|
||||
icon = ic
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val nodes by model.nodeDB.nodes.observeAsState()
|
||||
val nodeMarkers = remember(nodes) {
|
||||
mutableStateListOf<MarkerWithLabel>().apply {
|
||||
nodes?.values?.let { addAll(onNodesChanged(it)) }
|
||||
}
|
||||
}
|
||||
|
||||
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 = model.waypoints.value?.get(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.nodeDB.nodes.value?.get(id)?.user?.longName
|
||||
?: context.getString(R.string.unknown_username)
|
||||
|
||||
fun onWaypointChanged(waypoints: Collection<Packet>): List<MarkerWithLabel> {
|
||||
debug("Showing on map: ${waypoints.size} waypoints")
|
||||
return waypoints.mapNotNull { waypoint ->
|
||||
val pt = waypoint.data.waypoint ?: return@mapNotNull null
|
||||
val lock = if (pt.lockedTo != 0) "\uD83D\uDD12" else ""
|
||||
val time = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT)
|
||||
.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))
|
||||
MarkerWithLabel(map, label, emoji).apply {
|
||||
id = "${pt.id}"
|
||||
title = "${pt.name} (${getUsername(waypoint.data.from)}$lock)"
|
||||
snippet = "[$time] " + pt.description
|
||||
position = GeoPoint(pt.latitudeI * 1e-7, pt.longitudeI * 1e-7)
|
||||
setVisible(false)
|
||||
setOnLongClickListener {
|
||||
showMarkerLongPressDialog(pt.id)
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val waypoints by model.waypoints.observeAsState()
|
||||
val waypointMarkers = remember(waypoints) {
|
||||
mutableStateListOf<MarkerWithLabel>().apply {
|
||||
waypoints?.values?.let { addAll(onWaypointChanged(it)) }
|
||||
}
|
||||
}
|
||||
|
||||
fun purgeTileSource() {
|
||||
val cache = SqlTileWriterExt()
|
||||
val builder = MaterialAlertDialogBuilder(context)
|
||||
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)
|
||||
if (b) Toast.makeText(
|
||||
context,
|
||||
context.getString(R.string.map_purge_success).format(item.source),
|
||||
Toast.LENGTH_SHORT
|
||||
).show() else Toast.makeText(
|
||||
context,
|
||||
R.string.map_purge_fail,
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
}
|
||||
builder.setNegativeButton(R.string.cancel) { dialog, _ -> dialog.cancel() }
|
||||
builder.show()
|
||||
}
|
||||
|
||||
LaunchedEffect(showCurrentCacheInfo) {
|
||||
if (!showCurrentCacheInfo) return@LaunchedEffect
|
||||
Toast.makeText(context, R.string.calculating, Toast.LENGTH_SHORT).show()
|
||||
val cacheManager = CacheManager(map) // Make sure CacheManager has latest from 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()
|
||||
}
|
||||
|
||||
fun downloadRegion(
|
||||
cacheManager: CacheManager,
|
||||
writer: SqliteArchiveTileWriter,
|
||||
bb: BoundingBox,
|
||||
zoomMin: Int,
|
||||
zoomMax: Int
|
||||
) {
|
||||
cacheManager.downloadAreaAsync(
|
||||
context,
|
||||
bb,
|
||||
zoomMin,
|
||||
zoomMax,
|
||||
object : CacheManager.CacheManagerCallback {
|
||||
override fun onTaskComplete() {
|
||||
Toast.makeText(
|
||||
context,
|
||||
R.string.map_download_complete,
|
||||
Toast.LENGTH_LONG
|
||||
)
|
||||
.show()
|
||||
writer.onDetach()
|
||||
//defaultMapSettings()
|
||||
}
|
||||
|
||||
override fun onTaskFailed(errors: Int) {
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(R.string.map_download_errors).format(errors),
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
writer.onDetach()
|
||||
// defaultMapSettings()
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Create LatLong Grid line overlay
|
||||
* @param enabled: turn on/off gridlines
|
||||
*/
|
||||
fun createLatLongGrid(enabled: Boolean) {
|
||||
val latLongGridOverlay = LatLonGridlineOverlay2()
|
||||
latLongGridOverlay.isEnabled = enabled
|
||||
if (latLongGridOverlay.isEnabled) {
|
||||
val textPaint = Paint()
|
||||
textPaint.textSize = 40f
|
||||
textPaint.color = Color.GRAY
|
||||
textPaint.isAntiAlias = true
|
||||
textPaint.isFakeBoldText = true
|
||||
textPaint.textAlign = Paint.Align.CENTER
|
||||
latLongGridOverlay.textPaint = textPaint
|
||||
latLongGridOverlay.setBackgroundColor(Color.TRANSPARENT)
|
||||
latLongGridOverlay.setLineWidth(3.0f)
|
||||
latLongGridOverlay.setLineColor(Color.GRAY)
|
||||
map.overlayManager.add(latLongGridOverlay)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds copyright to map depending on what source is showing
|
||||
*/
|
||||
fun addCopyright() {
|
||||
if (map.tileProvider.tileSource.copyrightNotice != null) {
|
||||
val copyrightNotice: String = map.tileProvider.tileSource.copyrightNotice
|
||||
val copyrightOverlay = CopyrightOverlay(context)
|
||||
copyrightOverlay.setCopyrightNotice(copyrightNotice)
|
||||
map.overlays.add(copyrightOverlay)
|
||||
}
|
||||
}
|
||||
|
||||
fun drawOverlays() = map.apply {
|
||||
overlayManager.overlays().clear()
|
||||
addCopyright() // Copyright is required for certain map sources
|
||||
createLatLongGrid(false)
|
||||
overlayManager.addAll(nodeLayer, nodeMarkers)
|
||||
overlayManager.addAll(nodeLayer, waypointMarkers)
|
||||
overlayManager.add(nodeLayer, MapEventsOverlay(object : MapEventsReceiver {
|
||||
override fun singleTapConfirmedHelper(p: GeoPoint): Boolean {
|
||||
InfoWindow.closeAllInfoWindowsOn(map)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun longPressHelper(p: GeoPoint): Boolean {
|
||||
performHapticFeedback()
|
||||
if (!model.isConnected()) return true
|
||||
|
||||
showEditWaypointDialog = waypoint {
|
||||
latitudeI = (p.latitude * 1e7).toInt()
|
||||
longitudeI = (p.longitude * 1e7).toInt()
|
||||
}
|
||||
return true
|
||||
}
|
||||
}))
|
||||
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)
|
||||
// }
|
||||
// }
|
||||
|
||||
fun loadOnlineTileSourceBase(): ITileSource {
|
||||
val id = mPrefs.getInt(mapStyleId, 1)
|
||||
debug("mapStyleId from prefs: $id")
|
||||
return CustomTileSource.getTileSource(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates Box overlay showing what area can be downloaded
|
||||
*/
|
||||
fun generateBoxOverlay(zoomLevel: Double) = map.apply {
|
||||
overlayManager = CustomOverlayManager(TilesOverlay(tileProvider, context))
|
||||
setMultiTouchControls(false)
|
||||
// furthest back
|
||||
zoomLevelMax = zoomLevelHighest // FIXME zoomLevel
|
||||
// furthest in min should be > than max
|
||||
zoomLevelMin = map.tileProvider.tileSource.maximumZoomLevel.toDouble()
|
||||
controller.setZoom(zoomLevel)
|
||||
downloadRegionBoundingBox = map.boundingBox
|
||||
val polygon = Polygon().apply {
|
||||
points = Polygon.pointsAsRect(downloadRegionBoundingBox).map {
|
||||
GeoPoint(it.latitude, it.longitude)
|
||||
}
|
||||
}
|
||||
overlayManager.add(polygon)
|
||||
controller.setZoom(zoomLevel - 1.0)
|
||||
val tileCount: Int = CacheManager(map).possibleTilesInArea(
|
||||
downloadRegionBoundingBox,
|
||||
zoomLevelMax.toInt(),
|
||||
zoomLevelMin.toInt()
|
||||
)
|
||||
cacheEstimate = context.getString(R.string.map_cache_tiles).format(tileCount)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset map to default settings & visible buttons
|
||||
*/
|
||||
fun defaultMapSettings() = map.apply {
|
||||
setTileSource(loadOnlineTileSourceBase())
|
||||
setDestroyMode(false) // keeps map instance alive when in the background.
|
||||
isVerticalMapRepetitionEnabled = false // disables map repetition
|
||||
overlayManager = DefaultOverlayManager(TilesOverlay(tileProvider, context))
|
||||
setScrollableAreaLimitLatitude( // bounds scrollable map
|
||||
overlayManager.tilesOverlay.bounds.actualNorth,
|
||||
overlayManager.tilesOverlay.bounds.actualSouth,
|
||||
0
|
||||
)
|
||||
isTilesScaledToDpi = true // scales the map tiles to the display density of the screen
|
||||
minZoomLevel = defaultMinZoom // sets the minimum zoom level (the furthest out you can zoom)
|
||||
maxZoomLevel = defaultMaxZoom
|
||||
setMultiTouchControls(true) // Sets gesture controls to true.
|
||||
zoomController.setVisibility(CustomZoomButtonsController.Visibility.NEVER) // Disables default +/- button for zooming
|
||||
addMapListener(object : MapListener {
|
||||
override fun onScroll(event: ScrollEvent): Boolean {
|
||||
if (downloadRegionBoundingBox != null) {
|
||||
generateBoxOverlay(zoomLevelMax)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onZoom(event: ZoomEvent): Boolean {
|
||||
return false
|
||||
}
|
||||
})
|
||||
showDownloadButton =
|
||||
(tileProvider.tileSource as OnlineTileSourceBase).tileSourcePolicy.acceptsBulkDownload()
|
||||
}
|
||||
|
||||
fun startDownload() {
|
||||
val boundingBox = downloadRegionBoundingBox ?: return
|
||||
try {
|
||||
val outputName =
|
||||
Configuration.getInstance().osmdroidBasePath.absolutePath + File.separator + "mainFile.sqlite" // TODO: Accept filename input param from user
|
||||
writer = SqliteArchiveTileWriter(outputName)
|
||||
val cacheManager = CacheManager(map, writer) // Make sure cacheManager has latest from map
|
||||
//this triggers the download
|
||||
downloadRegion(
|
||||
cacheManager,
|
||||
writer,
|
||||
boundingBox,
|
||||
zoomLevelMax.toInt(),
|
||||
zoomLevelMin.toInt(),
|
||||
)
|
||||
} 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 = mPrefs.getInt(mapStyleId, 1)
|
||||
builder.setSingleChoiceItems(mapStyles, mapStyleInt) { dialog, which ->
|
||||
debug("Set mapStyleId pref to $which")
|
||||
mPrefs.edit().putInt(mapStyleId, which).apply()
|
||||
dialog.dismiss()
|
||||
map.setTileSource(loadOnlineTileSourceBase())
|
||||
showDownloadButton =
|
||||
(map.tileProvider.tileSource as OnlineTileSourceBase).tileSourcePolicy.acceptsBulkDownload()
|
||||
}
|
||||
val dialog = builder.create()
|
||||
dialog.show()
|
||||
}
|
||||
|
||||
fun showCacheManagerDialog() {
|
||||
MaterialAlertDialogBuilder(context)
|
||||
.setTitle(R.string.map_offline_manager)
|
||||
.setItems(
|
||||
arrayOf<CharSequence>(
|
||||
context.getString(R.string.map_cache_size),
|
||||
context.getString(R.string.map_download_region),
|
||||
context.getString(R.string.map_clear_tiles),
|
||||
context.getString(R.string.cancel)
|
||||
)
|
||||
) { dialog, which ->
|
||||
when (which) {
|
||||
0 -> showCurrentCacheInfo = true
|
||||
1 -> {
|
||||
generateBoxOverlay(zoomLevelHighest)
|
||||
showDownloadButton = false
|
||||
dialog.dismiss()
|
||||
}
|
||||
|
||||
2 -> purgeTileSource()
|
||||
else -> dialog.dismiss()
|
||||
}
|
||||
}.show()
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
floatingActionButton = {
|
||||
DownloadButton(showDownloadButton) { showCacheManagerDialog() }
|
||||
},
|
||||
) { innerPadding ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
) {
|
||||
AndroidView(
|
||||
factory = {
|
||||
map.apply {
|
||||
// Required to get online tiles
|
||||
Configuration.getInstance().userAgentValue = BuildConfig.APPLICATION_ID
|
||||
defaultMapSettings()
|
||||
if (nodeMarkers.isNotEmpty()) zoomToBoundingBox(
|
||||
BoundingBox.fromGeoPoints(nodeMarkers.map { it.position }),
|
||||
false
|
||||
) else controller.zoomIn()
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
update = { if (downloadRegionBoundingBox == null) drawOverlays() },
|
||||
)
|
||||
if (downloadRegionBoundingBox != null) CacheLayout(
|
||||
cacheEstimate = cacheEstimate,
|
||||
onExecuteJob = { startDownload() },
|
||||
onCancelDownload = {
|
||||
cacheEstimate = ""
|
||||
downloadRegionBoundingBox = null
|
||||
defaultMapSettings()
|
||||
},
|
||||
modifier = Modifier.align(Alignment.BottomCenter)
|
||||
) else MapStyleButton(
|
||||
onClick = { showMapStyleDialog() },
|
||||
modifier = Modifier.align(Alignment.TopEnd),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (showEditWaypointDialog != null) {
|
||||
EditWaypointDialog(
|
||||
waypoint = showEditWaypointDialog ?: return,
|
||||
onSendClicked = { waypoint ->
|
||||
debug("User clicked send waypoint ${waypoint.id}")
|
||||
showEditWaypointDialog = null
|
||||
model.sendWaypoint(waypoint.copy {
|
||||
if (id == 0) id = model.generatePacketId() ?: return@EditWaypointDialog
|
||||
expire = Int.MAX_VALUE // TODO add expire picker
|
||||
lockedTo = if (waypoint.lockedTo != 0) model.myNodeNum ?: 0 else 0
|
||||
})
|
||||
|
||||
},
|
||||
onDeleteClicked = { waypoint ->
|
||||
debug("User clicked delete waypoint ${waypoint.id}")
|
||||
showEditWaypointDialog = null
|
||||
showDeleteMarkerDialog(waypoint)
|
||||
},
|
||||
onDismissRequest = {
|
||||
debug("User clicked cancel marker edit dialog")
|
||||
showEditWaypointDialog = null
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,157 @@
|
|||
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.Row
|
||||
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.width
|
||||
import androidx.compose.foundation.layout.wrapContentHeight
|
||||
import androidx.compose.material.Button
|
||||
import androidx.compose.material.ButtonDefaults
|
||||
import androidx.compose.material.ContentAlpha
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
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.Modifier
|
||||
import androidx.compose.ui.res.colorResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.geeksville.mesh.R
|
||||
|
||||
@Composable
|
||||
fun CacheLayout(
|
||||
cacheEstimate: String,
|
||||
onExecuteJob: () -> Unit,
|
||||
onCancelDownload: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var selectedDistance by remember { mutableStateOf(5) }
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.wrapContentHeight()
|
||||
.background(colorResource(R.color.colorAdvancedBackground))
|
||||
.padding(16.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.map_select_download_region),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.h5,
|
||||
color = MaterialTheme.colors.onBackground.copy(alpha = ContentAlpha.medium)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
val distances = listOf(5, 10, 15)
|
||||
val selectedDistanceIndex = distances.indexOf(selectedDistance)
|
||||
|
||||
// ToggleButton(
|
||||
// options = distances.map { it.toString() },
|
||||
// selectedOptionIndex = selectedDistanceIndex,
|
||||
// onOptionSelected = { selectedDistance = distances[it] },
|
||||
// )
|
||||
|
||||
// Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.map_tile_download_estimate) + " " + cacheEstimate,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.body1,
|
||||
color = MaterialTheme.colors.onBackground.copy(alpha = ContentAlpha.medium)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Button(
|
||||
onClick = onCancelDownload,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(start = 8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.cancel),
|
||||
color = MaterialTheme.colors.onPrimary
|
||||
)
|
||||
}
|
||||
Button(
|
||||
onClick = onExecuteJob,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(end = 8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.map_start_download),
|
||||
color = MaterialTheme.colors.onPrimary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ToggleButton(
|
||||
options: List<String>,
|
||||
selectedOptionIndex: Int,
|
||||
onOptionSelected: (Int) -> Unit
|
||||
) {
|
||||
val backgroundColor = MaterialTheme.colors.background
|
||||
val selectedColor = MaterialTheme.colors.primary
|
||||
val textColor = MaterialTheme.colors.onBackground.copy(alpha = ContentAlpha.medium)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
options.forEachIndexed { index, option ->
|
||||
val isSelected = index == selectedOptionIndex
|
||||
|
||||
Button(
|
||||
onClick = { onOptionSelected(index) },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
backgroundColor = if (isSelected) selectedColor else backgroundColor,
|
||||
contentColor = textColor
|
||||
),
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text(
|
||||
text = option,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
|
||||
if (index != options.lastIndex) {
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
fun CacheLayoutPreview() {
|
||||
CacheLayout(
|
||||
cacheEstimate = "100 tiles",
|
||||
onExecuteJob = { },
|
||||
onCancelDownload = { }
|
||||
)
|
||||
}
|
|
@ -0,0 +1,51 @@
|
|||
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.foundation.Image
|
||||
import androidx.compose.material.FloatingActionButton
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.geeksville.mesh.R
|
||||
|
||||
@Composable
|
||||
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,
|
||||
backgroundColor = MaterialTheme.colors.primary,
|
||||
) {
|
||||
Image(
|
||||
painterResource(R.drawable.ic_twotone_download_24),
|
||||
stringResource(R.string.map_download_region),
|
||||
modifier = Modifier.scale(1.25f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//@Preview(showBackground = true)
|
||||
//@Composable
|
||||
//private fun DownloadButtonPreview() {
|
||||
// DownloadButton(true, onClick = {})
|
||||
//}
|
|
@ -0,0 +1,176 @@
|
|||
package com.geeksville.mesh.ui.map.components
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.AlertDialog
|
||||
import androidx.compose.material.Button
|
||||
import androidx.compose.material.IconButton
|
||||
import androidx.compose.material.Switch
|
||||
import androidx.compose.material.Text
|
||||
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.res.colorResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.emoji2.emojipicker.EmojiPickerView
|
||||
import androidx.emoji2.emojipicker.RecentEmojiProviderAdapter
|
||||
import com.geeksville.mesh.MeshProtos.Waypoint
|
||||
import com.geeksville.mesh.R
|
||||
import com.geeksville.mesh.copy
|
||||
import com.geeksville.mesh.ui.components.EditTextPreference
|
||||
import com.geeksville.mesh.ui.theme.AppTheme
|
||||
import com.geeksville.mesh.util.CustomRecentEmojiProvider
|
||||
import com.geeksville.mesh.waypoint
|
||||
|
||||
@Composable
|
||||
fun EditWaypointDialog(
|
||||
waypoint: Waypoint,
|
||||
onSendClicked: (Waypoint) -> Unit,
|
||||
onDeleteClicked: (Waypoint) -> Unit,
|
||||
onDismissRequest: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var waypointInput by remember { mutableStateOf(waypoint) }
|
||||
val title = if (waypoint.id == 0) R.string.waypoint_new else R.string.waypoint_edit
|
||||
val emoji = if (waypointInput.icon == 0) 128205 else waypointInput.icon
|
||||
var showEmojiPickerView by remember { mutableStateOf(false) }
|
||||
|
||||
if (!showEmojiPickerView) AlertDialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
title = { Text(stringResource(title)) },
|
||||
text = {
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
EditTextPreference(title = stringResource(R.string.name),
|
||||
value = waypointInput.name,
|
||||
maxSize = 29, // name max_size:30
|
||||
enabled = true,
|
||||
isError = false,
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
keyboardType = KeyboardType.Text, imeAction = ImeAction.Done
|
||||
),
|
||||
keyboardActions = KeyboardActions(onDone = { /*TODO*/ }),
|
||||
onValueChanged = { waypointInput = waypointInput.copy { name = it } },
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { showEmojiPickerView = true }) {
|
||||
Text(String(Character.toChars(emoji)), fontSize = 24.sp)
|
||||
}
|
||||
},
|
||||
)
|
||||
EditTextPreference(title = stringResource(R.string.description),
|
||||
value = waypointInput.description,
|
||||
maxSize = 99, // description max_size:100
|
||||
enabled = true,
|
||||
isError = false,
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
keyboardType = KeyboardType.Text, imeAction = ImeAction.Done
|
||||
),
|
||||
keyboardActions = KeyboardActions(onDone = { /*TODO*/ }),
|
||||
onValueChanged = { waypointInput = waypointInput.copy { description = it } }
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.size(48.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.ic_twotone_lock_24),
|
||||
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 }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
buttons = {
|
||||
Row(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Button(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
onClick = onDismissRequest
|
||||
) { Text(stringResource(R.string.cancel)) }
|
||||
if (waypoint.id != 0) Button(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
onClick = { onDeleteClicked(waypointInput) },
|
||||
enabled = waypointInput.name.isNotEmpty(),
|
||||
) { Text(stringResource(R.string.delete)) }
|
||||
Button(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
onClick = { onSendClicked(waypointInput) },
|
||||
enabled = waypointInput.name.isNotEmpty(),
|
||||
) { Text(stringResource(R.string.send)) }
|
||||
}
|
||||
},
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
) else AndroidView(
|
||||
factory = { context ->
|
||||
EmojiPickerView(context).apply {
|
||||
clipToOutline = true
|
||||
setRecentEmojiProvider(
|
||||
RecentEmojiProviderAdapter(CustomRecentEmojiProvider(context))
|
||||
)
|
||||
setOnEmojiPickedListener { emoji ->
|
||||
showEmojiPickerView = false
|
||||
waypointInput = waypointInput.copy { icon = emoji.emoji.codePointAt(0) }
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxHeight(0.4f) // FIXME
|
||||
.background(colorResource(R.color.colorAdvancedBackground))
|
||||
)
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
fun EditWaypointFormPreview() {
|
||||
AppTheme {
|
||||
EditWaypointDialog(
|
||||
waypoint = waypoint {
|
||||
id = 123
|
||||
name = "Test 123"
|
||||
description = "This is only a test"
|
||||
icon = 128169
|
||||
},
|
||||
onSendClicked = { },
|
||||
onDeleteClicked = { },
|
||||
onDismissRequest = { },
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,44 @@
|
|||
package com.geeksville.mesh.ui.map.components
|
||||
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.Button
|
||||
import androidx.compose.material.ButtonDefaults
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.scale
|
||||
import com.geeksville.mesh.R
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun MapStyleButton(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Button(
|
||||
onClick = onClick,
|
||||
modifier = modifier
|
||||
.padding(16.dp)
|
||||
.size(48.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
backgroundColor = MaterialTheme.colors.primary
|
||||
),
|
||||
) {
|
||||
Icon(
|
||||
painterResource(id = R.drawable.ic_twotone_layers_24),
|
||||
stringResource(R.string.map_style_selection),
|
||||
modifier = Modifier.scale(1.5f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun MapStyleButtonPreview() {
|
||||
MapStyleButton(onClick = {})
|
||||
}
|
|
@ -0,0 +1,48 @@
|
|||
package com.geeksville.mesh.util
|
||||
|
||||
import android.content.Context
|
||||
import androidx.emoji2.emojipicker.RecentEmojiAsyncProvider
|
||||
import com.google.common.util.concurrent.Futures
|
||||
import com.google.common.util.concurrent.ListenableFuture
|
||||
|
||||
/**
|
||||
* Define a custom recent emoji provider which shows most frequently used emoji
|
||||
*/
|
||||
class CustomRecentEmojiProvider(
|
||||
context: Context
|
||||
) : RecentEmojiAsyncProvider {
|
||||
|
||||
private val sharedPreferences =
|
||||
context.getSharedPreferences(RECENT_EMOJI_LIST_FILE_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
private val emoji2Frequency: MutableMap<String, Int> by lazy {
|
||||
sharedPreferences.getString(PREF_KEY_CUSTOM_EMOJI_FREQ, null)?.split(SPLIT_CHAR)
|
||||
?.associate { entry ->
|
||||
entry.split(KEY_VALUE_DELIMITER, limit = 2).takeIf { it.size == 2 }
|
||||
?.let { it[0] to it[1].toInt() } ?: ("" to 0)
|
||||
}?.toMutableMap() ?: mutableMapOf()
|
||||
}
|
||||
|
||||
override fun getRecentEmojiListAsync(): ListenableFuture<List<String>> =
|
||||
Futures.immediateFuture(emoji2Frequency.toList().sortedByDescending { it.second }
|
||||
.map { it.first })
|
||||
|
||||
override fun recordSelection(emoji: String) {
|
||||
emoji2Frequency[emoji] = (emoji2Frequency[emoji] ?: 0) + 1
|
||||
saveToPreferences()
|
||||
}
|
||||
|
||||
private fun saveToPreferences() {
|
||||
sharedPreferences
|
||||
.edit()
|
||||
.putString(PREF_KEY_CUSTOM_EMOJI_FREQ, emoji2Frequency.entries.joinToString(SPLIT_CHAR))
|
||||
.apply()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val PREF_KEY_CUSTOM_EMOJI_FREQ = "pref_key_custom_emoji_freq"
|
||||
private const val RECENT_EMOJI_LIST_FILE_NAME = "org.geeksville.emoji.prefs"
|
||||
private const val SPLIT_CHAR = ","
|
||||
private const val KEY_VALUE_DELIMITER = "="
|
||||
}
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="?attr/colorControlNormal"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillAlpha="0.3"
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M13,9V5h-2v6H9.83L12,13.17 14.17,11H13z"
|
||||
android:strokeAlpha="0.3" />
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M15,9L15,3L9,3v6L5,9l7,7 7,-7h-4zM12,13.17L9.83,11L11,11L11,5h2v6h1.17L12,13.17zM5,18h14v2L5,20z" />
|
||||
</vector>
|
Ładowanie…
Reference in New Issue