Refactor: organize ui screens to separate packages (#1982)

This commit is contained in:
James Rich 2025-05-29 18:18:45 -05:00 committed by GitHub
parent 32d9f29d7e
commit ad1897c564
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
108 changed files with 475 additions and 569 deletions

View file

@ -0,0 +1,49 @@
/*
* 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 com.geeksville.mesh.ui.common.components
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun AdaptiveTwoPane(
first: @Composable ColumnScope.() -> Unit,
second: @Composable ColumnScope.() -> Unit,
) = BoxWithConstraints {
val compactWidth = maxWidth < 600.dp
Row {
Column(modifier = Modifier.weight(1f)) {
first()
if (compactWidth) {
second()
}
}
if (!compactWidth) {
Column(modifier = Modifier.weight(1f)) {
second()
}
}
}
}

View file

@ -0,0 +1,122 @@
/*
* 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 com.geeksville.mesh.ui.common.components
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.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
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.TextDecoration
import androidx.compose.ui.unit.dp
import com.geeksville.mesh.R
@Composable
fun SimpleAlertDialog(
title: String,
message: String?,
html: String? = null,
onDismissRequest: () -> Unit,
onConfirmRequest: () -> Unit = onDismissRequest // Default confirm to dismiss
) {
val annotatedString = html?.let {
AnnotatedString.fromHtml(
html,
linkStyles = TextLinkStyles(
style = SpanStyle(
textDecoration = TextDecoration.Underline,
fontStyle = FontStyle.Italic,
color = Color.Blue
)
)
)
}
AlertDialog(
onDismissRequest = onDismissRequest,
title = { Text(text = title) },
text = {
if (annotatedString != null) {
Text(
text = annotatedString,
)
} else {
Text(
text = message.orEmpty()
)
}
},
confirmButton = {
TextButton(onClick = onConfirmRequest) {
Text(stringResource(id = R.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) },
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)
}
}
}
},
confirmButton = {
}
)
}

View file

@ -0,0 +1,93 @@
/*
* 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 com.geeksville.mesh.ui.common.components
import android.text.Spannable
import android.text.Spannable.Factory
import android.text.style.URLSpan
import android.text.util.Linkify
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withLink
import androidx.compose.ui.tooling.preview.Preview
import androidx.core.text.util.LinkifyCompat
import com.geeksville.mesh.ui.common.theme.HyperlinkBlue
private val DefaultTextLinkStyles = TextLinkStyles(
style = SpanStyle(
color = HyperlinkBlue,
textDecoration = TextDecoration.Underline,
)
)
@Composable
fun AutoLinkText(
text: String,
modifier: Modifier = Modifier,
style: TextStyle = TextStyle.Default,
linkStyles: TextLinkStyles = DefaultTextLinkStyles,
) {
val spannable = remember(text) {
linkify(text)
}
Text(
text = spannable.toAnnotatedString(linkStyles),
modifier = modifier,
style = style,
)
}
private fun linkify(text: String) = Factory.getInstance().newSpannable(text).also {
LinkifyCompat.addLinks(it, Linkify.WEB_URLS or Linkify.EMAIL_ADDRESSES or Linkify.PHONE_NUMBERS)
}
private fun Spannable.toAnnotatedString(
linkStyles: TextLinkStyles,
): AnnotatedString = buildAnnotatedString {
val spannable = this@toAnnotatedString
var lastEnd = 0
spannable.getSpans(0, spannable.length, Any::class.java).forEach { span ->
val start = spannable.getSpanStart(span)
val end = spannable.getSpanEnd(span)
append(spannable.subSequence(lastEnd, start))
when (span) {
is URLSpan -> withLink(LinkAnnotation.Url(url = span.url, styles = linkStyles)) {
append(spannable.subSequence(start, end))
}
else -> append(spannable.subSequence(start, end))
}
lastEnd = end
}
append(spannable.subSequence(lastEnd, spannable.length))
}
@Preview(showBackground = true)
@Composable
private fun AutoLinkTextPreview() {
AutoLinkText("A text containing a link https://example.com")
}

View file

@ -0,0 +1,110 @@
/*
* 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 com.geeksville.mesh.ui.common.components
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.height
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
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.res.vectorResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp
import com.geeksville.mesh.R
import com.geeksville.mesh.ui.common.theme.AppTheme
@Composable
fun BatteryInfo(
modifier: Modifier = Modifier,
batteryLevel: Int?,
voltage: Float?
) {
val infoString = "%d%% %.2fV".format(batteryLevel, voltage)
val (image, level) = when (batteryLevel) {
in 0 .. 4 -> R.drawable.ic_battery_alert to " $infoString"
in 5 .. 14 -> R.drawable.ic_battery_outline to infoString
in 15..34 -> R.drawable.ic_battery_low to infoString
in 35..79 -> R.drawable.ic_battery_medium to infoString
in 80..100 -> R.drawable.ic_battery_high to infoString
101 -> R.drawable.ic_power_plug_24 to "%.2fV".format(voltage)
else -> R.drawable.ic_battery_unknown to (voltage?.let { "%.2fV".format(it) } ?: "")
}
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically
) {
Icon(
modifier = Modifier.height(18.dp),
imageVector = ImageVector.vectorResource(id = image),
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurface,
)
Text(
text = level,
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.labelLarge.fontSize
)
}
}
@PreviewLightDark
@Composable
fun BatteryInfoPreview(
@PreviewParameter(BatteryInfoPreviewParameterProvider::class)
batteryInfo: Pair<Int?, Float?>
) {
AppTheme {
BatteryInfo(
batteryLevel = batteryInfo.first,
voltage = batteryInfo.second
)
}
}
@Composable
@Preview
fun BatteryInfoPreviewSimple() {
AppTheme {
BatteryInfo(
batteryLevel = 85,
voltage = 3.7F
)
}
}
class BatteryInfoPreviewParameterProvider : PreviewParameterProvider<Pair<Int?, Float?>> {
override val values: Sequence<Pair<Int?, Float?>>
get() = sequenceOf(
85 to 3.7F,
2 to 3.7F,
12 to 3.7F,
28 to 3.7F,
50 to 3.7F,
101 to 4.9F,
null to 4.5F,
null to null
)
}

View file

@ -0,0 +1,110 @@
/*
* 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 com.geeksville.mesh.ui.common.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.twotone.KeyboardArrowDown
import androidx.compose.material.icons.twotone.KeyboardArrowUp
import androidx.compose.material3.Checkbox
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import com.geeksville.mesh.R
@Composable
fun BitwisePreference(
title: String,
value: Int,
enabled: Boolean,
items: List<Pair<Int, String>>,
onItemSelected: (Int) -> Unit,
modifier: Modifier = Modifier,
) {
var dropDownExpanded by remember { mutableStateOf(value = false) }
RegularPreference(
title = title,
subtitle = value.toString(),
onClick = { dropDownExpanded = !dropDownExpanded },
enabled = enabled,
trailingIcon = if (dropDownExpanded) {
Icons.TwoTone.KeyboardArrowUp
} else {
Icons.TwoTone.KeyboardArrowDown
},
)
Box {
DropdownMenu(
expanded = dropDownExpanded,
onDismissRequest = { dropDownExpanded = !dropDownExpanded },
) {
items.forEach { item ->
DropdownMenuItem(
onClick = { onItemSelected(value xor item.first) },
modifier = modifier.fillMaxWidth(),
text = {
Text(
text = item.second,
overflow = TextOverflow.Ellipsis,
)
Checkbox(
modifier = modifier
.fillMaxWidth()
.wrapContentWidth(Alignment.End),
checked = value and item.first != 0,
onCheckedChange = { onItemSelected(value xor item.first) },
enabled = enabled,
)
}
)
}
PreferenceFooter(
enabled = enabled,
negativeText = R.string.clear,
onNegativeClicked = { onItemSelected(0) },
positiveText = R.string.close,
onPositiveClicked = { dropDownExpanded = false },
)
}
}
}
@Preview(showBackground = true)
@Composable
private fun BitwisePreferencePreview() {
BitwisePreference(
title = "Settings",
value = 3,
enabled = true,
items = listOf(1 to "TEST1", 2 to "TEST2"),
onItemSelected = {}
)
}

View file

@ -0,0 +1,69 @@
/*
* 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 com.geeksville.mesh.ui.common.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
@Composable
fun BottomSheetDialog(
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
content: @Composable ColumnScope.() -> Unit
) = Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Transparent)
.clickable(
onClick = onDismiss,
indication = null,
interactionSource = remember { MutableInteractionSource() }
)
) {
Column(
modifier = modifier
.align(Alignment.BottomCenter)
.background(
color = MaterialTheme.colorScheme.surface.copy(alpha = 1f),
shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp)
)
.padding(16.dp),
content = content
)
}
}

View file

@ -0,0 +1,58 @@
/*
* 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 com.geeksville.mesh.ui.common.components
import androidx.annotation.StringRes
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.material3.Icon
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
@Composable
fun ClickableTextField(
@StringRes label: Int,
enabled: Boolean,
trailingIcon: ImageVector,
value: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
isError: Boolean = false,
) {
val source = remember { MutableInteractionSource() }
val isPressed by source.collectIsPressedAsState()
if (isPressed) onClick()
OutlinedTextField(
value,
onValueChange = {},
enabled = enabled,
readOnly = true,
label = { Text(stringResource(label)) },
trailingIcon = { Icon(trailingIcon, null) },
isError = isError,
interactionSource = source,
modifier = modifier,
)
}

View file

@ -0,0 +1,57 @@
/*
* 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 com.geeksville.mesh.ui.common.components
import android.content.ClipData
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.twotone.ContentCopy
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.res.stringResource
import com.geeksville.mesh.R
import kotlinx.coroutines.launch
@Composable
fun CopyIconButton(
valueToCopy: String,
modifier: Modifier = Modifier,
label: String = stringResource(id = R.string.copy),
) {
val clipboardManager = LocalClipboard.current
val coroutineScope = rememberCoroutineScope()
IconButton(
modifier = modifier,
onClick = {
coroutineScope.launch {
val clipData = ClipData.newPlainText(label, valueToCopy)
val clipEntry = ClipEntry(clipData)
clipboardManager.setClipEntry(clipEntry)
}
}
) {
Icon(
imageVector = Icons.TwoTone.ContentCopy,
contentDescription = label
)
}
}

View file

@ -0,0 +1,148 @@
/*
* 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 com.geeksville.mesh.ui.common.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.twotone.KeyboardArrowDown
import androidx.compose.material.icons.twotone.KeyboardArrowUp
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import com.geeksville.mesh.R
import com.google.protobuf.ProtocolMessageEnum
@Composable
fun <T : Enum<T>> DropDownPreference(
title: String,
enabled: Boolean,
selectedItem: T,
onItemSelected: (T) -> Unit,
modifier: Modifier = Modifier,
summary: String? = null,
) {
DropDownPreference(
title = title,
enabled = enabled,
items = selectedItem.declaringJavaClass.enumConstants
?.filter { it.name != "UNRECOGNIZED" }?.map { it to it.name } ?: emptyList(),
selectedItem = selectedItem,
onItemSelected = onItemSelected,
modifier = modifier,
summary = summary,
)
}
@Composable
fun <T> DropDownPreference(
title: String,
enabled: Boolean,
items: List<Pair<T, String>>,
selectedItem: T,
onItemSelected: (T) -> Unit,
modifier: Modifier = Modifier,
summary: String? = null,
) {
var dropDownExpanded by remember { mutableStateOf(value = false) }
val deprecatedItems: List<T> = remember {
if (selectedItem is ProtocolMessageEnum) {
val enum = (selectedItem as? Enum<*>)?.declaringJavaClass?.enumConstants
val descriptor = (selectedItem as ProtocolMessageEnum).descriptorForType
@Suppress("UNCHECKED_CAST")
enum?.filter { entries ->
descriptor.values.any { it.name == entries.name && it.options.deprecated }
} as? List<T> ?: emptyList() // Safe cast to List<T> or return emptyList if cast fails
} else {
emptyList()
}
}
RegularPreference(
title = title,
subtitle = items.find { it.first == selectedItem }?.second
?: stringResource(id = R.string.unrecognized),
onClick = {
dropDownExpanded = true
},
enabled = enabled,
trailingIcon = if (dropDownExpanded) {
Icons.TwoTone.KeyboardArrowUp
} else {
Icons.TwoTone.KeyboardArrowDown
},
summary = summary,
)
Box {
DropdownMenu(
expanded = dropDownExpanded,
onDismissRequest = { dropDownExpanded = !dropDownExpanded },
) {
items.filterNot { it.first in deprecatedItems }.forEach { item ->
DropdownMenuItem(
onClick = {
dropDownExpanded = false
onItemSelected(item.first)
},
modifier = modifier
.background(
color = if (selectedItem == item.first) {
MaterialTheme.colorScheme.primary.copy(alpha = 0.3f)
} else {
Color.Unspecified
},
),
text = {
Text(
text = item.second,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
)
}
)
}
}
}
}
@Preview(showBackground = true)
@Composable
private fun DropDownPreferencePreview() {
DropDownPreference(
title = "Settings",
summary = "Lorem ipsum dolor sit amet",
enabled = true,
items = listOf("TEST1" to "text1", "TEST2" to "text2"),
selectedItem = "TEST2",
onItemSelected = {}
)
}

View file

@ -0,0 +1,142 @@
/*
* 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 com.geeksville.mesh.ui.common.components
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.twotone.Close
import androidx.compose.material.icons.twotone.Refresh
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.geeksville.mesh.R
import com.geeksville.mesh.model.Channel
import com.geeksville.mesh.util.encodeToString
import com.geeksville.mesh.util.toByteString
import com.google.protobuf.ByteString
@Suppress("LongMethod")
@Composable
fun EditBase64Preference(
modifier: Modifier = Modifier,
title: String,
value: ByteString,
enabled: Boolean,
readOnly: Boolean = false,
keyboardActions: KeyboardActions,
onValueChange: (ByteString) -> Unit,
onGenerateKey: (() -> Unit)? = null,
trailingIcon: (@Composable () -> Unit)? = null,
) {
var valueState by remember { mutableStateOf(value.encodeToString()) }
val isError = value.encodeToString() != valueState
// don't update values while the user is editing
var isFocused by remember { mutableStateOf(false) }
LaunchedEffect(value) {
if (!isFocused) {
valueState = value.encodeToString()
}
}
val (icon, description) = when {
isError -> Icons.TwoTone.Close to stringResource(R.string.error)
onGenerateKey != null && !isFocused -> Icons.TwoTone.Refresh to stringResource(R.string.reset)
else -> null to null
}
OutlinedTextField(
value = valueState,
onValueChange = {
valueState = it
runCatching { it.toByteString() }.onSuccess(onValueChange)
},
modifier = modifier
.fillMaxWidth()
.onFocusChanged { focusState -> isFocused = focusState.isFocused },
enabled = enabled,
readOnly = readOnly,
label = { Text(text = title) },
isError = isError,
keyboardOptions = KeyboardOptions.Default.copy(
keyboardType = KeyboardType.Password, imeAction = ImeAction.Done
),
keyboardActions = keyboardActions,
trailingIcon = {
if (icon != null) {
IconButton(
onClick = {
if (isError) {
valueState = value.encodeToString()
onValueChange(value)
} else if (onGenerateKey != null && !isFocused) {
onGenerateKey()
}
},
enabled = enabled,
) {
Icon(
imageVector = icon,
contentDescription = description,
tint = if (isError) {
MaterialTheme.colorScheme.error
} else {
LocalContentColor.current
}
)
}
} else if (trailingIcon != null) {
trailingIcon()
}
},
)
}
@Preview(showBackground = true)
@Composable
private fun EditBase64PreferencePreview() {
EditBase64Preference(
title = "Title",
value = Channel.getRandomKey(),
enabled = true,
keyboardActions = KeyboardActions {},
onValueChange = {},
onGenerateKey = {},
modifier = Modifier.padding(16.dp)
)
}

View file

@ -0,0 +1,83 @@
/*
* 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 com.geeksville.mesh.ui.common.components
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
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.Modifier
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview
@Composable
fun EditIPv4Preference(
title: String,
value: Int,
enabled: Boolean,
keyboardActions: KeyboardActions,
onValueChanged: (Int) -> Unit,
modifier: Modifier = Modifier,
) {
val pattern = """\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b""".toRegex()
fun convertIntToIpAddress(int: Int): String {
return "${int and 0xff}.${int shr 8 and 0xff}.${int shr 16 and 0xff}.${int shr 24 and 0xff}"
}
fun convertIpAddressToInt(ipAddress: String): Int? = ipAddress.split(".")
.map { it.toIntOrNull() }.reversed() // little-endian byte order
.fold(0) { total, next ->
if (next == null) return null else total shl 8 or next
}
var valueState by remember(value) { mutableStateOf(convertIntToIpAddress(value)) }
EditTextPreference(
title = title,
value = valueState,
enabled = enabled,
isError = convertIntToIpAddress(value) != valueState,
keyboardOptions = KeyboardOptions.Default.copy(
keyboardType = KeyboardType.Number, imeAction = ImeAction.Done
),
keyboardActions = keyboardActions,
onValueChanged = {
valueState = it
if (pattern.matches(it)) convertIpAddressToInt(it)?.let { int -> onValueChanged(int) }
},
onFocusChanged = {},
modifier = modifier
)
}
@Preview(showBackground = true)
@Composable
private fun EditIPv4PreferencePreview() {
EditIPv4Preference(
title = "IP Address",
value = 16820416,
enabled = true,
keyboardActions = KeyboardActions {},
onValueChanged = {}
)
}

View file

@ -0,0 +1,202 @@
/*
* 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 com.geeksville.mesh.ui.common.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.twotone.Close
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.geeksville.mesh.ModuleConfigProtos.RemoteHardwarePin
import com.geeksville.mesh.ModuleConfigProtos.RemoteHardwarePinType
import com.geeksville.mesh.R
import com.geeksville.mesh.copy
import com.geeksville.mesh.remoteHardwarePin
import com.google.protobuf.ByteString
@Composable
inline fun <reified T> EditListPreference(
title: String,
list: List<T>,
maxCount: Int,
enabled: Boolean,
keyboardActions: KeyboardActions,
crossinline onValuesChanged: (List<T>) -> Unit,
modifier: Modifier = Modifier,
) {
val focusManager = LocalFocusManager.current
val listState = remember(list) { mutableStateListOf<T>().apply { addAll(list) } }
Column(modifier = modifier) {
Text(
modifier = modifier.padding(16.dp),
text = title,
style = MaterialTheme.typography.bodyMedium,
)
listState.forEachIndexed { index, value ->
val trailingIcon = @Composable {
IconButton(
onClick = {
focusManager.clearFocus()
listState.removeAt(index)
onValuesChanged(listState)
}
) {
Icon(
imageVector = Icons.TwoTone.Close,
contentDescription = stringResource(R.string.delete),
modifier = Modifier.wrapContentSize(),
)
}
}
// handle lora.ignoreIncoming: List<Int>
if (value is Int) EditTextPreference(
title = "${index + 1}/$maxCount",
value = value,
enabled = enabled,
keyboardActions = keyboardActions,
onValueChanged = {
listState[index] = it as T
onValuesChanged(listState)
},
modifier = modifier.fillMaxWidth(),
trailingIcon = trailingIcon,
)
// handle security.adminKey: List<ByteString>
if (value is ByteString) EditBase64Preference(
title = "${index + 1}/$maxCount",
value = value,
enabled = enabled,
keyboardActions = keyboardActions,
onValueChange = {
listState[index] = it as T
onValuesChanged(listState)
},
modifier = modifier.fillMaxWidth(),
trailingIcon = trailingIcon,
)
// handle remoteHardware.availablePins: List<RemoteHardwarePin>
if (value is RemoteHardwarePin) {
EditTextPreference(
title = stringResource(R.string.gpio_pin),
value = value.gpioPin,
enabled = enabled,
keyboardActions = keyboardActions,
onValueChanged = {
if (it in 0..255) {
listState[index] = value.copy { gpioPin = it } as T
onValuesChanged(listState)
}
},
)
EditTextPreference(
title = stringResource(R.string.name),
value = value.name,
maxSize = 14, // name max_size:15
enabled = enabled,
isError = false,
keyboardOptions = KeyboardOptions.Default.copy(
keyboardType = KeyboardType.Text, imeAction = ImeAction.Done
),
keyboardActions = keyboardActions,
onValueChanged = {
listState[index] = value.copy { name = it } as T
onValuesChanged(listState)
},
trailingIcon = trailingIcon,
)
DropDownPreference(
title = stringResource(R.string.type),
enabled = enabled,
items = RemoteHardwarePinType.entries
.filter { it != RemoteHardwarePinType.UNRECOGNIZED }
.map { it to it.name },
selectedItem = value.type,
onItemSelected = {
listState[index] = value.copy { type = it } as T
onValuesChanged(listState)
},
)
}
}
OutlinedButton(
modifier = Modifier.fillMaxWidth(),
onClick = {
// Add element based on the type T
val newElement = when (T::class) {
Int::class -> 0 as T
ByteString::class -> ByteString.EMPTY as T
RemoteHardwarePin::class -> remoteHardwarePin {} as T
else -> throw IllegalArgumentException("Unsupported type: ${T::class}")
}
listState.add(listState.size, newElement)
},
enabled = maxCount > listState.size,
) { Text(text = stringResource(R.string.add)) }
}
}
@Preview(showBackground = true)
@Composable
private fun EditListPreferencePreview() {
Column {
EditListPreference(
title = "Ignore incoming",
list = listOf(12345, 67890),
maxCount = 4,
enabled = true,
keyboardActions = KeyboardActions {},
onValuesChanged = {},
)
EditListPreference(
title = "Available pins",
list = listOf(
remoteHardwarePin {
gpioPin = 12
name = "Front door"
type = RemoteHardwarePinType.DIGITAL_READ
},
),
maxCount = 4,
enabled = true,
keyboardActions = KeyboardActions {},
onValuesChanged = {},
)
}
}

View file

@ -0,0 +1,94 @@
/*
* 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 com.geeksville.mesh.ui.common.components
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.twotone.VisibilityOff
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
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.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.tooling.preview.Preview
import com.geeksville.mesh.R
@Composable
fun EditPasswordPreference(
title: String,
value: String,
maxSize: Int,
enabled: Boolean,
keyboardActions: KeyboardActions,
onValueChanged: (String) -> Unit,
modifier: Modifier = Modifier,
) {
var isPasswordVisible by remember { mutableStateOf(false) }
EditTextPreference(
title = title,
value = value,
maxSize = maxSize,
enabled = enabled,
isError = false,
keyboardOptions = KeyboardOptions.Default.copy(
keyboardType = KeyboardType.Password, imeAction = ImeAction.Done
),
keyboardActions = keyboardActions,
onValueChanged = {
onValueChanged(it)
},
onFocusChanged = {},
visualTransformation = if (isPasswordVisible) VisualTransformation.None else PasswordVisualTransformation(),
trailingIcon = {
IconButton(onClick = { isPasswordVisible = !isPasswordVisible }) {
Icon(
imageVector = if (isPasswordVisible) Icons.TwoTone.VisibilityOff else Icons.TwoTone.VisibilityOff,
contentDescription = if (isPasswordVisible) {
stringResource(R.string.hide_password)
} else {
stringResource(R.string.show_password)
},
)
}
},
modifier = modifier
)
}
@Preview(showBackground = true)
@Composable
private fun EditPasswordPreferencePreview() {
EditPasswordPreference(
title = "Password",
value = "top secret",
maxSize = 63,
enabled = true,
keyboardActions = KeyboardActions {},
onValueChanged = {}
)
}

View file

@ -0,0 +1,270 @@
/*
* 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 com.geeksville.mesh.ui.common.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.twotone.Info
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
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.focus.FocusState
import androidx.compose.ui.focus.onFocusEvent
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.geeksville.mesh.R
@Composable
fun SignedIntegerEditTextPreference(
title: String,
value: Int,
enabled: Boolean,
keyboardActions: KeyboardActions,
onValueChanged: (Int) -> Unit,
modifier: Modifier = Modifier,
onFocusChanged: (FocusState) -> Unit = {},
trailingIcon: (@Composable () -> Unit)? = null,
) {
var valueState by remember(value) { mutableStateOf(value.toString()) }
EditTextPreference(
title = title,
value = valueState,
enabled = enabled,
isError = valueState.toIntOrNull() == null,
keyboardOptions = KeyboardOptions.Default.copy(
keyboardType = KeyboardType.Number, imeAction = ImeAction.Done
),
keyboardActions = keyboardActions,
onValueChanged = {
valueState = it
it.toIntOrNull()?.let { int ->
onValueChanged(int)
}
},
onFocusChanged = onFocusChanged,
modifier = modifier,
trailingIcon = trailingIcon
)
}
@Composable
fun EditTextPreference(
title: String,
value: Int,
enabled: Boolean,
keyboardActions: KeyboardActions,
onValueChanged: (Int) -> Unit,
modifier: Modifier = Modifier,
onFocusChanged: (FocusState) -> Unit = {},
trailingIcon: (@Composable () -> Unit)? = null,
) {
var valueState by remember(value) { mutableStateOf(value.toUInt().toString()) }
EditTextPreference(
title = title,
value = valueState,
enabled = enabled,
isError = value.toUInt().toString() != valueState,
keyboardOptions = KeyboardOptions.Default.copy(
keyboardType = KeyboardType.Number, imeAction = ImeAction.Done
),
keyboardActions = keyboardActions,
onValueChanged = {
if (it.isEmpty()) valueState = it
else it.toUIntOrNull()?.toInt()?.let { int ->
valueState = it
onValueChanged(int)
}
},
onFocusChanged = onFocusChanged,
modifier = modifier,
trailingIcon = trailingIcon
)
}
@Composable
fun EditTextPreference(
title: String,
value: Float,
enabled: Boolean,
keyboardActions: KeyboardActions,
onValueChanged: (Float) -> Unit,
modifier: Modifier = Modifier,
onFocusChanged: (FocusState) -> Unit = {},
) {
var valueState by remember(value) { mutableStateOf(value.toString()) }
EditTextPreference(
title = title,
value = valueState,
enabled = enabled,
isError = value.toString() != valueState,
keyboardOptions = KeyboardOptions.Default.copy(
keyboardType = KeyboardType.Number, imeAction = ImeAction.Done
),
keyboardActions = keyboardActions,
onValueChanged = {
if (it.isEmpty()) valueState = it
else it.toFloatOrNull()?.let { float ->
valueState = it
onValueChanged(float)
}
},
onFocusChanged = onFocusChanged,
modifier = modifier
)
}
@Composable
fun EditTextPreference(
title: String,
value: Double,
enabled: Boolean,
keyboardActions: KeyboardActions,
onValueChanged: (Double) -> Unit,
modifier: Modifier = Modifier,
) {
var valueState by remember(value) { mutableStateOf(value.toString()) }
val decimalSeparators = setOf('.', ',', '٫', '、', '·') // set of possible decimal separators
EditTextPreference(
title = title,
value = valueState,
enabled = enabled,
isError = value.toString() != valueState,
keyboardOptions = KeyboardOptions.Default.copy(
keyboardType = KeyboardType.Number, imeAction = ImeAction.Done
),
keyboardActions = keyboardActions,
onValueChanged = {
if (it.length <= 1 || it.first() in decimalSeparators) valueState = it
else it.toDoubleOrNull()?.let { double ->
valueState = it
onValueChanged(double)
}
},
onFocusChanged = {},
modifier = modifier
)
}
@Composable
fun EditTextPreference(
title: String,
value: String,
enabled: Boolean,
isError: Boolean,
keyboardOptions: KeyboardOptions,
keyboardActions: KeyboardActions,
onValueChanged: (String) -> Unit,
modifier: Modifier = Modifier,
maxSize: Int = 0, // max_size - 1 (in bytes)
onFocusChanged: (FocusState) -> Unit = {},
trailingIcon: (@Composable () -> Unit)? = null,
visualTransformation: VisualTransformation = VisualTransformation.None,
) {
var isFocused by remember { mutableStateOf(false) }
TextField(
value = value,
singleLine = true,
modifier = modifier
.fillMaxWidth()
.onFocusEvent { isFocused = it.isFocused; onFocusChanged(it) },
enabled = enabled,
isError = isError,
onValueChange = {
if (maxSize > 0) {
if (it.toByteArray().size <= maxSize) {
onValueChanged(it)
}
} else onValueChanged(it)
},
label = { Text(title) },
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions,
visualTransformation = visualTransformation,
trailingIcon = {
if (trailingIcon != null) {
trailingIcon()
} else if (isError) {
Icon(
imageVector = Icons.TwoTone.Info,
contentDescription = stringResource(id = R.string.error),
tint = MaterialTheme.colorScheme.error
)
}
},
)
if (maxSize > 0 && isFocused) {
Box(
contentAlignment = Alignment.BottomEnd,
modifier = modifier.fillMaxWidth()
) {
Text(
text = "${value.toByteArray().size}/$maxSize",
style = MaterialTheme.typography.bodySmall,
color = if (isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onBackground,
modifier = Modifier.padding(end = 8.dp, bottom = 4.dp)
)
}
}
}
@Preview(showBackground = true)
@Composable
private fun EditTextPreferencePreview() {
Column {
EditTextPreference(
title = "String",
value = "Meshtastic",
maxSize = 39,
enabled = true,
isError = false,
keyboardOptions = KeyboardOptions.Default,
keyboardActions = KeyboardActions {},
onValueChanged = {},
)
EditTextPreference(
title = "Advanced Settings",
value = UInt.MAX_VALUE.toInt(),
enabled = true,
keyboardActions = KeyboardActions {},
onValueChanged = {}
)
}
}

View file

@ -0,0 +1,76 @@
/*
* 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 com.geeksville.mesh.ui.common.components
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.AndroidView
import androidx.emoji2.emojipicker.RecentEmojiProviderAdapter
import com.geeksville.mesh.util.CustomRecentEmojiProvider
@Composable
fun EmojiPicker(
onDismiss: () -> Unit = {},
onConfirm: (String) -> Unit
) {
Column(
verticalArrangement = Arrangement.Bottom
) {
BackHandler {
onDismiss()
}
AndroidView(
factory = { context ->
androidx.emoji2.emojipicker.EmojiPickerView(context).apply {
clipToOutline = true
setRecentEmojiProvider(
RecentEmojiProviderAdapter(CustomRecentEmojiProvider(context))
)
setOnEmojiPickedListener { emoji ->
onDismiss()
onConfirm(emoji.emoji)
}
}
},
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.background)
)
}
}
@Composable
fun EmojiPickerDialog(
onDismiss: () -> Unit = {},
onConfirm: (String) -> Unit
) = BottomSheetDialog(
onDismiss = onDismiss,
modifier = Modifier.fillMaxHeight(fraction = .4f),
) {
EmojiPicker(
onConfirm = onConfirm,
onDismiss = onDismiss,
)
}

View file

@ -0,0 +1,321 @@
/*
* 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 com.geeksville.mesh.ui.common.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
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.material.icons.Icons
import androidx.compose.material.icons.filled.ThumbUp
import androidx.compose.material.icons.filled.Warning
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
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
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
import com.geeksville.mesh.R
@Suppress("MagicNumber")
enum class Iaq(val color: Color, val description: String, val range: IntRange) {
Excellent(Color(0xFF00E400), "Excellent", 0..50),
Good(Color(0xFF92D050), "Good", 51..100),
LightlyPolluted(Color(0xFFFFFF00), "Lightly Polluted", 101..150),
ModeratelyPolluted(Color(0xFFFF7300), "Moderately Polluted", 151..200),
HeavilyPolluted(Color(0xFFFF0000), "Heavily Polluted", 201..300),
SeverelyPolluted(Color(0xFF99004C), "Severely Polluted", 301..400),
ExtremelyPolluted(Color(0xFF663300), "Extremely Polluted", 401..500),
DangerouslyPolluted(Color(0xFF663300), "Dangerously Polluted", 501..Int.MAX_VALUE)
}
fun getIaq(iaq: Int): Iaq {
return when {
iaq in Iaq.Excellent.range -> Iaq.Excellent
iaq in Iaq.Good.range -> Iaq.Good
iaq in Iaq.LightlyPolluted.range -> Iaq.LightlyPolluted
iaq in Iaq.ModeratelyPolluted.range -> Iaq.ModeratelyPolluted
iaq in Iaq.HeavilyPolluted.range -> Iaq.HeavilyPolluted
iaq in Iaq.SeverelyPolluted.range -> Iaq.SeverelyPolluted
iaq in Iaq.ExtremelyPolluted.range -> Iaq.ExtremelyPolluted
else -> Iaq.DangerouslyPolluted
}
}
private fun getIaqDescriptionWithRange(iaqEnum: Iaq): String {
return if (iaqEnum.range.last == Int.MAX_VALUE) {
"${iaqEnum.description} (${iaqEnum.range.first}+)"
} else {
"${iaqEnum.description} (${iaqEnum.range.first}-${iaqEnum.range.last})"
}
}
enum class IaqDisplayMode {
Pill, Dot, Text, Gauge, Gradient
}
@Suppress("LongMethod", "UnusedPrivateProperty")
@Composable
fun IndoorAirQuality(iaq: Int, displayMode: IaqDisplayMode = IaqDisplayMode.Pill) {
var isLegendOpen by remember { mutableStateOf(false) }
val iaqEnum = getIaq(iaq)
val gradient = Brush.linearGradient(
colors = Iaq.entries.map { it.color },
)
Column {
when (displayMode) {
IaqDisplayMode.Pill -> {
Box(
modifier = Modifier
.clip(RoundedCornerShape(10.dp))
.background(iaqEnum.color)
.width(125.dp)
.height(30.dp)
.clickable { isLegendOpen = true }
) {
Row(
modifier = Modifier
.padding(4.dp)
.align(Alignment.CenterStart),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "IAQ $iaq",
color = Color.White,
fontWeight = FontWeight.Bold
)
Icon(
imageVector = if (iaq < 100) Icons.Default.ThumbUp else Icons.Filled.Warning,
contentDescription = "AQI Icon",
tint = Color.White
)
}
}
}
IaqDisplayMode.Dot -> {
Column(modifier = Modifier.clickable { isLegendOpen = true }) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(text = "$iaq")
Spacer(modifier = Modifier.width(4.dp))
Box(
modifier = Modifier
.size(10.dp)
.background(iaqEnum.color, shape = CircleShape)
)
}
}
}
IaqDisplayMode.Text -> {
Text(
text = getIaqDescriptionWithRange(iaqEnum),
fontSize = 12.sp,
modifier = Modifier.clickable { isLegendOpen = true }
)
}
IaqDisplayMode.Gauge -> {
CircularProgressIndicator(
progress = iaq / 500f,
modifier = Modifier
.size(60.dp)
.clickable { isLegendOpen = true },
strokeWidth = 8.dp,
color = iaqEnum.color
)
Text(text = "$iaq")
}
IaqDisplayMode.Gradient -> {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.clickable { isLegendOpen = true }
) {
LinearProgressIndicator(
progress = iaq / 500f,
modifier = Modifier
.fillMaxWidth()
.height(20.dp),
color = iaqEnum.color,
)
Spacer(modifier = Modifier.width(8.dp))
Text(text = iaqEnum.description, fontSize = 12.sp)
}
}
}
if (isLegendOpen) {
AlertDialog(
onDismissRequest = { isLegendOpen = false },
shape = RoundedCornerShape(16.dp),
text = {
IAQScale()
},
confirmButton = {
TextButton(onClick = { isLegendOpen = false }) {
Text(text = stringResource(id = R.string.close))
}
}
)
}
}
}
// Assuming Iaq is an enum class with color and description properties
// and that it conforms to CaseIterable.
// Replace with your actual implementation
@Composable
fun IAQScale(modifier: Modifier = Modifier) {
Column(
modifier = modifier
.padding(16.dp),
horizontalAlignment = Alignment.Start
) {
Text(
text = stringResource(R.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) {
Box(
modifier = Modifier
.size(20.dp, 15.dp)
.clip(RoundedCornerShape(5.dp))
.background(iaq.color)
)
Spacer(modifier = Modifier.width(8.dp))
Text(getIaqDescriptionWithRange(iaq), style = MaterialTheme.typography.bodyMedium)
}
Spacer(modifier = Modifier.height(4.dp))
}
}
}
@Preview(showBackground = true)
@Composable
fun IAQScalePreview() {
IAQScale()
}
@Suppress("LongMethod")
@Preview(showBackground = true)
@Composable
private fun IndoorAirQualityPreview() {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text("Pill", style = MaterialTheme.typography.titleLarge)
Row {
IndoorAirQuality(iaq = 6)
IndoorAirQuality(iaq = 51)
}
Row {
IndoorAirQuality(iaq = 101)
IndoorAirQuality(iaq = 201)
}
Row {
IndoorAirQuality(iaq = 350)
IndoorAirQuality(iaq = 351)
}
Text("Dot", style = MaterialTheme.typography.titleLarge)
Row {
IndoorAirQuality(iaq = 6, displayMode = IaqDisplayMode.Dot)
IndoorAirQuality(iaq = 51, displayMode = IaqDisplayMode.Dot)
IndoorAirQuality(iaq = 101, displayMode = IaqDisplayMode.Dot)
IndoorAirQuality(iaq = 201, displayMode = IaqDisplayMode.Dot)
IndoorAirQuality(iaq = 350, displayMode = IaqDisplayMode.Dot)
IndoorAirQuality(iaq = 351, displayMode = IaqDisplayMode.Dot)
}
Text("Text", style = MaterialTheme.typography.titleLarge)
Row {
IndoorAirQuality(iaq = 6, displayMode = IaqDisplayMode.Text)
IndoorAirQuality(iaq = 51, displayMode = IaqDisplayMode.Text)
IndoorAirQuality(iaq = 101, displayMode = IaqDisplayMode.Text)
}
Row {
IndoorAirQuality(iaq = 201, displayMode = IaqDisplayMode.Text)
IndoorAirQuality(iaq = 350, displayMode = IaqDisplayMode.Text)
IndoorAirQuality(iaq = 500, displayMode = IaqDisplayMode.Text)
}
Text("Gauge", style = MaterialTheme.typography.titleLarge)
Row {
IndoorAirQuality(iaq = 6, displayMode = IaqDisplayMode.Gauge)
IndoorAirQuality(iaq = 51, displayMode = IaqDisplayMode.Gauge)
IndoorAirQuality(iaq = 101, displayMode = IaqDisplayMode.Gauge)
IndoorAirQuality(iaq = 151, displayMode = IaqDisplayMode.Gauge)
}
Row {
IndoorAirQuality(iaq = 201, displayMode = IaqDisplayMode.Gauge)
IndoorAirQuality(iaq = 251, displayMode = IaqDisplayMode.Gauge)
IndoorAirQuality(iaq = 301, displayMode = IaqDisplayMode.Gauge)
IndoorAirQuality(iaq = 351, displayMode = IaqDisplayMode.Gauge)
}
Row {
IndoorAirQuality(iaq = 401, displayMode = IaqDisplayMode.Gauge)
IndoorAirQuality(iaq = 500, displayMode = IaqDisplayMode.Gauge)
}
Text("Gradient", style = MaterialTheme.typography.titleLarge)
IndoorAirQuality(iaq = 6, displayMode = IaqDisplayMode.Gradient)
IndoorAirQuality(iaq = 51, displayMode = IaqDisplayMode.Gradient)
IndoorAirQuality(iaq = 101, displayMode = IaqDisplayMode.Gradient)
IndoorAirQuality(iaq = 201, displayMode = IaqDisplayMode.Gradient)
IndoorAirQuality(iaq = 351, displayMode = IaqDisplayMode.Gradient)
IndoorAirQuality(iaq = 401, displayMode = IaqDisplayMode.Gradient)
IndoorAirQuality(iaq = 500, displayMode = IaqDisplayMode.Gradient)
}
}

View file

@ -0,0 +1,285 @@
/*
* 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 com.geeksville.mesh.ui.common.components
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.foundation.lazy.LazyListItemInfo
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Card
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.hapticfeedback.HapticFeedback
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
// Derived in part from: https://github.com/androidx/androidx/blob/c92ad2941368202b2d78b8d14c71bf81e9525944/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/LazyColumnDragAndDropDemo.kt
@Preview
@Composable
fun LazyColumnDragAndDropDemo() {
var list by remember { mutableStateOf(List(50) { it }) }
val listState = rememberLazyListState()
val dragDropState = rememberDragDropState(listState, headerCount = 1) { fromIndex, toIndex ->
if (fromIndex in list.indices && toIndex in list.indices) {
list = list.toMutableList().apply { add(toIndex, removeAt(fromIndex)) }
}
}
LazyColumn(
modifier = Modifier.dragContainer(
dragDropState = dragDropState,
haptics = LocalHapticFeedback.current,
),
state = listState,
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
item {
Text("Header", Modifier.fillMaxWidth().padding(20.dp))
}
itemsIndexed(list, key = { _, item -> item }) { index, item ->
DraggableItem(dragDropState, index + 1) { isDragging ->
Card {
Text("Item $item", Modifier.fillMaxWidth().padding(20.dp))
}
}
}
item {
Text("Footer", Modifier.fillMaxWidth().padding(20.dp))
}
}
}
@Composable
fun rememberDragDropState(
lazyListState: LazyListState,
headerCount: Int = 0,
onMove: (Int, Int) -> Unit
): DragDropState {
val scope = rememberCoroutineScope()
val state = remember(lazyListState) { DragDropState(lazyListState, headerCount, scope, onMove) }
LaunchedEffect(state) {
while (true) {
val diff = state.scrollChannel.receive()
lazyListState.scrollBy(diff)
}
}
return state
}
class DragDropState
internal constructor(
private val state: LazyListState,
private val headerCount: Int,
private val scope: CoroutineScope,
private val onMove: (Int, Int) -> Unit
) {
private var draggingItemIndex by mutableStateOf<Int?>(null)
val adjustedItemIndex get() = draggingItemIndex?.minus(headerCount)
internal val scrollChannel = Channel<Float>()
private var draggingItemDraggedDelta by mutableFloatStateOf(0f)
private var draggingItemInitialOffset by mutableIntStateOf(0)
internal val draggingItemOffset: Float
get() = draggingItemLayoutInfo?.let { item ->
draggingItemInitialOffset + draggingItemDraggedDelta - item.offset
} ?: 0f
private val draggingItemLayoutInfo: LazyListItemInfo?
get() = state.layoutInfo.visibleItemsInfo.firstOrNull { it.index == draggingItemIndex }
internal var previousIndexOfDraggedItem by mutableStateOf<Int?>(null)
private set
internal var previousItemOffset = Animatable(0f)
private set
internal fun onDragStart(offset: Offset): LazyListItemInfo? = state.layoutInfo.visibleItemsInfo
.filter { it.contentType == DragDropContentType }
.firstOrNull { item -> offset.y.toInt() in item.offset..(item.offset + item.size) }
?.also {
draggingItemIndex = it.index
draggingItemInitialOffset = it.offset
}
internal fun onDragInterrupted() {
if (draggingItemIndex != null) {
previousIndexOfDraggedItem = draggingItemIndex
val startOffset = draggingItemOffset
scope.launch {
previousItemOffset.snapTo(startOffset)
previousItemOffset.animateTo(
0f,
spring(stiffness = Spring.StiffnessMediumLow, visibilityThreshold = 1f)
)
previousIndexOfDraggedItem = null
}
}
draggingItemDraggedDelta = 0f
draggingItemIndex = null
draggingItemInitialOffset = 0
}
internal fun onDrag(offset: Offset) {
draggingItemDraggedDelta += offset.y
val draggingItem = draggingItemLayoutInfo ?: return
val startOffset = draggingItem.offset + draggingItemOffset
val endOffset = startOffset + draggingItem.size
val middleOffset = startOffset + (endOffset - startOffset) / 2f
val targetItem = state.layoutInfo.visibleItemsInfo
.find { item ->
middleOffset.toInt() in item.offset..item.offsetEnd &&
draggingItem.index != item.index
}
if (targetItem != null) {
if (
draggingItem.index == state.firstVisibleItemIndex ||
targetItem.index == state.firstVisibleItemIndex
) {
state.requestScrollToItem(
state.firstVisibleItemIndex,
state.firstVisibleItemScrollOffset
)
}
onMove.invoke(draggingItem.index - headerCount, targetItem.index - headerCount)
draggingItemIndex = targetItem.index
} else {
val overscroll = when {
draggingItemDraggedDelta > 0 ->
(endOffset - state.layoutInfo.viewportEndOffset).coerceAtLeast(0f)
draggingItemDraggedDelta < 0 ->
(startOffset - state.layoutInfo.viewportStartOffset).coerceAtMost(0f)
else -> 0f
}
if (overscroll != 0f) {
scrollChannel.trySend(overscroll)
}
}
}
private val LazyListItemInfo.offsetEnd: Int
get() = this.offset + this.size
}
fun Modifier.dragContainer(
dragDropState: DragDropState,
haptics: HapticFeedback,
): Modifier {
return this.pointerInput(dragDropState) {
detectDragGesturesAfterLongPress(
onDrag = { change, offset ->
change.consume()
dragDropState.onDrag(offset = offset)
},
onDragStart = { offset ->
dragDropState.onDragStart(offset) ?: return@detectDragGesturesAfterLongPress
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
},
onDragEnd = { dragDropState.onDragInterrupted() },
onDragCancel = { dragDropState.onDragInterrupted() }
)
}
}
@Composable
fun LazyItemScope.DraggableItem(
dragDropState: DragDropState,
index: Int,
modifier: Modifier = Modifier,
content: @Composable ColumnScope.(isDragging: Boolean) -> Unit
) {
val dragging = index == dragDropState.adjustedItemIndex
val draggingModifier = if (dragging) {
Modifier
.zIndex(1f)
.graphicsLayer { translationY = dragDropState.draggingItemOffset }
} else if (index == dragDropState.previousIndexOfDraggedItem) {
Modifier
.zIndex(1f)
.graphicsLayer { translationY = dragDropState.previousItemOffset.value }
} else {
Modifier.animateItem(fadeInSpec = null, fadeOutSpec = null)
}
Column(modifier = modifier.then(draggingModifier)) { content(dragging) }
}
const val DragDropContentType = "drag-and-drop"
/**
* Extension function for [LazyListScope] with drag-and-drop functionality for indexed items.
*
* Wraps [itemsIndexed] function with [detectDragGesturesAfterLongPress] to enable long-press
* drag gestures and allow items in the list to be reordered using the provided [DragDropState].
*/
inline fun <T> LazyListScope.dragDropItemsIndexed(
items: List<T>,
dragDropState: DragDropState,
noinline key: ((index: Int, item: T) -> Any)? = null,
crossinline itemContent: @Composable LazyItemScope.(index: Int, item: T, isDragging: Boolean) -> Unit
) = itemsIndexed(
items = items,
key = key,
contentType = { _, _ -> DragDropContentType },
itemContent = { index, item ->
DraggableItem(
dragDropState = dragDropState,
index = index,
content = { isDragging -> itemContent(index, item, isDragging) }
)
}
)

View file

@ -0,0 +1,173 @@
/*
* 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/>.
*/
@file:Suppress("MagicNumber")
package com.geeksville.mesh.ui.common.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.SignalCellular4Bar
import androidx.compose.material.icons.filled.SignalCellularAlt
import androidx.compose.material.icons.filled.SignalCellularAlt1Bar
import androidx.compose.material.icons.filled.SignalCellularAlt2Bar
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.geeksville.mesh.R
private const val SNR_GOOD_THRESHOLD = -7f
private const val SNR_FAIR_THRESHOLD = -15f
private const val RSSI_GOOD_THRESHOLD = -115
private const val RSSI_FAIR_THRESHOLD = -126
private enum class Quality(
val nameRes: Int,
val imageVector: ImageVector,
val color: Color
) {
NONE(R.string.none_quality, Icons.Default.SignalCellularAlt1Bar, Color.Red),
BAD(R.string.bad, Icons.Default.SignalCellularAlt2Bar, Color(red = 247, green = 147, blue = 26)),
FAIR(R.string.fair, Icons.Default.SignalCellularAlt, Color(red = 255, green = 230, blue = 0)),
GOOD(R.string.good, Icons.Default.SignalCellular4Bar, Color(0xFF30C047))
}
/**
* Displays the `snr` and `rssi` color coded based on the signal quality, along with
* a human readable description and related icon.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun NodeSignalQuality(snr: Float, rssi: Int, modifier: Modifier = Modifier) {
val quality = determineSignalQuality(snr, rssi)
FlowRow(
modifier = modifier,
maxLines = 1,
) {
Snr(snr)
Spacer(Modifier.width(8.dp))
Rssi(rssi)
Spacer(Modifier.width(8.dp))
Text(
text = "${stringResource(R.string.signal)} ${stringResource(quality.nameRes)}",
fontSize = MaterialTheme.typography.labelLarge.fontSize,
maxLines = 1,
)
Spacer(Modifier.width(8.dp))
Icon(
imageVector = quality.imageVector,
contentDescription = stringResource(R.string.signal_quality),
tint = quality.color
)
}
}
/**
* Displays the `snr` and `rssi` with color depending on the values respectively.
*/
@Composable
fun SnrAndRssi(snr: Float, rssi: Int) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Snr(snr)
Rssi(rssi)
}
}
/**
* Displays a human readable description and icon representing the signal quality.
*/
@Composable
fun LoraSignalIndicator(snr: Float, rssi: Int) {
val quality = determineSignalQuality(snr, rssi)
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxSize()
.padding(8.dp)
) {
Icon(
imageVector = quality.imageVector,
contentDescription = stringResource(R.string.signal_quality),
tint = quality.color
)
Text(text = "${stringResource(R.string.signal)} ${stringResource(quality.nameRes)}")
}
}
@Composable
private fun Snr(snr: Float) {
val color: Color = if (snr > SNR_GOOD_THRESHOLD) {
Quality.GOOD.color
} else if (snr > SNR_FAIR_THRESHOLD) {
Quality.FAIR.color
} else {
Quality.BAD.color
}
Text(
text = "%s %.2fdB".format(stringResource(id = R.string.snr), snr),
color = color,
fontSize = MaterialTheme.typography.labelLarge.fontSize
)
}
@Composable
private fun Rssi(rssi: Int) {
val color: Color = if (rssi > RSSI_GOOD_THRESHOLD) {
Quality.GOOD.color
} else if (rssi > RSSI_FAIR_THRESHOLD) {
Quality.FAIR.color
} else {
Quality.BAD.color
}
Text(
text = "%s %ddBm".format(stringResource(id = R.string.rssi), rssi),
color = color,
fontSize = MaterialTheme.typography.labelLarge.fontSize
)
}
private fun determineSignalQuality(snr: Float, rssi: Int): Quality = when {
snr > SNR_GOOD_THRESHOLD && rssi > RSSI_GOOD_THRESHOLD -> Quality.GOOD
snr > SNR_GOOD_THRESHOLD && rssi > RSSI_FAIR_THRESHOLD -> Quality.FAIR
snr > SNR_FAIR_THRESHOLD && rssi > RSSI_GOOD_THRESHOLD -> Quality.FAIR
snr <= SNR_FAIR_THRESHOLD && rssi <= RSSI_FAIR_THRESHOLD -> Quality.NONE
else -> Quality.BAD
}

View file

@ -0,0 +1,117 @@
/*
* 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 com.geeksville.mesh.ui.common.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.geeksville.mesh.util.DistanceUnit
import com.geeksville.mesh.util.toDistanceString
import kotlin.math.pow
import kotlin.math.roundToInt
private const val PositionEnabled = 32
private const val PositionDisabled = 0
const val PositionPrecisionMin = 10
const val PositionPrecisionMax = 19
const val PositionPrecisionDefault = 13
@Suppress("MagicNumber")
fun precisionBitsToMeters(bits: Int): Double = 23905787.925008 * 0.5.pow(bits.toDouble())
@Composable
fun PositionPrecisionPreference(
title: String,
value: Int,
enabled: Boolean,
onValueChanged: (Int) -> Unit,
modifier: Modifier = Modifier,
) {
val unit = remember { DistanceUnit.getFromLocale() }
Column(modifier = modifier) {
SwitchPreference(
title = title,
checked = value != PositionDisabled,
enabled = enabled,
onCheckedChange = { enabled ->
val newValue = if (enabled) PositionEnabled else PositionDisabled
onValueChanged(newValue)
},
padding = PaddingValues(0.dp)
)
AnimatedVisibility(visible = value != PositionDisabled) {
SwitchPreference(
title = "Precise location",
checked = value == PositionEnabled,
enabled = enabled,
onCheckedChange = { enabled ->
val newValue = if (enabled) PositionEnabled else PositionPrecisionDefault
onValueChanged(newValue)
},
padding = PaddingValues(0.dp)
)
}
AnimatedVisibility(visible = value in (PositionDisabled + 1)..<PositionEnabled) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
) {
Slider(
value = value.toFloat(),
onValueChange = { onValueChanged(it.roundToInt()) },
enabled = enabled,
valueRange = PositionPrecisionMin.toFloat()..PositionPrecisionMax.toFloat(),
steps = PositionPrecisionMax - PositionPrecisionMin - 1,
)
val precisionMeters = precisionBitsToMeters(value).toInt()
Text(
text = precisionMeters.toDistanceString(unit),
modifier = Modifier.padding(bottom = 16.dp),
fontSize = MaterialTheme.typography.bodyLarge.fontSize,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
)
}
}
}
}
@Preview(showBackground = true)
@Composable
private fun PositionPrecisionPreferencePreview() {
PositionPrecisionPreference(
title = "Position enabled",
value = PositionPrecisionDefault,
enabled = true,
onValueChanged = {},
modifier = Modifier.padding(horizontal = 16.dp)
)
}

View file

@ -0,0 +1,69 @@
/*
* 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 com.geeksville.mesh.ui.common.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ProvideTextStyle
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@Composable
fun PreferenceCategory(
text: String,
modifier: Modifier = Modifier,
content: (@Composable ColumnScope.() -> Unit)? = null
) {
Text(
text,
modifier = modifier.padding(start = 16.dp, top = 24.dp, bottom = 8.dp, end = 16.dp),
style = MaterialTheme.typography.titleLarge,
)
if (content != null) {
Card(
modifier = modifier.padding(bottom = 8.dp),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
ProvideTextStyle(MaterialTheme.typography.bodyLarge) {
content()
}
}
}
}
}
@Preview(showBackground = true)
@Composable
private fun PreferenceCategoryPreview() {
PreferenceCategory(
text = "Advanced settings"
)
}

View file

@ -0,0 +1,97 @@
/*
* 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 com.geeksville.mesh.ui.common.components
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.geeksville.mesh.R
@Composable
fun PreferenceFooter(
enabled: Boolean,
onCancelClicked: () -> Unit,
onSaveClicked: () -> Unit,
modifier: Modifier = Modifier,
) {
PreferenceFooter(
enabled = enabled,
negativeText = R.string.cancel,
onNegativeClicked = onCancelClicked,
positiveText = R.string.send,
onPositiveClicked = onSaveClicked,
modifier = modifier,
)
}
@Composable
fun PreferenceFooter(
enabled: Boolean,
@StringRes negativeText: Int,
onNegativeClicked: () -> Unit,
@StringRes positiveText: Int,
onPositiveClicked: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
.fillMaxWidth()
.height(64.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedButton(
modifier = modifier
.height(48.dp)
.weight(1f),
enabled = enabled,
onClick = onNegativeClicked,
) {
Text(
text = stringResource(id = negativeText),
)
}
OutlinedButton(
modifier = modifier
.height(48.dp)
.weight(1f),
enabled = enabled,
onClick = onPositiveClicked,
) {
Text(
text = stringResource(id = positiveText),
)
}
}
}
@Preview(showBackground = true)
@Composable
private fun PreferenceFooterPreview() {
PreferenceFooter(enabled = true, onCancelClicked = {}, onSaveClicked = {})
}

View file

@ -0,0 +1,138 @@
/*
* 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 com.geeksville.mesh.ui.common.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@Composable
fun RegularPreference(
title: String,
subtitle: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
summary: String? = null,
trailingIcon: ImageVector? = null,
) {
RegularPreference(
title = title,
subtitle = AnnotatedString(text = subtitle),
onClick = onClick,
modifier = modifier,
enabled = enabled,
summary = summary,
trailingIcon = trailingIcon,
)
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun RegularPreference(
title: String,
subtitle: AnnotatedString,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
summary: String? = null,
trailingIcon: ImageVector? = null,
) {
val color = if (enabled) {
MaterialTheme.colorScheme.onSurface
} else {
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
}
Column(
modifier = modifier
.fillMaxWidth()
.clickable(enabled = enabled, onClick = onClick)
.padding(all = 16.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
FlowRow(
modifier = Modifier.weight(1f),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = title,
style = MaterialTheme.typography.bodyLarge,
color = if (enabled) {
Color.Unspecified
} else {
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
},
)
Text(
text = subtitle,
style = MaterialTheme.typography.bodyLarge,
color = color,
)
}
if (trailingIcon != null) {
Icon(
imageVector = trailingIcon,
contentDescription = "trailingIcon",
modifier = Modifier
.padding(start = 8.dp)
.wrapContentWidth(Alignment.End),
tint = color,
)
}
}
if (summary != null) {
Text(
text = summary,
style = MaterialTheme.typography.bodyMedium,
color = color,
)
}
}
}
@Preview(showBackground = true)
@Composable
private fun RegularPreferencePreview() {
RegularPreference(
title = "Advanced settings",
subtitle = "Text2",
onClick = { },
)
}

View file

@ -0,0 +1,239 @@
/*
* 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 com.geeksville.mesh.ui.common.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
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.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewScreenSizes
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.geeksville.mesh.AppOnlyProtos.ChannelSet
import com.geeksville.mesh.R
import com.geeksville.mesh.channelSet
import com.geeksville.mesh.copy
import com.geeksville.mesh.model.Channel
import com.geeksville.mesh.model.UIViewModel
import com.geeksville.mesh.ui.radioconfig.components.ChannelSelection
@Composable
fun ScannedQrCodeDialog(
viewModel: UIViewModel,
incoming: ChannelSet,
) {
val channels by viewModel.channels.collectAsStateWithLifecycle()
ScannedQrCodeDialog(
channels = channels,
incoming = incoming,
onDismiss = viewModel::clearRequestChannelUrl,
onConfirm = viewModel::setChannels,
)
}
/**
* Enables the user to select which channels to accept after scanning a QR code.
*/
@OptIn(ExperimentalLayoutApi::class)
@Suppress("LongMethod")
@Composable
fun ScannedQrCodeDialog(
channels: ChannelSet,
incoming: ChannelSet,
onDismiss: () -> Unit,
onConfirm: (ChannelSet) -> Unit
) {
var shouldReplace by remember { mutableStateOf(incoming.hasLoraConfig()) }
val channelSet = remember(shouldReplace) {
if (shouldReplace) {
incoming
} else {
channels.copy {
// To guarantee consistent ordering, using a LinkedHashSet which iterates through
// it's entries according to the order an item was *first* inserted.
// https://kotlinlang.org/api/latest/jvm/stdlib/kotlin.collections/-linked-hash-set/
val result = LinkedHashSet(settings + incoming.settingsList)
settings.clear()
settings.addAll(result)
}
}
}
val modemPresetName = Channel(loraConfig = channelSet.loraConfig).name
/* Holds selections made by the user */
val channelSelections = remember(channelSet) {
mutableStateListOf(elements = Array(size = channelSet.settingsCount, init = { true }))
}
val selectedChannelSet = channelSet.copy {
val result = settings.filterIndexed { i, _ -> channelSelections.getOrNull(i) == true }
settings.clear()
settings.addAll(result)
}
Dialog(
onDismissRequest = { onDismiss() },
properties = DialogProperties(usePlatformDefaultWidth = false, dismissOnBackPress = true)
) {
Surface(
modifier = Modifier.widthIn(max = 600.dp),
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.background
) {
LazyColumn(
contentPadding = PaddingValues(horizontal = 24.dp, vertical = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
item {
Text(
text = stringResource(id = R.string.new_channel_rcvd),
modifier = Modifier.padding(20.dp),
style = MaterialTheme.typography.titleLarge,
)
}
itemsIndexed(channelSet.settingsList) { index, channel ->
ChannelSelection(
index = index,
title = channel.name.ifEmpty { modemPresetName },
enabled = true,
isSelected = channelSelections[index],
onSelected = {
if (it || selectedChannelSet.settingsCount > 1) {
channelSelections[index] = it
}
},
)
}
item {
Row(
modifier = Modifier.padding(vertical = 20.dp),
) {
val selectedColors = ButtonDefaults.buttonColors()
val unselectedColors = ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.onSurface,
)
OutlinedButton(
onClick = { shouldReplace = false },
modifier = Modifier
.height(48.dp)
.weight(1f),
colors = if (!shouldReplace) selectedColors else unselectedColors,
) { Text(text = stringResource(R.string.add)) }
OutlinedButton(
onClick = { shouldReplace = true },
modifier = Modifier
.height(48.dp)
.weight(1f),
enabled = incoming.hasLoraConfig(),
colors = if (shouldReplace) selectedColors else unselectedColors,
) { Text(text = stringResource(R.string.replace)) }
}
}
/* User Actions via buttons */
item {
FlowRow(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
) {
TextButton(
onClick = {
onDismiss()
},
) {
Text(
text = stringResource(id = R.string.cancel),
color = MaterialTheme.colorScheme.onSurface,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
style = MaterialTheme.typography.bodyLarge,
)
}
TextButton(
onClick = {
onDismiss()
onConfirm(selectedChannelSet)
},
enabled = selectedChannelSet.settingsCount in 1..8,
) {
Text(
text = stringResource(id = R.string.accept),
color = MaterialTheme.colorScheme.onSurface,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
style = MaterialTheme.typography.bodyLarge,
)
}
}
}
}
}
}
}
@PreviewScreenSizes
@Composable
private fun ScannedQrCodeDialogPreview() {
ScannedQrCodeDialog(
channels = channelSet {
settings.add(Channel.default.settings)
loraConfig = Channel.default.loraConfig
},
incoming = channelSet {
settings.add(Channel.default.settings)
loraConfig = Channel.default.loraConfig
},
onDismiss = {},
onConfirm = {},
)
}

View file

@ -0,0 +1,126 @@
/*
* 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 com.geeksville.mesh.ui.common.components
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.tooling.preview.PreviewParameter
import com.geeksville.mesh.R
import com.geeksville.mesh.model.Node
import com.geeksville.mesh.ui.common.preview.NodePreviewParameterProvider
import com.geeksville.mesh.ui.common.theme.AppTheme
const val MAX_VALID_SNR = 100F
const val MAX_VALID_RSSI = 0
@Composable
fun SignalInfo(
modifier: Modifier = Modifier,
node: Node,
isThisNode: Boolean
) {
val text = if (isThisNode) {
stringResource(R.string.channel_air_util).format(
node.deviceMetrics.channelUtilization,
node.deviceMetrics.airUtilTx
)
} else {
buildList {
val hopsString = "%s: %s".format(
stringResource(R.string.hops_away),
if (node.hopsAway == -1) {
"?"
} else {
node.hopsAway.toString()
}
)
if (node.channel > 0) {
add("ch:${node.channel}")
}
if (node.hopsAway != 0) add(hopsString)
}.joinToString(" ")
}
Column {
if (text.isNotEmpty()) {
Text(
modifier = modifier,
text = text,
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.bodySmall.fontSize
)
}
/* We only know the Signal Quality from direct nodes aka 0 hop. */
if (node.hopsAway <= 0) {
if (node.snr < MAX_VALID_SNR && node.rssi < MAX_VALID_RSSI) {
NodeSignalQuality(node.snr, node.rssi)
}
}
}
}
@Composable
@Preview(showBackground = true)
fun SignalInfoSimplePreview() {
AppTheme {
SignalInfo(
node = Node(
num = 1,
lastHeard = 0,
channel = 0,
snr = 12.5F,
rssi = -42,
hopsAway = 0
),
isThisNode = false
)
}
}
@PreviewLightDark
@Composable
fun SignalInfoPreview(
@PreviewParameter(NodePreviewParameterProvider::class)
node: Node
) {
AppTheme {
SignalInfo(
node = node,
isThisNode = false
)
}
}
@Composable
@PreviewLightDark
fun SignalInfoSelfPreview(
@PreviewParameter(NodePreviewParameterProvider::class)
node: Node
) {
AppTheme {
SignalInfo(
node = node,
isThisNode = true
)
}
}

View file

@ -0,0 +1,128 @@
/*
* 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 com.geeksville.mesh.ui.common.components
import androidx.annotation.StringRes
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.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import com.geeksville.mesh.R
import com.geeksville.mesh.ui.common.theme.AppTheme
@Composable
fun SimpleAlertDialog(
@StringRes title: Int,
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(id = R.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(id = R.string.okay)) }
}
},
title = {
Text(
text = stringResource(id = title),
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
)
},
text = text,
shape = RoundedCornerShape(16.dp),
)
@Composable
fun SimpleAlertDialog(
@StringRes title: Int,
@StringRes text: Int,
onConfirm: (() -> Unit)? = null,
onDismiss: () -> Unit = {},
) = SimpleAlertDialog(
onConfirm = onConfirm,
onDismiss = onDismiss,
title = title,
text = {
Text(
text = stringResource(id = text),
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
)
},
)
@Composable
fun SimpleAlertDialog(
@StringRes title: Int,
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 = R.string.message,
text = R.string.sample_message,
)
}
}

View file

@ -0,0 +1,429 @@
/*
* 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 com.geeksville.mesh.ui.common.components
import android.annotation.SuppressLint
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.horizontalDrag
import androidx.compose.foundation.layout.Arrangement.spacedBy
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.AwaitPointerEventScope
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.PointerInputChange
import androidx.compose.ui.input.pointer.changedToUp
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.onClick
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.selected
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow.Companion.Ellipsis
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import com.geeksville.mesh.model.TimeFrame
private const val NO_OPTION_INDEX = -1
private val TRACK_PADDING = 2.dp
private val TRACK_COLOR = Color.LightGray.copy(alpha = .5f)
private val PRESSED_TRACK_PADDING = 1.dp
private val OPTION_PADDING = 5.dp
private const val PRESSED_UNSELECTED_ALPHA = .6f
private val BACKGROUND_SHAPE = RoundedCornerShape(8.dp)
/**
* Provides the user with a set of options they can choose from.
*
* (Inspired by https://gist.github.com/zach-klippenstein/7ae8874db304f957d6bb91263e292117)
*/
@Composable
fun <T : Any> SlidingSelector(
options: List<T>,
selectedOption: T,
onOptionSelected: (T) -> Unit,
modifier: Modifier = Modifier,
content: @Composable (T) -> Unit
) {
val state = remember { SelectorState() }
state.optionCount = options.size
state.selectedOption = options.indexOf(selectedOption)
state.onOptionSelected = { onOptionSelected(options[it]) }
/* Animate between whole-number indices so we don't need to do pixel calculations. */
val selectedIndexOffset by animateFloatAsState(
state.selectedOption.toFloat(),
label = "Selected Index Offset"
)
Layout(
content = {
SelectedIndicator(state)
Dividers(state)
Options(state, options, content)
},
modifier = modifier
.fillMaxWidth()
.then(state.inputModifier)
.background(TRACK_COLOR, BACKGROUND_SHAPE)
.padding(TRACK_PADDING)
) { measurables, constraints ->
val (indicatorMeasurable, dividersMeasurable, optionsMeasurable) = measurables
/* Measure the options first so we know how tall to make the indicator. */
val optionsPlaceable = optionsMeasurable.measure(constraints)
state.updatePressedScale(optionsPlaceable.height, this)
/* Measure the indicator and dividers to be the right size. */
val indicatorPlaceable = indicatorMeasurable.measure(
Constraints.fixed(
width = optionsPlaceable.width / options.size,
height = optionsPlaceable.height
)
)
val dividersPlaceable = dividersMeasurable.measure(
Constraints.fixed(
width = optionsPlaceable.width,
height = optionsPlaceable.height
)
)
layout(optionsPlaceable.width, optionsPlaceable.height) {
val optionWidth = optionsPlaceable.width / options.size
/* Place the indicator first so that it's below the option labels. */
indicatorPlaceable.placeRelative(
x = (selectedIndexOffset * optionWidth).toInt(),
y = 0
)
dividersPlaceable.placeRelative(IntOffset.Zero)
optionsPlaceable.placeRelative(IntOffset.Zero)
}
}
}
/**
* Visual representation of the option the user may select.
*/
@Composable
fun OptionLabel(text: String) {
Text(text, maxLines = 1, overflow = Ellipsis)
}
/**
* Draws the selected indicator on the [SlidingSelector] track.
*/
@Composable
private fun SelectedIndicator(state: SelectorState) {
Box(
Modifier
.then(
state.optionScaleModifier(
pressed = state.pressedOption == state.selectedOption,
option = state.selectedOption
)
)
.shadow(4.dp, BACKGROUND_SHAPE)
.background(MaterialTheme.colorScheme.background, BACKGROUND_SHAPE)
)
}
/**
* Draws dividers between [OptionLabel]s.
*/
@Composable
private fun Dividers(state: SelectorState) {
/* Animate each divider independently. */
val alphas = (0 until state.optionCount).map { i ->
val selectionAdjacent = i == state.selectedOption || i - 1 == state.selectedOption
animateFloatAsState(if (selectionAdjacent) 0f else 1f, label = "Dividers")
}
Canvas(Modifier.fillMaxSize()) {
val optionWidth = size.width / state.optionCount
val dividerPadding = TRACK_PADDING + PRESSED_TRACK_PADDING
alphas.forEachIndexed { i, alpha ->
val x = i * optionWidth
drawLine(
Color.White,
alpha = alpha.value,
start = Offset(x, dividerPadding.toPx()),
end = Offset(x, size.height - dividerPadding.toPx())
)
}
}
}
/**
* Draws the options available to the user.
*/
@Composable
private fun <T> Options(
state: SelectorState,
options: List<T>,
content: @Composable (T) -> Unit
) {
CompositionLocalProvider(
LocalTextStyle provides TextStyle(fontWeight = FontWeight.Medium)
) {
Row(
horizontalArrangement = spacedBy(TRACK_PADDING),
modifier = Modifier
.fillMaxWidth()
.selectableGroup()
) {
options.forEachIndexed { i, timeFrame ->
val isSelected = i == state.selectedOption
val isPressed = i == state.pressedOption
/* Unselected presses are represented by fading. */
val alpha by animateFloatAsState(
if (!isSelected && isPressed) PRESSED_UNSELECTED_ALPHA else 1f,
label = "Unselected"
)
val semanticsModifier = Modifier.semantics(mergeDescendants = true) {
selected = isSelected
role = Role.Button
onClick { state.onOptionSelected(i); true }
stateDescription = if (isSelected) "Selected" else "Not selected"
}
Box(
Modifier
/* Divide space evenly between all options. */
.weight(1f)
.then(semanticsModifier)
.padding(OPTION_PADDING)
/* Draw pressed indication when not selected. */
.alpha(alpha)
/* Selected presses are represented by scaling. */
.then(state.optionScaleModifier(isPressed && isSelected, i))
/* Center the option content. */
.wrapContentWidth()
) {
content(timeFrame)
}
}
}
}
}
/**
* Contains and handles the state necessary to present the [SlidingSelector] to the user.
*/
private class SelectorState {
var optionCount by mutableIntStateOf(0)
var selectedOption by mutableIntStateOf(0)
var onOptionSelected: (Int) -> Unit by mutableStateOf({})
var pressedOption by mutableIntStateOf(NO_OPTION_INDEX)
/**
* Scale factor that should be used to scale pressed option. When this scale is applied,
* exactly [PRESSED_TRACK_PADDING] will be added around the element's usual size.
*/
var pressedSelectedScale by mutableFloatStateOf(1f)
private set
/**
* Calculates the scale factor we need to use for pressed options to get the desired padding.
*/
fun updatePressedScale(controlHeight: Int, density: Density) {
with(density) {
val pressedPadding = PRESSED_TRACK_PADDING * 2
val pressedHeight = controlHeight - pressedPadding.toPx()
pressedSelectedScale = pressedHeight / controlHeight
}
}
/**
* Returns a [Modifier] that will scale an element so that it gets [PRESSED_TRACK_PADDING] extra
* padding around it. The scale will be animated.
*
* The scale is also performed around either the left or right edge of the element if the option
* is the first or last option, respectively. In those cases, the scale will also be translated so
* that [PRESSED_TRACK_PADDING] will be added on the left or right edge.
*/
@SuppressLint("ModifierFactoryExtensionFunction")
fun optionScaleModifier(
pressed: Boolean,
option: Int,
): Modifier = Modifier.composed {
val scale by animateFloatAsState(if (pressed) pressedSelectedScale else 1f, label = "Scale")
val xOffset by animateDpAsState(
if (pressed) PRESSED_TRACK_PADDING else 0.dp,
label = "x Offset"
)
graphicsLayer {
this.scaleX = scale
this.scaleY = scale
/* Scales on the ends should gravitate to that edge. */
this.transformOrigin = TransformOrigin(
pivotFractionX = when (option) {
0 -> 0f
optionCount - 1 -> 1f
else -> .5f
},
pivotFractionY = .5f
)
/* But should still move inwards to keep the pressed padding consistent with top and bottom. */
this.translationX = when (option) {
0 -> xOffset.toPx()
optionCount - 1 -> -xOffset.toPx()
else -> 0f
}
}
}
/**
* A [Modifier] that will listen for touch gestures and update the selected and pressed properties
* of this state appropriately.
*/
val inputModifier = Modifier.pointerInput(optionCount) {
val optionWidth = size.width / optionCount
/* Helper to calculate which option an event occurred in. */
fun optionIndex(change: PointerInputChange): Int =
((change.position.x / size.width.toFloat()) * optionCount)
.toInt()
.coerceIn(0, optionCount - 1)
awaitEachGesture {
val down = awaitFirstDown()
pressedOption = optionIndex(down)
val downOnSelected = pressedOption == selectedOption
val optionBounds = Rect(
left = pressedOption * optionWidth.toFloat(),
right = (pressedOption + 1) * optionWidth.toFloat(),
top = 0f,
bottom = size.height.toFloat()
)
if (downOnSelected) {
horizontalDrag(down.id) { change ->
pressedOption = optionIndex(change)
if (pressedOption != selectedOption) {
onOptionSelected(pressedOption)
}
}
} else {
waitForUpOrCancellation(inBounds = optionBounds)
/* Null means the gesture was cancelled (e.g. dragged out of bounds). */
?.let { onOptionSelected(pressedOption) }
}
pressedOption = NO_OPTION_INDEX
}
}
}
/**
* Works with bounds that may not be at 0,0.
*/
@Suppress("ReturnCount")
private suspend fun AwaitPointerEventScope.waitForUpOrCancellation(inBounds: Rect): PointerInputChange? {
while (true) {
val event = awaitPointerEvent(PointerEventPass.Main)
if (event.changes.all { it.changedToUp() }) {
/* All pointers are up */
return event.changes[0]
}
if (event.changes.any { it.isConsumed || !inBounds.contains(it.position) }) {
/* Canceled */
return null
}
val consumeCheck = awaitPointerEvent(PointerEventPass.Final)
if (consumeCheck.changes.any { it.isConsumed }) {
return null
}
}
}
@Preview
@Composable
fun SlidingSelectorPreview() {
MaterialTheme {
Surface {
Column(Modifier.padding(8.dp)) {
var selectedOption by remember { mutableStateOf(TimeFrame.TWENTY_FOUR_HOURS) }
SlidingSelector(
TimeFrame.entries.toList(),
selectedOption,
onOptionSelected = { selectedOption = it }
) {
OptionLabel(stringResource(it.strRes))
}
}
}
}
}

View file

@ -0,0 +1,115 @@
/*
* 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 com.geeksville.mesh.ui.common.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@Composable
fun SwitchPreference(
title: String,
summary: String,
checked: Boolean,
enabled: Boolean,
onCheckedChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
) {
val color = if (enabled) {
Color.Unspecified
} else {
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
}
ListItem(
modifier = modifier,
trailingContent = {
Switch(
enabled = enabled,
checked = checked,
onCheckedChange = onCheckedChange,
)
},
supportingContent = {
Text(
text = summary,
modifier = Modifier.padding(bottom = 16.dp),
color = color,
)
},
headlineContent = {
Text(
text = title,
color = color,
)
}
)
}
@Composable
fun SwitchPreference(
title: String,
checked: Boolean,
enabled: Boolean,
onCheckedChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
padding: PaddingValues = PaddingValues(horizontal = 16.dp),
) {
Row(
modifier = modifier
.fillMaxWidth()
.size(48.dp)
.padding(padding),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = title,
style = MaterialTheme.typography.bodyLarge,
color = if (enabled) {
Color.Unspecified
} else {
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
},
)
Switch(
enabled = enabled,
checked = checked,
onCheckedChange = onCheckedChange,
)
}
}
@Preview(showBackground = true)
@Composable
private fun SwitchPreferencePreview() {
SwitchPreference(title = "Setting", checked = true, enabled = true, onCheckedChange = {})
}

View file

@ -0,0 +1,93 @@
/*
* 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 com.geeksville.mesh.ui.common.components
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.material3.Card
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@Composable
fun TextDividerPreference(
title: String,
modifier: Modifier = Modifier,
enabled: Boolean = true,
trailingIcon: ImageVector? = null,
) {
TextDividerPreference(
title = AnnotatedString(text = title),
enabled = enabled,
modifier = modifier,
trailingIcon = trailingIcon,
)
}
@Composable
fun TextDividerPreference(
title: AnnotatedString,
modifier: Modifier = Modifier,
enabled: Boolean = true,
trailingIcon: ImageVector? = null,
) {
Card(
modifier = modifier.fillMaxWidth(),
) {
Row(
modifier = modifier
.fillMaxWidth()
.padding(all = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = title,
style = MaterialTheme.typography.bodyLarge,
color = if (!enabled) {
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
} else {
Color.Unspecified
},
)
if (trailingIcon != null) {
Icon(
trailingIcon, "trailingIcon",
modifier = modifier
.fillMaxWidth()
.wrapContentWidth(Alignment.End),
)
}
}
}
}
@Preview(showBackground = true)
@Composable
private fun TextDividerPreferencePreview() {
TextDividerPreference(title = "Advanced settings")
}

View file

@ -0,0 +1,73 @@
/*
* 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 com.geeksville.mesh.ui.common.components
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.compose.LifecycleResumeEffect
@Composable
fun rememberTimeTickWithLifecycle(): Long {
val context = LocalContext.current
var value by remember { mutableLongStateOf(System.currentTimeMillis()) }
val receiver = TimeBroadcastReceiver { value = System.currentTimeMillis() }
LifecycleResumeEffect(Unit) {
receiver.register(context)
value = System.currentTimeMillis()
onPauseOrDispose {
receiver.unregister(context)
}
}
return value
}
private class TimeBroadcastReceiver(
val onTimeChanged: () -> Unit,
) : BroadcastReceiver() {
private var registered = false
override fun onReceive(context: Context, intent: Intent) {
onTimeChanged()
}
fun register(context: Context) {
if (!registered) {
val filter = IntentFilter(Intent.ACTION_TIME_TICK)
context.registerReceiver(this, filter)
registered = true
}
}
fun unregister(context: Context) {
if (registered) {
context.unregisterReceiver(this)
registered = false
}
}
}

View file

@ -0,0 +1,23 @@
/*
* 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 com.geeksville.mesh.ui.common.preview
import androidx.compose.ui.tooling.preview.Preview
@Preview(name = "Large Font", fontScale = 2f)
annotation class LargeFontPreview

View file

@ -0,0 +1,145 @@
/*
* 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 com.geeksville.mesh.ui.common.preview
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import com.geeksville.mesh.ConfigProtos
import com.geeksville.mesh.DeviceMetrics.Companion.currentTime
import com.geeksville.mesh.MeshProtos
import com.geeksville.mesh.deviceMetrics
import com.geeksville.mesh.environmentMetrics
import com.geeksville.mesh.model.Node
import com.geeksville.mesh.paxcount
import com.geeksville.mesh.position
import com.geeksville.mesh.user
import com.google.protobuf.ByteString
import kotlin.random.Random
class NodePreviewParameterProvider : PreviewParameterProvider<Node> {
val mickeyMouse = Node(
num = 1955,
user = user {
id = "mickeyMouseId"
longName = "Mickey Mouse"
shortName = "MM"
hwModel = MeshProtos.HardwareModel.TBEAM
role = ConfigProtos.Config.DeviceConfig.Role.ROUTER
},
position = position {
latitudeI = 338125110
longitudeI = -1179189760
altitude = 138
satsInView = 4
},
lastHeard = currentTime(),
channel = 0,
snr = 12.5F,
rssi = -42,
deviceMetrics = deviceMetrics {
channelUtilization = 2.4F
airUtilTx = 3.5F
batteryLevel = 85
voltage = 3.7F
uptimeSeconds = 3600
},
isFavorite = true,
hopsAway = 0
)
private val minnieMouse = mickeyMouse.copy(
num = Random.nextInt(),
user = user {
longName = "Minnie Mouse"
shortName = "MiMo"
id = "minnieMouseId"
hwModel = MeshProtos.HardwareModel.HELTEC_V3
},
snr = 12.5F,
rssi = -42,
position = position {},
hopsAway = 1
)
private val donaldDuck = Node(
num = Random.nextInt(),
position = position {
latitudeI = 338052347
longitudeI = -1179208460
altitude = 121
satsInView = 66
},
lastHeard = currentTime() - 300,
channel = 0,
snr = 12.5F,
rssi = -42,
deviceMetrics = deviceMetrics {
channelUtilization = 2.4F
airUtilTx = 3.5F
batteryLevel = 85
voltage = 3.7F
uptimeSeconds = 3600
},
user = user {
id = "donaldDuckId"
longName = "Donald Duck, the Grand Duck of the Ducks"
shortName = "DoDu"
hwModel = MeshProtos.HardwareModel.HELTEC_V3
publicKey = ByteString.copyFrom(ByteArray(32) { 1 })
},
environmentMetrics = environmentMetrics {
temperature = 28.0F
relativeHumidity = 50.0F
barometricPressure = 1013.25F
gasResistance = 0.0F
voltage = 3.7F
current = 0.0F
iaq = 100
},
paxcounter = paxcount {
wifi = 30
ble = 39
uptime = 420
},
isFavorite = true,
hopsAway = 2
)
private val unknown = donaldDuck.copy(
user = user {
id = "myId"
longName = "Meshtastic myId"
shortName = "myId"
hwModel = MeshProtos.HardwareModel.UNSET
},
environmentMetrics = environmentMetrics {},
paxcounter = paxcount {},
)
private val almostNothing = Node(
num = Random.nextInt(),
)
override val values: Sequence<Node>
get() = sequenceOf(
mickeyMouse, // "this" node
unknown,
almostNothing,
minnieMouse,
donaldDuck
)
}

View file

@ -0,0 +1,241 @@
/*
* 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 com.geeksville.mesh.ui.common.theme
import androidx.compose.ui.graphics.Color
val MeshtasticGreen = Color(0xFF67EA94)
val MeshtasticAlt = Color(0xFF2C2D3C)
val HyperlinkBlue = Color(0xFF43C3B0)
val InfantryBlue = Color(red = 75, green = 119, blue = 190)
val Orange = Color(red = 247, green = 147, blue = 26)
val primaryLight = Color(0xFF306A42)
val onPrimaryLight = Color(0xFFFFFFFF)
val primaryContainerLight = Color(0xFFB3F1BF)
val onPrimaryContainerLight = Color(0xFF00210D)
val secondaryLight = Color(0xFF506353)
val onSecondaryLight = Color(0xFFFFFFFF)
val secondaryContainerLight = Color(0xFFD2E8D3)
val onSecondaryContainerLight = Color(0xFF0D1F12)
val tertiaryLight = Color(0xFF3A656E)
val onTertiaryLight = Color(0xFFFFFFFF)
val tertiaryContainerLight = Color(0xFFBEEAF6)
val onTertiaryContainerLight = Color(0xFF001F25)
val errorLight = Color(0xFFBA1A1A)
val onErrorLight = Color(0xFFFFFFFF)
val errorContainerLight = Color(0xFFFFDAD6)
val onErrorContainerLight = Color(0xFF410002)
val backgroundLight = Color(0xFFF6FBF3)
val onBackgroundLight = Color(0xFF181D18)
val surfaceLight = Color(0xFFF6FBF3)
val onSurfaceLight = Color(0xFF181D18)
val surfaceVariantLight = Color(0xFFDDE5DA)
val onSurfaceVariantLight = Color(0xFF414941)
val outlineLight = Color(0xFF717971)
val outlineVariantLight = Color(0xFFC1C9BF)
val scrimLight = Color(0xFF000000)
val inverseSurfaceLight = Color(0xFF2D322D)
val inverseOnSurfaceLight = Color(0xFFEEF2EA)
val inversePrimaryLight = Color(0xFF97D5A5)
val surfaceDimLight = Color(0xFFD7DBD4)
val surfaceBrightLight = Color(0xFFF6FBF3)
val surfaceContainerLowestLight = Color(0xFFFFFFFF)
val surfaceContainerLowLight = Color(0xFFF0F5ED)
val surfaceContainerLight = Color(0xFFEBEFE7)
val surfaceContainerHighLight = Color(0xFFE5EAE2)
val surfaceContainerHighestLight = Color(0xFFDFE4DC)
val primaryLightMediumContrast = Color(0xFF0F4D29)
val onPrimaryLightMediumContrast = Color(0xFFFFFFFF)
val primaryContainerLightMediumContrast = Color(0xFF478157)
val onPrimaryContainerLightMediumContrast = Color(0xFFFFFFFF)
val secondaryLightMediumContrast = Color(0xFF344738)
val onSecondaryLightMediumContrast = Color(0xFFFFFFFF)
val secondaryContainerLightMediumContrast = Color(0xFF657A68)
val onSecondaryContainerLightMediumContrast = Color(0xFFFFFFFF)
val tertiaryLightMediumContrast = Color(0xFF1C4952)
val onTertiaryLightMediumContrast = Color(0xFFFFFFFF)
val tertiaryContainerLightMediumContrast = Color(0xFF517B85)
val onTertiaryContainerLightMediumContrast = Color(0xFFFFFFFF)
val errorLightMediumContrast = Color(0xFF8C0009)
val onErrorLightMediumContrast = Color(0xFFFFFFFF)
val errorContainerLightMediumContrast = Color(0xFFDA342E)
val onErrorContainerLightMediumContrast = Color(0xFFFFFFFF)
val backgroundLightMediumContrast = Color(0xFFF6FBF3)
val onBackgroundLightMediumContrast = Color(0xFF181D18)
val surfaceLightMediumContrast = Color(0xFFF6FBF3)
val onSurfaceLightMediumContrast = Color(0xFF181D18)
val surfaceVariantLightMediumContrast = Color(0xFFDDE5DA)
val onSurfaceVariantLightMediumContrast = Color(0xFF3D453D)
val outlineLightMediumContrast = Color(0xFF596159)
val outlineVariantLightMediumContrast = Color(0xFF757D74)
val scrimLightMediumContrast = Color(0xFF000000)
val inverseSurfaceLightMediumContrast = Color(0xFF2D322D)
val inverseOnSurfaceLightMediumContrast = Color(0xFFEEF2EA)
val inversePrimaryLightMediumContrast = Color(0xFF97D5A5)
val surfaceDimLightMediumContrast = Color(0xFFD7DBD4)
val surfaceBrightLightMediumContrast = Color(0xFFF6FBF3)
val surfaceContainerLowestLightMediumContrast = Color(0xFFFFFFFF)
val surfaceContainerLowLightMediumContrast = Color(0xFFF0F5ED)
val surfaceContainerLightMediumContrast = Color(0xFFEBEFE7)
val surfaceContainerHighLightMediumContrast = Color(0xFFE5EAE2)
val surfaceContainerHighestLightMediumContrast = Color(0xFFDFE4DC)
val primaryLightHighContrast = Color(0xFF002911)
val onPrimaryLightHighContrast = Color(0xFFFFFFFF)
val primaryContainerLightHighContrast = Color(0xFF0F4D29)
val onPrimaryContainerLightHighContrast = Color(0xFFFFFFFF)
val secondaryLightHighContrast = Color(0xFF142619)
val onSecondaryLightHighContrast = Color(0xFFFFFFFF)
val secondaryContainerLightHighContrast = Color(0xFF344738)
val onSecondaryContainerLightHighContrast = Color(0xFFFFFFFF)
val tertiaryLightHighContrast = Color(0xFF00262E)
val onTertiaryLightHighContrast = Color(0xFFFFFFFF)
val tertiaryContainerLightHighContrast = Color(0xFF1C4952)
val onTertiaryContainerLightHighContrast = Color(0xFFFFFFFF)
val errorLightHighContrast = Color(0xFF4E0002)
val onErrorLightHighContrast = Color(0xFFFFFFFF)
val errorContainerLightHighContrast = Color(0xFF8C0009)
val onErrorContainerLightHighContrast = Color(0xFFFFFFFF)
val backgroundLightHighContrast = Color(0xFFF6FBF3)
val onBackgroundLightHighContrast = Color(0xFF181D18)
val surfaceLightHighContrast = Color(0xFFF6FBF3)
val onSurfaceLightHighContrast = Color(0xFF000000)
val surfaceVariantLightHighContrast = Color(0xFFDDE5DA)
val onSurfaceVariantLightHighContrast = Color(0xFF1E261F)
val outlineLightHighContrast = Color(0xFF3D453D)
val outlineVariantLightHighContrast = Color(0xFF3D453D)
val scrimLightHighContrast = Color(0xFF000000)
val inverseSurfaceLightHighContrast = Color(0xFF2D322D)
val inverseOnSurfaceLightHighContrast = Color(0xFFFFFFFF)
val inversePrimaryLightHighContrast = Color(0xFFBCFBC8)
val surfaceDimLightHighContrast = Color(0xFFD7DBD4)
val surfaceBrightLightHighContrast = Color(0xFFF6FBF3)
val surfaceContainerLowestLightHighContrast = Color(0xFFFFFFFF)
val surfaceContainerLowLightHighContrast = Color(0xFFF0F5ED)
val surfaceContainerLightHighContrast = Color(0xFFEBEFE7)
val surfaceContainerHighLightHighContrast = Color(0xFFE5EAE2)
val surfaceContainerHighestLightHighContrast = Color(0xFFDFE4DC)
val primaryDark = Color(0xFF97D5A5)
val onPrimaryDark = Color(0xFF00391A)
val primaryContainerDark = Color(0xFF15512C)
val onPrimaryContainerDark = Color(0xFFB3F1BF)
val secondaryDark = Color(0xFFB6CCB8)
val onSecondaryDark = Color(0xFF223526)
val secondaryContainerDark = Color(0xFF384B3C)
val onSecondaryContainerDark = Color(0xFFD2E8D3)
val tertiaryDark = Color(0xFFA2CED9)
val onTertiaryDark = Color(0xFF01363F)
val tertiaryContainerDark = Color(0xFF204D56)
val onTertiaryContainerDark = Color(0xFFBEEAF6)
val errorDark = Color(0xFFFFB4AB)
val onErrorDark = Color(0xFF690005)
val errorContainerDark = Color(0xFF93000A)
val onErrorContainerDark = Color(0xFFFFDAD6)
val backgroundDark = Color(0xFF101510)
val onBackgroundDark = Color(0xFFDFE4DC)
val surfaceDark = Color(0xFF101510)
val onSurfaceDark = Color(0xFFDFE4DC)
val surfaceVariantDark = Color(0xFF414941)
val onSurfaceVariantDark = Color(0xFFC1C9BF)
val outlineDark = Color(0xFF8B938A)
val outlineVariantDark = Color(0xFF414941)
val scrimDark = Color(0xFF000000)
val inverseSurfaceDark = Color(0xFFDFE4DC)
val inverseOnSurfaceDark = Color(0xFF2D322D)
val inversePrimaryDark = Color(0xFF306A42)
val surfaceDimDark = Color(0xFF101510)
val surfaceBrightDark = Color(0xFF353A35)
val surfaceContainerLowestDark = Color(0xFF0A0F0B)
val surfaceContainerLowDark = Color(0xFF181D18)
val surfaceContainerDark = Color(0xFF1C211C)
val surfaceContainerHighDark = Color(0xFF262B26)
val surfaceContainerHighestDark = Color(0xFF313631)
val primaryDarkMediumContrast = Color(0xFF9BD9A9)
val onPrimaryDarkMediumContrast = Color(0xFF001B09)
val primaryContainerDarkMediumContrast = Color(0xFF639D72)
val onPrimaryContainerDarkMediumContrast = Color(0xFF000000)
val secondaryDarkMediumContrast = Color(0xFFBBD0BC)
val onSecondaryDarkMediumContrast = Color(0xFF081A0D)
val secondaryContainerDarkMediumContrast = Color(0xFF819683)
val onSecondaryContainerDarkMediumContrast = Color(0xFF000000)
val tertiaryDarkMediumContrast = Color(0xFFA6D2DD)
val onTertiaryDarkMediumContrast = Color(0xFF00191F)
val tertiaryContainerDarkMediumContrast = Color(0xFF6D97A2)
val onTertiaryContainerDarkMediumContrast = Color(0xFF000000)
val errorDarkMediumContrast = Color(0xFFFFBAB1)
val onErrorDarkMediumContrast = Color(0xFF370001)
val errorContainerDarkMediumContrast = Color(0xFFFF5449)
val onErrorContainerDarkMediumContrast = Color(0xFF000000)
val backgroundDarkMediumContrast = Color(0xFF101510)
val onBackgroundDarkMediumContrast = Color(0xFFDFE4DC)
val surfaceDarkMediumContrast = Color(0xFF101510)
val onSurfaceDarkMediumContrast = Color(0xFFF8FCF4)
val surfaceVariantDarkMediumContrast = Color(0xFF414941)
val onSurfaceVariantDarkMediumContrast = Color(0xFFC5CDC3)
val outlineDarkMediumContrast = Color(0xFF9DA59C)
val outlineVariantDarkMediumContrast = Color(0xFF7D857D)
val scrimDarkMediumContrast = Color(0xFF000000)
val inverseSurfaceDarkMediumContrast = Color(0xFFDFE4DC)
val inverseOnSurfaceDarkMediumContrast = Color(0xFF262B26)
val inversePrimaryDarkMediumContrast = Color(0xFF16522E)
val surfaceDimDarkMediumContrast = Color(0xFF101510)
val surfaceBrightDarkMediumContrast = Color(0xFF353A35)
val surfaceContainerLowestDarkMediumContrast = Color(0xFF0A0F0B)
val surfaceContainerLowDarkMediumContrast = Color(0xFF181D18)
val surfaceContainerDarkMediumContrast = Color(0xFF1C211C)
val surfaceContainerHighDarkMediumContrast = Color(0xFF262B26)
val surfaceContainerHighestDarkMediumContrast = Color(0xFF313631)
val primaryDarkHighContrast = Color(0xFFEFFFEE)
val onPrimaryDarkHighContrast = Color(0xFF000000)
val primaryContainerDarkHighContrast = Color(0xFF9BD9A9)
val onPrimaryContainerDarkHighContrast = Color(0xFF000000)
val secondaryDarkHighContrast = Color(0xFFEFFFEE)
val onSecondaryDarkHighContrast = Color(0xFF000000)
val secondaryContainerDarkHighContrast = Color(0xFFBBD0BC)
val onSecondaryContainerDarkHighContrast = Color(0xFF000000)
val tertiaryDarkHighContrast = Color(0xFFF3FCFF)
val onTertiaryDarkHighContrast = Color(0xFF000000)
val tertiaryContainerDarkHighContrast = Color(0xFFA6D2DD)
val onTertiaryContainerDarkHighContrast = Color(0xFF000000)
val errorDarkHighContrast = Color(0xFFFFF9F9)
val onErrorDarkHighContrast = Color(0xFF000000)
val errorContainerDarkHighContrast = Color(0xFFFFBAB1)
val onErrorContainerDarkHighContrast = Color(0xFF000000)
val backgroundDarkHighContrast = Color(0xFF101510)
val onBackgroundDarkHighContrast = Color(0xFFDFE4DC)
val surfaceDarkHighContrast = Color(0xFF101510)
val onSurfaceDarkHighContrast = Color(0xFFFFFFFF)
val surfaceVariantDarkHighContrast = Color(0xFF414941)
val onSurfaceVariantDarkHighContrast = Color(0xFFF5FDF2)
val outlineDarkHighContrast = Color(0xFFC5CDC3)
val outlineVariantDarkHighContrast = Color(0xFFC5CDC3)
val scrimDarkHighContrast = Color(0xFF000000)
val inverseSurfaceDarkHighContrast = Color(0xFFDFE4DC)
val inverseOnSurfaceDarkHighContrast = Color(0xFF000000)
val inversePrimaryDarkHighContrast = Color(0xFF003216)
val surfaceDimDarkHighContrast = Color(0xFF101510)
val surfaceBrightDarkHighContrast = Color(0xFF353A35)
val surfaceContainerLowestDarkHighContrast = Color(0xFF0A0F0B)
val surfaceContainerLowDarkHighContrast = Color(0xFF181D18)
val surfaceContainerDarkHighContrast = Color(0xFF1C211C)
val surfaceContainerHighDarkHighContrast = Color(0xFF262B26)
val surfaceContainerHighestDarkHighContrast = Color(0xFF313631)

View file

@ -0,0 +1,298 @@
/*
* 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/>.
*/
@file:Suppress("UnusedPrivateProperty")
package com.geeksville.mesh.ui.common.theme
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
private val lightScheme = lightColorScheme(
primary = primaryLight,
onPrimary = onPrimaryLight,
primaryContainer = primaryContainerLight,
onPrimaryContainer = onPrimaryContainerLight,
secondary = secondaryLight,
onSecondary = onSecondaryLight,
secondaryContainer = secondaryContainerLight,
onSecondaryContainer = onSecondaryContainerLight,
tertiary = tertiaryLight,
onTertiary = onTertiaryLight,
tertiaryContainer = tertiaryContainerLight,
onTertiaryContainer = onTertiaryContainerLight,
error = errorLight,
onError = onErrorLight,
errorContainer = errorContainerLight,
onErrorContainer = onErrorContainerLight,
background = backgroundLight,
onBackground = onBackgroundLight,
surface = surfaceLight,
onSurface = onSurfaceLight,
surfaceVariant = surfaceVariantLight,
onSurfaceVariant = onSurfaceVariantLight,
outline = outlineLight,
outlineVariant = outlineVariantLight,
scrim = scrimLight,
inverseSurface = inverseSurfaceLight,
inverseOnSurface = inverseOnSurfaceLight,
inversePrimary = inversePrimaryLight,
surfaceDim = surfaceDimLight,
surfaceBright = surfaceBrightLight,
surfaceContainerLowest = surfaceContainerLowestLight,
surfaceContainerLow = surfaceContainerLowLight,
surfaceContainer = surfaceContainerLight,
surfaceContainerHigh = surfaceContainerHighLight,
surfaceContainerHighest = surfaceContainerHighestLight,
)
private val darkScheme = darkColorScheme(
primary = primaryDark,
onPrimary = onPrimaryDark,
primaryContainer = primaryContainerDark,
onPrimaryContainer = onPrimaryContainerDark,
secondary = secondaryDark,
onSecondary = onSecondaryDark,
secondaryContainer = secondaryContainerDark,
onSecondaryContainer = onSecondaryContainerDark,
tertiary = tertiaryDark,
onTertiary = onTertiaryDark,
tertiaryContainer = tertiaryContainerDark,
onTertiaryContainer = onTertiaryContainerDark,
error = errorDark,
onError = onErrorDark,
errorContainer = errorContainerDark,
onErrorContainer = onErrorContainerDark,
background = backgroundDark,
onBackground = onBackgroundDark,
surface = surfaceDark,
onSurface = onSurfaceDark,
surfaceVariant = surfaceVariantDark,
onSurfaceVariant = onSurfaceVariantDark,
outline = outlineDark,
outlineVariant = outlineVariantDark,
scrim = scrimDark,
inverseSurface = inverseSurfaceDark,
inverseOnSurface = inverseOnSurfaceDark,
inversePrimary = inversePrimaryDark,
surfaceDim = surfaceDimDark,
surfaceBright = surfaceBrightDark,
surfaceContainerLowest = surfaceContainerLowestDark,
surfaceContainerLow = surfaceContainerLowDark,
surfaceContainer = surfaceContainerDark,
surfaceContainerHigh = surfaceContainerHighDark,
surfaceContainerHighest = surfaceContainerHighestDark,
)
private val mediumContrastLightColorScheme = lightColorScheme(
primary = primaryLightMediumContrast,
onPrimary = onPrimaryLightMediumContrast,
primaryContainer = primaryContainerLightMediumContrast,
onPrimaryContainer = onPrimaryContainerLightMediumContrast,
secondary = secondaryLightMediumContrast,
onSecondary = onSecondaryLightMediumContrast,
secondaryContainer = secondaryContainerLightMediumContrast,
onSecondaryContainer = onSecondaryContainerLightMediumContrast,
tertiary = tertiaryLightMediumContrast,
onTertiary = onTertiaryLightMediumContrast,
tertiaryContainer = tertiaryContainerLightMediumContrast,
onTertiaryContainer = onTertiaryContainerLightMediumContrast,
error = errorLightMediumContrast,
onError = onErrorLightMediumContrast,
errorContainer = errorContainerLightMediumContrast,
onErrorContainer = onErrorContainerLightMediumContrast,
background = backgroundLightMediumContrast,
onBackground = onBackgroundLightMediumContrast,
surface = surfaceLightMediumContrast,
onSurface = onSurfaceLightMediumContrast,
surfaceVariant = surfaceVariantLightMediumContrast,
onSurfaceVariant = onSurfaceVariantLightMediumContrast,
outline = outlineLightMediumContrast,
outlineVariant = outlineVariantLightMediumContrast,
scrim = scrimLightMediumContrast,
inverseSurface = inverseSurfaceLightMediumContrast,
inverseOnSurface = inverseOnSurfaceLightMediumContrast,
inversePrimary = inversePrimaryLightMediumContrast,
surfaceDim = surfaceDimLightMediumContrast,
surfaceBright = surfaceBrightLightMediumContrast,
surfaceContainerLowest = surfaceContainerLowestLightMediumContrast,
surfaceContainerLow = surfaceContainerLowLightMediumContrast,
surfaceContainer = surfaceContainerLightMediumContrast,
surfaceContainerHigh = surfaceContainerHighLightMediumContrast,
surfaceContainerHighest = surfaceContainerHighestLightMediumContrast,
)
private val highContrastLightColorScheme = lightColorScheme(
primary = primaryLightHighContrast,
onPrimary = onPrimaryLightHighContrast,
primaryContainer = primaryContainerLightHighContrast,
onPrimaryContainer = onPrimaryContainerLightHighContrast,
secondary = secondaryLightHighContrast,
onSecondary = onSecondaryLightHighContrast,
secondaryContainer = secondaryContainerLightHighContrast,
onSecondaryContainer = onSecondaryContainerLightHighContrast,
tertiary = tertiaryLightHighContrast,
onTertiary = onTertiaryLightHighContrast,
tertiaryContainer = tertiaryContainerLightHighContrast,
onTertiaryContainer = onTertiaryContainerLightHighContrast,
error = errorLightHighContrast,
onError = onErrorLightHighContrast,
errorContainer = errorContainerLightHighContrast,
onErrorContainer = onErrorContainerLightHighContrast,
background = backgroundLightHighContrast,
onBackground = onBackgroundLightHighContrast,
surface = surfaceLightHighContrast,
onSurface = onSurfaceLightHighContrast,
surfaceVariant = surfaceVariantLightHighContrast,
onSurfaceVariant = onSurfaceVariantLightHighContrast,
outline = outlineLightHighContrast,
outlineVariant = outlineVariantLightHighContrast,
scrim = scrimLightHighContrast,
inverseSurface = inverseSurfaceLightHighContrast,
inverseOnSurface = inverseOnSurfaceLightHighContrast,
inversePrimary = inversePrimaryLightHighContrast,
surfaceDim = surfaceDimLightHighContrast,
surfaceBright = surfaceBrightLightHighContrast,
surfaceContainerLowest = surfaceContainerLowestLightHighContrast,
surfaceContainerLow = surfaceContainerLowLightHighContrast,
surfaceContainer = surfaceContainerLightHighContrast,
surfaceContainerHigh = surfaceContainerHighLightHighContrast,
surfaceContainerHighest = surfaceContainerHighestLightHighContrast,
)
private val mediumContrastDarkColorScheme = darkColorScheme(
primary = primaryDarkMediumContrast,
onPrimary = onPrimaryDarkMediumContrast,
primaryContainer = primaryContainerDarkMediumContrast,
onPrimaryContainer = onPrimaryContainerDarkMediumContrast,
secondary = secondaryDarkMediumContrast,
onSecondary = onSecondaryDarkMediumContrast,
secondaryContainer = secondaryContainerDarkMediumContrast,
onSecondaryContainer = onSecondaryContainerDarkMediumContrast,
tertiary = tertiaryDarkMediumContrast,
onTertiary = onTertiaryDarkMediumContrast,
tertiaryContainer = tertiaryContainerDarkMediumContrast,
onTertiaryContainer = onTertiaryContainerDarkMediumContrast,
error = errorDarkMediumContrast,
onError = onErrorDarkMediumContrast,
errorContainer = errorContainerDarkMediumContrast,
onErrorContainer = onErrorContainerDarkMediumContrast,
background = backgroundDarkMediumContrast,
onBackground = onBackgroundDarkMediumContrast,
surface = surfaceDarkMediumContrast,
onSurface = onSurfaceDarkMediumContrast,
surfaceVariant = surfaceVariantDarkMediumContrast,
onSurfaceVariant = onSurfaceVariantDarkMediumContrast,
outline = outlineDarkMediumContrast,
outlineVariant = outlineVariantDarkMediumContrast,
scrim = scrimDarkMediumContrast,
inverseSurface = inverseSurfaceDarkMediumContrast,
inverseOnSurface = inverseOnSurfaceDarkMediumContrast,
inversePrimary = inversePrimaryDarkMediumContrast,
surfaceDim = surfaceDimDarkMediumContrast,
surfaceBright = surfaceBrightDarkMediumContrast,
surfaceContainerLowest = surfaceContainerLowestDarkMediumContrast,
surfaceContainerLow = surfaceContainerLowDarkMediumContrast,
surfaceContainer = surfaceContainerDarkMediumContrast,
surfaceContainerHigh = surfaceContainerHighDarkMediumContrast,
surfaceContainerHighest = surfaceContainerHighestDarkMediumContrast,
)
private val highContrastDarkColorScheme = darkColorScheme(
primary = primaryDarkHighContrast,
onPrimary = onPrimaryDarkHighContrast,
primaryContainer = primaryContainerDarkHighContrast,
onPrimaryContainer = onPrimaryContainerDarkHighContrast,
secondary = secondaryDarkHighContrast,
onSecondary = onSecondaryDarkHighContrast,
secondaryContainer = secondaryContainerDarkHighContrast,
onSecondaryContainer = onSecondaryContainerDarkHighContrast,
tertiary = tertiaryDarkHighContrast,
onTertiary = onTertiaryDarkHighContrast,
tertiaryContainer = tertiaryContainerDarkHighContrast,
onTertiaryContainer = onTertiaryContainerDarkHighContrast,
error = errorDarkHighContrast,
onError = onErrorDarkHighContrast,
errorContainer = errorContainerDarkHighContrast,
onErrorContainer = onErrorContainerDarkHighContrast,
background = backgroundDarkHighContrast,
onBackground = onBackgroundDarkHighContrast,
surface = surfaceDarkHighContrast,
onSurface = onSurfaceDarkHighContrast,
surfaceVariant = surfaceVariantDarkHighContrast,
onSurfaceVariant = onSurfaceVariantDarkHighContrast,
outline = outlineDarkHighContrast,
outlineVariant = outlineVariantDarkHighContrast,
scrim = scrimDarkHighContrast,
inverseSurface = inverseSurfaceDarkHighContrast,
inverseOnSurface = inverseOnSurfaceDarkHighContrast,
inversePrimary = inversePrimaryDarkHighContrast,
surfaceDim = surfaceDimDarkHighContrast,
surfaceBright = surfaceBrightDarkHighContrast,
surfaceContainerLowest = surfaceContainerLowestDarkHighContrast,
surfaceContainerLow = surfaceContainerLowDarkHighContrast,
surfaceContainer = surfaceContainerDarkHighContrast,
surfaceContainerHigh = surfaceContainerHighDarkHighContrast,
surfaceContainerHighest = surfaceContainerHighestDarkHighContrast,
)
@Immutable
data class ColorFamily(
val color: Color,
val onColor: Color,
val colorContainer: Color,
val onColorContainer: Color
)
val unspecified_scheme = ColorFamily(
Color.Unspecified, Color.Unspecified, Color.Unspecified, Color.Unspecified
)
@Composable
fun AppTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
// Dynamic color is available on Android 12+
dynamicColor: Boolean = true,
content: @Composable() () -> Unit
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> darkScheme
else -> lightScheme
}
MaterialTheme(
colorScheme = colorScheme,
typography = AppTypography,
content = content
)
}
const val MODE_DYNAMIC = 6969420

View file

@ -0,0 +1,22 @@
/*
* 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 com.geeksville.mesh.ui.common.theme
import androidx.compose.material3.Typography
val AppTypography = Typography()