mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-02-02 20:02:26 -05:00
feat(message): add overflow menu to message screen (#2540)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
@@ -321,52 +321,46 @@ constructor(
|
||||
private val showPrecisionCircleOnMap =
|
||||
MutableStateFlow(preferences.getBoolean("show-precision-circle-on-map", true))
|
||||
|
||||
private val showIgnored = MutableStateFlow(preferences.getBoolean("show-ignored", false))
|
||||
private val _showIgnored = MutableStateFlow(preferences.getBoolean("show-ignored", false))
|
||||
val showIgnored: StateFlow<Boolean> = _showIgnored
|
||||
|
||||
fun toggleShowIgnored() {
|
||||
showIgnored.value = !showIgnored.value
|
||||
preferences.edit { putBoolean("show-ignored", showIgnored.value) }
|
||||
private val _showQuickChat = MutableStateFlow(preferences.getBoolean("show-quick-chat", false))
|
||||
val showQuickChat: StateFlow<Boolean> = _showQuickChat
|
||||
|
||||
private fun toggleBooleanPreference(
|
||||
state: MutableStateFlow<Boolean>,
|
||||
key: String,
|
||||
onChanged: (Boolean) -> Unit = {},
|
||||
) {
|
||||
val newValue = !state.value
|
||||
state.value = newValue
|
||||
preferences.edit { putBoolean(key, newValue) }
|
||||
onChanged(newValue)
|
||||
}
|
||||
|
||||
fun toggleShowIgnored() = toggleBooleanPreference(_showIgnored, "show-ignored")
|
||||
|
||||
fun toggleShowQuickChat() = toggleBooleanPreference(_showQuickChat, "show-quick-chat")
|
||||
|
||||
fun setSortOption(sort: NodeSortOption) {
|
||||
nodeSortOption.value = sort
|
||||
preferences.edit { putInt("node-sort-option", sort.ordinal) }
|
||||
}
|
||||
|
||||
fun toggleShowDetails() {
|
||||
showDetails.value = !showDetails.value
|
||||
preferences.edit { putBoolean("show-details", showDetails.value) }
|
||||
}
|
||||
fun toggleShowDetails() = toggleBooleanPreference(showDetails, "show-details")
|
||||
|
||||
fun toggleIncludeUnknown() {
|
||||
includeUnknown.value = !includeUnknown.value
|
||||
preferences.edit { putBoolean("include-unknown", includeUnknown.value) }
|
||||
}
|
||||
fun toggleIncludeUnknown() = toggleBooleanPreference(includeUnknown, "include-unknown")
|
||||
|
||||
fun toggleOnlyOnline() {
|
||||
onlyOnline.value = !onlyOnline.value
|
||||
preferences.edit { putBoolean("only-online", onlyOnline.value) }
|
||||
}
|
||||
fun toggleOnlyOnline() = toggleBooleanPreference(onlyOnline, "only-online")
|
||||
|
||||
fun toggleOnlyDirect() {
|
||||
onlyDirect.value = !onlyDirect.value
|
||||
preferences.edit { putBoolean("only-direct", onlyDirect.value) }
|
||||
}
|
||||
fun toggleOnlyDirect() = toggleBooleanPreference(onlyDirect, "only-direct")
|
||||
|
||||
fun setOnlyFavorites(value: Boolean) {
|
||||
onlyFavorites.value = value
|
||||
preferences.edit { putBoolean("only-favorites", onlyFavorites.value) }
|
||||
}
|
||||
fun toggleOnlyFavorites() = toggleBooleanPreference(onlyFavorites, "only-favorites")
|
||||
|
||||
fun setShowWaypointsOnMap(value: Boolean) {
|
||||
showWaypointsOnMap.value = value
|
||||
preferences.edit { putBoolean("show-waypoints-on-map", value) }
|
||||
}
|
||||
fun toggleShowWaypointsOnMap() = toggleBooleanPreference(showWaypointsOnMap, "show-waypoints-on-map")
|
||||
|
||||
fun setShowPrecisionCircleOnMap(value: Boolean) {
|
||||
showPrecisionCircleOnMap.value = value
|
||||
preferences.edit { putBoolean("show-precision-circle-on-map", value) }
|
||||
}
|
||||
fun toggleShowPrecisionCircleOnMap() =
|
||||
toggleBooleanPreference(showPrecisionCircleOnMap, "show-precision-circle-on-map")
|
||||
|
||||
data class NodeFilterState(
|
||||
val filterText: String,
|
||||
|
||||
@@ -31,42 +31,30 @@ import com.geeksville.mesh.ui.sharing.ShareScreen
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
sealed class ContactsRoutes {
|
||||
@Serializable
|
||||
data object Contacts : Route
|
||||
@Serializable data object Contacts : Route
|
||||
|
||||
@Serializable
|
||||
data class Messages(val contactKey: String, val message: String = "") : Route
|
||||
@Serializable data class Messages(val contactKey: String, val message: String = "") : Route
|
||||
|
||||
@Serializable
|
||||
data class Share(val message: String) : Route
|
||||
@Serializable data class Share(val message: String) : Route
|
||||
|
||||
@Serializable
|
||||
data object QuickChat : Route
|
||||
@Serializable data object QuickChat : Route
|
||||
|
||||
@Serializable
|
||||
data object ContactsGraph : Graph
|
||||
@Serializable data object ContactsGraph : Graph
|
||||
}
|
||||
|
||||
fun NavGraphBuilder.contactsGraph(
|
||||
navController: NavHostController,
|
||||
uiViewModel: UIViewModel,
|
||||
) {
|
||||
navigation<ContactsRoutes.ContactsGraph>(
|
||||
startDestination = ContactsRoutes.Contacts,
|
||||
) {
|
||||
fun NavGraphBuilder.contactsGraph(navController: NavHostController, uiViewModel: UIViewModel) {
|
||||
navigation<ContactsRoutes.ContactsGraph>(startDestination = ContactsRoutes.Contacts) {
|
||||
composable<ContactsRoutes.Contacts> {
|
||||
ContactsScreen(
|
||||
uiViewModel,
|
||||
onNavigateToMessages = { navController.navigate(ContactsRoutes.Messages(it)) }
|
||||
)
|
||||
ContactsScreen(uiViewModel, onNavigateToMessages = { navController.navigate(ContactsRoutes.Messages(it)) })
|
||||
}
|
||||
composable<ContactsRoutes.Messages>(
|
||||
deepLinks = listOf(
|
||||
deepLinks =
|
||||
listOf(
|
||||
navDeepLink {
|
||||
uriPattern = "$DEEP_LINK_BASE_URI/messages/{contactKey}?message={message}"
|
||||
action = "android.intent.action.VIEW"
|
||||
},
|
||||
)
|
||||
),
|
||||
) { backStackEntry ->
|
||||
val args = backStackEntry.toRoute<ContactsRoutes.Messages>()
|
||||
MessageScreen(
|
||||
@@ -75,17 +63,19 @@ fun NavGraphBuilder.contactsGraph(
|
||||
viewModel = uiViewModel,
|
||||
navigateToMessages = { navController.navigate(ContactsRoutes.Messages(it)) },
|
||||
navigateToNodeDetails = { navController.navigate(NodesRoutes.NodeDetailGraph(it)) },
|
||||
navigateToQuickChatOptions = { navController.navigate(ContactsRoutes.QuickChat) },
|
||||
onNavigateBack = navController::navigateUp,
|
||||
)
|
||||
}
|
||||
}
|
||||
composable<ContactsRoutes.Share>(
|
||||
deepLinks = listOf(
|
||||
deepLinks =
|
||||
listOf(
|
||||
navDeepLink {
|
||||
uriPattern = "$DEEP_LINK_BASE_URI/share?message={message}"
|
||||
action = "android.intent.action.VIEW"
|
||||
}
|
||||
)
|
||||
},
|
||||
),
|
||||
) { backStackEntry ->
|
||||
val message = backStackEntry.toRoute<ContactsRoutes.Share>().message
|
||||
ShareScreen(uiViewModel) {
|
||||
@@ -94,7 +84,5 @@ fun NavGraphBuilder.contactsGraph(
|
||||
}
|
||||
}
|
||||
}
|
||||
composable<ContactsRoutes.QuickChat> {
|
||||
QuickChatScreen()
|
||||
}
|
||||
composable<ContactsRoutes.QuickChat> { QuickChatScreen() }
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import android.content.Context
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.content.res.AppCompatResources
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -32,14 +33,17 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Lens
|
||||
import androidx.compose.material.icons.filled.LocationDisabled
|
||||
import androidx.compose.material.icons.filled.PinDrop
|
||||
import androidx.compose.material.icons.filled.Star
|
||||
import androidx.compose.material.icons.outlined.Layers
|
||||
import androidx.compose.material.icons.outlined.MyLocation
|
||||
import androidx.compose.material.icons.outlined.Tune
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
@@ -50,10 +54,6 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.material.icons.filled.Star
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
@@ -116,7 +116,7 @@ import java.text.DateFormat
|
||||
private fun MapView.UpdateMarkers(
|
||||
nodeMarkers: List<MarkerWithLabel>,
|
||||
waypointMarkers: List<MarkerWithLabel>,
|
||||
nodeClusterer: RadiusMarkerClusterer
|
||||
nodeClusterer: RadiusMarkerClusterer,
|
||||
) {
|
||||
debug("Showing on map: ${nodeMarkers.size} nodes ${waypointMarkers.size} waypoints")
|
||||
overlays.removeAll { it is MarkerWithLabel }
|
||||
@@ -143,35 +143,28 @@ private fun MapView.UpdateMarkers(
|
||||
// }
|
||||
// }
|
||||
|
||||
private fun cacheManagerCallback(
|
||||
onTaskComplete: () -> Unit,
|
||||
onTaskFailed: (Int) -> Unit,
|
||||
) = object : CacheManager.CacheManagerCallback {
|
||||
override fun onTaskComplete() {
|
||||
onTaskComplete()
|
||||
}
|
||||
private fun cacheManagerCallback(onTaskComplete: () -> Unit, onTaskFailed: (Int) -> Unit) =
|
||||
object : CacheManager.CacheManagerCallback {
|
||||
override fun onTaskComplete() {
|
||||
onTaskComplete()
|
||||
}
|
||||
|
||||
override fun onTaskFailed(errors: Int) {
|
||||
onTaskFailed(errors)
|
||||
}
|
||||
override fun onTaskFailed(errors: Int) {
|
||||
onTaskFailed(errors)
|
||||
}
|
||||
|
||||
override fun updateProgress(
|
||||
progress: Int,
|
||||
currentZoomLevel: Int,
|
||||
zoomMin: Int,
|
||||
zoomMax: Int
|
||||
) {
|
||||
// NOOP since we are using the build in UI
|
||||
}
|
||||
override fun 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 downloadStarted() {
|
||||
// NOOP since we are using the build in UI
|
||||
}
|
||||
|
||||
override fun setPossibleTilesInArea(total: Int) {
|
||||
// NOOP since we are using the build in UI
|
||||
override fun setPossibleTilesInArea(total: Int) {
|
||||
// NOOP since we are using the build in UI
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Context.purgeTileSource(onResult: (String) -> Unit) {
|
||||
val cache = SqlTileWriterExt()
|
||||
@@ -184,10 +177,7 @@ private fun Context.purgeTileSource(onResult: (String) -> Unit) {
|
||||
}
|
||||
val selected: BooleanArray? = null
|
||||
val selectedList = mutableListOf<Int>()
|
||||
builder.setMultiChoiceItems(
|
||||
sourceList.toTypedArray(),
|
||||
selected
|
||||
) { _, i, b ->
|
||||
builder.setMultiChoiceItems(sourceList.toTypedArray(), selected) { _, i, b ->
|
||||
if (b) {
|
||||
selectedList.add(i)
|
||||
} else {
|
||||
@@ -203,7 +193,7 @@ private fun Context.purgeTileSource(onResult: (String) -> Unit) {
|
||||
getString(R.string.map_purge_success, item.source)
|
||||
} else {
|
||||
getString(R.string.map_purge_fail)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -213,10 +203,7 @@ private fun Context.purgeTileSource(onResult: (String) -> Unit) {
|
||||
|
||||
@Suppress("CyclomaticComplexMethod", "LongMethod")
|
||||
@Composable
|
||||
fun MapView(
|
||||
model: UIViewModel = viewModel(),
|
||||
navigateToNodeDetails: (Int) -> Unit,
|
||||
) {
|
||||
fun MapView(model: UIViewModel = viewModel(), navigateToNodeDetails: (Int) -> Unit) {
|
||||
var mapFilterExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
val mapFilterState by model.mapFilterStateFlow.collectAsState()
|
||||
@@ -248,8 +235,7 @@ fun MapView(
|
||||
debug("mapStyleId from prefs: $id")
|
||||
return CustomTileSource.getTileSource(id).also {
|
||||
zoomLevelMax = it.maximumZoomLevel.toDouble()
|
||||
showDownloadButton =
|
||||
if (it is OnlineTileSourceBase) it.tileSourcePolicy.acceptsBulkDownload() else false
|
||||
showDownloadButton = if (it is OnlineTileSourceBase) it.tileSourcePolicy.acceptsBulkDownload() else false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,18 +255,19 @@ fun MapView(
|
||||
}
|
||||
debug("user clicked MyLocationNewOverlay ${myLocationOverlay == null}")
|
||||
if (myLocationOverlay == null) {
|
||||
myLocationOverlay = MyLocationNewOverlay(this).apply {
|
||||
enableMyLocation()
|
||||
enableFollowLocation()
|
||||
getBitmapFromVectorDrawable(context, R.drawable.ic_map_location_dot_24)?.let {
|
||||
setPersonIcon(it)
|
||||
setPersonAnchor(0.5f, 0.5f)
|
||||
myLocationOverlay =
|
||||
MyLocationNewOverlay(this).apply {
|
||||
enableMyLocation()
|
||||
enableFollowLocation()
|
||||
getBitmapFromVectorDrawable(context, R.drawable.ic_map_location_dot_24)?.let {
|
||||
setPersonIcon(it)
|
||||
setPersonAnchor(0.5f, 0.5f)
|
||||
}
|
||||
getBitmapFromVectorDrawable(context, R.drawable.ic_map_navigation_24)?.let {
|
||||
setDirectionIcon(it)
|
||||
setDirectionAnchor(0.5f, 0.5f)
|
||||
}
|
||||
}
|
||||
getBitmapFromVectorDrawable(context, R.drawable.ic_map_navigation_24)?.let {
|
||||
setDirectionIcon(it)
|
||||
setDirectionAnchor(0.5f, 0.5f)
|
||||
}
|
||||
}
|
||||
overlays.add(myLocationOverlay)
|
||||
} else {
|
||||
myLocationOverlay?.apply {
|
||||
@@ -300,9 +287,7 @@ fun MapView(
|
||||
val nodes by model.filteredNodeList.collectAsStateWithLifecycle()
|
||||
val waypoints by model.waypoints.collectAsStateWithLifecycle(emptyMap())
|
||||
|
||||
val markerIcon = remember {
|
||||
AppCompatResources.getDrawable(context, R.drawable.ic_baseline_location_on_24)
|
||||
}
|
||||
val markerIcon = remember { AppCompatResources.getDrawable(context, R.drawable.ic_baseline_location_on_24) }
|
||||
|
||||
fun MapView.onNodesChanged(nodes: Collection<Node>): List<MarkerWithLabel> {
|
||||
val nodesWithPosition = nodes.filter { it.validPosition != null }
|
||||
@@ -317,25 +302,19 @@ fun MapView(
|
||||
|
||||
val (p, u) = node.position to node.user
|
||||
val nodePosition = GeoPoint(node.latitude, node.longitude)
|
||||
MarkerWithLabel(
|
||||
mapView = this,
|
||||
label = "${u.shortName} ${formatAgo(p.time)}"
|
||||
).apply {
|
||||
MarkerWithLabel(mapView = this, label = "${u.shortName} ${formatAgo(p.time)}").apply {
|
||||
id = u.id
|
||||
title = u.longName
|
||||
snippet = context.getString(
|
||||
R.string.map_node_popup_details,
|
||||
node.gpsString(gpsFormat),
|
||||
formatAgo(node.lastHeard),
|
||||
formatAgo(p.time),
|
||||
if (node.batteryStr != "") node.batteryStr else "?"
|
||||
)
|
||||
ourNode?.distanceStr(node, displayUnits)?.let { dist ->
|
||||
subDescription = context.getString(
|
||||
R.string.map_subDescription,
|
||||
ourNode.bearing(node),
|
||||
dist
|
||||
snippet =
|
||||
context.getString(
|
||||
R.string.map_node_popup_details,
|
||||
node.gpsString(gpsFormat),
|
||||
formatAgo(node.lastHeard),
|
||||
formatAgo(p.time),
|
||||
if (node.batteryStr != "") node.batteryStr else "?",
|
||||
)
|
||||
ourNode?.distanceStr(node, displayUnits)?.let { dist ->
|
||||
subDescription = context.getString(R.string.map_subDescription, ourNode.bearing(node), dist)
|
||||
}
|
||||
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
|
||||
position = nodePosition
|
||||
@@ -357,9 +336,7 @@ fun MapView(
|
||||
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.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)
|
||||
@@ -372,13 +349,18 @@ fun MapView(
|
||||
}
|
||||
}
|
||||
val dialog = builder.show()
|
||||
for (button in setOf(
|
||||
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
|
||||
androidx.appcompat.app.AlertDialog.BUTTON_POSITIVE,
|
||||
)
|
||||
) {
|
||||
with(dialog.getButton(button)) {
|
||||
textSize = 12F
|
||||
isAllCaps = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -412,24 +394,25 @@ fun MapView(
|
||||
val label = pt.name + " " + formatAgo((waypoint.received_time / 1000).toInt())
|
||||
val emoji = String(Character.toChars(if (pt.icon == 0) 128205 else pt.icon))
|
||||
val timeLeft = pt.expire * 1000L - System.currentTimeMillis()
|
||||
val expireTimeStr = when {
|
||||
pt.expire == 0 || pt.expire == Int.MAX_VALUE -> "Never"
|
||||
timeLeft <= 0 -> "Expired"
|
||||
timeLeft < 60_000 -> "${timeLeft / 1000} seconds"
|
||||
timeLeft < 3_600_000 -> "${timeLeft / 60_000} minute${if (timeLeft / 60_000 != 1L) "s" else ""}"
|
||||
timeLeft < 86_400_000 -> {
|
||||
val hours = (timeLeft / 3_600_000).toInt()
|
||||
val minutes = ((timeLeft % 3_600_000) / 60_000).toInt()
|
||||
if (minutes >= 30) {
|
||||
"${hours + 1} hour${if (hours + 1 != 1) "s" else ""}"
|
||||
} else if (minutes > 0) {
|
||||
"$hours hour${if (hours != 1) "s" else ""}, $minutes minute${if (minutes != 1) "s" else ""}"
|
||||
} else {
|
||||
"$hours hour${if (hours != 1) "s" else ""}"
|
||||
val expireTimeStr =
|
||||
when {
|
||||
pt.expire == 0 || pt.expire == Int.MAX_VALUE -> "Never"
|
||||
timeLeft <= 0 -> "Expired"
|
||||
timeLeft < 60_000 -> "${timeLeft / 1000} seconds"
|
||||
timeLeft < 3_600_000 -> "${timeLeft / 60_000} minute${if (timeLeft / 60_000 != 1L) "s" else ""}"
|
||||
timeLeft < 86_400_000 -> {
|
||||
val hours = (timeLeft / 3_600_000).toInt()
|
||||
val minutes = ((timeLeft % 3_600_000) / 60_000).toInt()
|
||||
if (minutes >= 30) {
|
||||
"${hours + 1} hour${if (hours + 1 != 1) "s" else ""}"
|
||||
} else if (minutes > 0) {
|
||||
"$hours hour${if (hours != 1) "s" else ""}, $minutes minute${if (minutes != 1) "s" else ""}"
|
||||
} else {
|
||||
"$hours hour${if (hours != 1) "s" else ""}"
|
||||
}
|
||||
}
|
||||
else -> "${timeLeft / 86_400_000} day${if (timeLeft / 86_400_000 != 1L) "s" else ""}"
|
||||
}
|
||||
else -> "${timeLeft / 86_400_000} day${if (timeLeft / 86_400_000 != 1L) "s" else ""}"
|
||||
}
|
||||
MarkerWithLabel(this, label, emoji).apply {
|
||||
id = "${pt.id}"
|
||||
title = "${pt.name} (${getUsername(waypoint.data.from)}$lock)"
|
||||
@@ -451,11 +434,12 @@ fun MapView(
|
||||
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)
|
||||
)
|
||||
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)
|
||||
@@ -467,25 +451,26 @@ fun MapView(
|
||||
.show()
|
||||
}
|
||||
|
||||
val mapEventsReceiver = object : MapEventsReceiver {
|
||||
override fun singleTapConfirmedHelper(p: GeoPoint): Boolean {
|
||||
InfoWindow.closeAllInfoWindowsOn(map)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun longPressHelper(p: GeoPoint): Boolean {
|
||||
performHapticFeedback()
|
||||
val enabled = model.isConnected() && downloadRegionBoundingBox == null
|
||||
|
||||
if (enabled) {
|
||||
showEditWaypointDialog = waypoint {
|
||||
latitudeI = (p.latitude * 1e7).toInt()
|
||||
longitudeI = (p.longitude * 1e7).toInt()
|
||||
}
|
||||
val mapEventsReceiver =
|
||||
object : MapEventsReceiver {
|
||||
override fun singleTapConfirmedHelper(p: GeoPoint): Boolean {
|
||||
InfoWindow.closeAllInfoWindowsOn(map)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun longPressHelper(p: GeoPoint): Boolean {
|
||||
performHapticFeedback()
|
||||
val enabled = model.isConnected() && downloadRegionBoundingBox == null
|
||||
|
||||
if (enabled) {
|
||||
showEditWaypointDialog = waypoint {
|
||||
latitudeI = (p.latitude * 1e7).toInt()
|
||||
longitudeI = (p.longitude * 1e7).toInt()
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
fun MapView.drawOverlays() {
|
||||
if (overlays.none { it is MapEventsOverlay }) {
|
||||
@@ -505,45 +490,37 @@ fun MapView(
|
||||
invalidate()
|
||||
}
|
||||
|
||||
with(map) {
|
||||
UpdateMarkers(onNodesChanged(nodes), onWaypointChanged(waypoints.values), nodeClusterer)
|
||||
}
|
||||
with(map) { UpdateMarkers(onNodesChanged(nodes), onWaypointChanged(waypoints.values), nodeClusterer) }
|
||||
|
||||
/**
|
||||
* Creates Box overlay showing what area can be downloaded
|
||||
*/
|
||||
/** Creates Box overlay showing what area can be downloaded */
|
||||
fun MapView.generateBoxOverlay() {
|
||||
overlays.removeAll { it is Polygon }
|
||||
val zoomFactor = 1.3 // zoom difference between view and download area polygon
|
||||
zoomLevelMin = minOf(zoomLevelDouble, zoomLevelMax)
|
||||
downloadRegionBoundingBox = boundingBox.zoomIn(zoomFactor)
|
||||
val polygon = Polygon().apply {
|
||||
points = Polygon.pointsAsRect(downloadRegionBoundingBox).map {
|
||||
GeoPoint(it.latitude, it.longitude)
|
||||
val polygon =
|
||||
Polygon().apply {
|
||||
points = Polygon.pointsAsRect(downloadRegionBoundingBox).map { GeoPoint(it.latitude, it.longitude) }
|
||||
}
|
||||
}
|
||||
overlays.add(polygon)
|
||||
invalidate()
|
||||
val tileCount: Int = CacheManager(this).possibleTilesInArea(
|
||||
downloadRegionBoundingBox,
|
||||
zoomLevelMin.toInt(),
|
||||
zoomLevelMax.toInt(),
|
||||
)
|
||||
val tileCount: Int =
|
||||
CacheManager(this)
|
||||
.possibleTilesInArea(downloadRegionBoundingBox, zoomLevelMin.toInt(), zoomLevelMax.toInt())
|
||||
cacheEstimate = context.getString(R.string.map_cache_tiles, tileCount)
|
||||
}
|
||||
|
||||
val boxOverlayListener = object : MapListener {
|
||||
override fun onScroll(event: ScrollEvent): Boolean {
|
||||
if (downloadRegionBoundingBox != null) {
|
||||
event.source.generateBoxOverlay()
|
||||
val boxOverlayListener =
|
||||
object : MapListener {
|
||||
override fun onScroll(event: ScrollEvent): Boolean {
|
||||
if (downloadRegionBoundingBox != null) {
|
||||
event.source.generateBoxOverlay()
|
||||
}
|
||||
return true
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onZoom(event: ZoomEvent): Boolean {
|
||||
return false
|
||||
override fun onZoom(event: ZoomEvent): Boolean = false
|
||||
}
|
||||
}
|
||||
|
||||
fun startDownload() {
|
||||
val boundingBox = downloadRegionBoundingBox ?: return
|
||||
@@ -571,7 +548,7 @@ fun MapView(
|
||||
model.showSnackbar(context.getString(R.string.map_download_errors, errors))
|
||||
writer.onDetach()
|
||||
},
|
||||
)
|
||||
),
|
||||
)
|
||||
} catch (ex: TileSourcePolicyException) {
|
||||
debug("Tile source does not allow archiving: ${ex.message}")
|
||||
@@ -603,8 +580,8 @@ fun MapView(
|
||||
getString(R.string.map_cache_size),
|
||||
getString(R.string.map_download_region),
|
||||
getString(R.string.map_clear_tiles),
|
||||
getString(R.string.cancel)
|
||||
)
|
||||
getString(R.string.cancel),
|
||||
),
|
||||
) { dialog, which ->
|
||||
when (which) {
|
||||
0 -> showCurrentCacheInfo = true
|
||||
@@ -616,21 +593,16 @@ fun MapView(
|
||||
2 -> purgeTileSource { model.showSnackbar(it) }
|
||||
else -> dialog.dismiss()
|
||||
}
|
||||
}.show()
|
||||
}
|
||||
.show()
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
floatingActionButton = {
|
||||
DownloadButton(showDownloadButton && downloadRegionBoundingBox == null) {
|
||||
context.showCacheManagerDialog()
|
||||
}
|
||||
DownloadButton(showDownloadButton && downloadRegionBoundingBox == null) { context.showCacheManagerDialog() }
|
||||
},
|
||||
) { innerPadding ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize().padding(innerPadding)) {
|
||||
AndroidView(
|
||||
factory = {
|
||||
map.apply {
|
||||
@@ -650,13 +622,11 @@ fun MapView(
|
||||
map.overlays.removeAll { it is Polygon }
|
||||
map.invalidate()
|
||||
},
|
||||
modifier = Modifier.align(Alignment.BottomCenter)
|
||||
modifier = Modifier.align(Alignment.BottomCenter),
|
||||
)
|
||||
} else {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(top = 16.dp, end = 16.dp)
|
||||
.align(Alignment.TopEnd),
|
||||
modifier = Modifier.padding(top = 16.dp, end = 16.dp).align(Alignment.TopEnd),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
MapButton(
|
||||
@@ -673,99 +643,90 @@ fun MapView(
|
||||
DropdownMenu(
|
||||
expanded = mapFilterExpanded,
|
||||
onDismissRequest = { mapFilterExpanded = false },
|
||||
modifier = Modifier.background(MaterialTheme.colorScheme.surface)
|
||||
modifier = Modifier.background(MaterialTheme.colorScheme.surface),
|
||||
) {
|
||||
// Only Favorites toggle
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Star,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(end = 8.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurface
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.only_favorites),
|
||||
modifier = Modifier.weight(1f)
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Checkbox(
|
||||
checked = mapFilterState.onlyFavorites,
|
||||
onCheckedChange = { enabled ->
|
||||
model.setOnlyFavorites(enabled)
|
||||
},
|
||||
modifier = Modifier.padding(start = 8.dp)
|
||||
onCheckedChange = { model.toggleOnlyFavorites() },
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
)
|
||||
}
|
||||
},
|
||||
onClick = {
|
||||
model.setOnlyFavorites(!mapFilterState.onlyFavorites)
|
||||
}
|
||||
onClick = { model.toggleOnlyFavorites() },
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.PinDrop,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(end = 8.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurface
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.show_waypoints),
|
||||
modifier = Modifier.weight(1f)
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Checkbox(
|
||||
checked = mapFilterState.showWaypoints,
|
||||
onCheckedChange = model::setShowWaypointsOnMap,
|
||||
modifier = Modifier.padding(start = 8.dp)
|
||||
onCheckedChange = { model.toggleShowWaypointsOnMap() },
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
)
|
||||
}
|
||||
},
|
||||
onClick = {
|
||||
model.setShowWaypointsOnMap(!mapFilterState.showWaypoints)
|
||||
}
|
||||
onClick = { model.toggleShowWaypointsOnMap() },
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Lens,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(end = 8.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurface
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.show_precision_circle),
|
||||
modifier = Modifier.weight(1f)
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Checkbox(
|
||||
checked = mapFilterState.showPrecisionCircle,
|
||||
onCheckedChange = { enabled ->
|
||||
model.setShowPrecisionCircleOnMap(enabled)
|
||||
},
|
||||
modifier = Modifier.padding(start = 8.dp)
|
||||
onCheckedChange = { enabled -> model.toggleShowPrecisionCircleOnMap() },
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
)
|
||||
}
|
||||
},
|
||||
onClick = {
|
||||
model.setShowPrecisionCircleOnMap(!mapFilterState.showPrecisionCircle)
|
||||
}
|
||||
onClick = { model.toggleShowPrecisionCircleOnMap() },
|
||||
)
|
||||
}
|
||||
}
|
||||
if (hasGps) {
|
||||
MapButton(
|
||||
icon = if (myLocationOverlay == null) {
|
||||
icon =
|
||||
if (myLocationOverlay == null) {
|
||||
Icons.Outlined.MyLocation
|
||||
} else {
|
||||
Icons.Default.LocationDisabled
|
||||
@@ -797,7 +758,7 @@ fun MapView(
|
||||
if (expire == 0) expire = Int.MAX_VALUE
|
||||
lockedTo = if (waypoint.lockedTo != 0) model.myNodeNum ?: 0 else 0
|
||||
if (waypoint.icon == 0) icon = 128205
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
onDeleteClicked = { waypoint ->
|
||||
|
||||
@@ -46,17 +46,21 @@ import androidx.compose.foundation.text.input.rememberTextFieldState
|
||||
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.Chat
|
||||
import androidx.compose.material.icons.automirrored.filled.Reply
|
||||
import androidx.compose.material.icons.automirrored.filled.Send
|
||||
import androidx.compose.material.icons.filled.ArrowDownward
|
||||
import androidx.compose.material.icons.filled.ChatBubbleOutline
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.SelectAll
|
||||
import androidx.compose.material.icons.filled.SpeakerNotes
|
||||
import androidx.compose.material.icons.filled.SpeakerNotesOff
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
@@ -129,6 +133,7 @@ internal fun MessageScreen(
|
||||
viewModel: UIViewModel = hiltViewModel(),
|
||||
navigateToMessages: (String) -> Unit,
|
||||
navigateToNodeDetails: (Int) -> Unit,
|
||||
navigateToQuickChatOptions: () -> Unit,
|
||||
onNavigateBack: () -> Unit,
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
@@ -147,7 +152,7 @@ internal fun MessageScreen(
|
||||
var sharedContact by rememberSaveable { mutableStateOf<Node?>(null) }
|
||||
val selectedMessageIds = rememberSaveable { mutableStateOf(emptySet<Long>()) }
|
||||
val messageInputState = rememberTextFieldState(message)
|
||||
var showQuickChat by rememberSaveable { mutableStateOf(false) }
|
||||
val showQuickChat by viewModel.showQuickChat.collectAsStateWithLifecycle()
|
||||
|
||||
// Derived state, memoized for performance
|
||||
val channelInfo =
|
||||
@@ -282,7 +287,8 @@ internal fun MessageScreen(
|
||||
channels = channels,
|
||||
channelIndexParam = channelIndex,
|
||||
showQuickChat = showQuickChat,
|
||||
onToggleQuickChat = { showQuickChat = !showQuickChat },
|
||||
onToggleQuickChat = viewModel::toggleShowQuickChat,
|
||||
onNavigateToQuickChatOptions = navigateToQuickChatOptions,
|
||||
)
|
||||
}
|
||||
},
|
||||
@@ -571,6 +577,7 @@ private fun MessageTopBar(
|
||||
channelIndexParam: Int?,
|
||||
showQuickChat: Boolean,
|
||||
onToggleQuickChat: () -> Unit,
|
||||
onNavigateToQuickChatOptions: () -> Unit = {},
|
||||
) = TopAppBar(
|
||||
title = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
@@ -591,22 +598,92 @@ private fun MessageTopBar(
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = onToggleQuickChat) {
|
||||
Icon(
|
||||
imageVector = if (showQuickChat) Icons.Filled.SpeakerNotesOff else Icons.AutoMirrored.Filled.Chat,
|
||||
contentDescription =
|
||||
MessageTopBarActions(
|
||||
showQuickChat,
|
||||
onToggleQuickChat,
|
||||
onNavigateToQuickChatOptions,
|
||||
channelIndex,
|
||||
mismatchKey,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun MessageTopBarActions(
|
||||
showQuickChat: Boolean,
|
||||
onToggleQuickChat: () -> Unit,
|
||||
onNavigateToQuickChatOptions: () -> Unit,
|
||||
channelIndex: Int?,
|
||||
mismatchKey: Boolean,
|
||||
) {
|
||||
if (channelIndex == DataPacket.PKC_CHANNEL_INDEX) {
|
||||
NodeKeyStatusIcon(hasPKC = true, mismatchKey = mismatchKey)
|
||||
}
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
Box {
|
||||
IconButton(onClick = { expanded = true }, enabled = true) {
|
||||
Icon(imageVector = Icons.Default.MoreVert, contentDescription = stringResource(id = R.string.overflow_menu))
|
||||
}
|
||||
OverFlowMenu(
|
||||
expanded = expanded,
|
||||
onDismiss = { expanded = false },
|
||||
showQuickChat = showQuickChat,
|
||||
onToggleQuickChat = onToggleQuickChat,
|
||||
onNavigateToQuickChatOptions = onNavigateToQuickChatOptions,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OverFlowMenu(
|
||||
expanded: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
showQuickChat: Boolean,
|
||||
onToggleQuickChat: () -> Unit,
|
||||
onNavigateToQuickChatOptions: () -> Unit,
|
||||
) {
|
||||
if (expanded) {
|
||||
DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) {
|
||||
val quickChatToggleTitle =
|
||||
if (showQuickChat) {
|
||||
stringResource(id = R.string.quick_chat_hide)
|
||||
stringResource(R.string.quick_chat_hide)
|
||||
} else {
|
||||
stringResource(id = R.string.quick_chat_show)
|
||||
stringResource(R.string.quick_chat_show)
|
||||
}
|
||||
DropdownMenuItem(
|
||||
text = { Text(quickChatToggleTitle) },
|
||||
onClick = {
|
||||
onDismiss()
|
||||
onToggleQuickChat()
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector =
|
||||
if (showQuickChat) {
|
||||
Icons.Default.SpeakerNotesOff
|
||||
} else {
|
||||
Icons.Default.SpeakerNotes
|
||||
},
|
||||
contentDescription = quickChatToggleTitle,
|
||||
)
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(id = R.string.quick_chat)) },
|
||||
onClick = {
|
||||
onDismiss()
|
||||
onNavigateToQuickChatOptions()
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Default.ChatBubbleOutline,
|
||||
contentDescription = stringResource(id = R.string.quick_chat),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
if (channelIndex == DataPacket.PKC_CHANNEL_INDEX) {
|
||||
NodeKeyStatusIcon(hasPKC = true, mismatchKey = mismatchKey)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A row of quick chat action buttons.
|
||||
|
||||
Reference in New Issue
Block a user