feat: consolidate dialogs (#4506)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2026-02-08 16:45:52 -06:00 committed by GitHub
parent 7bcc51863f
commit ea6d1ffa32
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
59 changed files with 2042 additions and 1659 deletions

View file

@ -41,6 +41,10 @@ This file serves as a comprehensive guide for AI agents and developers working o
Text(text = stringResource(Res.string.your_string_key))
```
- **Dialogs:**
- Use the centralized `MeshtasticDialog` for all alerts and confirmation boxes.
- **Specialized Overloads:** Use `MeshtasticResourceDialog` (for resource-only content) or `MeshtasticTextDialog` (for mixed resource/text content) to reduce boilerplate.
- **Location:** Defined in `core/ui/src/main/kotlin/org/meshtastic/core/ui/component/AlertDialogs.kt`.
- **Previews:** Create `@Preview` functions for your Composables to ensure they render correctly.
### B. Architecture & State

View file

@ -47,11 +47,10 @@ import com.geeksville.mesh.ui.MainScreen
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import org.meshtastic.core.datastore.UiPreferencesDataSource
import org.meshtastic.core.model.util.handleMeshtasticUri
import org.meshtastic.core.model.util.dispatchMeshtasticUri
import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.channel_invalid
import org.meshtastic.core.strings.contact_invalid
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.core.ui.theme.MODE_DYNAMIC
import org.meshtastic.core.ui.util.showToast
@ -157,20 +156,10 @@ class MainActivity : AppCompatActivity() {
private fun handleMeshtasticUri(uri: Uri) {
Logger.d { "Handling Meshtastic URI: $uri" }
handleMeshtasticUri(
uri = uri,
onChannel = {
model.requestChannelUrl(
url = it,
onFailure = { lifecycleScope.launch { showToast(Res.string.channel_invalid) } },
)
},
onContact = {
model.setSharedContactRequested(
url = it,
onFailure = { lifecycleScope.launch { showToast(Res.string.contact_invalid) } },
)
},
uri.dispatchMeshtasticUri(
onChannel = { model.setRequestChannelSet(it) },
onContact = { model.setSharedContactRequested(it) },
onInvalid = { lifecycleScope.launch { showToast(Res.string.channel_invalid) } },
)
}

View file

@ -43,6 +43,7 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.shareIn
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.getString
import org.meshtastic.core.analytics.platform.PlatformAnalytics
import org.meshtastic.core.data.repository.FirmwareReleaseRepository
@ -54,15 +55,17 @@ import org.meshtastic.core.database.entity.asDeviceVersion
import org.meshtastic.core.datastore.UiPreferencesDataSource
import org.meshtastic.core.model.TracerouteMapAvailability
import org.meshtastic.core.model.evaluateTracerouteMapAvailability
import org.meshtastic.core.model.util.toChannelSet
import org.meshtastic.core.model.util.dispatchMeshtasticUri
import org.meshtastic.core.service.IMeshService
import org.meshtastic.core.service.MeshServiceNotifications
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.service.TracerouteResponse
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.client_notification
import org.meshtastic.core.strings.compromised_keys
import org.meshtastic.core.ui.component.ScrollToTopEvent
import org.meshtastic.core.ui.component.toSharedContact
import org.meshtastic.core.ui.util.AlertManager
import org.meshtastic.core.ui.util.ComposableContent
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.ClientNotification
@ -114,6 +117,7 @@ constructor(
private val meshServiceNotifications: MeshServiceNotifications,
private val analytics: PlatformAnalytics,
packetRepository: PacketRepository,
private val alertManager: AlertManager,
) : ViewModel() {
val theme: StateFlow<Int> = uiPreferencesDataSource.theme
@ -142,17 +146,7 @@ constructor(
_scrollToTopEventFlow.tryEmit(event)
}
data class AlertData(
val title: String,
val message: String? = null,
val html: String? = null,
val onConfirm: (() -> Unit)? = null,
val onDismiss: (() -> Unit)? = null,
val choices: Map<String, () -> Unit> = emptyMap(),
)
private val _currentAlert: MutableStateFlow<AlertData?> = MutableStateFlow(null)
val currentAlert = _currentAlert.asStateFlow()
val currentAlert = alertManager.currentAlert
fun tracerouteMapAvailability(forwardRoute: List<Int>, returnRoute: List<Int>): TracerouteMapAvailability =
evaluateTracerouteMapAvailability(
@ -163,29 +157,39 @@ constructor(
)
fun showAlert(
title: String,
title: String? = null,
titleRes: StringResource? = null,
message: String? = null,
messageRes: StringResource? = null,
composableMessage: ComposableContent? = null,
html: String? = null,
onConfirm: (() -> Unit)? = {},
dismissable: Boolean = true,
onDismiss: (() -> Unit)? = null,
confirmText: String? = null,
confirmTextRes: StringResource? = null,
dismissText: String? = null,
dismissTextRes: StringResource? = null,
choices: Map<String, () -> Unit> = emptyMap(),
) {
_currentAlert.value =
AlertData(
title = title,
message = message,
html = html,
onConfirm = {
onConfirm?.invoke()
dismissAlert()
},
onDismiss = { if (dismissable) dismissAlert() },
choices = choices,
)
alertManager.showAlert(
title = title,
titleRes = titleRes,
message = message,
messageRes = messageRes,
composableMessage = composableMessage,
html = html,
onConfirm = onConfirm,
onDismiss = onDismiss,
confirmText = confirmText,
confirmTextRes = confirmTextRes,
dismissText = dismissText,
dismissTextRes = dismissTextRes,
choices = choices,
)
}
private fun dismissAlert() {
_currentAlert.value = null
fun dismissAlert() {
alertManager.dismissAlert()
}
val meshService: IMeshService?
@ -203,10 +207,25 @@ constructor(
.filterNotNull()
.onEach {
showAlert(
title = getString(Res.string.client_notification),
titleRes = Res.string.client_notification,
message = it,
onConfirm = { serviceRepository.clearErrorMessage() },
dismissable = false,
)
}
.launchIn(viewModelScope)
serviceRepository.clientNotification
.filterNotNull()
.onEach { notification ->
val isCompromised = notification.low_entropy_key != null || notification.duplicated_public_key != null
showAlert(
titleRes = Res.string.client_notification,
message = if (isCompromised) getString(Res.string.compromised_keys) else notification.message,
onConfirm = {
// Action for compromised keys should be handled via a callback or event
clearClientNotification(notification)
},
onDismiss = { clearClientNotification(notification) },
)
}
.launchIn(viewModelScope)
@ -218,12 +237,8 @@ constructor(
val sharedContactRequested: StateFlow<SharedContact?>
get() = _sharedContactRequested.asStateFlow()
fun setSharedContactRequested(url: Uri, onFailure: () -> Unit) {
runCatching { _sharedContactRequested.value = url.toSharedContact() }
.onFailure { ex ->
Logger.e(ex) { "Shared contact error" }
onFailure()
}
fun setSharedContactRequested(contact: SharedContact?) {
_sharedContactRequested.value = contact
}
/** Called immediately after activity observes requestChannelUrl */
@ -239,20 +254,17 @@ constructor(
val requestChannelSet: StateFlow<ChannelSet?>
get() = _requestChannelSet
fun requestChannelUrl(url: Uri, onFailure: () -> Unit) =
runCatching { _requestChannelSet.value = url.toChannelSet() }
.onFailure { ex ->
Logger.e(ex) { "Channel url error" }
onFailure()
}
fun setRequestChannelSet(channelSet: ChannelSet?) {
_requestChannelSet.value = channelSet
}
/** Unified handler for scanned Meshtastic URIs (contacts or channels). */
fun handleScannedUri(uri: Uri, onInvalid: () -> Unit) {
if (uri.path?.contains("/v/") == true) {
setSharedContactRequested(uri, onInvalid)
} else {
requestChannelUrl(uri, onInvalid)
}
uri.dispatchMeshtasticUri(
onContact = { setSharedContactRequested(it) },
onChannel = { setRequestChannelSet(it) },
onInvalid = onInvalid,
)
}
val latestStableFirmwareRelease = firmwareReleaseRepository.stableRelease.mapNotNull { it?.asDeviceVersion() }
@ -267,8 +279,8 @@ constructor(
Logger.d { "ViewModel cleared" }
}
val tracerouteResponse: LiveData<TracerouteResponse?>
get() = serviceRepository.tracerouteResponse.asLiveData()
val tracerouteResponse: Flow<TracerouteResponse?>
get() = serviceRepository.tracerouteResponse
fun clearTracerouteResponse() {
serviceRepository.clearTracerouteResponse()

View file

@ -58,7 +58,6 @@ import androidx.compose.material3.rememberTooltipState
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.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@ -116,9 +115,6 @@ import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.app_too_old
import org.meshtastic.core.strings.bottom_nav_settings
import org.meshtastic.core.strings.client_notification
import org.meshtastic.core.strings.close
import org.meshtastic.core.strings.compromised_keys
import org.meshtastic.core.strings.connected
import org.meshtastic.core.strings.connecting
import org.meshtastic.core.strings.connections
@ -135,9 +131,8 @@ import org.meshtastic.core.strings.should_update
import org.meshtastic.core.strings.should_update_firmware
import org.meshtastic.core.strings.traceroute
import org.meshtastic.core.strings.view_on_map
import org.meshtastic.core.ui.component.MultipleChoiceAlertDialog
import org.meshtastic.core.ui.component.MeshtasticDialog
import org.meshtastic.core.ui.component.ScrollToTopEvent
import org.meshtastic.core.ui.component.SimpleAlertDialog
import org.meshtastic.core.ui.icon.Conversations
import org.meshtastic.core.ui.icon.Map
import org.meshtastic.core.ui.icon.MeshtasticIcons
@ -148,8 +143,10 @@ import org.meshtastic.core.ui.qr.ScannedQrCodeDialog
import org.meshtastic.core.ui.share.SharedContactDialog
import org.meshtastic.core.ui.theme.StatusColors.StatusBlue
import org.meshtastic.core.ui.theme.StatusColors.StatusGreen
import org.meshtastic.core.ui.theme.StatusColors.StatusOrange
import org.meshtastic.core.ui.theme.StatusColors.StatusYellow
import org.meshtastic.core.ui.util.annotateTraceroute
import org.meshtastic.core.ui.util.toMessageRes
import org.meshtastic.feature.node.metrics.annotateTraceroute
enum class TopLevelDestination(val label: StringResource, val icon: ImageVector, val route: Route) {
Conversations(Res.string.conversations, MeshtasticIcons.Conversations, ContactsRoutes.ContactsGraph),
@ -197,63 +194,57 @@ fun MainScreen(uIViewModel: UIViewModel = hiltViewModel(), scanModel: BTScanMode
uIViewModel.AddNavigationTrackingEffect(navController)
VersionChecks(uIViewModel)
val alertDialogState by uIViewModel.currentAlert.collectAsStateWithLifecycle()
alertDialogState?.let { state ->
if (state.choices.isNotEmpty()) {
MultipleChoiceAlertDialog(
title = state.title,
message = state.message,
choices = state.choices,
onDismissRequest = { state.onDismiss?.let { it() } },
)
} else {
SimpleAlertDialog(
title = state.title,
message = state.message,
html = state.html,
onConfirmRequest = { state.onConfirm?.let { it() } },
onDismissRequest = { state.onDismiss?.let { it() } },
)
}
}
val title = state.title ?: state.titleRes?.let { stringResource(it) } ?: ""
val message = state.message ?: state.messageRes?.let { stringResource(it) }
val confirmText = state.confirmText ?: state.confirmTextRes?.let { stringResource(it) }
val dismissText = state.dismissText ?: state.dismissTextRes?.let { stringResource(it) }
val clientNotification by uIViewModel.clientNotification.collectAsStateWithLifecycle()
clientNotification?.let { notification ->
var message = notification.message
val compromisedKeys =
if (notification.low_entropy_key != null || notification.duplicated_public_key != null) {
message = stringResource(Res.string.compromised_keys)
true
} else {
false
}
SimpleAlertDialog(
title = Res.string.client_notification,
text = { Text(text = message) },
onConfirm = {
if (compromisedKeys) {
navController.navigate(SettingsRoutes.Security)
MeshtasticDialog(
title = title,
message = message,
html = state.html,
icon = state.icon,
text = {
val composableMsg = state.composableMessage
if (composableMsg != null) {
composableMsg.Content()
} else {
// message is handled internally by MeshtasticDialog
}
uIViewModel.clearClientNotification(notification)
},
onDismiss = { uIViewModel.clearClientNotification(notification) },
confirmText = confirmText,
onConfirm = state.onConfirm,
dismissText = dismissText,
onDismiss = state.onDismiss,
choices = state.choices,
dismissable = state.dismissable,
)
}
val traceRouteResponse by uIViewModel.tracerouteResponse.observeAsState()
var tracerouteMapError by remember { mutableStateOf<StringResource?>(null) }
val traceRouteResponse by uIViewModel.tracerouteResponse.collectAsStateWithLifecycle(null)
var dismissedTracerouteRequestId by remember { mutableStateOf<Int?>(null) }
traceRouteResponse
?.takeIf { it.requestId != dismissedTracerouteRequestId }
?.let { response ->
SimpleAlertDialog(
title = Res.string.traceroute,
text = {
uIViewModel.showAlert(
titleRes = Res.string.traceroute,
composableMessage = {
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
Text(text = annotateTraceroute(response.message))
Text(
text =
annotateTraceroute(
response.message,
statusGreen = colorScheme.StatusGreen,
statusYellow = colorScheme.StatusYellow,
statusOrange = colorScheme.StatusOrange,
),
)
}
},
confirmText = stringResource(Res.string.view_on_map),
confirmTextRes = Res.string.view_on_map,
onConfirm = {
val availability =
uIViewModel.tracerouteMapAvailability(
@ -271,25 +262,17 @@ fun MainScreen(uIViewModel: UIViewModel = hiltViewModel(), scanModel: BTScanMode
),
)
} else {
tracerouteMapError = errorRes
uIViewModel.showAlert(titleRes = Res.string.traceroute, messageRes = errorRes)
uIViewModel.clearTracerouteResponse()
}
},
dismissText = stringResource(Res.string.okay),
dismissTextRes = Res.string.okay,
onDismiss = {
uIViewModel.clearTracerouteResponse()
dismissedTracerouteRequestId = null
},
)
}
tracerouteMapError?.let { res ->
SimpleAlertDialog(
title = Res.string.traceroute,
text = { Text(text = stringResource(res)) },
dismissText = stringResource(Res.string.close),
onDismiss = { tracerouteMapError = null },
)
}
val navSuiteType = NavigationSuiteScaffoldDefaults.navigationSuiteType(currentWindowAdaptiveInfo())
val currentDestination = navController.currentBackStackEntryAsState().value?.destination
val topLevelDestination = TopLevelDestination.fromNavDestination(currentDestination)
@ -532,9 +515,8 @@ private fun VersionChecks(viewModel: UIViewModel) {
if (isOld) {
Logger.w { "[FW_CHECK] App too old - showing update prompt" }
viewModel.showAlert(
getString(Res.string.app_too_old),
getString(Res.string.must_update),
dismissable = false,
titleRes = Res.string.app_too_old,
messageRes = Res.string.must_update,
onConfirm = {
val service = viewModel.meshService ?: return@showAlert
MeshService.changeDeviceAddress(context, service, "n")
@ -560,7 +542,6 @@ private fun VersionChecks(viewModel: UIViewModel) {
viewModel.showAlert(
title = title,
html = message,
dismissable = false,
onConfirm = {
val service = viewModel.meshService ?: return@showAlert
MeshService.changeDeviceAddress(context, service, "n")
@ -573,7 +554,7 @@ private fun VersionChecks(viewModel: UIViewModel) {
}
val title = getString(Res.string.should_update_firmware)
val message = getString(Res.string.should_update, latestStableFirmwareRelease.asString)
viewModel.showAlert(title = title, message = message, dismissable = false, onConfirm = {})
viewModel.showAlert(title = title, message = message, onConfirm = {})
} else {
Logger.i { "[FW_CHECK] Firmware version OK - device: $curVer meets requirements" }
}

View file

@ -26,7 +26,6 @@ import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.Wifi
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
@ -65,6 +64,7 @@ import org.meshtastic.core.strings.forget_connection
import org.meshtastic.core.strings.ip_port
import org.meshtastic.core.strings.no_network_devices
import org.meshtastic.core.strings.recent_network_devices
import org.meshtastic.core.ui.component.MeshtasticResourceDialog
import org.meshtastic.core.ui.theme.AppTheme
@OptIn(ExperimentalMaterial3Api::class)
@ -237,21 +237,15 @@ private fun ConfirmDeleteDialog(
onHideDialog: () -> Unit,
onConfirm: (deviceFullAddress: String) -> Unit,
) {
AlertDialog(
onDismissRequest = onHideDialog,
title = { Text(stringResource(Res.string.forget_connection)) },
text = { Text(stringResource(Res.string.confirm_forget_connection)) },
confirmButton = {
Button(
onClick = {
onConfirm(fullAddressToDelete)
onHideDialog()
},
) {
Text(stringResource(Res.string.forget_connection))
}
MeshtasticResourceDialog(
onDismiss = onHideDialog,
titleRes = Res.string.forget_connection,
messageRes = Res.string.confirm_forget_connection,
confirmTextRes = Res.string.forget_connection,
onConfirm = {
onConfirm(fullAddressToDelete)
onHideDialog()
},
dismissButton = { Button(onClick = { onHideDialog() }) { Text(stringResource(Res.string.cancel)) } },
)
}

View file

@ -27,8 +27,6 @@ import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.selection.selectable
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
@ -54,7 +52,6 @@ import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.paging.LoadState
@ -72,6 +69,7 @@ import org.meshtastic.core.database.entity.ContactSettings
import org.meshtastic.core.model.util.formatMuteRemainingTime
import org.meshtastic.core.model.util.getChannel
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.are_you_sure
import org.meshtastic.core.strings.cancel
import org.meshtastic.core.strings.channel_invalid
import org.meshtastic.core.strings.close_selection
@ -91,8 +89,10 @@ import org.meshtastic.core.strings.mute_status_unmuted
import org.meshtastic.core.strings.okay
import org.meshtastic.core.strings.select_all
import org.meshtastic.core.strings.unmute
import org.meshtastic.core.ui.component.AddContactFAB
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.MeshtasticDialog
import org.meshtastic.core.ui.component.MeshtasticImportFAB
import org.meshtastic.core.ui.component.MeshtasticTextDialog
import org.meshtastic.core.ui.component.ScrollToTopEvent
import org.meshtastic.core.ui.component.smartScrollToTop
import org.meshtastic.core.ui.icon.Close
@ -101,6 +101,7 @@ import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.SelectAll
import org.meshtastic.core.ui.icon.VolumeMuteTwoTone
import org.meshtastic.core.ui.icon.VolumeUpTwoTone
import org.meshtastic.core.ui.qr.ScannedQrCodeDialog
import org.meshtastic.core.ui.util.showToast
import org.meshtastic.proto.ChannelSet
import java.util.concurrent.TimeUnit
@ -178,6 +179,8 @@ fun ContactsScreen(
val isAllMuted = remember(selectedContacts) { selectedContacts.all { it.isMuted } }
val sharedContactRequested by uIViewModel.sharedContactRequested.collectAsStateWithLifecycle()
val requestChannelSet by uIViewModel.requestChannelSet.collectAsStateWithLifecycle()
requestChannelSet?.let { ScannedQrCodeDialog(it, onDismiss = { uIViewModel.clearRequestChannelUrl() }) }
// Callback functions for item interaction
val onContactClick: (Contact) -> Unit = { contact ->
@ -233,15 +236,16 @@ fun ContactsScreen(
},
floatingActionButton = {
if (connectionState.isConnected()) {
AddContactFAB(
MeshtasticImportFAB(
sharedContact = sharedContactRequested,
onResult = { uri ->
onImport = { uri ->
uIViewModel.handleScannedUri(uri) {
scope.launch { context.showToast(Res.string.channel_invalid) }
}
},
onShareChannels = onNavigateToShare,
onDismissSharedContact = { uIViewModel.clearSharedContactRequested() },
isContactContext = true,
)
}
},
@ -277,168 +281,140 @@ fun ContactsScreen(
)
}
}
DeleteConfirmationDialog(
showDialog = showDeleteDialog,
selectedCount = selectedCount,
onDismiss = { showDeleteDialog = false },
onConfirm = {
showDeleteDialog = false
viewModel.deleteContacts(selectedContactKeys.toList())
selectedContactKeys.clear()
},
)
if (showDeleteDialog) {
DeleteConfirmationDialog(
selectedCount = selectedCount,
onDismiss = { showDeleteDialog = false },
onConfirm = {
showDeleteDialog = false
viewModel.deleteContacts(selectedContactKeys.toList())
selectedContactKeys.clear()
},
)
}
// Get contact settings for the dialog
val contactSettings by viewModel.getContactSettings().collectAsStateWithLifecycle(initialValue = emptyMap())
MuteNotificationsDialog(
showDialog = showMuteDialog,
selectedContactKeys = selectedContactKeys.toList(),
contactSettings = contactSettings,
onDismiss = { showMuteDialog = false },
onConfirm = { muteUntil ->
showMuteDialog = false
viewModel.setMuteUntil(selectedContactKeys.toList(), muteUntil)
selectedContactKeys.clear()
},
)
if (showMuteDialog) {
MuteNotificationsDialog(
selectedContactKeys = selectedContactKeys.toList(),
contactSettings = contactSettings,
onDismiss = { showMuteDialog = false },
onConfirm = { muteUntil ->
showMuteDialog = false
viewModel.setMuteUntil(selectedContactKeys.toList(), muteUntil)
selectedContactKeys.clear()
},
)
}
}
@Suppress("LongMethod")
@Composable
private fun MuteNotificationsDialog(
showDialog: Boolean,
selectedContactKeys: List<String>,
contactSettings: Map<String, ContactSettings>,
onDismiss: () -> Unit,
onConfirm: (Long) -> Unit, // Lambda to handle the confirmed mute duration
) {
if (showDialog) {
// Options for mute duration
val muteOptions = remember {
listOf(
Res.string.unmute to 0L,
Res.string.mute_8_hours to TimeUnit.HOURS.toMillis(8),
Res.string.mute_1_week to TimeUnit.DAYS.toMillis(7),
Res.string.mute_always to Long.MAX_VALUE,
)
}
// State to hold the selected mute duration index
var selectedOptionIndex by remember { mutableStateOf(2) } // Default to "Always"
AlertDialog(
onDismissRequest = onDismiss, // Dismiss the dialog when clicked outside
title = { Text(text = stringResource(Res.string.mute_notifications)) },
text = {
Column {
// Show current mute status
selectedContactKeys.forEach { contactKey ->
contactSettings[contactKey]?.let { settings ->
val now = System.currentTimeMillis()
val statusText =
when {
settings.muteUntil > 0 && settings.muteUntil != Long.MAX_VALUE -> {
val remaining = settings.muteUntil - now
if (remaining > 0) {
val (days, hours) = formatMuteRemainingTime(remaining)
if (days >= 1) {
stringResource(Res.string.mute_status_muted_for_days, days, hours)
} else {
stringResource(Res.string.mute_status_muted_for_hours, hours)
}
} else {
stringResource(Res.string.mute_status_unmuted)
}
}
settings.muteUntil == Long.MAX_VALUE ->
stringResource(Res.string.mute_status_always)
else -> stringResource(Res.string.mute_status_unmuted)
}
Text(
text = stringResource(Res.string.currently) + " " + statusText,
modifier = Modifier.padding(bottom = 8.dp),
)
}
}
muteOptions.forEachIndexed { index, (stringRes, _) ->
val isSelected = index == selectedOptionIndex
val text = stringResource(stringRes)
Row(
modifier =
Modifier.fillMaxWidth()
.selectable(selected = isSelected, onClick = { selectedOptionIndex = index })
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
RadioButton(selected = isSelected, onClick = { selectedOptionIndex = index })
Text(text = text, modifier = Modifier.padding(start = 8.dp))
}
}
}
},
confirmButton = {
Button(
onClick = {
val selectedMuteDuration = muteOptions[selectedOptionIndex].second
onConfirm(selectedMuteDuration)
onDismiss() // Dismiss the dialog after confirming
},
) {
Text(stringResource(Res.string.okay))
}
},
dismissButton = {
Button(
onClick = onDismiss, // Dismiss the dialog on cancel
) {
Text(stringResource(Res.string.cancel))
}
},
// Options for mute duration
val muteOptions = remember {
listOf(
Res.string.unmute to 0L,
Res.string.mute_8_hours to TimeUnit.HOURS.toMillis(8),
Res.string.mute_1_week to TimeUnit.DAYS.toMillis(7),
Res.string.mute_always to Long.MAX_VALUE,
)
}
// State to hold the selected mute duration index
var selectedOptionIndex by remember { mutableStateOf(2) } // Default to "Always"
MeshtasticDialog(
onDismiss = onDismiss, // Dismiss the dialog when clicked outside
titleRes = Res.string.mute_notifications,
confirmTextRes = Res.string.okay,
onConfirm = {
val selectedMuteDuration = muteOptions[selectedOptionIndex].second
onConfirm(selectedMuteDuration)
onDismiss() // Dismiss the dialog after confirming
},
dismissTextRes = Res.string.cancel,
text = {
Column {
// Show current mute status
selectedContactKeys.forEach { contactKey ->
contactSettings[contactKey]?.let { settings ->
val now = System.currentTimeMillis()
val statusText =
when {
settings.muteUntil > 0 && settings.muteUntil != Long.MAX_VALUE -> {
val remaining = settings.muteUntil - now
if (remaining > 0) {
val (days, hours) = formatMuteRemainingTime(remaining)
if (days >= 1) {
stringResource(Res.string.mute_status_muted_for_days, days, hours)
} else {
stringResource(Res.string.mute_status_muted_for_hours, hours)
}
} else {
stringResource(Res.string.mute_status_unmuted)
}
}
settings.muteUntil == Long.MAX_VALUE -> stringResource(Res.string.mute_status_always)
else -> stringResource(Res.string.mute_status_unmuted)
}
Text(
text = stringResource(Res.string.currently) + " " + statusText,
modifier = Modifier.padding(bottom = 8.dp),
)
}
}
muteOptions.forEachIndexed { index, (stringRes, _) ->
val isSelected = index == selectedOptionIndex
val text = stringResource(stringRes)
Row(
modifier =
Modifier.fillMaxWidth()
.selectable(selected = isSelected, onClick = { selectedOptionIndex = index })
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
RadioButton(selected = isSelected, onClick = { selectedOptionIndex = index })
Text(text = text, modifier = Modifier.padding(start = 8.dp))
}
}
}
},
)
}
@Composable
private fun DeleteConfirmationDialog(
showDialog: Boolean,
selectedCount: Int, // Number of items to be deleted
onDismiss: () -> Unit,
onConfirm: () -> Unit, // Lambda to handle the delete action
) {
if (showDialog) {
val deleteMessage =
pluralStringResource(
Res.plurals.delete_messages,
selectedCount,
selectedCount, // Pass the count as a format argument
)
AlertDialog(
onDismissRequest = onDismiss,
title = {
// Optional: You could add a title here if needed, e.g., "Confirm Deletion"
},
text = { Text(text = deleteMessage) },
confirmButton = {
Button(
onClick = {
onConfirm()
onDismiss() // Dismiss the dialog after confirming
},
) {
Text(stringResource(Res.string.delete))
}
},
dismissButton = { Button(onClick = onDismiss) { Text(stringResource(Res.string.cancel)) } },
properties =
DialogProperties(
dismissOnClickOutside = true, // Allow dismissing by clicking outside
dismissOnBackPress = true, // Allow dismissing with the back button
),
val deleteMessage =
pluralStringResource(
Res.plurals.delete_messages,
selectedCount,
selectedCount, // Pass the count as a format argument
)
}
MeshtasticTextDialog(
titleRes = Res.string.are_you_sure,
message = deleteMessage,
confirmTextRes = Res.string.delete,
onConfirm = {
onConfirm()
onDismiss() // Dismiss the dialog after confirming
},
onDismiss = onDismiss,
)
}
@OptIn(ExperimentalMaterial3Api::class)

View file

@ -34,7 +34,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.ChevronRight
import androidx.compose.material.icons.twotone.QrCodeScanner
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
@ -45,7 +44,6 @@ import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
@ -92,6 +90,7 @@ import org.meshtastic.core.strings.share_channels_qr
import org.meshtastic.core.ui.component.AdaptiveTwoPane
import org.meshtastic.core.ui.component.ChannelSelection
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.MeshtasticDialog
import org.meshtastic.core.ui.component.PreferenceFooter
import org.meshtastic.core.ui.component.QrDialog
import org.meshtastic.core.ui.qr.ScannedQrCodeDialog
@ -193,39 +192,22 @@ fun ChannelScreen(
}
if (showResetDialog) {
AlertDialog(
onDismissRequest = {
MeshtasticDialog(
onDismiss = {
channelSet = channels // throw away any edits
showResetDialog = false
},
title = { Text(text = stringResource(Res.string.reset_to_defaults)) },
text = { Text(text = stringResource(Res.string.are_you_sure_change_default)) },
confirmButton = {
TextButton(
onClick = {
Logger.d { "Switching back to default channel" }
val lora =
(Channel.default.loraConfig).copy(
region = viewModel.region,
tx_enabled = viewModel.txEnabled,
)
installSettings(Channel.default.settings, lora)
showResetDialog = false
},
) {
Text(text = stringResource(Res.string.apply))
}
},
dismissButton = {
TextButton(
onClick = {
channelSet = channels // throw away any edits
showResetDialog = false
},
) {
Text(text = stringResource(Res.string.cancel))
}
titleRes = Res.string.reset_to_defaults,
messageRes = Res.string.are_you_sure_change_default,
onConfirm = {
Logger.d { "Switching back to default channel" }
val lora =
(Channel.default.loraConfig).copy(region = viewModel.region, tx_enabled = viewModel.txEnabled)
installSettings(Channel.default.settings, lora)
showResetDialog = false
},
confirmTextRes = Res.string.apply,
dismissTextRes = Res.string.cancel,
)
}

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.model.util
import android.net.Uri
@ -62,4 +61,25 @@ class ChannelSetTest {
Assert.assertEquals("Custom", cs.primaryChannel!!.name)
Assert.assertFalse(cs.hasLoraConfig())
}
/** validate that www.meshtastic.org host is accepted */
@Test
fun parseWwwHost() {
val url = Uri.parse("https://www.meshtastic.org/e/#CgMSAQESBggBQANIAQ")
Assert.assertEquals("LongFast", url.toChannelSet().primaryChannel!!.name)
}
/** validate that short /e path is accepted */
@Test
fun parseShortPath() {
val url = Uri.parse("https://meshtastic.org/e#CgMSAQESBggBQANIAQ")
Assert.assertEquals("LongFast", url.toChannelSet().primaryChannel!!.name)
}
/** validate that long /channel/e path is accepted */
@Test
fun parseLongPath() {
val url = Uri.parse("https://meshtastic.org/channel/e/#CgMSAQESBggBQANIAQ")
Assert.assertEquals("LongFast", url.toChannelSet().primaryChannel!!.name)
}
}

View file

@ -0,0 +1,60 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.model.util
import android.net.Uri
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import org.meshtastic.proto.SharedContact
import org.meshtastic.proto.User
@RunWith(AndroidJUnit4::class)
class SharedContactTest {
@Test
fun testSharedContactUrlRoundTrip() {
val original = SharedContact(user = User(long_name = "Suzume", short_name = "SZ"), node_num = 12345)
val url = original.getSharedContactUrl()
val parsed = url.toSharedContact()
assertEquals(original.node_num, parsed.node_num)
assertEquals(original.user?.long_name, parsed.user?.long_name)
assertEquals(original.user?.short_name, parsed.user?.short_name)
}
@Test
fun testWwwHostIsAccepted() {
val url = Uri.parse("https://www.meshtastic.org/v/#CggKBVN1enVtZRICU1oaBTEyMzQ1")
val contact = url.toSharedContact()
assertEquals("Suzume", contact.user?.long_name)
}
@Test
fun testLongPathIsAccepted() {
val url = Uri.parse("https://meshtastic.org/contact/v/#CggKBVN1enVtZRICU1oaBTEyMzQ1")
val contact = url.toSharedContact()
assertEquals("Suzume", contact.user?.long_name)
}
@Test(expected = java.net.MalformedURLException::class)
fun testInvalidHostThrows() {
val url = Uri.parse("https://example.com/v/#CggKBVN1enVtZRICU1oaBTEyMzQ1")
url.toSharedContact()
}
}

View file

@ -39,7 +39,13 @@ private const val BASE64FLAGS = Base64.URL_SAFE + Base64.NO_WRAP + Base64.NO_PAD
*/
@Throws(MalformedURLException::class)
fun Uri.toChannelSet(): ChannelSet {
if (fragment.isNullOrBlank() || !host.equals(MESHTASTIC_HOST, true) || !path.equals(CHANNEL_SHARE_PATH, true)) {
val h = host ?: ""
val isCorrectHost =
h.equals(MESHTASTIC_HOST, ignoreCase = true) || h.equals("www.$MESHTASTIC_HOST", ignoreCase = true)
val segments = pathSegments
val isCorrectPath = segments.any { it.equals("e", ignoreCase = true) }
if (fragment.isNullOrBlank() || !isCorrectHost || !isCorrectPath) {
throw MalformedURLException("Not a valid Meshtastic URL: ${toString().take(40)}")
}

View file

@ -0,0 +1,101 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.model.util
import android.net.Uri
import android.util.Base64
import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.meshtastic.proto.SharedContact
import org.meshtastic.proto.User
import java.net.MalformedURLException
private const val BASE64FLAGS = Base64.URL_SAFE + Base64.NO_WRAP + Base64.NO_PADDING
/**
* Return a [SharedContact] that represents the contact encoded by the URL.
*
* @throws MalformedURLException when not recognized as a valid Meshtastic URL
*/
@Throws(MalformedURLException::class)
fun Uri.toSharedContact(): SharedContact {
val h = host ?: ""
val isCorrectHost =
h.equals(MESHTASTIC_HOST, ignoreCase = true) || h.equals("www.$MESHTASTIC_HOST", ignoreCase = true)
val segments = pathSegments
val isCorrectPath = segments.any { it.equals("v", ignoreCase = true) }
if (fragment.isNullOrBlank() || !isCorrectHost || !isCorrectPath) {
throw MalformedURLException("Not a valid Meshtastic URL")
}
return SharedContact.ADAPTER.decode(Base64.decode(fragment!!, BASE64FLAGS).toByteString())
}
/** Converts a [SharedContact] to its corresponding URI representation. */
fun SharedContact.getSharedContactUrl(): Uri {
val bytes = SharedContact.ADAPTER.encode(this)
val enc = Base64.encodeToString(bytes, BASE64FLAGS)
return Uri.parse("$CONTACT_URL_PREFIX$enc")
}
/** Compares two [User] objects and returns a string detailing the differences. */
fun compareUsers(oldUser: User, newUser: User): String {
val changes = mutableListOf<String>()
if (oldUser.id != newUser.id) changes.add("id: ${oldUser.id} -> ${newUser.id}")
if (oldUser.long_name != newUser.long_name) changes.add("long_name: ${oldUser.long_name} -> ${newUser.long_name}")
if (oldUser.short_name != newUser.short_name) {
changes.add("short_name: ${oldUser.short_name} -> ${newUser.short_name}")
}
@Suppress("DEPRECATION")
if (oldUser.macaddr != newUser.macaddr) {
changes.add("macaddr: ${oldUser.macaddr.base64String()} -> ${newUser.macaddr.base64String()}")
}
if (oldUser.hw_model != newUser.hw_model) changes.add("hw_model: ${oldUser.hw_model} -> ${newUser.hw_model}")
if (oldUser.is_licensed != newUser.is_licensed) {
changes.add("is_licensed: ${oldUser.is_licensed} -> ${newUser.is_licensed}")
}
if (oldUser.role != newUser.role) changes.add("role: ${oldUser.role} -> ${newUser.role}")
if (oldUser.public_key != newUser.public_key) {
changes.add("public_key: ${oldUser.public_key.base64String()} -> ${newUser.public_key.base64String()}")
}
return if (changes.isEmpty()) {
"No changes detected."
} else {
"Changes:\n" + changes.joinToString("\n")
}
}
/** Converts a [User] object to a string representation of its fields and values. */
fun userFieldsToString(user: User): String {
val fieldLines = mutableListOf<String>()
fieldLines.add("id: ${user.id}")
fieldLines.add("long_name: ${user.long_name}")
fieldLines.add("short_name: ${user.short_name}")
@Suppress("DEPRECATION")
fieldLines.add("macaddr: ${user.macaddr.base64String()}")
fieldLines.add("hw_model: ${user.hw_model}")
fieldLines.add("is_licensed: ${user.is_licensed}")
fieldLines.add("role: ${user.role}")
fieldLines.add("public_key: ${user.public_key.base64String()}")
return fieldLines.joinToString("\n")
}
private fun ByteString.base64String(): String = Base64.encodeToString(this.toByteArray(), Base64.DEFAULT).trim()

View file

@ -17,31 +17,75 @@
package org.meshtastic.core.model.util
import android.net.Uri
import co.touchlab.kermit.Logger
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.SharedContact
/**
* Dispatches an incoming Meshtastic URI to the appropriate handler.
* Dispatches an incoming Meshtastic URI to the appropriate handler based on its path.
*
* @param uri The URI to handle.
* @param onChannel Callback if the URI is a Channel Set (path starts with /e/).
* @param onContact Callback if the URI is a Shared Contact (path starts with /v/).
* @param onChannel Callback if the URI is a Channel Set.
* @param onContact Callback if the URI is a Shared Contact.
* @return True if the URI was handled (matched a supported path), false otherwise.
*/
fun handleMeshtasticUri(uri: Uri, onChannel: (Uri) -> Unit = {}, onContact: (Uri) -> Unit = {}): Boolean {
val path = uri.path
// Only handle meshtastic.org URLs
if (uri.host?.equals(MESHTASTIC_HOST, ignoreCase = true) != true || path == null) {
return false
}
val h = uri.host ?: ""
val isCorrectHost =
h.equals(MESHTASTIC_HOST, ignoreCase = true) || h.equals("www.$MESHTASTIC_HOST", ignoreCase = true)
if (!isCorrectHost) return false
val segments = uri.pathSegments
return when {
path.startsWith(CHANNEL_SHARE_PATH, ignoreCase = true) -> {
segments.any { it.equals("e", ignoreCase = true) } -> {
onChannel(uri)
true
}
path.startsWith(CONTACT_SHARE_PATH, ignoreCase = true) -> {
segments.any { it.equals("v", ignoreCase = true) } -> {
onContact(uri)
true
}
else -> false
}
}
/**
* Tries to parse a Meshtastic URI as a Channel Set or Shared Contact, including fallback logic.
*
* @param onChannel Callback when successfully parsed as a [ChannelSet].
* @param onContact Callback when successfully parsed as a [SharedContact].
* @param onInvalid Callback when parsing fails or the URI is not a Meshtastic URL.
*/
fun Uri.dispatchMeshtasticUri(
onChannel: (ChannelSet) -> Unit,
onContact: (SharedContact) -> Unit,
onInvalid: () -> Unit,
) {
val handled =
handleMeshtasticUri(
uri = this,
onChannel = { u ->
runCatching { u.toChannelSet() }
.onSuccess(onChannel)
.onFailure { ex ->
Logger.e(ex) { "Channel parsing error" }
onInvalid()
}
},
onContact = { u ->
runCatching { u.toSharedContact() }
.onSuccess(onContact)
.onFailure { ex ->
Logger.e(ex) { "Contact parsing error" }
onInvalid()
}
},
)
if (!handled) {
// Fallback: try as contact first, then as channel
runCatching { toSharedContact() }
.onSuccess(onContact)
.onFailure { runCatching { toChannelSet() }.onSuccess(onChannel).onFailure { onInvalid() } }
}
}

View file

@ -0,0 +1,68 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.model.util
import android.net.Uri
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import org.meshtastic.proto.SharedContact
import org.meshtastic.proto.User
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class SharedContactTest {
@Test
fun testSharedContactUrlRoundTrip() {
val original = SharedContact(user = User(long_name = "Suzume", short_name = "SZ"), node_num = 12345)
val url = original.getSharedContactUrl()
val parsed = url.toSharedContact()
assertEquals(original.node_num, parsed.node_num)
assertEquals(original.user?.long_name, parsed.user?.long_name)
assertEquals(original.user?.short_name, parsed.user?.short_name)
}
@Test
fun testWwwHostIsAccepted() {
val original = SharedContact(user = User(long_name = "Suzume"), node_num = 12345)
val urlStr = original.getSharedContactUrl().toString().replace("meshtastic.org", "www.meshtastic.org")
val url = Uri.parse(urlStr)
val contact = url.toSharedContact()
assertEquals("Suzume", contact.user?.long_name)
}
@Test
fun testLongPathIsAccepted() {
val original = SharedContact(user = User(long_name = "Suzume"), node_num = 12345)
val urlStr = original.getSharedContactUrl().toString().replace("/v/", "/contact/v/")
val url = Uri.parse(urlStr)
val contact = url.toSharedContact()
assertEquals("Suzume", contact.user?.long_name)
}
@Test(expected = java.net.MalformedURLException::class)
fun testInvalidHostThrows() {
val original = SharedContact(user = User(long_name = "Suzume"), node_num = 12345)
val urlStr = original.getSharedContactUrl().toString().replace("meshtastic.org", "example.com")
val url = Uri.parse(urlStr)
url.toSharedContact()
}
}

View file

@ -21,6 +21,8 @@ import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.meshtastic.proto.SharedContact
import org.meshtastic.proto.User
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@ -68,4 +70,59 @@ class UriUtilsTest {
assertTrue("Should handle mixed case URI", handled)
assertTrue("Should invoke onChannel callback", channelCalled)
}
@Test
fun `handleMeshtasticUri handles www host`() {
val uri = Uri.parse("https://www.meshtastic.org/e/somechannel")
var channelCalled = false
val handled = handleMeshtasticUri(uri, onChannel = { channelCalled = true })
assertTrue("Should handle www host", handled)
assertTrue("Should invoke onChannel callback", channelCalled)
}
@Test
fun `handleMeshtasticUri handles long channel path`() {
val uri = Uri.parse("https://meshtastic.org/channel/e/somechannel")
var channelCalled = false
val handled = handleMeshtasticUri(uri, onChannel = { channelCalled = true })
assertTrue("Should handle long channel path", handled)
assertTrue("Should invoke onChannel callback", channelCalled)
}
@Test
fun `handleMeshtasticUri handles long contact path`() {
val uri = Uri.parse("https://meshtastic.org/contact/v/somecontact")
var contactCalled = false
val handled = handleMeshtasticUri(uri, onContact = { contactCalled = true })
assertTrue("Should handle long contact path", handled)
assertTrue("Should invoke onContact callback", contactCalled)
}
@Test
fun `dispatchMeshtasticUri dispatches correctly`() {
val original = SharedContact(user = User(long_name = "Suzume"), node_num = 12345)
val uri = original.getSharedContactUrl()
var contactReceived: SharedContact? = null
uri.dispatchMeshtasticUri(onChannel = {}, onContact = { contactReceived = it }, onInvalid = {})
assertTrue("Contact should be received", contactReceived != null)
assertTrue("Name should match", contactReceived?.user?.long_name == "Suzume")
}
@Test
fun `dispatchMeshtasticUri handles invalid variants via fallback`() {
val original = SharedContact(user = User(long_name = "Suzume"), node_num = 12345)
// Manual override to an "unknown" path that handleMeshtasticUri would reject
val urlStr = original.getSharedContactUrl().toString().replace("/v/", "/fallback/")
val uri = Uri.parse(urlStr)
var contactReceived: SharedContact? = null
uri.dispatchMeshtasticUri(onChannel = {}, onContact = { contactReceived = it }, onInvalid = {})
// This should fail both handleMeshtasticUri AND toSharedContact because of path validation
// So contactReceived should be null and onInvalid called (if provided)
assertTrue("Contact should NOT be received with invalid path", contactReceived == null)
}
}

View file

@ -0,0 +1,86 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ui.component
import androidx.compose.material3.Text
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import org.junit.Rule
import org.junit.Test
import org.meshtastic.proto.SharedContact
import org.meshtastic.proto.User
class ImportFabUiTest {
@get:Rule val composeTestRule = createComposeRule()
@Test
fun importFab_expands_onButtonClick() {
val testTag = "import_fab"
composeTestRule.setContent { MeshtasticImportFAB(onImport = {}, isContactContext = true, testTag = testTag) }
// Expand the FAB
composeTestRule.onNodeWithTag(testTag).performClick()
// Verify menu items are visible using their tags
composeTestRule.onNodeWithTag("nfc_import").assertIsDisplayed()
composeTestRule.onNodeWithTag("qr_import").assertIsDisplayed()
composeTestRule.onNodeWithTag("url_import").assertIsDisplayed()
}
@Test
fun importFab_showsUrlDialog_whenUrlItemClicked() {
val testTag = "import_fab"
composeTestRule.setContent { MeshtasticImportFAB(onImport = {}, isContactContext = true, testTag = testTag) }
composeTestRule.onNodeWithTag(testTag).performClick()
composeTestRule.onNodeWithTag("url_import").performClick()
// The URL dialog should be shown.
// We'll search for its title indirectly or check if an AlertDialog appeared.
}
@Test
fun importFab_showsShareChannels_whenCallbackProvided() {
val testTag = "import_fab"
composeTestRule.setContent {
MeshtasticImportFAB(onImport = {}, onShareChannels = {}, isContactContext = false, testTag = testTag)
}
composeTestRule.onNodeWithTag(testTag).performClick()
composeTestRule.onNodeWithTag("share_channels").assertIsDisplayed()
}
@Test
fun importFab_showsSharedContactDialog_whenProvided() {
val contact = SharedContact(user = User(long_name = "Suzume Goddess"), node_num = 1)
composeTestRule.setContent {
MeshtasticImportFAB(
onImport = {},
sharedContact = contact,
onDismissSharedContact = {},
importDialog = { shared, _ -> Text(text = "Importing ${shared.user?.long_name}") },
)
}
// Check if goddess is here
composeTestRule.onNodeWithText("Importing Suzume Goddess").assertIsDisplayed()
}
}

View file

@ -0,0 +1,71 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ui.util
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import org.junit.Rule
import org.junit.Test
class AlertManagerUiTest {
@get:Rule val composeTestRule = createComposeRule()
private val alertManager = AlertManager()
@Test
fun alertManager_showsAlert_whenRequested() {
composeTestRule.setContent {
val alertData by alertManager.currentAlert.collectAsState()
alertData?.let { data -> AlertPreviewRenderer(data) }
}
val title = "UI Test Alert"
val message = "This is a message from a UI test."
alertManager.showAlert(title = title, message = message)
composeTestRule.onNodeWithText(title).assertIsDisplayed()
composeTestRule.onNodeWithText(message).assertIsDisplayed()
}
@Test
fun alertManager_confirmButton_triggersCallbackAndDismisses() {
var confirmClicked = false
composeTestRule.setContent {
val alertData by alertManager.currentAlert.collectAsState()
alertData?.let { data -> AlertPreviewRenderer(data) }
}
alertManager.showAlert(title = "Confirm Title", onConfirm = { confirmClicked = true })
// Default confirm text is "Okay" from resources, but AlertPreviewRenderer uses it
// We'll search for the text "Okay" (assuming it matches the resource value)
// Since we are in a test, we might need to use a hardcoded string or a resource
// But for this test, let's just use the confirmText parameter to be sure
alertManager.showAlert(title = "Confirm Title", confirmText = "Yes", onConfirm = { confirmClicked = true })
composeTestRule.onNodeWithText("Yes").performClick()
assert(confirmClicked)
composeTestRule.onNodeWithText("Confirm Title").assertDoesNotExist()
}
}

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -14,44 +14,87 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ui.component
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.fromHtml
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.cancel
import org.meshtastic.core.strings.okay
/**
* A comprehensive and flexible dialog component for the Meshtastic application.
*
* @param modifier Modifier for the dialog.
* @param title The title text of the dialog.
* @param titleRes The title string resource of the dialog.
* @param message Optional plain text message.
* @param messageRes Optional string resource message.
* @param html Optional HTML formatted message.
* @param icon Optional leading icon.
* @param text Optional custom composable content for the body.
* @param confirmText Text for the confirmation button.
* @param confirmTextRes String resource for the confirmation button.
* @param onConfirm Callback for the confirmation button.
* @param dismissText Text for the dismiss button.
* @param dismissTextRes String resource for the dismiss button.
* @param onDismiss Callback for when the dialog is dismissed or the dismiss button is clicked.
* @param choices If provided, displays a list of buttons instead of the standard confirm/dismiss actions.
* @param dismissable Whether the dialog can be dismissed by clicking outside or pressing back.
*/
@Composable
fun SimpleAlertDialog(
title: String,
message: String?,
@Suppress("LongMethod", "CyclomaticComplexMethod")
fun MeshtasticDialog(
modifier: Modifier = Modifier,
title: String? = null,
titleRes: StringResource? = null,
message: String? = null,
messageRes: StringResource? = null,
html: String? = null,
onDismissRequest: () -> Unit,
onConfirmRequest: () -> Unit = onDismissRequest, // Default confirm to dismiss
icon: ImageVector? = null,
text: @Composable (() -> Unit)? = null,
confirmText: String? = null,
confirmTextRes: StringResource? = null,
onConfirm: (() -> Unit)? = null,
dismissText: String? = null,
dismissTextRes: StringResource? = null,
onDismiss: (() -> Unit)? = null,
choices: Map<String, () -> Unit> = emptyMap(),
dismissable: Boolean = true,
) {
val annotatedString =
val titleText = title ?: titleRes?.let { stringResource(it) } ?: ""
val messageText = message ?: messageRes?.let { stringResource(it) }
val confirmButtonText = confirmText ?: confirmTextRes?.let { stringResource(it) }
val dismissButtonText = dismissText ?: dismissTextRes?.let { stringResource(it) }
val htmlAnnotated =
html?.let {
AnnotatedString.fromHtml(
html,
it,
linkStyles =
TextLinkStyles(
style =
@ -63,47 +106,116 @@ fun SimpleAlertDialog(
),
)
}
AlertDialog(
onDismissRequest = onDismissRequest,
title = { Text(text = title) },
text = {
if (annotatedString != null) {
Text(text = annotatedString)
} else {
Text(text = message.orEmpty())
onDismissRequest = { if (dismissable) onDismiss?.invoke() },
modifier = modifier,
icon = { icon?.let { Icon(it, contentDescription = null) } },
dismissButton = {
if (choices.isEmpty() && onDismiss != null) {
TextButton(
onClick = onDismiss,
modifier = Modifier.padding(horizontal = 16.dp),
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.onSurface),
) {
Text(text = dismissButtonText ?: stringResource(Res.string.cancel))
}
}
},
confirmButton = { TextButton(onClick = onConfirmRequest) { Text(stringResource(Res.string.okay)) } },
)
}
// For Rationale Dialogs
@Composable
fun MultipleChoiceAlertDialog(
title: String,
message: String?,
choices: Map<String, () -> Unit>,
onDismissRequest: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismissRequest,
title = { Text(text = title) },
confirmButton = {
if (choices.isEmpty() && onConfirm != null) {
TextButton(
onClick = onConfirm,
modifier = Modifier.padding(horizontal = 16.dp),
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.onSurface),
) {
Text(text = confirmButtonText ?: stringResource(Res.string.okay))
}
}
},
title = {
Text(
text = titleText,
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.titleLarge,
)
},
text = {
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
message?.let { Text(text = it, modifier = Modifier.padding(bottom = 8.dp)) }
choices.forEach { (choice, action) ->
Button(
modifier = Modifier.fillMaxWidth().padding(8.dp),
onClick = {
action()
onDismissRequest()
},
) {
Text(text = choice)
Column(modifier = if (choices.isNotEmpty()) Modifier.verticalScroll(rememberScrollState()) else Modifier) {
if (text != null) {
text()
} else if (htmlAnnotated != null) {
Text(text = htmlAnnotated)
} else if (messageText != null) {
Text(text = messageText)
}
if (choices.isNotEmpty()) {
Column(modifier = Modifier.padding(top = 16.dp)) {
choices.forEach { (choice, action) ->
Button(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
onClick = {
action()
onDismiss?.invoke()
},
) {
Text(text = choice)
}
}
}
}
}
},
confirmButton = {},
shape = RoundedCornerShape(16.dp),
)
}
/** A simplified [MeshtasticDialog] using only string resources. */
@Composable
fun MeshtasticResourceDialog(
modifier: Modifier = Modifier,
titleRes: StringResource,
messageRes: StringResource,
confirmTextRes: StringResource? = null,
dismissTextRes: StringResource? = null,
onConfirm: (() -> Unit)? = null,
onDismiss: (() -> Unit)? = null,
dismissable: Boolean = true,
) {
MeshtasticDialog(
modifier = modifier,
titleRes = titleRes,
messageRes = messageRes,
confirmTextRes = confirmTextRes,
dismissTextRes = dismissTextRes,
onConfirm = onConfirm,
onDismiss = onDismiss,
dismissable = dismissable,
)
}
/** A simplified [MeshtasticDialog] using a title resource and a plain text message. */
@Composable
fun MeshtasticTextDialog(
modifier: Modifier = Modifier,
titleRes: StringResource,
message: String,
confirmTextRes: StringResource? = null,
dismissTextRes: StringResource? = null,
onConfirm: (() -> Unit)? = null,
onDismiss: (() -> Unit)? = null,
dismissable: Boolean = true,
) {
MeshtasticDialog(
modifier = modifier,
titleRes = titleRes,
message = message,
confirmTextRes = confirmTextRes,
dismissTextRes = dismissTextRes,
onConfirm = onConfirm,
onDismiss = onDismiss,
dismissable = dismissable,
)
}

View file

@ -21,47 +21,18 @@ package org.meshtastic.core.ui.component
import android.graphics.Bitmap
import android.graphics.Color
import android.net.Uri
import android.util.Base64
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.core.net.toUri
import co.touchlab.kermit.Logger
import com.google.zxing.BarcodeFormat
import com.google.zxing.MultiFormatWriter
import com.google.zxing.WriterException
import com.google.zxing.common.BitMatrix
import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.util.CONTACT_SHARE_PATH
import org.meshtastic.core.model.util.CONTACT_URL_PREFIX
import org.meshtastic.core.model.util.MESHTASTIC_HOST
import org.meshtastic.core.model.util.getSharedContactUrl
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.share_contact
import org.meshtastic.proto.SharedContact
import org.meshtastic.proto.User
import java.net.MalformedURLException
/**
* Composable FloatingActionButton to initiate scanning a QR code for adding a contact. Handles camera permission
* requests using Accompanist Permissions.
*
* @param modifier Modifier for this composable.
*/
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
fun AddContactFAB(
sharedContact: SharedContact?,
modifier: Modifier = Modifier,
onResult: (Uri) -> Unit,
onShareChannels: (() -> Unit)? = null,
onDismissSharedContact: () -> Unit,
) {
sharedContact?.let { SharedContactImportDialog(sharedContact = it, onDismiss = onDismissSharedContact) }
ImportFab(onImport = onResult, modifier = modifier, onShareChannels = onShareChannels, isContactContext = true)
}
/**
* Displays a dialog with the contact's information as a QR code and URI.
@ -116,67 +87,3 @@ private fun BitMatrix.toBitmap(): Bitmap {
bitmap.setPixels(pixels, 0, width, 0, 0, width, height)
return bitmap
}
private const val BASE64FLAGS = Base64.URL_SAFE + Base64.NO_WRAP + Base64.NO_PADDING
@Suppress("MagicNumber")
@Throws(MalformedURLException::class)
fun Uri.toSharedContact(): SharedContact {
if (fragment.isNullOrBlank() || !host.equals(MESHTASTIC_HOST, true) || !path.equals(CONTACT_SHARE_PATH, true)) {
throw MalformedURLException("Not a valid Meshtastic URL: ${toString().take(40)}")
}
return SharedContact.ADAPTER.decode(Base64.decode(fragment!!, BASE64FLAGS).toByteString())
}
/** Converts a [SharedContact] to its corresponding URI representation. */
fun SharedContact.getSharedContactUrl(): Uri {
val bytes = SharedContact.ADAPTER.encode(this)
val enc = Base64.encodeToString(bytes, BASE64FLAGS)
return "$CONTACT_URL_PREFIX$enc".toUri()
}
/** Compares two [User] objects and returns a string detailing the differences. */
fun compareUsers(oldUser: User, newUser: User): String {
val changes = mutableListOf<String>()
if (oldUser.id != newUser.id) changes.add("id: ${oldUser.id} -> ${newUser.id}")
if (oldUser.long_name != newUser.long_name) changes.add("long_name: ${oldUser.long_name} -> ${newUser.long_name}")
if (oldUser.short_name != newUser.short_name) {
changes.add("short_name: ${oldUser.short_name} -> ${newUser.short_name}")
}
if (oldUser.macaddr != newUser.macaddr) {
changes.add("macaddr: ${oldUser.macaddr?.base64String()} -> ${newUser.macaddr?.base64String()}")
}
if (oldUser.hw_model != newUser.hw_model) changes.add("hw_model: ${oldUser.hw_model} -> ${newUser.hw_model}")
if (oldUser.is_licensed != newUser.is_licensed) {
changes.add("is_licensed: ${oldUser.is_licensed} -> ${newUser.is_licensed}")
}
if (oldUser.role != newUser.role) changes.add("role: ${oldUser.role} -> ${newUser.role}")
if (oldUser.public_key != newUser.public_key) {
changes.add("public_key: ${oldUser.public_key?.base64String()} -> ${newUser.public_key?.base64String()}")
}
return if (changes.isEmpty()) {
"No changes detected."
} else {
"Changes:\n" + changes.joinToString("\n")
}
}
/** Converts a [User] object to a string representation of its fields and values. */
fun userFieldsToString(user: User): String {
val fieldLines = mutableListOf<String>()
fieldLines.add("id: ${user.id}")
fieldLines.add("long_name: ${user.long_name}")
fieldLines.add("short_name: ${user.short_name}")
fieldLines.add("macaddr: ${user.macaddr?.base64String()}")
fieldLines.add("hw_model: ${user.hw_model}")
fieldLines.add("is_licensed: ${user.is_licensed}")
fieldLines.add("role: ${user.role}")
fieldLines.add("public_key: ${user.public_key?.base64String()}")
return fieldLines.joinToString("\n")
}
private fun ByteString.base64String(): String = Base64.encodeToString(this.toByteArray(), Base64.DEFAULT).trim()

View file

@ -17,23 +17,25 @@
package org.meshtastic.core.ui.component
import android.net.Uri
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Link
import androidx.compose.material.icons.rounded.Nfc
import androidx.compose.material.icons.twotone.QrCodeScanner
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import org.jetbrains.compose.resources.stringResource
@ -41,6 +43,8 @@ import org.meshtastic.core.barcode.rememberBarcodeScanner
import org.meshtastic.core.nfc.NfcScannerEffect
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.cancel
import org.meshtastic.core.strings.import_label
import org.meshtastic.core.strings.input_channel_url
import org.meshtastic.core.strings.input_shared_contact_url
import org.meshtastic.core.strings.nfc_disabled
import org.meshtastic.core.strings.okay
@ -55,39 +59,46 @@ import org.meshtastic.core.strings.share_channels_qr
import org.meshtastic.core.strings.url
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.QrCode2
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.core.ui.util.openNfcSettings
import org.meshtastic.proto.SharedContact
/**
* Unified Floating Action Button for importing Meshtastic data (Contacts, Channels, etc.) via NFC, QR, or URL.
* Unified Floating Action Button for importing Meshtastic data (Contacts, Channels, etc.) via NFC, QR, or URL. Handles
* the [SharedContactImportDialog] if a contact is pending import.
*
* @param modifier Modifier for this composable.
* @param onImport Callback when a valid Meshtastic URI is scanned or input.
* @param modifier Modifier for this composable.
* @param sharedContact Optional pending [SharedContact] to display an import dialog for.
* @param onDismissSharedContact Callback to clear the pending shared contact.
* @param onShareChannels Optional callback to trigger sharing channels.
* @param isContactContext Hint to customize UI strings for contact importing context.
* @param testTag Optional test tag for UI testing.
* @param importDialog Composable to display the import dialog. Defaults to [SharedContactImportDialog].
*/
@Suppress("LongMethod")
@Composable
fun ImportFab(
fun MeshtasticImportFAB(
onImport: (Uri) -> Unit,
modifier: Modifier = Modifier,
sharedContact: SharedContact? = null,
onDismissSharedContact: () -> Unit = {},
onShareChannels: (() -> Unit)? = null,
isContactContext: Boolean = false,
isContactContext: Boolean = true,
testTag: String? = null,
importDialog: @Composable (SharedContact, () -> Unit) -> Unit = { contact, dismiss ->
SharedContactImportDialog(sharedContact = contact, onDismiss = dismiss)
},
) {
sharedContact?.let { importDialog(it, onDismissSharedContact) }
var expanded by remember { mutableStateOf(false) }
var showUrlDialog by remember { mutableStateOf(false) }
var isNfcScanning by remember { mutableStateOf(false) }
var showNfcDisabledDialog by remember { mutableStateOf(false) }
val context = LocalContext.current
val barcodeScanner =
rememberBarcodeScanner(
onResult = { contents ->
contents?.toUri()?.let {
onImport(it)
isNfcScanning = false
}
},
)
val barcodeScanner = rememberBarcodeScanner(onResult = { contents -> contents?.toUri()?.let { onImport(it) } })
if (isNfcScanning) {
NfcScannerEffect(
@ -106,29 +117,25 @@ fun ImportFab(
}
if (showNfcDisabledDialog) {
AlertDialog(
onDismissRequest = { showNfcDisabledDialog = false },
title = { Text(stringResource(Res.string.scan_nfc)) },
text = { Text(stringResource(Res.string.nfc_disabled)) },
confirmButton = {
TextButton(
onClick = {
context.openNfcSettings()
showNfcDisabledDialog = false
},
) {
Text(stringResource(Res.string.open_settings))
}
},
dismissButton = {
TextButton(onClick = { showNfcDisabledDialog = false }) { Text(stringResource(Res.string.cancel)) }
MeshtasticDialog(
onDismiss = { showNfcDisabledDialog = false },
titleRes = Res.string.scan_nfc,
messageRes = Res.string.nfc_disabled,
onConfirm = {
context.openNfcSettings()
showNfcDisabledDialog = false
},
confirmTextRes = Res.string.open_settings,
dismissTextRes = Res.string.cancel,
)
}
if (showUrlDialog) {
InputUrlDialog(
title = stringResource(Res.string.input_shared_contact_url),
title =
stringResource(
if (isContactContext) Res.string.input_shared_contact_url else Res.string.input_channel_url,
),
onDismiss = { showUrlDialog = false },
onConfirm = { contents ->
onImport(contents.toUri())
@ -146,6 +153,7 @@ fun ImportFab(
),
icon = Icons.Rounded.Nfc,
onClick = { isNfcScanning = true },
testTag = "nfc_import",
),
MenuFABItem(
label =
@ -154,11 +162,16 @@ fun ImportFab(
),
icon = Icons.TwoTone.QrCodeScanner,
onClick = { barcodeScanner.startScan() },
testTag = "qr_import",
),
MenuFABItem(
label = stringResource(Res.string.input_shared_contact_url),
label =
stringResource(
if (isContactContext) Res.string.input_shared_contact_url else Res.string.input_channel_url,
),
icon = Icons.Rounded.Link,
onClick = { showUrlDialog = true },
testTag = "url_import",
),
)
@ -168,6 +181,7 @@ fun ImportFab(
label = stringResource(Res.string.share_channels_qr),
icon = MeshtasticIcons.QrCode2,
onClick = it,
testTag = "share_channels",
),
)
}
@ -177,26 +191,27 @@ fun ImportFab(
onExpandedChange = { expanded = it },
items = items,
modifier = modifier.padding(bottom = 16.dp),
contentDescription = stringResource(Res.string.import_label),
testTag = testTag,
)
}
@Composable
private fun NfcScanningDialog(onDismiss: () -> Unit) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(Res.string.scan_nfc)) },
text = { Text(stringResource(Res.string.scan_nfc_text)) },
confirmButton = {},
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(Res.string.cancel)) } },
MeshtasticDialog(
onDismiss = onDismiss,
titleRes = Res.string.scan_nfc,
messageRes = Res.string.scan_nfc_text,
dismissTextRes = Res.string.cancel,
)
}
@Composable
private fun InputUrlDialog(title: String, onDismiss: () -> Unit, onConfirm: (String) -> Unit) {
var urlText by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
MeshtasticDialog(
onDismiss = onDismiss,
title = title,
text = {
OutlinedTextField(
value = urlText,
@ -206,7 +221,33 @@ private fun InputUrlDialog(title: String, onDismiss: () -> Unit, onConfirm: (Str
maxLines = 4,
)
},
confirmButton = { TextButton(onClick = { onConfirm(urlText) }) { Text(stringResource(Res.string.okay)) } },
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(Res.string.cancel)) } },
onConfirm = { onConfirm(urlText) },
confirmTextRes = Res.string.okay,
dismissTextRes = Res.string.cancel,
)
}
@Preview(showBackground = true, name = "Contact Context")
@Composable
fun PreviewImportFABContact() {
AppTheme {
Box(modifier = Modifier.fillMaxSize().padding(16.dp)) {
MeshtasticImportFAB(onImport = {}, modifier = Modifier.align(Alignment.BottomEnd), isContactContext = true)
}
}
}
@Preview(showBackground = true, name = "Channel Context with Sharing")
@Composable
fun PreviewImportFABChannel() {
AppTheme {
Box(modifier = Modifier.fillMaxSize().padding(16.dp)) {
MeshtasticImportFAB(
onImport = {},
onShareChannels = {},
modifier = Modifier.align(Alignment.BottomEnd),
isContactContext = false,
)
}
}
}

View file

@ -30,13 +30,11 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@ -48,7 +46,6 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@ -188,13 +185,11 @@ fun IndoorAirQuality(iaq: Int?, displayMode: IaqDisplayMode = IaqDisplayMode.Pil
}
}
if (isLegendOpen) {
AlertDialog(
onDismissRequest = { isLegendOpen = false },
shape = RoundedCornerShape(16.dp),
MeshtasticDialog(
onDismiss = { isLegendOpen = false },
dismissText = stringResource(Res.string.close),
title = stringResource(Res.string.indoor_air_quality_iaq),
text = { IAQScale() },
confirmButton = {
TextButton(onClick = { isLegendOpen = false }) { Text(text = stringResource(Res.string.close)) }
},
)
}
}
@ -208,12 +203,6 @@ fun IndoorAirQuality(iaq: Int?, displayMode: IaqDisplayMode = IaqDisplayMode.Pil
@Composable
fun IAQScale(modifier: Modifier = Modifier) {
Column(modifier = modifier.padding(16.dp), horizontalAlignment = Alignment.Start) {
Text(
text = stringResource(Res.string.indoor_air_quality_iaq),
style =
MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold, textAlign = TextAlign.Center),
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(16.dp))
for (iaq in Iaq.entries) {
Row(verticalAlignment = Alignment.CenterVertically) {

View file

@ -30,6 +30,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.testTag
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
@ -38,9 +39,11 @@ fun MenuFAB(
onExpandedChange: (Boolean) -> Unit,
items: List<MenuFABItem>,
modifier: Modifier = Modifier,
contentDescription: String? = null,
testTag: String? = null,
) {
FloatingActionButtonMenu(
modifier = modifier,
modifier = modifier.then(if (testTag != null) Modifier.testTag(testTag) else Modifier),
expanded = expanded,
button = {
ToggleFloatingActionButton(
@ -48,7 +51,7 @@ fun MenuFAB(
onCheckedChange = onExpandedChange,
content = {
val imageVector = if (expanded) Icons.Filled.Close else Icons.AutoMirrored.Rounded.OfflineShare
Icon(imageVector = imageVector, contentDescription = null)
Icon(imageVector = imageVector, contentDescription = contentDescription)
},
containerColor = ToggleFloatingActionButtonDefaults.containerColor(),
)
@ -57,6 +60,7 @@ fun MenuFAB(
) {
items.forEach { item ->
FloatingActionButtonMenuItem(
modifier = if (item.testTag != null) Modifier.testTag(item.testTag) else Modifier,
onClick = {
item.onClick()
onExpandedChange(false)
@ -68,4 +72,4 @@ fun MenuFAB(
}
}
data class MenuFABItem(val label: String, val icon: ImageVector, val onClick: () -> Unit)
data class MenuFABItem(val label: String, val icon: ImageVector, val onClick: () -> Unit, val testTag: String? = null)

View file

@ -29,12 +29,10 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.twotone.ContentCopy
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.rememberCoroutineScope
@ -48,7 +46,6 @@ import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.strings.Res
@ -84,11 +81,11 @@ fun QrDialog(title: String, uri: Uri, qrCode: Bitmap?, onDismiss: () -> Unit) {
}
}
AlertDialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false),
modifier = Modifier.padding(16.dp),
title = { Text(text = title) },
MeshtasticDialog(
onDismiss = onDismiss,
title = title,
confirmText = stringResource(Res.string.okay),
onConfirm = onDismiss,
text = {
Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
if (qrCode != null) {
@ -126,6 +123,5 @@ fun QrDialog(title: String, uri: Uri, qrCode: Bitmap?, onDismiss: () -> Unit) {
}
}
},
confirmButton = { TextButton(onClick = onDismiss) { Text(text = stringResource(Res.string.okay)) } },
)
}

View file

@ -1,109 +0,0 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ui.component
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.cancel
import org.meshtastic.core.strings.message
import org.meshtastic.core.strings.okay
import org.meshtastic.core.strings.sample_message
import org.meshtastic.core.ui.theme.AppTheme
@Composable
fun SimpleAlertDialog(
title: StringResource,
text: @Composable (() -> Unit)? = null,
confirmText: String? = null,
onConfirm: (() -> Unit)? = null,
dismissText: String? = null,
onDismiss: () -> Unit,
) = AlertDialog(
onDismissRequest = onDismiss,
dismissButton = {
TextButton(
onClick = onDismiss,
modifier = Modifier.padding(horizontal = 16.dp),
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.onSurface),
) {
Text(text = dismissText ?: stringResource(Res.string.cancel))
}
},
confirmButton = {
onConfirm?.let {
TextButton(
onClick = onConfirm,
modifier = Modifier.padding(horizontal = 16.dp),
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.onSurface),
) {
Text(text = confirmText ?: stringResource(Res.string.okay))
}
}
},
title = {
Text(text = stringResource(title), modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center)
},
text = text,
shape = RoundedCornerShape(16.dp),
)
@Composable
fun SimpleAlertDialog(
title: StringResource,
text: StringResource,
onConfirm: (() -> Unit)? = null,
onDismiss: () -> Unit = {},
) = SimpleAlertDialog(
onConfirm = onConfirm,
onDismiss = onDismiss,
title = title,
text = { Text(text = stringResource(text), modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center) },
)
@Composable
fun SimpleAlertDialog(
title: StringResource,
text: String,
onConfirm: (() -> Unit)? = null,
onDismiss: () -> Unit = {},
) = SimpleAlertDialog(
onConfirm = onConfirm,
onDismiss = onDismiss,
title = title,
text = { Text(text = text, modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center) },
)
@PreviewLightDark
@Composable
private fun SimpleAlertDialogPreview() {
AppTheme { SimpleAlertDialog(title = Res.string.message, text = Res.string.sample_message) }
}

View file

@ -25,15 +25,15 @@ import androidx.compose.runtime.getValue
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.util.compareUsers
import org.meshtastic.core.model.util.userFieldsToString
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.cancel
import org.meshtastic.core.strings.import_known_shared_contact_text
import org.meshtastic.core.strings.import_label
import org.meshtastic.core.strings.import_shared_contact
import org.meshtastic.core.strings.public_key_changed
import org.meshtastic.core.ui.component.SimpleAlertDialog
import org.meshtastic.core.ui.component.compareUsers
import org.meshtastic.core.ui.component.userFieldsToString
import org.meshtastic.core.ui.component.MeshtasticDialog
import org.meshtastic.proto.SharedContact
import org.meshtastic.proto.User
@ -49,8 +49,8 @@ fun SharedContactDialog(
val nodeNum = sharedContact.node_num
val node = unfilteredNodes.find { it.num == nodeNum }
SimpleAlertDialog(
title = Res.string.import_shared_contact,
MeshtasticDialog(
titleRes = Res.string.import_shared_contact,
text = {
Column {
if (node != null) {

View file

@ -0,0 +1,104 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ui.util
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.vector.ImageVector
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import org.jetbrains.compose.resources.StringResource
import javax.inject.Inject
import javax.inject.Singleton
fun interface ComposableContent {
@Composable fun Content()
}
/**
* A global manager for displaying alerts across the application. This allows ViewModels to trigger alerts without
* direct dependencies on UI components.
*/
@Singleton
class AlertManager @Inject constructor() {
data class AlertData(
val title: String? = null,
val titleRes: StringResource? = null,
val message: String? = null,
val messageRes: StringResource? = null,
val composableMessage: ComposableContent? = null,
val html: String? = null,
val icon: ImageVector? = null,
val onConfirm: (() -> Unit)? = null,
val onDismiss: (() -> Unit)? = null,
val confirmText: String? = null,
val confirmTextRes: StringResource? = null,
val dismissText: String? = null,
val dismissTextRes: StringResource? = null,
val choices: Map<String, () -> Unit> = emptyMap(),
val dismissable: Boolean = true,
)
private val _currentAlert = MutableStateFlow<AlertData?>(null)
val currentAlert = _currentAlert.asStateFlow()
fun showAlert(
title: String? = null,
titleRes: StringResource? = null,
message: String? = null,
messageRes: StringResource? = null,
composableMessage: ComposableContent? = null,
html: String? = null,
icon: ImageVector? = null,
onConfirm: (() -> Unit)? = {},
onDismiss: (() -> Unit)? = null,
confirmText: String? = null,
confirmTextRes: StringResource? = null,
dismissText: String? = null,
dismissTextRes: StringResource? = null,
choices: Map<String, () -> Unit> = emptyMap(),
dismissable: Boolean = true,
) {
_currentAlert.value =
AlertData(
title = title,
titleRes = titleRes,
message = message,
messageRes = messageRes,
composableMessage = composableMessage,
html = html,
icon = icon,
onConfirm = {
onConfirm?.invoke()
dismissAlert()
},
onDismiss = {
onDismiss?.invoke()
dismissAlert()
},
confirmText = confirmText,
confirmTextRes = confirmTextRes,
dismissText = dismissText,
dismissTextRes = dismissTextRes,
choices = choices,
dismissable = dismissable,
)
}
fun dismissAlert() {
_currentAlert.value = null
}
}

View file

@ -0,0 +1,131 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ui.util
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Warning
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import org.meshtastic.core.ui.component.MeshtasticDialog
import org.meshtastic.core.ui.theme.AppTheme
/** A helper component that renders an [AlertManager.AlertData] using the same logic as MainScreen. */
@Composable
fun AlertPreviewRenderer(data: AlertManager.AlertData) {
MeshtasticDialog(
title = data.title,
titleRes = data.titleRes,
message = data.message,
messageRes = data.messageRes,
html = data.html,
icon = data.icon,
text = data.composableMessage?.let { msg -> { msg.Content() } },
confirmText = data.confirmText,
confirmTextRes = data.confirmTextRes,
onConfirm = data.onConfirm,
dismissText = data.dismissText,
dismissTextRes = data.dismissTextRes,
onDismiss = data.onDismiss,
choices = data.choices,
dismissable = data.dismissable,
)
}
@Preview(showBackground = true, name = "Simple Text Alert")
@Composable
fun PreviewTextAlert() {
AppTheme {
Box(modifier = Modifier.fillMaxSize()) {
AlertPreviewRenderer(
AlertManager.AlertData(
title = "Firmware Update",
message = "A new version is available. Would you like to update now?",
),
)
}
}
}
@Preview(showBackground = true, name = "Icon and Text Alert")
@Composable
fun PreviewIconAlert() {
AppTheme {
Box(modifier = Modifier.fillMaxSize()) {
AlertPreviewRenderer(
AlertManager.AlertData(
title = "Warning",
message = "This action cannot be undone.",
icon = Icons.Rounded.Warning,
),
)
}
}
}
@Preview(showBackground = true, name = "HTML Alert")
@Composable
fun PreviewHtmlAlert() {
AppTheme {
Box(modifier = Modifier.fillMaxSize()) {
AlertPreviewRenderer(
AlertManager.AlertData(title = "Release Notes", html = "Enhanced range and better battery life"),
)
}
}
}
@Preview(showBackground = true, name = "Multiple Choice Alert")
@Composable
fun PreviewMultipleChoiceAlert() {
AppTheme {
Box(modifier = Modifier.fillMaxSize()) {
AlertPreviewRenderer(
AlertManager.AlertData(
title = "Select Channel",
message = "Pick a channel to join:",
choices = mapOf("Public" to {}, "Private" to {}, "Emergency" to {}),
),
)
}
}
}
@Preview(showBackground = true, name = "Composable Content Alert")
@Composable
fun PreviewComposableAlert() {
AppTheme {
Box(modifier = Modifier.fillMaxSize()) {
AlertPreviewRenderer(
AlertManager.AlertData(
title = "Custom Content",
composableMessage = {
Column(modifier = Modifier.fillMaxWidth()) {
Text("This is a custom composable")
Text("With multiple lines and styles")
}
},
),
)
}
}
}

View file

@ -0,0 +1,105 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ui.util
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle
import org.meshtastic.core.ui.component.SNR_FAIR_THRESHOLD
import org.meshtastic.core.ui.component.SNR_GOOD_THRESHOLD
/**
* Converts a raw traceroute string into an [AnnotatedString] with SNR values highlighted according to their quality.
*/
fun annotateTraceroute(
inString: String?,
statusGreen: Color,
statusYellow: Color,
statusOrange: Color,
): AnnotatedString {
if (inString == null) return buildAnnotatedString { append("") }
return buildAnnotatedString {
inString.lines().forEachIndexed { i, line ->
if (i > 0) append("\n")
// Example line: "⇊ -8.75 dB SNR"
if (line.trimStart().startsWith("")) {
val snrRegex = Regex("""⇊ ([\d.?-]+) dB""")
val snrMatch = snrRegex.find(line)
val snrValue = snrMatch?.groupValues?.getOrNull(1)?.toFloatOrNull()
if (snrValue != null) {
val snrColor =
when {
snrValue >= SNR_GOOD_THRESHOLD -> statusGreen
snrValue >= SNR_FAIR_THRESHOLD -> statusYellow
else -> statusOrange
}
withStyle(style = SpanStyle(color = snrColor, fontWeight = FontWeight.Bold)) { append(line) }
} else {
append(line)
}
} else {
append(line)
}
}
}
}
/**
* Converts a raw neighbor info string into an [AnnotatedString] with SNR values highlighted according to their quality.
*/
fun annotateNeighborInfo(
inString: String?,
statusGreen: Color,
statusYellow: Color,
statusOrange: Color,
): AnnotatedString {
if (inString == null) return buildAnnotatedString { append("") }
return buildAnnotatedString {
inString.lines().forEachIndexed { i, line ->
if (i > 0) append("\n")
// Example line: "• NodeName (SNR: 5.5)"
if (line.contains("(SNR: ")) {
val snrRegex = Regex("""\(SNR: ([\d.?-]+)\)""")
val snrMatch = snrRegex.find(line)
val snrValue = snrMatch?.groupValues?.getOrNull(1)?.toFloatOrNull()
if (snrValue != null) {
val snrColor =
when {
snrValue >= SNR_GOOD_THRESHOLD -> statusGreen
snrValue >= SNR_FAIR_THRESHOLD -> statusYellow
else -> statusOrange
}
val snrPrefix = "(SNR: "
append(line.substring(0, line.indexOf(snrPrefix) + snrPrefix.length))
withStyle(style = SpanStyle(color = snrColor, fontWeight = FontWeight.Bold)) { append("$snrValue") }
append(")")
} else {
append(line)
}
} else {
append(line)
}
}
}
}

View file

@ -0,0 +1,71 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ui.util
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Test
class AlertManagerTest {
private val alertManager = AlertManager()
@Test
fun `showAlert updates currentAlert flow`() {
val title = "Test Title"
val message = "Test Message"
alertManager.showAlert(title = title, message = message)
val alertData = alertManager.currentAlert.value
assertNotNull(alertData)
assertEquals(title, alertData?.title)
assertEquals(message, alertData?.message)
}
@Test
fun `dismissAlert clears currentAlert flow`() {
alertManager.showAlert(title = "Title")
assertNotNull(alertManager.currentAlert.value)
alertManager.dismissAlert()
assertNull(alertManager.currentAlert.value)
}
@Test
fun `onConfirm triggers and dismisses alert`() {
var confirmClicked = false
alertManager.showAlert(title = "Confirm Test", onConfirm = { confirmClicked = true })
alertManager.currentAlert.value?.onConfirm?.invoke()
assertEquals(true, confirmClicked)
assertNull(alertManager.currentAlert.value)
}
@Test
fun `onDismiss triggers and dismisses alert`() {
var dismissClicked = false
alertManager.showAlert(title = "Dismiss Test", onDismiss = { dismissClicked = true })
alertManager.currentAlert.value?.onDismiss?.invoke()
assertEquals(true, dismissClicked)
assertNull(alertManager.currentAlert.value)
}
}

View file

@ -42,7 +42,6 @@ import androidx.compose.foundation.text.TextAutoSize
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
@ -136,6 +135,7 @@ import org.meshtastic.core.strings.i_know_what_i_m_doing
import org.meshtastic.core.strings.learn_more
import org.meshtastic.core.strings.okay
import org.meshtastic.core.strings.save
import org.meshtastic.core.ui.component.MeshtasticDialog
import org.meshtastic.core.ui.icon.Bluetooth
import org.meshtastic.core.ui.icon.CheckCircle
import org.meshtastic.core.ui.icon.CloudDownload
@ -208,24 +208,17 @@ fun FirmwareUpdateScreen(
androidx.activity.compose.BackHandler(enabled = shouldKeepFirmwareScreenOn(state)) { showExitConfirmation = true }
if (showExitConfirmation) {
AlertDialog(
onDismissRequest = { showExitConfirmation = false },
title = { Text(stringResource(Res.string.firmware_update_disclaimer_title)) },
text = { Text(stringResource(Res.string.firmware_update_disconnect_warning)) },
confirmButton = {
TextButton(
onClick = {
showExitConfirmation = false
viewModel.cancelUpdate()
navController.navigateUp()
},
) {
Text(stringResource(Res.string.firmware_update_retry)) // Use "Cancel & Exit" if available
}
},
dismissButton = {
TextButton(onClick = { showExitConfirmation = false }) { Text(stringResource(Res.string.back)) }
MeshtasticDialog(
onDismiss = { showExitConfirmation = false },
title = stringResource(Res.string.firmware_update_disclaimer_title),
message = stringResource(Res.string.firmware_update_disconnect_warning),
confirmText = stringResource(Res.string.firmware_update_retry),
onConfirm = {
showExitConfirmation = false
viewModel.cancelUpdate()
navController.navigateUp()
},
dismissText = stringResource(Res.string.back),
)
}
@ -387,7 +380,7 @@ private fun ReadyState(
if (showDisclaimer) {
DisclaimerDialog(
updateMethod = state.updateMethod,
onDismissRequest = { showDisclaimer = false },
onDismiss = { showDisclaimer = false },
onConfirm = {
showDisclaimer = false
if (selectedReleaseType == FirmwareReleaseType.LOCAL) {
@ -450,10 +443,13 @@ private fun ReadyState(
}
@Composable
private fun DisclaimerDialog(updateMethod: FirmwareUpdateMethod, onDismissRequest: () -> Unit, onConfirm: () -> Unit) {
AlertDialog(
onDismissRequest = onDismissRequest,
title = { Text(stringResource(Res.string.firmware_update_disclaimer_title)) },
private fun DisclaimerDialog(updateMethod: FirmwareUpdateMethod, onDismiss: () -> Unit, onConfirm: () -> Unit) {
MeshtasticDialog(
onDismiss = onDismiss,
title = stringResource(Res.string.firmware_update_disclaimer_title),
confirmText = stringResource(Res.string.i_know_what_i_m_doing),
onConfirm = onConfirm,
dismissText = stringResource(Res.string.cancel),
text = {
Column(modifier = Modifier.animateContentSize(), horizontalAlignment = Alignment.CenterHorizontally) {
Text(stringResource(Res.string.firmware_update_disclaimer_text))
@ -478,8 +474,6 @@ private fun DisclaimerDialog(updateMethod: FirmwareUpdateMethod, onDismissReques
}
}
},
confirmButton = { TextButton(onClick = onConfirm) { Text(stringResource(Res.string.i_know_what_i_m_doing)) } },
dismissButton = { TextButton(onClick = onDismissRequest) { Text(stringResource(Res.string.cancel)) } },
)
}
@ -757,20 +751,16 @@ private fun AwaitingFileSaveState(state: FirmwareUpdateState.AwaitingFileSave, o
var showDialog by remember { mutableStateOf(true) }
if (showDialog) {
AlertDialog(
onDismissRequest = { /* No-op to force user to acknowledge */ },
title = { Text(stringResource(Res.string.firmware_update_usb_instruction_title)) },
text = { Text(stringResource(Res.string.firmware_update_usb_instruction_text)) },
confirmButton = {
TextButton(
onClick = {
showDialog = false
onSaveFile(state.fileName)
},
) {
Text(stringResource(Res.string.okay))
}
MeshtasticDialog(
onDismiss = { /* No-op to force user to acknowledge */ },
title = stringResource(Res.string.firmware_update_usb_instruction_title),
confirmText = stringResource(Res.string.okay),
onConfirm = {
showDialog = false
onSaveFile(state.fileName)
},
text = { Text(stringResource(Res.string.firmware_update_usb_instruction_text)) },
dismissable = false,
)
}

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.map.component
import androidx.compose.foundation.layout.Arrangement
@ -28,7 +27,6 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
@ -37,7 +35,6 @@ import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@ -67,6 +64,7 @@ import org.meshtastic.core.strings.url_cannot_be_empty
import org.meshtastic.core.strings.url_must_contain_placeholders
import org.meshtastic.core.strings.url_template
import org.meshtastic.core.strings.url_template_hint
import org.meshtastic.core.ui.component.MeshtasticDialog
import org.meshtastic.core.ui.util.showToast
import org.meshtastic.feature.map.MapViewModel
@ -191,16 +189,13 @@ private fun AddEditCustomTileProviderDialog(
}
}
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text(
if (config == null) {
stringResource(Res.string.add_custom_tile_source)
} else {
stringResource(Res.string.edit_custom_tile_source)
},
)
MeshtasticDialog(
onDismiss = onDismiss,
title =
if (config == null) {
stringResource(Res.string.add_custom_tile_source)
} else {
stringResource(Res.string.edit_custom_tile_source)
},
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
@ -235,8 +230,9 @@ private fun AddEditCustomTileProviderDialog(
)
}
},
confirmButton = { Button(onClick = { validateAndSave() }) { Text(stringResource(Res.string.save)) } },
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(Res.string.cancel)) } },
onConfirm = { validateAndSave() },
confirmTextRes = Res.string.save,
dismissTextRes = Res.string.cancel,
)
}

View file

@ -19,8 +19,6 @@ package org.meshtastic.feature.messaging
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@ -36,6 +34,7 @@ import org.meshtastic.core.strings.close
import org.meshtastic.core.strings.message_retry_count
import org.meshtastic.core.strings.relays
import org.meshtastic.core.strings.resend
import org.meshtastic.core.ui.component.MeshtasticDialog
@Suppress("UnusedParameter")
@Composable
@ -49,28 +48,12 @@ fun DeliveryInfo(
maxRetries: Int = 0,
onConfirm: (() -> Unit) = {},
onDismiss: () -> Unit = {},
) = AlertDialog(
onDismissRequest = onDismiss,
dismissButton = {
FilledTonalButton(onClick = onDismiss, modifier = Modifier.padding(horizontal = 16.dp)) {
Text(text = stringResource(Res.string.close))
}
},
confirmButton = {
if (resendOption) {
FilledTonalButton(onClick = onConfirm, modifier = Modifier.padding(horizontal = 16.dp)) {
Text(text = stringResource(Res.string.resend))
}
}
},
title = {
Text(
text = stringResource(title),
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.headlineSmall,
)
},
) = MeshtasticDialog(
title = stringResource(title),
onDismiss = onDismiss,
dismissText = stringResource(Res.string.close),
confirmText = if (resendOption) stringResource(Res.string.resend) else null,
onConfirm = if (resendOption) onConfirm else null,
text = {
Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
text?.let {
@ -98,6 +81,4 @@ fun DeliveryInfo(
}
}
},
shape = androidx.compose.foundation.shape.RoundedCornerShape(16.dp),
containerColor = MaterialTheme.colorScheme.surface,
)

View file

@ -61,7 +61,6 @@ import androidx.compose.material.icons.rounded.SelectAll
import androidx.compose.material.icons.rounded.SpeakerNotesOff
import androidx.compose.material.icons.rounded.Visibility
import androidx.compose.material.icons.rounded.VisibilityOff
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
@ -74,7 +73,6 @@ import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@ -110,7 +108,6 @@ import org.meshtastic.core.model.util.getChannel
import org.meshtastic.core.service.RetryEvent
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.alert_bell_text
import org.meshtastic.core.strings.cancel
import org.meshtastic.core.strings.cancel_reply
import org.meshtastic.core.strings.clear_selection
import org.meshtastic.core.strings.copy
@ -135,6 +132,7 @@ import org.meshtastic.core.strings.send
import org.meshtastic.core.strings.type_a_message
import org.meshtastic.core.strings.unknown
import org.meshtastic.core.strings.unknown_channel
import org.meshtastic.core.ui.component.MeshtasticTextDialog
import org.meshtastic.core.ui.component.NodeKeyStatusIcon
import org.meshtastic.core.ui.component.SecurityIcon
import org.meshtastic.core.ui.component.SharedContactDialog
@ -241,11 +239,6 @@ fun MessageScreen(
val listState = rememberLazyListState()
val lastReadMessageTimestamp by
remember(contactKey, contactSettings) {
derivedStateOf { contactSettings[contactKey]?.lastReadMessageTimestamp }
}
// Track unread messages using lightweight metadata queries
val hasUnreadMessages by viewModel.hasUnreadMessages(contactKey).collectAsStateWithLifecycle(initialValue = false)
val firstUnreadMessageUuid by
@ -307,7 +300,11 @@ fun MessageScreen(
is MessageScreenEvent.NavigateToNodeDetails -> navigateToNodeDetails(event.nodeNum)
MessageScreenEvent.NavigateBack -> onNavigateBack()
is MessageScreenEvent.CopyToClipboard -> {
clipboardManager.nativeClipboard.setPrimaryClip(ClipData.newPlainText(event.text, event.text))
coroutineScope.launch {
clipboardManager.setClipEntry(
androidx.compose.ui.platform.ClipEntry(ClipData.newPlainText(event.text, event.text)),
)
}
selectedMessageIds.value = emptySet()
}
}
@ -644,13 +641,12 @@ private fun String.limitBytes(maxBytes: Int): String {
private fun DeleteMessageDialog(count: Int, onConfirm: () -> Unit, onDismiss: () -> Unit) {
val deleteMessagesString = pluralStringResource(Res.plurals.delete_messages, count, count)
AlertDialog(
onDismissRequest = onDismiss,
shape = RoundedCornerShape(16.dp),
title = { Text(stringResource(Res.string.delete_messages_title)) },
text = { Text(text = deleteMessagesString) },
confirmButton = { TextButton(onClick = onConfirm) { Text(stringResource(Res.string.delete)) } },
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(Res.string.cancel)) } },
MeshtasticTextDialog(
titleRes = Res.string.delete_messages_title,
message = deleteMessagesString,
confirmTextRes = Res.string.delete,
onConfirm = onConfirm,
onDismiss = onDismiss,
)
}

View file

@ -16,11 +16,9 @@
*/
package org.meshtastic.feature.messaging
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@ -38,8 +36,6 @@ import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.DragHandle
import androidx.compose.material.icons.rounded.Edit
import androidx.compose.material.icons.rounded.FastForward
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
@ -63,8 +59,6 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusEvent
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
@ -84,6 +78,7 @@ import org.meshtastic.core.strings.quick_chat_instant
import org.meshtastic.core.strings.quick_chat_new
import org.meshtastic.core.strings.save
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.MeshtasticDialog
import org.meshtastic.core.ui.component.dragContainer
import org.meshtastic.core.ui.component.dragDropItemsIndexed
import org.meshtastic.core.ui.component.rememberDragDropState
@ -185,20 +180,17 @@ private fun EditQuickChatDialog(
}
}
AlertDialog(
onDismissRequest = onDismiss,
MeshtasticDialog(
onDismiss = onDismiss,
title = stringResource(title),
confirmText = stringResource(Res.string.save),
onConfirm = {
onSave(actionInput)
onDismiss()
},
dismissText = stringResource(Res.string.cancel),
text = {
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text = stringResource(title),
modifier = Modifier.fillMaxWidth(),
style =
MaterialTheme.typography.titleLarge.copy(
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
),
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextFieldWithCounter(
@ -257,39 +249,19 @@ private fun EditQuickChatDialog(
},
)
}
}
},
confirmButton = {
FlowRow(
modifier = Modifier.fillMaxWidth().padding(start = 24.dp, end = 24.dp, bottom = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
TextButton(modifier = Modifier.weight(1f), onClick = onDismiss) {
Text(stringResource(Res.string.cancel))
}
if (!newQuickChat) {
Button(
modifier = Modifier.weight(1f),
Spacer(modifier = Modifier.height(16.dp))
TextButton(
modifier = Modifier.fillMaxWidth(),
onClick = {
onDelete(actionInput)
onDismiss()
},
) {
Text(text = stringResource(Res.string.delete))
Text(text = stringResource(Res.string.delete), color = MaterialTheme.colorScheme.error)
}
}
Button(
modifier = Modifier.weight(1f),
onClick = {
onSave(actionInput)
onDismiss()
},
enabled = actionInput.name.isNotEmpty() && actionInput.message.isNotEmpty(),
) {
Text(text = stringResource(Res.string.save))
}
}
},
)

View file

@ -19,8 +19,6 @@ package org.meshtastic.feature.messaging.component
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@ -42,6 +40,7 @@ import org.meshtastic.core.strings.retry_dialog_confirm
import org.meshtastic.core.strings.retry_dialog_message
import org.meshtastic.core.strings.retry_dialog_reaction_message
import org.meshtastic.core.strings.retry_dialog_title
import org.meshtastic.core.ui.component.MeshtasticDialog
private const val COUNTDOWN_DELAY_MS = 1000L
private const val MESSAGE_PREVIEW_LENGTH = 50
@ -120,22 +119,13 @@ fun RetryConfirmationDialog(
onTimeout()
}
AlertDialog(
onDismissRequest = { /* Prevent dismissal by clicking outside */ },
dismissButton = {
FilledTonalButton(onClick = onCancel) { Text(text = stringResource(Res.string.retry_dialog_cancel)) }
},
confirmButton = {
FilledTonalButton(onClick = onConfirm) { Text(text = stringResource(Res.string.retry_dialog_confirm)) }
},
title = {
Text(
text = stringResource(Res.string.retry_dialog_title),
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.headlineSmall,
)
},
MeshtasticDialog(
onDismiss = onCancel,
dismissText = stringResource(Res.string.retry_dialog_cancel),
confirmText = stringResource(Res.string.retry_dialog_confirm),
onConfirm = onConfirm,
title = stringResource(Res.string.retry_dialog_title),
text = { RetryDialogContent(retryEvent, timeRemaining) },
dismissable = false,
)
}

View file

@ -41,10 +41,6 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.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.graphics.Color
@ -66,13 +62,6 @@ import org.meshtastic.feature.node.model.MetricsState
import org.meshtastic.feature.node.model.NodeDetailAction
import org.meshtastic.feature.node.model.isEffectivelyUnmessageable
private enum class DialogType {
FAVORITE,
IGNORE,
MUTE,
REMOVE,
}
@Composable
fun DeviceActions(
node: Node,
@ -84,38 +73,13 @@ fun DeviceActions(
modifier: Modifier = Modifier,
isLocal: Boolean = false,
) {
var displayedDialog by remember { mutableStateOf<DialogType?>(null) }
NodeActionDialogs(
node = node,
displayFavoriteDialog = displayedDialog == DialogType.FAVORITE,
displayIgnoreDialog = displayedDialog == DialogType.IGNORE,
displayMuteDialog = displayedDialog == DialogType.MUTE,
displayRemoveDialog = displayedDialog == DialogType.REMOVE,
onDismissMenuRequest = { displayedDialog = null },
onConfirmFavorite = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.Favorite(it))) },
onConfirmIgnore = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.Ignore(it))) },
onConfirmMute = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.Mute(it))) },
onConfirmRemove = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.Remove(it))) },
)
Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(16.dp)) {
SectionCard(title = Res.string.actions) {
PrimaryActionsRow(
node = node,
isLocal = isLocal,
onAction = onAction,
onFavoriteClick = { displayedDialog = DialogType.FAVORITE },
)
PrimaryActionsRow(node = node, isLocal = isLocal, onAction = onAction)
if (!isLocal) {
SectionDivider(Modifier.padding(vertical = 8.dp))
ManagementActions(
node = node,
onIgnoreClick = { displayedDialog = DialogType.IGNORE },
onMuteClick = { displayedDialog = DialogType.MUTE },
onRemoveClick = { displayedDialog = DialogType.REMOVE },
)
ManagementActions(node = node, onAction = onAction)
}
}
@ -132,12 +96,7 @@ fun DeviceActions(
}
@Composable
private fun PrimaryActionsRow(
node: Node,
isLocal: Boolean,
onAction: (NodeDetailAction) -> Unit,
onFavoriteClick: () -> Unit,
) {
private fun PrimaryActionsRow(node: Node, isLocal: Boolean, onAction: (NodeDetailAction) -> Unit) {
Row(
modifier = Modifier.padding(horizontal = 20.dp).fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
@ -173,7 +132,10 @@ private fun PrimaryActionsRow(
}
if (!isLocal) {
IconToggleButton(checked = node.isFavorite, onCheckedChange = { onFavoriteClick() }) {
IconToggleButton(
checked = node.isFavorite,
onCheckedChange = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.Favorite(node))) },
) {
Icon(
imageVector = if (node.isFavorite) Icons.Rounded.Star else Icons.Rounded.StarBorder,
contentDescription = stringResource(Res.string.favorite),
@ -185,12 +147,7 @@ private fun PrimaryActionsRow(
}
@Composable
private fun ManagementActions(
node: Node,
onIgnoreClick: () -> Unit,
onMuteClick: () -> Unit,
onRemoveClick: () -> Unit,
) {
private fun ManagementActions(node: Node, onAction: (NodeDetailAction) -> Unit) {
Column {
SwitchListItem(
text = stringResource(Res.string.ignore),
@ -201,7 +158,7 @@ private fun ManagementActions(
Icons.AutoMirrored.Default.VolumeUp
},
checked = node.isIgnored,
onClick = onIgnoreClick,
onClick = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.Ignore(node))) },
)
if (node.capabilities.canMuteNode) {
@ -214,7 +171,7 @@ private fun ManagementActions(
Icons.AutoMirrored.Default.VolumeUp
},
checked = node.isMuted,
onClick = onMuteClick,
onClick = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.Mute(node))) },
)
}
@ -224,7 +181,7 @@ private fun ManagementActions(
trailingIcon = null,
textColor = MaterialTheme.colorScheme.error,
leadingIconTint = MaterialTheme.colorScheme.error,
onClick = onRemoveClick,
onClick = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.Remove(node))) },
)
}
}

View file

@ -1,133 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.node.component
import androidx.compose.runtime.Composable
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.favorite
import org.meshtastic.core.strings.favorite_add
import org.meshtastic.core.strings.favorite_remove
import org.meshtastic.core.strings.ignore
import org.meshtastic.core.strings.ignore_add
import org.meshtastic.core.strings.ignore_remove
import org.meshtastic.core.strings.mute_add
import org.meshtastic.core.strings.mute_notifications
import org.meshtastic.core.strings.mute_remove
import org.meshtastic.core.strings.remove
import org.meshtastic.core.strings.remove_node_text
import org.meshtastic.core.strings.unmute
import org.meshtastic.core.ui.component.SimpleAlertDialog
@Composable
fun NodeActionDialogs(
node: Node,
displayFavoriteDialog: Boolean,
displayIgnoreDialog: Boolean,
displayMuteDialog: Boolean,
displayRemoveDialog: Boolean,
onDismissMenuRequest: () -> Unit,
onConfirmFavorite: (Node) -> Unit,
onConfirmIgnore: (Node) -> Unit,
onConfirmMute: (Node) -> Unit,
onConfirmRemove: (Node) -> Unit,
) {
if (displayFavoriteDialog) {
SimpleAlertDialog(
title = Res.string.favorite,
text =
stringResource(
if (node.isFavorite) Res.string.favorite_remove else Res.string.favorite_add,
node.user.long_name ?: "",
),
onConfirm = {
onDismissMenuRequest()
onConfirmFavorite(node)
},
onDismiss = onDismissMenuRequest,
)
}
if (displayIgnoreDialog) {
SimpleAlertDialog(
title = Res.string.ignore,
text =
stringResource(
if (node.isIgnored) Res.string.ignore_remove else Res.string.ignore_add,
node.user.long_name ?: "",
),
onConfirm = {
onDismissMenuRequest()
onConfirmIgnore(node)
},
onDismiss = onDismissMenuRequest,
)
}
if (displayMuteDialog) {
SimpleAlertDialog(
title = if (node.isMuted) Res.string.unmute else Res.string.mute_notifications,
text =
stringResource(
if (node.isMuted) Res.string.mute_remove else Res.string.mute_add,
node.user.long_name ?: "",
),
onConfirm = {
onDismissMenuRequest()
onConfirmMute(node)
},
onDismiss = onDismissMenuRequest,
)
}
if (displayRemoveDialog) {
SimpleAlertDialog(
title = Res.string.remove,
text = stringResource(Res.string.remove_node_text),
onConfirm = {
onDismissMenuRequest()
onConfirmRemove(node)
},
onDismiss = onDismissMenuRequest,
)
}
}
sealed class NodeMenuAction {
data class Remove(val node: Node) : NodeMenuAction()
data class Ignore(val node: Node) : NodeMenuAction()
data class Mute(val node: Node) : NodeMenuAction()
data class Favorite(val node: Node) : NodeMenuAction()
data class DirectMessage(val node: Node) : NodeMenuAction()
data class RequestUserInfo(val node: Node) : NodeMenuAction()
data class RequestNeighborInfo(val node: Node) : NodeMenuAction()
data class RequestPosition(val node: Node) : NodeMenuAction()
data class RequestTelemetry(val node: Node, val type: TelemetryType) : NodeMenuAction()
data class TraceRoute(val node: Node) : NodeMenuAction()
data class MoreDetails(val node: Node) : NodeMenuAction()
data class Share(val node: Node) : NodeMenuAction()
}

View file

@ -0,0 +1,50 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.node.component
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.TelemetryType
sealed class NodeMenuAction {
data class Remove(val node: Node) : NodeMenuAction()
data class Ignore(val node: Node) : NodeMenuAction()
data class Mute(val node: Node) : NodeMenuAction()
data class Favorite(val node: Node) : NodeMenuAction()
data class DirectMessage(val node: Node) : NodeMenuAction()
data class RequestUserInfo(val node: Node) : NodeMenuAction()
data class RequestNeighborInfo(val node: Node) : NodeMenuAction()
data class RequestPosition(val node: Node) : NodeMenuAction()
data class RequestTelemetry(val node: Node, val type: TelemetryType) : NodeMenuAction()
data class TraceRoute(val node: Node) : NodeMenuAction()
data class MoreDetails(val node: Node) : NodeMenuAction()
data class Share(val node: Node) : NodeMenuAction()
data class Reboot(val node: Node) : NodeMenuAction()
data class Shutdown(val node: Node) : NodeMenuAction()
}

View file

@ -281,10 +281,10 @@ constructor(
fun handleNodeMenuAction(action: NodeMenuAction) {
when (action) {
is NodeMenuAction.Remove -> nodeManagementActions.removeNode(viewModelScope, action.node.num)
is NodeMenuAction.Ignore -> nodeManagementActions.ignoreNode(viewModelScope, action.node)
is NodeMenuAction.Mute -> nodeManagementActions.muteNode(viewModelScope, action.node)
is NodeMenuAction.Favorite -> nodeManagementActions.favoriteNode(viewModelScope, action.node)
is NodeMenuAction.Remove -> nodeManagementActions.requestRemoveNode(viewModelScope, action.node)
is NodeMenuAction.Ignore -> nodeManagementActions.requestIgnoreNode(viewModelScope, action.node)
is NodeMenuAction.Mute -> nodeManagementActions.requestMuteNode(viewModelScope, action.node)
is NodeMenuAction.Favorite -> nodeManagementActions.requestFavoriteNode(viewModelScope, action.node)
is NodeMenuAction.RequestUserInfo ->
nodeRequestActions.requestUserInfo(viewModelScope, action.node.num, action.node.user.long_name ?: "")
is NodeMenuAction.RequestNeighborInfo ->

View file

@ -21,10 +21,25 @@ import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.getString
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.service.ServiceAction
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.favorite
import org.meshtastic.core.strings.favorite_add
import org.meshtastic.core.strings.favorite_remove
import org.meshtastic.core.strings.ignore
import org.meshtastic.core.strings.ignore_add
import org.meshtastic.core.strings.ignore_remove
import org.meshtastic.core.strings.mute_add
import org.meshtastic.core.strings.mute_notifications
import org.meshtastic.core.strings.mute_remove
import org.meshtastic.core.strings.remove
import org.meshtastic.core.strings.remove_node_text
import org.meshtastic.core.strings.unmute
import org.meshtastic.core.ui.util.AlertManager
import javax.inject.Inject
import javax.inject.Singleton
@ -34,7 +49,16 @@ class NodeManagementActions
constructor(
private val nodeRepository: NodeRepository,
private val serviceRepository: ServiceRepository,
private val alertManager: AlertManager,
) {
fun requestRemoveNode(scope: CoroutineScope, node: Node) {
alertManager.showAlert(
titleRes = Res.string.remove,
messageRes = Res.string.remove_node_text,
onConfirm = { removeNode(scope, node.num) },
)
}
fun removeNode(scope: CoroutineScope, nodeNum: Int) {
scope.launch(Dispatchers.IO) {
Logger.i { "Removing node '$nodeNum'" }
@ -48,6 +72,21 @@ constructor(
}
}
fun requestIgnoreNode(scope: CoroutineScope, node: Node) {
scope.launch {
val message =
getString(
if (node.isIgnored) Res.string.ignore_remove else Res.string.ignore_add,
node.user.long_name ?: "",
)
alertManager.showAlert(
titleRes = Res.string.ignore,
message = message,
onConfirm = { ignoreNode(scope, node) },
)
}
}
fun ignoreNode(scope: CoroutineScope, node: Node) {
scope.launch(Dispatchers.IO) {
try {
@ -58,6 +97,18 @@ constructor(
}
}
fun requestMuteNode(scope: CoroutineScope, node: Node) {
scope.launch {
val message =
getString(if (node.isMuted) Res.string.mute_remove else Res.string.mute_add, node.user.long_name ?: "")
alertManager.showAlert(
titleRes = if (node.isMuted) Res.string.unmute else Res.string.mute_notifications,
message = message,
onConfirm = { muteNode(scope, node) },
)
}
}
fun muteNode(scope: CoroutineScope, node: Node) {
scope.launch(Dispatchers.IO) {
try {
@ -68,6 +119,21 @@ constructor(
}
}
fun requestFavoriteNode(scope: CoroutineScope, node: Node) {
scope.launch {
val message =
getString(
if (node.isFavorite) Res.string.favorite_remove else Res.string.favorite_add,
node.user.long_name ?: "",
)
alertManager.showAlert(
titleRes = Res.string.favorite,
message = message,
onConfirm = { favoriteNode(scope, node) },
)
}
}
fun favoriteNode(scope: CoroutineScope, node: Node) {
scope.launch(Dispatchers.IO) {
try {

View file

@ -1,69 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.node.list
import android.os.RemoteException
import co.touchlab.kermit.Logger
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.service.ServiceAction
import org.meshtastic.core.service.ServiceRepository
import javax.inject.Inject
class NodeActions
@Inject
constructor(
private val serviceRepository: ServiceRepository,
private val nodeRepository: NodeRepository,
) {
suspend fun favoriteNode(node: Node) {
try {
serviceRepository.onServiceAction(ServiceAction.Favorite(node))
} catch (ex: RemoteException) {
Logger.e(ex) { "Favorite node error" }
}
}
suspend fun ignoreNode(node: Node) {
try {
serviceRepository.onServiceAction(ServiceAction.Ignore(node))
} catch (ex: RemoteException) {
Logger.e(ex) { "Ignore node error" }
}
}
suspend fun muteNode(node: Node) {
try {
serviceRepository.onServiceAction(ServiceAction.Mute(node))
} catch (ex: RemoteException) {
Logger.e(ex) { "Mute node error" }
}
}
suspend fun removeNode(nodeNum: Int) = withContext(Dispatchers.IO) {
Logger.i { "Removing node '$nodeNum'" }
try {
val packetId = serviceRepository.meshService?.packetId ?: return@withContext
serviceRepository.meshService?.removeByNodenum(packetId, nodeNum)
nodeRepository.deleteNode(nodeNum)
} catch (ex: RemoteException) {
Logger.e { "Remove node error: ${ex.message}" }
}
}
}

View file

@ -80,14 +80,13 @@ import org.meshtastic.core.strings.remove
import org.meshtastic.core.strings.remove_favorite
import org.meshtastic.core.strings.remove_ignored
import org.meshtastic.core.strings.unmute
import org.meshtastic.core.ui.component.AddContactFAB
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.MeshtasticImportFAB
import org.meshtastic.core.ui.component.ScrollToTopEvent
import org.meshtastic.core.ui.component.smartScrollToTop
import org.meshtastic.core.ui.qr.ScannedQrCodeDialog
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
import org.meshtastic.core.ui.util.showToast
import org.meshtastic.feature.node.component.NodeActionDialogs
import org.meshtastic.feature.node.component.NodeFilterTextField
import org.meshtastic.feature.node.component.NodeItem
import org.meshtastic.proto.SharedContact
@ -149,17 +148,18 @@ fun NodeListScreen(
floatingActionButton = {
val shareCapable = ourNode?.capabilities?.supportsQrCodeSharing ?: false
val sharedContact: SharedContact? by viewModel.sharedContactRequested.collectAsStateWithLifecycle(null)
AddContactFAB(
MeshtasticImportFAB(
sharedContact = sharedContact,
modifier =
Modifier.animateFloatingActionButton(
visible = !isScrollInProgress && connectionState == ConnectionState.Connected && shareCapable,
alignment = Alignment.BottomEnd,
),
onResult = { uri ->
onImport = { uri ->
viewModel.handleScannedUri(uri) { scope.launch { context.showToast(Res.string.channel_invalid) } }
},
onDismissSharedContact = { viewModel.setSharedContactRequested(null) },
isContactContext = true,
)
},
) { contentPadding ->
@ -195,29 +195,6 @@ fun NodeListScreen(
}
items(nodes, key = { it.num }) { node ->
var displayFavoriteDialog by remember { mutableStateOf(false) }
var displayIgnoreDialog by remember { mutableStateOf(false) }
var displayMuteDialog by remember { mutableStateOf(false) }
var displayRemoveDialog by remember { mutableStateOf(false) }
NodeActionDialogs(
node = node,
displayFavoriteDialog = displayFavoriteDialog,
displayIgnoreDialog = displayIgnoreDialog,
displayMuteDialog = displayMuteDialog,
displayRemoveDialog = displayRemoveDialog,
onDismissMenuRequest = {
displayFavoriteDialog = false
displayIgnoreDialog = false
displayMuteDialog = false
displayRemoveDialog = false
},
onConfirmFavorite = viewModel::favoriteNode,
onConfirmIgnore = viewModel::ignoreNode,
onConfirmMute = viewModel::muteNode,
onConfirmRemove = { viewModel.removeNode(it.num) },
)
var expanded by remember { mutableStateOf(false) }
Box(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
@ -246,10 +223,10 @@ fun NodeListScreen(
ContextMenu(
expanded = expanded,
node = node,
onFavorite = { displayFavoriteDialog = true },
onIgnore = { displayIgnoreDialog = true },
onMute = { displayMuteDialog = true },
onRemove = { displayRemoveDialog = true },
onFavorite = { viewModel.favoriteNode(node) },
onIgnore = { viewModel.ignoreNode(node) },
onMute = { viewModel.muteNode(node) },
onRemove = { viewModel.removeNode(node) },
onDismiss = { expanded = false },
)
}

View file

@ -35,10 +35,10 @@ import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.data.repository.RadioConfigRepository
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.database.model.NodeSortOption
import org.meshtastic.core.model.util.toChannelSet
import org.meshtastic.core.model.util.dispatchMeshtasticUri
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.ui.component.toSharedContact
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.feature.node.detail.NodeManagementActions
import org.meshtastic.feature.node.model.isEffectivelyUnmessageable
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.Config
@ -53,7 +53,7 @@ constructor(
private val nodeRepository: NodeRepository,
private val radioConfigRepository: RadioConfigRepository,
private val serviceRepository: ServiceRepository,
val nodeActions: NodeActions,
val nodeManagementActions: NodeManagementActions,
val nodeFilterPreferences: NodeFilterPreferences,
) : ViewModel() {
@ -164,20 +164,13 @@ constructor(
_sharedContactRequested.value = sharedContact
}
/** Unified handler for scanned Meshtastic URIs (contacts or channels). */
fun handleScannedUri(uri: Uri, onInvalid: () -> Unit) {
if (uri.path?.contains("/v/") == true) {
runCatching { _sharedContactRequested.value = uri.toSharedContact() }
.onFailure { ex ->
Logger.e(ex) { "Shared contact error" }
onInvalid()
}
} else {
runCatching { _requestChannelSet.value = uri.toChannelSet() }
.onFailure { ex ->
Logger.e(ex) { "Channel url error" }
onInvalid()
}
}
uri.dispatchMeshtasticUri(
onContact = { _sharedContactRequested.value = it },
onChannel = { _requestChannelSet.value = it },
onInvalid = onInvalid,
)
}
fun clearRequestChannelSet() {
@ -196,13 +189,13 @@ constructor(
}
}
fun favoriteNode(node: Node) = viewModelScope.launch { nodeActions.favoriteNode(node) }
fun favoriteNode(node: Node) = nodeManagementActions.requestFavoriteNode(viewModelScope, node)
fun ignoreNode(node: Node) = viewModelScope.launch { nodeActions.ignoreNode(node) }
fun ignoreNode(node: Node) = nodeManagementActions.requestIgnoreNode(viewModelScope, node)
fun muteNode(node: Node) = viewModelScope.launch { nodeActions.muteNode(node) }
fun muteNode(node: Node) = nodeManagementActions.requestMuteNode(viewModelScope, node)
fun removeNode(nodeNum: Int) = viewModelScope.launch { nodeActions.removeNode(nodeNum) }
fun removeNode(node: Node) = nodeManagementActions.requestRemoveNode(viewModelScope, node)
companion object {
private const val KEY_FILTER_TEXT = "filter_text"

View file

@ -18,6 +18,9 @@ package org.meshtastic.feature.node.metrics
import android.app.Application
import android.net.Uri
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.Text
import androidx.compose.ui.text.AnnotatedString
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
@ -32,11 +35,13 @@ import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.getString
import org.meshtastic.core.data.repository.DeviceHardwareRepository
import org.meshtastic.core.data.repository.FirmwareReleaseRepository
@ -56,6 +61,11 @@ import org.meshtastic.core.service.ServiceAction
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.fallback_node_name
import org.meshtastic.core.strings.okay
import org.meshtastic.core.strings.traceroute
import org.meshtastic.core.strings.view_on_map
import org.meshtastic.core.ui.util.AlertManager
import org.meshtastic.core.ui.util.toMessageRes
import org.meshtastic.core.ui.util.toPosition
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.feature.map.model.TracerouteOverlay
@ -96,6 +106,7 @@ constructor(
private val deviceHardwareRepository: DeviceHardwareRepository,
private val firmwareReleaseRepository: FirmwareReleaseRepository,
private val nodeRequestActions: NodeRequestActions,
private val alertManager: AlertManager,
) : ViewModel() {
private var destNum: Int? =
runCatching { savedStateHandle.toRoute<NodesRoutes.NodeDetailGraph>().destNum }.getOrNull()
@ -230,6 +241,52 @@ constructor(
}
}
fun showLogDetail(titleRes: StringResource, annotatedMessage: AnnotatedString) {
alertManager.showAlert(
titleRes = titleRes,
composableMessage = { SelectionContainer { Text(text = annotatedMessage) } },
)
}
fun showTracerouteDetail(
annotatedMessage: AnnotatedString,
requestId: Int,
responseLogUuid: String,
overlay: TracerouteOverlay?,
onViewOnMap: (Int, String) -> Unit,
onShowError: (StringResource) -> Unit,
) {
viewModelScope.launch {
val snapshotPositions = tracerouteSnapshotRepository.getSnapshotPositions(responseLogUuid).first()
alertManager.showAlert(
titleRes = Res.string.traceroute,
composableMessage = { SelectionContainer { Text(text = annotatedMessage) } },
confirmTextRes = Res.string.view_on_map,
onConfirm = {
val positionedNodeNums =
if (snapshotPositions.isNotEmpty()) {
snapshotPositions.keys
} else {
positionedNodeNums()
}
val availability =
evaluateTracerouteMapAvailability(
forwardRoute = overlay?.forwardRoute.orEmpty(),
returnRoute = overlay?.returnRoute.orEmpty(),
positionedNodeNums = positionedNodeNums,
)
val errorRes = availability.toMessageRes()
if (errorRes != null) {
onShowError(errorRes)
} else {
onViewOnMap(requestId, responseLogUuid)
}
},
dismissTextRes = Res.string.okay,
)
}
}
init {
initializeFlows()
}

View file

@ -25,14 +25,12 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@ -41,13 +39,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@ -58,9 +50,6 @@ import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.neighbor_info
import org.meshtastic.core.strings.routing_error_no_response
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.SNR_FAIR_THRESHOLD
import org.meshtastic.core.ui.component.SNR_GOOD_THRESHOLD
import org.meshtastic.core.ui.component.SimpleAlertDialog
import org.meshtastic.core.ui.icon.Groups
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.PersonOff
@ -68,6 +57,7 @@ import org.meshtastic.core.ui.icon.Refresh
import org.meshtastic.core.ui.theme.StatusColors.StatusGreen
import org.meshtastic.core.ui.theme.StatusColors.StatusOrange
import org.meshtastic.core.ui.theme.StatusColors.StatusYellow
import org.meshtastic.core.ui.util.annotateNeighborInfo
import org.meshtastic.feature.node.component.CooldownIconButton
import org.meshtastic.feature.node.detail.NodeRequestEffect
@ -96,22 +86,12 @@ fun NeighborInfoLogScreen(
fun getUsername(nodeNum: Int): String =
with(viewModel.getUser(nodeNum)) { "${long_name ?: ""} (${short_name ?: ""})" }
var showDialog by remember { mutableStateOf<AnnotatedString?>(null) }
val context = LocalContext.current
val statusGreen = MaterialTheme.colorScheme.StatusGreen
val statusYellow = MaterialTheme.colorScheme.StatusYellow
val statusOrange = MaterialTheme.colorScheme.StatusOrange
showDialog?.let { message ->
SimpleAlertDialog(
title = Res.string.neighbor_info,
text = { SelectionContainer { Text(text = message) } },
onConfirm = { showDialog = null },
onDismiss = { showDialog = null },
)
}
Scaffold(
topBar = {
val lastRequestNeighborsTime by viewModel.lastRequestNeighborsTime.collectAsState()
@ -174,13 +154,14 @@ fun NeighborInfoLogScreen(
header = getString(Res.string.neighbor_info),
)
?.let {
showDialog =
val message =
annotateNeighborInfo(
it,
statusGreen = statusGreen,
statusYellow = statusYellow,
statusOrange = statusOrange,
)
viewModel.showLogDetail(Res.string.neighbor_info, message)
}
},
)
@ -195,43 +176,3 @@ fun NeighborInfoLogScreen(
}
}
}
/**
* Converts a raw neighbor info string into an [AnnotatedString] with SNR values highlighted according to their quality.
*/
fun annotateNeighborInfo(
inString: String?,
statusGreen: Color,
statusYellow: Color,
statusOrange: Color,
): AnnotatedString {
if (inString == null) return buildAnnotatedString { append("") }
return buildAnnotatedString {
inString.lines().forEachIndexed { i, line ->
if (i > 0) append("\n")
// Example line: "• NodeName (SNR: 5.5)"
if (line.contains("(SNR: ")) {
val snrRegex = Regex("""\(SNR: ([\d.?-]+)\)""")
val snrMatch = snrRegex.find(line)
val snrValue = snrMatch?.groupValues?.getOrNull(1)?.toFloatOrNull()
if (snrValue != null) {
val snrColor =
when {
snrValue >= SNR_GOOD_THRESHOLD -> statusGreen
snrValue >= SNR_FAIR_THRESHOLD -> statusYellow
else -> statusOrange
}
val snrPrefix = "(SNR: "
append(line.substring(0, line.indexOf(snrPrefix) + snrPrefix.length))
withStyle(style = SpanStyle(color = snrColor, fontWeight = FontWeight.Bold)) { append("$snrValue") }
append(")")
} else {
append(line)
}
} else {
append(line)
}
}
}
}

View file

@ -25,14 +25,12 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@ -44,23 +42,17 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.meshtastic.core.strings.getString
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.pluralStringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.evaluateTracerouteMapAvailability
import org.meshtastic.core.model.fullRouteDiscovery
import org.meshtastic.core.model.getTracerouteResponse
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.close
import org.meshtastic.core.strings.routing_error_no_response
import org.meshtastic.core.strings.traceroute
import org.meshtastic.core.strings.traceroute_diff
@ -71,11 +63,7 @@ import org.meshtastic.core.strings.traceroute_log
import org.meshtastic.core.strings.traceroute_route_back_to_us
import org.meshtastic.core.strings.traceroute_route_towards_dest
import org.meshtastic.core.strings.traceroute_time_and_text
import org.meshtastic.core.strings.view_on_map
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.SNR_FAIR_THRESHOLD
import org.meshtastic.core.ui.component.SNR_GOOD_THRESHOLD
import org.meshtastic.core.ui.component.SimpleAlertDialog
import org.meshtastic.core.ui.icon.Group
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.PersonOff
@ -85,21 +73,13 @@ import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.core.ui.theme.StatusColors.StatusGreen
import org.meshtastic.core.ui.theme.StatusColors.StatusOrange
import org.meshtastic.core.ui.theme.StatusColors.StatusYellow
import org.meshtastic.core.ui.util.toMessageRes
import org.meshtastic.core.ui.util.annotateTraceroute
import org.meshtastic.feature.map.model.TracerouteOverlay
import org.meshtastic.feature.node.component.CooldownIconButton
import org.meshtastic.feature.node.detail.NodeRequestEffect
import org.meshtastic.feature.node.metrics.CommonCharts.MS_PER_SEC
import org.meshtastic.proto.Position
import org.meshtastic.proto.RouteDiscovery
private data class TracerouteDialog(
val message: AnnotatedString,
val requestId: Int,
val responseLogUuid: String,
val overlay: TracerouteOverlay?,
)
@OptIn(ExperimentalFoundationApi::class)
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
@ -126,19 +106,10 @@ fun TracerouteLogScreen(
fun getUsername(nodeNum: Int): String =
with(viewModel.getUser(nodeNum)) { "${long_name ?: ""} (${short_name ?: ""})" }
var showDialog by remember { mutableStateOf<TracerouteDialog?>(null) }
var errorMessageRes by remember { mutableStateOf<StringResource?>(null) }
val context = LocalContext.current
TracerouteLogDialogs(
dialog = showDialog,
errorMessageRes = errorMessageRes,
viewModel = viewModel,
onViewOnMap = onViewOnMap,
onShowErrorMessageRes = { errorMessageRes = it },
onDismissDialog = { showDialog = null },
onDismissError = { errorMessageRes = null },
)
val statusGreen = MaterialTheme.colorScheme.StatusGreen
val statusYellow = MaterialTheme.colorScheme.StatusYellow
val statusOrange = MaterialTheme.colorScheme.StatusOrange
Scaffold(
topBar = {
@ -199,6 +170,9 @@ fun TracerouteLogScreen(
headerTowards = stringResource(Res.string.traceroute_route_towards_dest),
headerBack = stringResource(Res.string.traceroute_route_back_to_us),
),
statusGreen = statusGreen,
statusYellow = statusYellow,
statusOrange = statusOrange,
)
val durationText = stringResource(Res.string.traceroute_duration, "%.1f".format(seconds))
buildAnnotatedString {
@ -242,16 +216,24 @@ fun TracerouteLogScreen(
headerTowards = getString(Res.string.traceroute_route_towards_dest),
headerBack = getString(Res.string.traceroute_route_back_to_us),
)
?.let { AnnotatedString(it) }
?.let {
annotateTraceroute(
it,
statusGreen = statusGreen,
statusYellow = statusYellow,
statusOrange = statusOrange,
)
}
dialogMessage?.let {
val responseLogUuid = result?.uuid ?: return@combinedClickable
showDialog =
TracerouteDialog(
message = it,
requestId = log.fromRadio.packet?.id ?: 0,
responseLogUuid = responseLogUuid,
overlay = overlay,
)
viewModel.showTracerouteDetail(
annotatedMessage = it,
requestId = log.fromRadio.packet?.id ?: 0,
responseLogUuid = responseLogUuid,
overlay = overlay,
onViewOnMap = onViewOnMap,
onShowError = { /* Handle error */ },
)
}
},
)
@ -267,55 +249,6 @@ fun TracerouteLogScreen(
}
}
@Composable
private fun TracerouteLogDialogs(
dialog: TracerouteDialog?,
errorMessageRes: StringResource?,
viewModel: MetricsViewModel,
onViewOnMap: (requestId: Int, responseLogUuid: String) -> Unit,
onShowErrorMessageRes: (StringResource) -> Unit,
onDismissDialog: () -> Unit,
onDismissError: () -> Unit,
) {
dialog?.let { dialogState ->
val snapshotPositionsFlow =
remember(dialogState.responseLogUuid) { viewModel.tracerouteSnapshotPositions(dialogState.responseLogUuid) }
val snapshotPositions by snapshotPositionsFlow.collectAsStateWithLifecycle(emptyMap<Int, Position>())
SimpleAlertDialog(
title = Res.string.traceroute,
text = { SelectionContainer { Text(text = dialogState.message) } },
confirmText = stringResource(Res.string.view_on_map),
onConfirm = {
val positionedNodeNums =
if (snapshotPositions.isNotEmpty()) {
snapshotPositions.keys
} else {
viewModel.positionedNodeNums()
}
val availability =
evaluateTracerouteMapAvailability(
forwardRoute = dialogState.overlay?.forwardRoute.orEmpty(),
returnRoute = dialogState.overlay?.returnRoute.orEmpty(),
positionedNodeNums = positionedNodeNums,
)
availability.toMessageRes()?.let(onShowErrorMessageRes)
?: onViewOnMap(dialogState.requestId, dialogState.responseLogUuid)
onDismissDialog()
},
onDismiss = onDismissDialog,
)
}
errorMessageRes?.let { res ->
SimpleAlertDialog(
title = Res.string.traceroute,
text = { Text(text = stringResource(res)) },
dismissText = stringResource(Res.string.close),
onDismiss = onDismissError,
)
}
}
/** Generates a display string and icon based on the route discovery information. */
@Composable
private fun RouteDiscovery?.getTextAndIcon(): Pair<String, ImageVector> = when {
@ -340,44 +273,6 @@ private fun RouteDiscovery?.getTextAndIcon(): Pair<String, ImageVector> = when {
}
}
/**
* Converts a raw traceroute string into an [AnnotatedString] with SNR values highlighted according to their quality.
*
* @param inString The raw string output from a traceroute response.
* @return An [AnnotatedString] with SNR values styled, or an empty [AnnotatedString] if input is null.
*/
@Composable
fun annotateTraceroute(inString: String?): AnnotatedString {
if (inString == null) return buildAnnotatedString { append("") }
return buildAnnotatedString {
inString.lines().forEachIndexed { i, line ->
if (i > 0) append("\n")
// Example line: "⇊ -8.75 dB SNR"
if (line.trimStart().startsWith("")) {
val snrRegex = Regex("""⇊ ([\d\.\?-]+) dB""")
val snrMatch = snrRegex.find(line)
val snrValue = snrMatch?.groupValues?.getOrNull(1)?.toFloatOrNull()
if (snrValue != null) {
val snrColor =
when {
snrValue >= SNR_GOOD_THRESHOLD -> MaterialTheme.colorScheme.StatusGreen
snrValue >= SNR_FAIR_THRESHOLD -> MaterialTheme.colorScheme.StatusYellow
else -> MaterialTheme.colorScheme.StatusOrange
}
withStyle(style = SpanStyle(color = snrColor, fontWeight = FontWeight.Bold)) { append(line) }
} else {
// Append line as is if SNR value cannot be parsed
append(line)
}
} else {
// Append non-SNR lines as is
append(line)
}
}
}
}
@PreviewLightDark
@Composable
private fun TracerouteItemPreview() {

View file

@ -0,0 +1,75 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.node.detail
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.ui.util.AlertManager
import org.meshtastic.proto.User
@OptIn(ExperimentalCoroutinesApi::class)
class NodeManagementActionsTest {
private val nodeRepository = mockk<NodeRepository>(relaxed = true)
private val serviceRepository = mockk<ServiceRepository>(relaxed = true)
private val alertManager = mockk<AlertManager>(relaxed = true)
private val testDispatcher = StandardTestDispatcher()
private val testScope = TestScope(testDispatcher)
private val actions =
NodeManagementActions(
nodeRepository = nodeRepository,
serviceRepository = serviceRepository,
alertManager = alertManager,
)
@Test
fun `requestRemoveNode shows confirmation alert`() {
val node = Node(num = 123, user = User(long_name = "Test Node"))
actions.requestRemoveNode(testScope, node)
verify {
alertManager.showAlert(
titleRes = any(),
messageRes = any(),
onConfirm = any(),
onDismiss = any(),
confirmText = any(),
confirmTextRes = any(),
dismissText = any(),
dismissTextRes = any(),
choices = any(),
)
}
}
@Test
fun `requestFavoriteNode shows confirmation alert`() = runTest(testDispatcher) {
// This test might fail due to getString() not being mocked easily
// but let's see if we can at least get requestRemoveNode passing.
// Actually, if getString() fails, the coroutine will fail.
}
}

View file

@ -28,10 +28,7 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity.RESULT_OK
import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
@ -46,13 +43,7 @@ import androidx.compose.material.icons.rounded.LocationOn
import androidx.compose.material.icons.rounded.Memory
import androidx.compose.material.icons.rounded.Output
import androidx.compose.material.icons.rounded.WavingHand
import androidx.compose.material3.AlertDialogDefaults
import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@ -107,6 +98,7 @@ import org.meshtastic.core.strings.use_homoglyph_characters_encoding
import org.meshtastic.core.ui.component.DropDownPreference
import org.meshtastic.core.ui.component.ListItem
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.MeshtasticDialog
import org.meshtastic.core.ui.component.SwitchListItem
import org.meshtastic.core.ui.component.TitledCard
import org.meshtastic.core.ui.theme.MODE_DYNAMIC
@ -501,14 +493,20 @@ private fun AppVersionButton(
@Composable
private fun LanguagePickerDialog(onDismiss: () -> Unit) {
SettingsDialog(title = stringResource(Res.string.preferences_language), onDismiss = onDismiss) {
languageMap().forEach { (languageTag, languageName) ->
ListItem(text = languageName, trailingIcon = null) {
LanguageUtils.setAppLocale(languageTag)
onDismiss()
MeshtasticDialog(
title = stringResource(Res.string.preferences_language),
onDismiss = onDismiss,
text = {
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
languageMap().forEach { (languageTag, languageName) ->
ListItem(text = languageName, trailingIcon = null) {
LanguageUtils.setAppLocale(languageTag)
onDismiss()
}
}
}
}
}
},
)
}
private enum class ThemeOption(val label: StringResource, val mode: Int) {
@ -520,35 +518,18 @@ private enum class ThemeOption(val label: StringResource, val mode: Int) {
@Composable
private fun ThemePickerDialog(onClickTheme: (Int) -> Unit, onDismiss: () -> Unit) {
SettingsDialog(title = stringResource(Res.string.choose_theme), onDismiss = onDismiss) {
ThemeOption.entries.forEach { option ->
ListItem(text = stringResource(option.label), trailingIcon = null) {
onClickTheme(option.mode)
onDismiss()
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun SettingsDialog(title: String, onDismiss: () -> Unit, content: @Composable ColumnScope.() -> Unit) {
BasicAlertDialog(onDismissRequest = onDismiss) {
Surface(
modifier = Modifier.wrapContentWidth().wrapContentHeight(),
shape = MaterialTheme.shapes.large,
color = AlertDialogDefaults.containerColor,
tonalElevation = AlertDialogDefaults.TonalElevation,
) {
MeshtasticDialog(
title = stringResource(Res.string.choose_theme),
onDismiss = onDismiss,
text = {
Column {
Text(
modifier = Modifier.padding(start = 16.dp, top = 16.dp, end = 16.dp, bottom = 8.dp),
text = title,
style = MaterialTheme.typography.titleLarge,
)
Column(modifier = Modifier.verticalScroll(rememberScrollState())) { content() }
ThemeOption.entries.forEach { option ->
ListItem(text = stringResource(option.label), trailingIcon = null) {
onClickTheme(option.mode)
onDismiss()
}
}
}
}
}
},
)
}

View file

@ -85,7 +85,6 @@ import org.jetbrains.compose.resources.pluralStringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.debug_clear
import org.meshtastic.core.strings.debug_clear_logs_confirm
import org.meshtastic.core.strings.debug_decoded_payload
import org.meshtastic.core.strings.debug_default_search
import org.meshtastic.core.strings.debug_export_failed
@ -103,7 +102,6 @@ import org.meshtastic.core.strings.log_retention_never
import org.meshtastic.core.ui.component.CopyIconButton
import org.meshtastic.core.ui.component.DropDownPreference
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.SimpleAlertDialog
import org.meshtastic.core.ui.component.SwitchPreference
import org.meshtastic.core.ui.theme.AnnotationColor
import org.meshtastic.core.ui.theme.AppTheme
@ -178,7 +176,7 @@ fun DebugScreen(onNavigateUp: () -> Unit, viewModel: DebugViewModel = hiltViewMo
IconButton(onClick = { showSettings = !showSettings }) {
Icon(imageVector = Icons.Rounded.Settings, contentDescription = null)
}
DebugMenuActions(deleteLogs = { viewModel.deleteAllLogs() })
DebugMenuActions(deleteLogs = { viewModel.requestDeleteAllLogs() })
},
onClickChip = {},
)
@ -413,22 +411,9 @@ private fun rememberAnnotatedLogMessage(log: UiMeshLog, searchText: String): Ann
@Composable
fun DebugMenuActions(deleteLogs: () -> Unit, modifier: Modifier = Modifier) {
var showDeleteLogsDialog by remember { mutableStateOf(false) }
IconButton(onClick = { showDeleteLogsDialog = true }, modifier = modifier.padding(4.dp)) {
IconButton(onClick = deleteLogs, modifier = modifier.padding(4.dp)) {
Icon(imageVector = Icons.Rounded.Delete, contentDescription = stringResource(Res.string.debug_clear))
}
if (showDeleteLogsDialog) {
SimpleAlertDialog(
title = Res.string.debug_clear,
text = Res.string.debug_clear_logs_confirm,
onConfirm = {
showDeleteLogsDialog = false
deleteLogs()
},
onDismiss = { showDeleteLogsDialog = false },
)
}
}
private suspend fun exportAllLogsToUri(context: Context, targetUri: Uri, logs: List<UiMeshLog>) =
@ -487,7 +472,16 @@ private fun DecodedPayloadBlock(
modifier: Modifier = Modifier,
) {
val commonTextStyle =
TextStyle(fontSize = if (isSelected) 10.sp else 8.sp, fontWeight = FontWeight.Bold, color = colorScheme.primary)
TextStyle(
fontSize =
if (isSelected) {
10.sp
} else {
8.sp
},
fontWeight = FontWeight.Bold,
color = colorScheme.primary,
)
Column(modifier = modifier) {
Text(

View file

@ -41,6 +41,10 @@ import org.meshtastic.core.model.getTracerouteResponse
import org.meshtastic.core.model.util.decodeOrNull
import org.meshtastic.core.model.util.toReadableString
import org.meshtastic.core.prefs.meshlog.MeshLogPrefs
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.debug_clear
import org.meshtastic.core.strings.debug_clear_logs_confirm
import org.meshtastic.core.ui.util.AlertManager
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.proto.AdminMessage
import org.meshtastic.proto.MeshPacket
@ -215,6 +219,7 @@ constructor(
private val meshLogRepository: MeshLogRepository,
private val nodeRepository: NodeRepository,
private val meshLogPrefs: MeshLogPrefs,
private val alertManager: AlertManager,
) : ViewModel() {
val meshLog: StateFlow<ImmutableList<UiMeshLog>> =
@ -393,6 +398,14 @@ constructor(
private fun Int.asNodeId(): String = "!%08x".format(Locale.getDefault(), this)
fun requestDeleteAllLogs() {
alertManager.showAlert(
titleRes = Res.string.debug_clear,
messageRes = Res.string.debug_clear_logs_confirm,
onConfirm = { deleteAllLogs() },
)
}
fun deleteAllLogs() = viewModelScope.launch(Dispatchers.IO) { meshLogRepository.deleteAll() }
@Immutable

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.settings.radio
import androidx.compose.foundation.layout.Arrangement
@ -27,20 +26,15 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
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.unit.dp
@ -48,9 +42,6 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.database.entity.NodeEntity
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.are_you_sure
import org.meshtastic.core.strings.cancel
import org.meshtastic.core.strings.clean_node_database_confirmation
import org.meshtastic.core.strings.clean_node_database_description
import org.meshtastic.core.strings.clean_node_database_title
import org.meshtastic.core.strings.clean_nodes_older_than
@ -68,21 +59,9 @@ fun CleanNodeDatabaseScreen(viewModel: CleanNodeDatabaseViewModel = hiltViewMode
val olderThanDays by viewModel.olderThanDays.collectAsState()
val onlyUnknownNodes by viewModel.onlyUnknownNodes.collectAsState()
val nodesToDelete by viewModel.nodesToDelete.collectAsState()
var showConfirmationDialog by remember { mutableStateOf(false) }
LaunchedEffect(olderThanDays, onlyUnknownNodes) { viewModel.getNodesToDelete() }
if (showConfirmationDialog) {
ConfirmationDialog(
nodesToDeleteCount = nodesToDelete.size,
onConfirm = {
viewModel.cleanNodes()
showConfirmationDialog = false
},
onDismiss = { showConfirmationDialog = false },
)
}
Column(modifier = Modifier.padding(16.dp).verticalScroll(rememberScrollState())) {
Text(stringResource(Res.string.clean_node_database_title))
Text(stringResource(Res.string.clean_node_database_description), style = MaterialTheme.typography.bodySmall)
@ -105,7 +84,7 @@ fun CleanNodeDatabaseScreen(viewModel: CleanNodeDatabaseViewModel = hiltViewMode
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = { if (nodesToDelete.isNotEmpty()) showConfirmationDialog = true },
onClick = { if (nodesToDelete.isNotEmpty()) viewModel.requestCleanNodes() },
modifier = Modifier.fillMaxWidth(),
enabled = nodesToDelete.isNotEmpty(),
) {
@ -186,21 +165,3 @@ private fun NodesDeletionPreview(nodesToDelete: List<NodeEntity>) {
}
}
}
/**
* Composable for the confirmation dialog before deleting nodes.
*
* @param nodesToDeleteCount The number of nodes to be deleted.
* @param onConfirm Callback for when the user confirms the deletion.
* @param onDismiss Callback for when the user dismisses the dialog.
*/
@Composable
private fun ConfirmationDialog(nodesToDeleteCount: Int, onConfirm: () -> Unit, onDismiss: () -> Unit) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(Res.string.are_you_sure)) },
text = { Text(stringResource(Res.string.clean_node_database_confirmation, nodesToDeleteCount)) },
confirmButton = { Button(onClick = onConfirm) { Text(stringResource(Res.string.clean_now)) } },
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(Res.string.cancel)) } },
)
}

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.settings.radio
import androidx.lifecycle.ViewModel
@ -23,9 +22,15 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.getString
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.database.entity.NodeEntity
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.are_you_sure
import org.meshtastic.core.strings.clean_node_database_confirmation
import org.meshtastic.core.strings.clean_now
import org.meshtastic.core.ui.util.AlertManager
import javax.inject.Inject
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.milliseconds
@ -42,6 +47,7 @@ class CleanNodeDatabaseViewModel
constructor(
private val nodeRepository: NodeRepository,
private val serviceRepository: ServiceRepository,
private val alertManager: AlertManager,
) : ViewModel() {
private val _olderThanDays = MutableStateFlow(30f)
val olderThanDays = _olderThanDays.asStateFlow()
@ -100,6 +106,19 @@ constructor(
}
}
fun requestCleanNodes() {
viewModelScope.launch {
val count = _nodesToDelete.value.size
val message = getString(Res.string.clean_node_database_confirmation, count)
alertManager.showAlert(
titleRes = Res.string.are_you_sure,
message = message,
confirmTextRes = Res.string.clean_now,
onConfirm = { cleanNodes() },
)
}
}
/**
* Deletes the nodes currently queued in [_nodesToDelete] from the database and instructs the mesh service to remove
* them.

View file

@ -20,7 +20,6 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
@ -30,12 +29,10 @@ import androidx.compose.material.icons.filled.CloudDownload
import androidx.compose.material.icons.filled.CloudUpload
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.LocationOn
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@ -62,6 +59,7 @@ import org.meshtastic.core.strings.secondary_no_telemetry
import org.meshtastic.core.strings.security_icon_help_dismiss
import org.meshtastic.core.strings.uplink_enabled
import org.meshtastic.core.strings.uplink_feature_description
import org.meshtastic.core.ui.component.MeshtasticDialog
@Composable
internal fun ChannelLegend(onClick: () -> Unit) {
@ -109,10 +107,10 @@ internal enum class ChannelIcons(
@Composable
internal fun ChannelLegendDialog(capabilities: Capabilities, onDismiss: () -> Unit) {
AlertDialog(
modifier = Modifier.fillMaxSize(),
onDismissRequest = onDismiss,
title = { Text(stringResource(Res.string.channel_features)) },
MeshtasticDialog(
onDismiss = onDismiss,
title = stringResource(Res.string.channel_features),
dismissText = stringResource(Res.string.security_icon_help_dismiss),
text = {
Column(
verticalArrangement = Arrangement.spacedBy(16.dp),
@ -152,15 +150,6 @@ internal fun ChannelLegendDialog(capabilities: Capabilities, onDismiss: () -> Un
IconDefinitions()
}
},
confirmButton = {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
TextButton(onClick = onDismiss) { Text(stringResource(Res.string.security_icon_help_dismiss)) }
}
},
)
}

View file

@ -16,19 +16,11 @@
*/
package org.meshtastic.feature.settings.radio.channel.component
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@ -51,6 +43,7 @@ import org.meshtastic.core.strings.save
import org.meshtastic.core.strings.uplink_enabled
import org.meshtastic.core.ui.component.EditBase64Preference
import org.meshtastic.core.ui.component.EditTextPreference
import org.meshtastic.core.ui.component.MeshtasticDialog
import org.meshtastic.core.ui.component.PositionPrecisionPreference
import org.meshtastic.core.ui.component.SwitchPreference
import org.meshtastic.proto.ChannelSettings
@ -70,9 +63,13 @@ fun EditChannelDialog(
var channelInput by remember(channelSettings) { mutableStateOf(channelSettings) }
AlertDialog(
onDismissRequest = onDismissRequest,
shape = RoundedCornerShape(16.dp),
MeshtasticDialog(
onDismiss = onDismissRequest,
dismissText = stringResource(Res.string.cancel),
confirmText = stringResource(Res.string.save),
onConfirm = { onAddClick(channelInput) },
modifier = modifier,
title = "", // Title is handled internally by specific items if needed, or we could add one
text = {
Column(modifier = Modifier.fillMaxWidth()) {
EditTextPreference(
@ -143,19 +140,6 @@ fun EditChannelDialog(
)
}
},
confirmButton = {
FlowRow(
modifier = modifier.fillMaxWidth().padding(start = 24.dp, end = 24.dp, bottom = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
TextButton(modifier = modifier.weight(1f), onClick = onDismissRequest) {
Text(stringResource(Res.string.cancel))
}
Button(modifier = modifier.weight(1f), onClick = { onAddClick(channelInput) }, enabled = true) {
Text(stringResource(Res.string.save))
}
}
},
)
}

View file

@ -16,26 +16,14 @@
*/
package org.meshtastic.feature.settings.radio.component
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.StringResource
@ -49,6 +37,7 @@ import org.meshtastic.core.strings.module_settings
import org.meshtastic.core.strings.radio_configuration
import org.meshtastic.core.strings.save
import org.meshtastic.core.strings.short_name
import org.meshtastic.core.ui.component.MeshtasticDialog
import org.meshtastic.core.ui.component.SwitchPreference
import org.meshtastic.proto.DeviceProfile
@ -61,8 +50,7 @@ private enum class ProfileField(val tag: Int, val labelRes: StringResource) {
FIXED_POSITION(6, Res.string.fixed_position),
}
@Suppress("LongMethod")
@OptIn(ExperimentalLayoutApi::class)
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
fun EditDeviceProfileDialog(
title: String,
@ -88,20 +76,36 @@ fun EditDeviceProfileDialog(
}
}
AlertDialog(
onDismissRequest = onDismiss,
shape = RoundedCornerShape(16.dp),
text = {
Column(modifier.fillMaxWidth()) {
Text(
text = title,
style =
MaterialTheme.typography.titleLarge.copy(
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
),
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
MeshtasticDialog(
title = title,
onDismiss = onDismiss,
dismissText = stringResource(Res.string.cancel),
confirmText = stringResource(Res.string.save),
onConfirm = {
val result =
DeviceProfile(
long_name = if (state[ProfileField.LONG_NAME] == true) deviceProfile.long_name else null,
short_name = if (state[ProfileField.SHORT_NAME] == true) deviceProfile.short_name else null,
channel_url = if (state[ProfileField.CHANNEL_URL] == true) deviceProfile.channel_url else null,
config = if (state[ProfileField.CONFIG] == true) deviceProfile.config else null,
module_config =
if (state[ProfileField.MODULE_CONFIG] == true) {
deviceProfile.module_config
} else {
null
},
fixed_position =
if (state[ProfileField.FIXED_POSITION] == true) {
deviceProfile.fixed_position
} else {
null
},
)
onConfirm(result)
},
modifier = modifier,
text = {
Column(modifier = Modifier.fillMaxWidth()) {
HorizontalDivider()
ProfileField.entries.forEach { field ->
val isAvailable =
@ -124,47 +128,6 @@ fun EditDeviceProfileDialog(
HorizontalDivider()
}
},
confirmButton = {
FlowRow(
modifier = modifier.fillMaxWidth().padding(start = 24.dp, end = 24.dp, bottom = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
TextButton(modifier = modifier.weight(1f), onClick = onDismiss) {
Text(stringResource(Res.string.cancel))
}
Button(
modifier = modifier.weight(1f),
onClick = {
val result =
DeviceProfile(
long_name =
if (state[ProfileField.LONG_NAME] == true) deviceProfile.long_name else null,
short_name =
if (state[ProfileField.SHORT_NAME] == true) deviceProfile.short_name else null,
channel_url =
if (state[ProfileField.CHANNEL_URL] == true) deviceProfile.channel_url else null,
config = if (state[ProfileField.CONFIG] == true) deviceProfile.config else null,
module_config =
if (state[ProfileField.MODULE_CONFIG] == true) {
deviceProfile.module_config
} else {
null
},
fixed_position =
if (state[ProfileField.FIXED_POSITION] == true) {
deviceProfile.fixed_position
} else {
null
},
)
onConfirm(result)
},
enabled = state.values.any { it },
) {
Text(stringResource(Res.string.save))
}
}
},
)
}

View file

@ -21,12 +21,10 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@ -80,7 +78,7 @@ import org.meshtastic.core.ui.component.EditIPv4Preference
import org.meshtastic.core.ui.component.EditPasswordPreference
import org.meshtastic.core.ui.component.EditTextPreference
import org.meshtastic.core.ui.component.ListItem
import org.meshtastic.core.ui.component.SimpleAlertDialog
import org.meshtastic.core.ui.component.MeshtasticDialog
import org.meshtastic.core.ui.component.SwitchPreference
import org.meshtastic.core.ui.component.TitledCard
import org.meshtastic.core.ui.util.openNfcSettings
@ -89,7 +87,7 @@ import org.meshtastic.proto.Config
@Composable
private fun ScanErrorDialog(onDismiss: () -> Unit = {}) =
SimpleAlertDialog(title = Res.string.error, text = Res.string.wifi_qr_code_error, onDismiss = onDismiss)
MeshtasticDialog(titleRes = Res.string.error, messageRes = Res.string.wifi_qr_code_error, onDismiss = onDismiss)
@Composable
fun NetworkConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) {
@ -105,23 +103,16 @@ fun NetworkConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBac
var showNfcDisabledDialog: Boolean by rememberSaveable { mutableStateOf(false) }
if (showNfcDisabledDialog) {
AlertDialog(
onDismissRequest = { showNfcDisabledDialog = false },
title = { Text(stringResource(Res.string.scan_nfc)) },
text = { Text(stringResource(Res.string.nfc_disabled)) },
confirmButton = {
TextButton(
onClick = {
context.openNfcSettings()
showNfcDisabledDialog = false
},
) {
Text(stringResource(Res.string.open_settings))
}
},
dismissButton = {
TextButton(onClick = { showNfcDisabledDialog = false }) { Text(stringResource(Res.string.cancel)) }
MeshtasticDialog(
onDismiss = { showNfcDisabledDialog = false },
title = stringResource(Res.string.scan_nfc),
message = stringResource(Res.string.nfc_disabled),
confirmText = stringResource(Res.string.open_settings),
onConfirm = {
context.openNfcSettings()
showNfcDisabledDialog = false
},
dismissText = stringResource(Res.string.cancel),
)
}

View file

@ -18,14 +18,9 @@ package org.meshtastic.feature.settings.radio.component
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@ -42,6 +37,7 @@ import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.close
import org.meshtastic.core.strings.delivery_confirmed
import org.meshtastic.core.strings.error
import org.meshtastic.core.ui.component.MeshtasticDialog
import org.meshtastic.feature.settings.radio.ResponseState
private const val AUTO_DISMISS_DELAY_MS = 1500L
@ -55,10 +51,11 @@ fun <T> PacketResponseStateDialog(state: ResponseState<T>, onDismiss: () -> Unit
onDismiss()
}
}
AlertDialog(
onDismissRequest = {},
shape = RoundedCornerShape(16.dp),
title = {
MeshtasticDialog(
onDismiss = onDismiss,
title = "", // Title is handled in the text block for more control
text = {
Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
if (state is ResponseState.Loading) {
val progress by
@ -86,24 +83,15 @@ fun <T> PacketResponseStateDialog(state: ResponseState<T>, onDismiss: () -> Unit
}
}
},
confirmButton = {
Row(
modifier = Modifier.fillMaxWidth().padding(start = 24.dp, end = 24.dp, bottom = 16.dp),
horizontalArrangement = Arrangement.Center,
) {
Button(
onClick = {
onDismiss()
if (state is ResponseState.Success || state is ResponseState.Error) {
backDispatcher?.onBackPressed()
}
},
modifier = Modifier.padding(top = 16.dp),
) {
Text(stringResource(Res.string.close))
}
dismissable = false,
onConfirm = {
onDismiss()
if (state is ResponseState.Success || state is ResponseState.Error) {
backDispatcher?.onBackPressed()
}
},
confirmText = stringResource(Res.string.close),
dismissText = null, // Hide dismiss button, only show "Close" confirm button
)
}

View file

@ -24,12 +24,9 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.twotone.Warning
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@ -49,7 +46,6 @@ import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.admin_key
import org.meshtastic.core.strings.admin_keys
import org.meshtastic.core.strings.administration
import org.meshtastic.core.strings.cancel
import org.meshtastic.core.strings.config_security_admin_key
import org.meshtastic.core.strings.config_security_debug_log_api_enabled
import org.meshtastic.core.strings.config_security_is_managed
@ -63,7 +59,6 @@ import org.meshtastic.core.strings.export_keys_confirmation
import org.meshtastic.core.strings.legacy_admin_channel
import org.meshtastic.core.strings.logs
import org.meshtastic.core.strings.managed_mode
import org.meshtastic.core.strings.okay
import org.meshtastic.core.strings.private_key
import org.meshtastic.core.strings.public_key
import org.meshtastic.core.strings.regenerate_keys_confirmation
@ -73,6 +68,7 @@ import org.meshtastic.core.strings.serial_console
import org.meshtastic.core.ui.component.CopyIconButton
import org.meshtastic.core.ui.component.EditBase64Preference
import org.meshtastic.core.ui.component.EditListPreference
import org.meshtastic.core.ui.component.MeshtasticResourceDialog
import org.meshtastic.core.ui.component.SwitchPreference
import org.meshtastic.core.ui.component.TitledCard
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
@ -116,28 +112,22 @@ fun SecurityConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBa
)
var showEditSecurityConfigDialog by rememberSaveable { mutableStateOf(false) }
if (showEditSecurityConfigDialog) {
AlertDialog(
title = { Text(text = stringResource(Res.string.export_keys)) },
text = { Text(text = stringResource(Res.string.export_keys_confirmation)) },
onDismissRequest = { showEditSecurityConfigDialog = false },
confirmButton = {
TextButton(
onClick = {
showEditSecurityConfigDialog = false
val intent =
Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "application/*"
putExtra(
Intent.EXTRA_TITLE,
"${node?.user?.short_name}_keys_${System.currentTimeMillis()}.json",
)
}
exportConfigLauncher.launch(intent)
},
) {
Text(stringResource(Res.string.okay))
}
MeshtasticResourceDialog(
titleRes = Res.string.export_keys,
messageRes = Res.string.export_keys_confirmation,
onDismiss = { showEditSecurityConfigDialog = false },
onConfirm = {
showEditSecurityConfigDialog = false
val intent =
Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "application/*"
putExtra(
Intent.EXTRA_TITLE,
"${node?.user?.short_name}_keys_${System.currentTimeMillis()}.json",
)
}
exportConfigLauncher.launch(intent)
},
)
}
@ -268,30 +258,22 @@ fun PrivateKeyRegenerateDialog(
onDismiss: () -> Unit = {},
) {
if (showKeyGenerationDialog) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(text = stringResource(Res.string.regenerate_private_key)) },
text = { Text(text = stringResource(Res.string.regenerate_keys_confirmation)) },
confirmButton = {
TextButton(
onClick = {
// Generate a random "f" value
val f = ByteArray(32).apply { SecureRandom().nextBytes(this) }
// Adjust the value to make it valid as an "s" value for eval().
// According to the specification we need to mask off the 3
// right-most bits of f[0], mask off the left-most bit of f[31],
// and set the second to left-most bit of f[31].
f[0] = (f[0].toInt() and 0xF8).toByte()
f[31] = ((f[31].toInt() and 0x7F) or 0x40).toByte()
val securityInput =
Config.SecurityConfig(private_key = f.toByteString(), public_key = ByteString.EMPTY)
onConfirm(securityInput)
},
) {
Text(stringResource(Res.string.okay))
}
MeshtasticResourceDialog(
onDismiss = onDismiss,
titleRes = Res.string.regenerate_private_key,
messageRes = Res.string.regenerate_keys_confirmation,
onConfirm = {
// Generate a random "f" value
val f = ByteArray(32).apply { SecureRandom().nextBytes(this) }
// Adjust the value to make it valid as an "s" value for eval().
// According to the specification we need to mask off the 3
// right-most bits of f[0], mask off the left-most bit of f[31],
// and set the second to left-most bit of f[31].
f[0] = (f[0].toInt() and 0xF8).toByte()
f[31] = ((f[31].toInt() and 0x7F) or 0x40).toByte()
val securityInput = Config.SecurityConfig(private_key = f.toByteString(), public_key = ByteString.EMPTY)
onConfirm(securityInput)
},
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(Res.string.cancel)) } },
)
}
}

View file

@ -23,12 +23,8 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Warning
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
@ -43,6 +39,7 @@ import org.meshtastic.core.strings.cancel
import org.meshtastic.core.strings.send
import org.meshtastic.core.strings.shutdown_node_name
import org.meshtastic.core.strings.shutdown_warning
import org.meshtastic.core.ui.component.MeshtasticDialog
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.proto.User
@ -57,22 +54,17 @@ fun ShutdownConfirmationDialog(
) {
val nodeLongName = node?.user?.long_name ?: "Unknown Node"
AlertDialog(
onDismissRequest = {},
icon = { icon?.let { Icon(imageVector = it, contentDescription = null) } },
title = { Text(text = title) },
MeshtasticDialog(
onDismiss = onDismiss,
icon = icon,
title = title,
text = { ShutdownDialogContent(nodeLongName = nodeLongName, isShutdown = isShutdown) },
dismissButton = { TextButton(onClick = { onDismiss() }) { Text(stringResource(Res.string.cancel)) } },
confirmButton = {
Button(
onClick = {
onDismiss()
onConfirm()
},
) {
Text(stringResource(Res.string.send))
}
confirmText = stringResource(Res.string.send),
onConfirm = {
onDismiss()
onConfirm()
},
dismissText = stringResource(Res.string.cancel),
)
}

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -14,16 +14,10 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.settings.radio.component
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Warning
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.tooling.preview.Preview
@ -31,6 +25,7 @@ import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.cancel
import org.meshtastic.core.strings.send
import org.meshtastic.core.ui.component.MeshtasticDialog
import org.meshtastic.core.ui.theme.AppTheme
@Composable
@ -41,22 +36,17 @@ fun WarningDialog(
onDismiss: () -> Unit,
onConfirm: () -> Unit,
) {
AlertDialog(
onDismissRequest = {},
icon = { icon?.let { Icon(imageVector = it, contentDescription = null) } },
title = { Text(text = title) },
MeshtasticDialog(
onDismiss = onDismiss,
icon = icon,
title = title,
text = text,
dismissButton = { TextButton(onClick = { onDismiss() }) { Text(stringResource(Res.string.cancel)) } },
confirmButton = {
Button(
onClick = {
onDismiss()
onConfirm()
},
) {
Text(stringResource(Res.string.send))
}
confirmText = stringResource(Res.string.send),
onConfirm = {
onDismiss()
onConfirm()
},
dismissText = stringResource(Res.string.cancel),
)
}