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

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