refactor: migrate MapFragment to Composable (#647)

This commit is contained in:
Andre K 2023-06-24 07:58:01 -03:00 committed by GitHub
parent e15cdc42f1
commit d4879ceea9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 1207 additions and 823 deletions

View file

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

View file

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

View file

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

View file

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

View file

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