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:
James Rich
2025-07-27 17:09:41 -05:00
committed by GitHub
parent bbaac9e143
commit 26530cbe18
4 changed files with 282 additions and 262 deletions

View File

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

View File

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

View File

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

View File

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