mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
feat: consolidate dialogs (#4506)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
parent
7bcc51863f
commit
ea6d1ffa32
59 changed files with 2042 additions and 1659 deletions
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)) } },
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue