refactor: migrate MapFragment to Composable (#647)

pull/650/head
Andre K 2023-06-24 07:58:01 -03:00 zatwierdzone przez GitHub
rodzic e15cdc42f1
commit d4879ceea9
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: 4AEE18F83AFDEB23
13 zmienionych plików z 1207 dodań i 823 usunięć

Wyświetl plik

@ -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"

Wyświetl plik

@ -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

Wyświetl plik

@ -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)
}
}
}

Wyświetl plik

@ -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")

Wyświetl plik

@ -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) }
}
}

Wyświetl plik

@ -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) }
}
}
}

Wyświetl plik

@ -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
},
)
}
}

Wyświetl plik

@ -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 = { }
)
}

Wyświetl plik

@ -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 = {})
//}

Wyświetl plik

@ -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 = { },
)
}
}

Wyświetl plik

@ -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 = {})
}

Wyświetl plik

@ -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 = "="
}
}

Wyświetl plik

@ -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>