From d4879ceea9322bcbc1568ef27e461d34c1795887 Mon Sep 17 00:00:00 2001 From: Andre K Date: Sat, 24 Jun 2023 07:58:01 -0300 Subject: [PATCH] refactor: migrate MapFragment to Composable (#647) --- app/build.gradle | 8 +- .../java/com/geeksville/mesh/MainActivity.kt | 1 + .../mesh/model/map/CustomOverlayManager.kt | 23 +- .../mesh/model/map/CustomTileSource.kt | 26 +- .../mesh/model/map/MarkerWithLabel.kt | 63 ++ .../com/geeksville/mesh/ui/MapFragment.kt | 788 ------------------ .../com/geeksville/mesh/ui/map/MapFragment.kt | 630 ++++++++++++++ .../mesh/ui/map/components/CacheLayout.kt | 157 ++++ .../mesh/ui/map/components/DownloadButton.kt | 51 ++ .../ui/map/components/EditWaypointDialog.kt | 176 ++++ .../mesh/ui/map/components/MapStyleButton.kt | 44 + .../mesh/util/CustomRecentEmojiProvider.kt | 48 ++ .../res/drawable/ic_twotone_download_24.xml | 15 + 13 files changed, 1207 insertions(+), 823 deletions(-) create mode 100644 app/src/main/java/com/geeksville/mesh/model/map/MarkerWithLabel.kt delete mode 100644 app/src/main/java/com/geeksville/mesh/ui/MapFragment.kt create mode 100644 app/src/main/java/com/geeksville/mesh/ui/map/MapFragment.kt create mode 100644 app/src/main/java/com/geeksville/mesh/ui/map/components/CacheLayout.kt create mode 100644 app/src/main/java/com/geeksville/mesh/ui/map/components/DownloadButton.kt create mode 100644 app/src/main/java/com/geeksville/mesh/ui/map/components/EditWaypointDialog.kt create mode 100644 app/src/main/java/com/geeksville/mesh/ui/map/components/MapStyleButton.kt create mode 100644 app/src/main/java/com/geeksville/mesh/util/CustomRecentEmojiProvider.kt create mode 100644 app/src/main/res/drawable/ic_twotone_download_24.xml diff --git a/app/build.gradle b/app/build.gradle index 194b3032..afac8a38 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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" diff --git a/app/src/main/java/com/geeksville/mesh/MainActivity.kt b/app/src/main/java/com/geeksville/mesh/MainActivity.kt index 99781da5..8794c87b 100644 --- a/app/src/main/java/com/geeksville/mesh/MainActivity.kt +++ b/app/src/main/java/com/geeksville/mesh/MainActivity.kt @@ -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 diff --git a/app/src/main/java/com/geeksville/mesh/model/map/CustomOverlayManager.kt b/app/src/main/java/com/geeksville/mesh/model/map/CustomOverlayManager.kt index 74a3bb46..157ebc64 100644 --- a/app/src/main/java/com/geeksville/mesh/model/map/CustomOverlayManager.kt +++ b/app/src/main/java/com/geeksville/mesh/model/map/CustomOverlayManager.kt @@ -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) - } - } } \ No newline at end of file diff --git a/app/src/main/java/com/geeksville/mesh/model/map/CustomTileSource.kt b/app/src/main/java/com/geeksville/mesh/model/map/CustomTileSource.kt index bcfaecd1..c8892b82 100644 --- a/app/src/main/java/com/geeksville/mesh/model/map/CustomTileSource.kt +++ b/app/src/main/java/com/geeksville/mesh/model/map/CustomTileSource.kt @@ -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 = - listOf( - MAPNIK, - USGS_TOPO, - OPEN_TOPO, - ESRI_WORLD_TOPO, - USGS_SAT, - ESRI_IMAGERY, - ) + val mTileSources: Map = 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") diff --git a/app/src/main/java/com/geeksville/mesh/model/map/MarkerWithLabel.kt b/app/src/main/java/com/geeksville/mesh/model/map/MarkerWithLabel.kt new file mode 100644 index 00000000..389b0361 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/model/map/MarkerWithLabel.kt @@ -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) } + } +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/MapFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/MapFragment.kt deleted file mode 100644 index e9a6270d..00000000 --- a/app/src/main/java/com/geeksville/mesh/ui/MapFragment.kt +++ /dev/null @@ -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() - private var waypoints = mapOf() - private var waypointMarkers = listOf() - 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( - 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() - for (i in sources.indices) { - sourceList.add(sources[i].source as String) - } - val selected: BooleanArray? = null - val selectedList = mutableListOf() - 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( - 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( - "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) { - - /** - * Using the latest waypoint, generate GeoPoint - */ - // Find all waypoints - fun getCurrentWayPoints(): List { - 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) { - 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 = 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) } - } - } -} - - - diff --git a/app/src/main/java/com/geeksville/mesh/ui/map/MapFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/map/MapFragment.kt new file mode 100644 index 00000000..999dd0a2 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/map/MapFragment.kt @@ -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(null) } + var showCurrentCacheInfo by remember { mutableStateOf(false) } + + fun onNodesChanged(nodes: Collection): List { + 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().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): List { + 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().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() + for (i in sources.indices) { + sourceList.add(sources[i].source as String) + } + val selected: BooleanArray? = null + val selectedList = mutableListOf() + 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 = 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( + 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 + }, + ) + } +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/map/components/CacheLayout.kt b/app/src/main/java/com/geeksville/mesh/ui/map/components/CacheLayout.kt new file mode 100644 index 00000000..dc45f8a6 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/map/components/CacheLayout.kt @@ -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, + 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 = { } + ) +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/map/components/DownloadButton.kt b/app/src/main/java/com/geeksville/mesh/ui/map/components/DownloadButton.kt new file mode 100644 index 00000000..f20183ce --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/map/components/DownloadButton.kt @@ -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 = {}) +//} diff --git a/app/src/main/java/com/geeksville/mesh/ui/map/components/EditWaypointDialog.kt b/app/src/main/java/com/geeksville/mesh/ui/map/components/EditWaypointDialog.kt new file mode 100644 index 00000000..0b9ed74e --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/map/components/EditWaypointDialog.kt @@ -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 = { }, + ) + } +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/map/components/MapStyleButton.kt b/app/src/main/java/com/geeksville/mesh/ui/map/components/MapStyleButton.kt new file mode 100644 index 00000000..59f7faa2 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/map/components/MapStyleButton.kt @@ -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 = {}) +} diff --git a/app/src/main/java/com/geeksville/mesh/util/CustomRecentEmojiProvider.kt b/app/src/main/java/com/geeksville/mesh/util/CustomRecentEmojiProvider.kt new file mode 100644 index 00000000..d1d2a3ae --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/util/CustomRecentEmojiProvider.kt @@ -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 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> = + 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 = "=" + } +} diff --git a/app/src/main/res/drawable/ic_twotone_download_24.xml b/app/src/main/res/drawable/ic_twotone_download_24.xml new file mode 100644 index 00000000..16e437be --- /dev/null +++ b/app/src/main/res/drawable/ic_twotone_download_24.xml @@ -0,0 +1,15 @@ + + + +