Add :core:ui (#3203)

This commit is contained in:
Phil Oliver 2025-09-25 17:01:53 -04:00 committed by GitHub
parent b139c7edd7
commit c5360086b7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
128 changed files with 594 additions and 750 deletions

28
core/ui/build.gradle.kts Normal file
View file

@ -0,0 +1,28 @@
/*
* 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/>.
*/
plugins {
alias(libs.plugins.meshtastic.android.library)
alias(libs.plugins.meshtastic.android.library.compose)
}
android { namespace = "org.meshtastic.core.ui" }
dependencies {
implementation(projects.core.strings)
implementation(libs.bundles.markdown)
}

View file

@ -0,0 +1,57 @@
<?xml version="1.0" ?>
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>ComposableParamOrder:AlertDialogs.kt$SimpleAlertDialog</ID>
<ID>ComposableParamOrder:BatteryInfo.kt$BatteryInfo</ID>
<ID>ComposableParamOrder:EditTextPreference.kt$EditTextPreference</ID>
<ID>ComposableParamOrder:MaterialBatteryInfo.kt$MaterialBatteryInfo</ID>
<ID>ComposableParamOrder:SwitchPreference.kt$SwitchPreference</ID>
<ID>ContentSlotReused:AdaptiveTwoPane.kt$second</ID>
<ID>MagicNumber:BatteryInfo.kt$100</ID>
<ID>MagicNumber:BatteryInfo.kt$101</ID>
<ID>MagicNumber:BatteryInfo.kt$14</ID>
<ID>MagicNumber:BatteryInfo.kt$15</ID>
<ID>MagicNumber:BatteryInfo.kt$34</ID>
<ID>MagicNumber:BatteryInfo.kt$35</ID>
<ID>MagicNumber:BatteryInfo.kt$4</ID>
<ID>MagicNumber:BatteryInfo.kt$5</ID>
<ID>MagicNumber:BatteryInfo.kt$79</ID>
<ID>MagicNumber:BatteryInfo.kt$80</ID>
<ID>MagicNumber:EditIPv4Preference.kt$0xff</ID>
<ID>MagicNumber:EditIPv4Preference.kt$16</ID>
<ID>MagicNumber:EditIPv4Preference.kt$24</ID>
<ID>MagicNumber:EditIPv4Preference.kt$8</ID>
<ID>MagicNumber:LazyColumnDragAndDropDemo.kt$50</ID>
<ID>ModifierMissing:AdaptiveTwoPane.kt$AdaptiveTwoPane</ID>
<ID>ModifierMissing:IndoorAirQuality.kt$IndoorAirQuality</ID>
<ID>ModifierMissing:LoraSignalIndicator.kt$LoraSignalIndicator</ID>
<ID>ModifierMissing:LoraSignalIndicator.kt$Rssi</ID>
<ID>ModifierMissing:LoraSignalIndicator.kt$Snr</ID>
<ID>ModifierMissing:LoraSignalIndicator.kt$SnrAndRssi</ID>
<ID>ModifierMissing:SimpleAlertDialog.kt$SimpleAlertDialog</ID>
<ID>ModifierMissing:SlidingSelector.kt$OptionLabel</ID>
<ID>ModifierNotUsedAtRoot:TextDividerPreference.kt$modifier = modifier.fillMaxWidth().padding(all = 16.dp)</ID>
<ID>ModifierNotUsedAtRoot:TextDividerPreference.kt$modifier = modifier.fillMaxWidth().wrapContentWidth(Alignment.End)</ID>
<ID>ModifierReused:PreferenceCategory.kt$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() } } }</ID>
<ID>ModifierReused:PreferenceCategory.kt$Text( text, modifier = modifier.padding(start = 16.dp, top = 24.dp, bottom = 8.dp, end = 16.dp), style = MaterialTheme.typography.titleLarge, )</ID>
<ID>ModifierReused:TextDividerPreference.kt$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)) } } }</ID>
<ID>ModifierReused:TextDividerPreference.kt$Icon(trailingIcon, "trailingIcon", modifier = modifier.fillMaxWidth().wrapContentWidth(Alignment.End))</ID>
<ID>ModifierReused:TextDividerPreference.kt$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)) } }</ID>
<ID>MultipleEmitters:PreferenceCategory.kt$PreferenceCategory</ID>
<ID>ParameterNaming:BitwisePreference.kt$onItemSelected</ID>
<ID>ParameterNaming:EditIPv4Preference.kt$onValueChanged</ID>
<ID>ParameterNaming:EditPasswordPreference.kt$onValueChanged</ID>
<ID>ParameterNaming:EditTextPreference.kt$onValueChanged</ID>
<ID>ParameterNaming:PreferenceFooter.kt$onCancelClicked</ID>
<ID>ParameterNaming:PreferenceFooter.kt$onNegativeClicked</ID>
<ID>ParameterNaming:PreferenceFooter.kt$onPositiveClicked</ID>
<ID>ParameterNaming:PreferenceFooter.kt$onSaveClicked</ID>
<ID>ParameterNaming:SlidingSelector.kt$onOptionSelected</ID>
<ID>PreviewPublic:BatteryInfo.kt$BatteryInfoPreview</ID>
<ID>PreviewPublic:BatteryInfo.kt$BatteryInfoPreviewSimple</ID>
<ID>PreviewPublic:IndoorAirQuality.kt$IAQScalePreview</ID>
<ID>PreviewPublic:LazyColumnDragAndDropDemo.kt$LazyColumnDragAndDropDemo</ID>
<ID>PreviewPublic:MaterialBatteryInfo.kt$MaterialBatteryInfoPreview</ID>
</CurrentIssues>
</SmellBaseline>

View file

@ -0,0 +1,45 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ui.component
import androidx.compose.foundation.layout.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,108 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ui.component
import androidx.compose.foundation.layout.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.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.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 org.meshtastic.core.strings.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 = MaterialTheme.colorScheme.primary,
),
),
)
}
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,92 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ui.component
import androidx.compose.foundation.layout.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 org.meshtastic.core.ui.R
import org.meshtastic.core.ui.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,113 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ui.component
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.material3.Checkbox
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuAnchorType
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.OutlinedTextField
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 androidx.compose.ui.unit.dp
import org.meshtastic.core.strings.R
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BitwisePreference(
title: String,
value: Int,
enabled: Boolean,
items: List<Pair<Int, String>>,
onItemSelected: (Int) -> Unit,
modifier: Modifier = Modifier,
summary: String? = null,
) {
var expanded by remember { mutableStateOf(false) }
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = {
if (enabled) {
expanded = !expanded
}
},
modifier = modifier.padding(vertical = 8.dp),
) {
OutlinedTextField(
modifier = Modifier.fillMaxWidth().menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable, enabled),
readOnly = true,
value = value.toString(),
onValueChange = {},
label = { Text(title) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
colors = ExposedDropdownMenuDefaults.textFieldColors(),
enabled = enabled,
supportingText = { if (summary != null) Text(text = summary) },
)
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
items.forEach { item ->
DropdownMenuItem(
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,
)
},
onClick = { onItemSelected(value xor item.first) },
)
}
PreferenceFooter(
enabled = enabled,
negativeText = R.string.clear,
onNegativeClicked = { onItemSelected(0) },
positiveText = R.string.close,
onPositiveClicked = { expanded = false },
)
}
}
}
@Preview(showBackground = true)
@Composable
private fun BitwisePreferencePreview() {
BitwisePreference(
title = "Settings",
value = 3,
summary = "This is a summary",
enabled = true,
items = listOf(1 to "TEST1", 2 to "TEST2"),
onItemSelected = {},
)
}

View file

@ -0,0 +1,67 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ui.component
import androidx.compose.foundation.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 org.meshtastic.core.ui.component
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,54 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ui.component
import 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 kotlinx.coroutines.launch
import org.meshtastic.core.strings.R
@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,80 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ui.component
import androidx.compose.foundation.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 =
"${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,92 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ui.component
import androidx.compose.foundation.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 org.meshtastic.core.strings.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,291 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ui.component
import androidx.compose.foundation.layout.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.OutlinedTextField
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.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 org.meshtastic.core.strings.R
@Composable
fun SignedIntegerEditTextPreference(
title: String,
value: Int,
enabled: Boolean,
keyboardActions: KeyboardActions,
onValueChanged: (Int) -> Unit,
modifier: Modifier = Modifier,
summary: String? = null,
onFocusChanged: (FocusState) -> Unit = {},
trailingIcon: (@Composable () -> Unit)? = null,
) {
var valueState by remember(value) { mutableStateOf(value.toString()) }
EditTextPreference(
title = title,
value = valueState,
enabled = enabled,
summary = summary,
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,
isError: Boolean = false,
keyboardActions: KeyboardActions,
onValueChanged: (Int) -> Unit,
modifier: Modifier = Modifier,
summary: String? = null,
onFocusChanged: (FocusState) -> Unit = {},
trailingIcon: (@Composable () -> Unit)? = null,
) {
var valueState by remember(value) { mutableStateOf(value.toUInt().toString()) }
EditTextPreference(
title = title,
value = valueState,
enabled = enabled,
summary = summary,
isError = value.toUInt().toString() != valueState || isError,
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,
summary: String? = null,
onFocusChanged: (FocusState) -> Unit = {},
) {
var valueState by remember(value) { mutableStateOf(value.toString()) }
EditTextPreference(
title = title,
value = valueState,
enabled = enabled,
summary = summary,
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,
summary: String? = null,
) {
var valueState by remember(value) { mutableStateOf(value.toString()) }
val decimalSeparators = setOf('.', ',', '٫', '、', '·') // set of possible decimal separators
EditTextPreference(
title = title,
value = valueState,
enabled = enabled,
summary = summary,
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,
summary: String? = null,
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) }
Column(modifier = modifier.padding(vertical = 8.dp)) {
OutlinedTextField(
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 (summary != null) {
Text(
text = summary,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
}
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",
summary = "This is a summary",
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,307 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ui.component
import androidx.compose.foundation.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 org.meshtastic.core.strings.R
import org.meshtastic.core.ui.theme.IAQColors.IAQDangerouslyPolluted
import org.meshtastic.core.ui.theme.IAQColors.IAQExcellent
import org.meshtastic.core.ui.theme.IAQColors.IAQExtremelyPolluted
import org.meshtastic.core.ui.theme.IAQColors.IAQGood
import org.meshtastic.core.ui.theme.IAQColors.IAQHeavilyPolluted
import org.meshtastic.core.ui.theme.IAQColors.IAQLightlyPolluted
import org.meshtastic.core.ui.theme.IAQColors.IAQModeratelyPolluted
import org.meshtastic.core.ui.theme.IAQColors.IAQSeverelyPolluted
@Suppress("MagicNumber")
enum class Iaq(val color: Color, val description: String, val range: IntRange) {
Excellent(IAQExcellent, "Excellent", 0..50),
Good(IAQGood, "Good", 51..100),
LightlyPolluted(IAQLightlyPolluted, "Lightly Polluted", 101..150),
ModeratelyPolluted(IAQModeratelyPolluted, "Moderately Polluted", 151..200),
HeavilyPolluted(IAQHeavilyPolluted, "Heavily Polluted", 201..300),
SeverelyPolluted(IAQSeverelyPolluted, "Severely Polluted", 301..400),
ExtremelyPolluted(IAQExtremelyPolluted, "Extremely Polluted", 401..500),
DangerouslyPolluted(IAQDangerouslyPolluted, "Dangerously Polluted", 501..Int.MAX_VALUE),
}
fun getIaq(iaq: Int): Iaq? = when {
iaq == Int.MIN_VALUE -> null
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 = 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) {
if (iaq == null || iaq == Int.MIN_VALUE) {
return
}
var isLegendOpen by remember { mutableStateOf(false) }
val iaqEnum = if (iaq != null) getIaq(iaq) else null
val gradient = Brush.linearGradient(colors = Iaq.entries.map { it.color })
if (iaqEnum != null) {
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 (iaqEnum.range.first < 100) Icons.Default.ThumbUp else Icons.Filled.Warning,
contentDescription = stringResource(R.string.air_quality_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 = "${iaqEnum.description}")
}
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,266 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ui.component
import androidx.compose.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 == DRAG_DROP_CONTENT_TYPE }
.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 DRAG_DROP_CONTENT_TYPE = "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 = { _, _ -> DRAG_DROP_CONTENT_TYPE },
itemContent = { index, item ->
DraggableItem(
dragDropState = dragDropState,
index = index,
content = { isDragging -> itemContent(index, item, isDragging) },
)
},
)

View file

@ -0,0 +1,162 @@
/*
* 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 org.meshtastic.core.ui.component
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.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.MaterialTheme.colorScheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
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.TextUnit
import androidx.compose.ui.unit.dp
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.theme.StatusColors.StatusGreen
import org.meshtastic.core.ui.theme.StatusColors.StatusOrange
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
import org.meshtastic.core.ui.theme.StatusColors.StatusYellow
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
@Stable
private enum class Quality(
@Stable val nameRes: Int,
@Stable val imageVector: ImageVector,
@Stable val color: @Composable () -> Color,
) {
NONE(R.string.none_quality, Icons.Default.SignalCellularAlt1Bar, { colorScheme.StatusRed }),
BAD(R.string.bad, Icons.Default.SignalCellularAlt2Bar, { colorScheme.StatusOrange }),
FAIR(R.string.fair, Icons.Default.SignalCellularAlt, { colorScheme.StatusYellow }),
GOOD(R.string.good, Icons.Default.SignalCellular4Bar, { colorScheme.StatusGreen }),
}
/**
* 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.invoke(),
)
}
}
/** 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.invoke(),
)
Text(text = "${stringResource(R.string.signal)} ${stringResource(quality.nameRes)}")
}
}
@Composable
fun Snr(snr: Float, fontSize: TextUnit = MaterialTheme.typography.labelLarge.fontSize) {
val color: Color =
if (snr > SNR_GOOD_THRESHOLD) {
Quality.GOOD.color.invoke()
} else if (snr > SNR_FAIR_THRESHOLD) {
Quality.FAIR.color.invoke()
} else {
Quality.BAD.color.invoke()
}
Text(text = "%s %.2fdB".format(stringResource(id = R.string.snr), snr), color = color, fontSize = fontSize)
}
@Composable
fun Rssi(rssi: Int, fontSize: TextUnit = MaterialTheme.typography.labelLarge.fontSize) {
val color: Color =
if (rssi > RSSI_GOOD_THRESHOLD) {
Quality.GOOD.color.invoke()
} else if (rssi > RSSI_FAIR_THRESHOLD) {
Quality.FAIR.color.invoke()
} else {
Quality.BAD.color.invoke()
}
Text(text = "%s %ddBm".format(stringResource(id = R.string.rssi), rssi), color = color, fontSize = 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,102 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ui.component
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.tooling.preview.Preview
import com.mikepenz.markdown.compose.components.markdownComponents
import com.mikepenz.markdown.m3.Markdown
import com.mikepenz.markdown.model.DefaultMarkdownColors
import com.mikepenz.markdown.model.DefaultMarkdownTypography
import org.meshtastic.core.ui.theme.HyperlinkBlue
@Composable
fun MDText(
text: String,
modifier: Modifier = Modifier,
style: TextStyle = MaterialTheme.typography.bodyMedium,
color: Color = Color.Unspecified,
) {
val colors =
DefaultMarkdownColors(
text = color,
codeBackground = MaterialTheme.colorScheme.surfaceContainerHigh,
inlineCodeBackground = MaterialTheme.colorScheme.surfaceContainerHigh,
dividerColor = MaterialTheme.colorScheme.onSurface,
tableBackground = MaterialTheme.colorScheme.surfaceContainer,
)
val typography =
DefaultMarkdownTypography(
// Restrict max size of the text
h1 = MaterialTheme.typography.headlineMedium.copy(color = color),
h2 = MaterialTheme.typography.headlineMedium.copy(color = color),
h3 = MaterialTheme.typography.headlineSmall.copy(color = color),
h4 = MaterialTheme.typography.titleLarge.copy(color = color),
h5 = MaterialTheme.typography.titleMedium.copy(color = color),
h6 = MaterialTheme.typography.titleSmall.copy(color = color),
text = style,
code =
MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurface,
),
inlineCode =
MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurface,
background = MaterialTheme.colorScheme.surfaceContainerHigh,
),
quote = MaterialTheme.typography.bodyLarge.copy(color = color),
paragraph = MaterialTheme.typography.bodyMedium.copy(color = color),
ordered = MaterialTheme.typography.bodyMedium.copy(color = color),
bullet = MaterialTheme.typography.bodyMedium.copy(color = color),
list = MaterialTheme.typography.bodyMedium.copy(color = color),
textLink =
TextLinkStyles(style = SpanStyle(color = HyperlinkBlue, textDecoration = TextDecoration.Underline)),
table = MaterialTheme.typography.bodyMedium.copy(color = MaterialTheme.colorScheme.onSurface),
)
// Custom Markdown components to disable image rendering
val customComponents = markdownComponents(image = { /* Empty composable to disable image rendering */ })
Markdown(
content = text,
modifier = modifier,
colors = colors,
typography = typography,
components = customComponents, // Use custom components
)
}
@Preview(showBackground = true)
@Composable
private fun AutoLinkTextPreview() {
MDText(
"A text containing a link https://example.com **bold** _Italics_" +
"\n # hello \n ## hello \n ### hello \n #### hello \n ##### hello \n ###### hello \n ```code```",
)
}

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 org.meshtastic.core.ui.component
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Power
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.draw.drawBehind
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
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 org.meshtastic.core.ui.icon.BatteryEmpty
import org.meshtastic.core.ui.icon.BatteryUnknown
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.core.ui.theme.StatusColors.StatusGreen
import org.meshtastic.core.ui.theme.StatusColors.StatusOrange
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
private const val FORMAT = "%d%%"
private const val SIZE_ICON = 20
@Suppress("MagicNumber")
@Composable
fun MaterialBatteryInfo(modifier: Modifier = Modifier, level: Int) {
val levelString = FORMAT.format(level)
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(2.dp),
) {
if (level > 100) {
Icon(
modifier = Modifier.size(SIZE_ICON.dp).rotate(90f),
imageVector = Icons.Rounded.Power,
tint = MaterialTheme.colorScheme.onSurface,
contentDescription = null,
)
Text(text = "PWD", color = MaterialTheme.colorScheme.onSurface, style = MaterialTheme.typography.labelLarge)
} else if (level < 0) {
Icon(
modifier = Modifier.size(SIZE_ICON.dp),
imageVector = MeshtasticIcons.BatteryUnknown,
tint = MaterialTheme.colorScheme.onSurface,
contentDescription = null,
)
} else {
// Map battery percentage to color
val fillColor =
when (level) {
in 0..19 -> MaterialTheme.colorScheme.StatusRed
in 20..39 -> MaterialTheme.colorScheme.StatusOrange
else -> MaterialTheme.colorScheme.StatusGreen
}
Icon(
modifier =
Modifier.size(SIZE_ICON.dp).drawBehind {
val insetVertical = size.height * .28f
val insetLeft = size.width * .11f
val insetRight = size.width * .22f
val availableWidth = size.width - (insetLeft + insetRight)
val availableHeight = size.height - (insetVertical * 2)
// Fill (grow from left to right)
val fillWidth = availableWidth * (level / 100f)
drawRect(
color = fillColor,
topLeft = Offset(insetLeft, insetVertical),
size = Size(fillWidth, availableHeight),
)
},
imageVector = MeshtasticIcons.BatteryEmpty,
tint = MaterialTheme.colorScheme.onSurface,
contentDescription = null,
)
Text(
text = levelString,
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.labelLarge,
)
}
}
}
class BatteryLevelProvider : PreviewParameterProvider<Int> {
override val values: Sequence<Int> = sequenceOf(-1, 19, 39, 90, 101)
}
@PreviewLightDark
@Composable
fun MaterialBatteryInfoPreview(@PreviewParameter(BatteryLevelProvider::class) batteryLevel: Int) {
AppTheme { MaterialBatteryInfo(level = batteryLevel) }
}

View file

@ -0,0 +1,61 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ui.component
import androidx.compose.foundation.layout.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,79 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ui.component
import androidx.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 org.meshtastic.core.strings.R
@Composable
fun PreferenceFooter(
enabled: Boolean,
onCancelClicked: () -> Unit,
onSaveClicked: () -> Unit,
modifier: Modifier = Modifier,
) {
PreferenceFooter(
enabled = enabled,
negativeText = R.string.clear_changes,
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), 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,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 org.meshtastic.core.ui.component
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.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,
dropdownMenu: @Composable () -> Unit = {},
) {
RegularPreference(
title = title,
subtitle = AnnotatedString(text = subtitle),
onClick = onClick,
modifier = modifier,
enabled = enabled,
summary = summary,
trailingIcon = trailingIcon,
dropdownMenu = dropdownMenu,
)
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun RegularPreference(
title: String,
subtitle: AnnotatedString,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
summary: String? = null,
trailingIcon: ImageVector? = null,
dropdownMenu: @Composable () -> Unit = {},
) {
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) {
Box {
Icon(
imageVector = trailingIcon,
contentDescription = "trailingIcon",
modifier = Modifier.padding(start = 8.dp).wrapContentWidth(Alignment.End),
tint = color,
)
dropdownMenu()
}
}
}
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,107 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ui.component
import androidx.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 org.meshtastic.core.strings.R
import org.meshtastic.core.ui.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,392 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ui.component
import 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.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.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.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.unit.Constraints
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
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,96 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ui.component
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.selection.toggleable
import androidx.compose.material3.CircularWavyProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun SwitchPreference(
modifier: Modifier = Modifier,
title: String,
summary: String = "",
checked: Boolean,
enabled: Boolean,
onCheckedChange: (Boolean) -> Unit,
padding: PaddingValues? = null,
containerColor: Color? = null,
loading: Boolean = false,
) {
ListItem(
colors =
ListItemDefaults.colors()
.copy(
headlineColor =
if (enabled) {
ListItemDefaults.colors().headlineColor
} else {
ListItemDefaults.colors().headlineColor.copy(alpha = 0.5f)
},
supportingTextColor =
if (enabled) {
ListItemDefaults.colors().supportingTextColor
} else {
ListItemDefaults.colors().supportingTextColor.copy(alpha = 0.5f)
},
containerColor = containerColor ?: ListItemDefaults.colors().containerColor,
),
modifier =
(padding?.let { Modifier.padding(it) } ?: modifier).toggleable(
value = checked,
enabled = enabled,
onValueChange = onCheckedChange,
),
trailingContent = {
AnimatedContent(targetState = loading) { loading ->
if (loading) {
CircularWavyProgressIndicator(modifier = Modifier.size(24.dp))
} else {
Switch(enabled = enabled, checked = checked, onCheckedChange = null)
}
}
},
supportingContent = {
if (summary.isNotEmpty()) {
Text(text = summary, modifier = Modifier.padding(bottom = 16.dp))
}
},
headlineContent = { Text(text = title) },
)
}
@Preview(showBackground = true)
@Composable
private fun SwitchPreferencePreview() {
SwitchPreference(title = "Setting", checked = true, enabled = true, onCheckedChange = {})
}

View file

@ -0,0 +1,82 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ui.component
import androidx.compose.foundation.layout.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,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 org.meshtastic.core.ui.component
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,56 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ui.component
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import org.meshtastic.core.ui.theme.AppTheme
@Composable
fun TitledCard(title: String?, modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit) {
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) {
title?.let {
Text(
text = it,
modifier = Modifier.padding(horizontal = 16.dp).fillMaxWidth(),
style = MaterialTheme.typography.titleLarge,
)
}
Card(content = content)
}
}
@PreviewLightDark
@Composable
private fun TitledCardPreview() {
AppTheme { Surface { TitledCard(title = "Title") { Box(modifier = Modifier.fillMaxWidth().height(100.dp)) {} } } }
}

View file

@ -0,0 +1,184 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ui.icon
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.unit.dp
/**
* This is from Material Symbols.
*
* @see
* [battery_android_0](https://fonts.google.com/icons?icon.query=battery+android+0&icon.size=24&icon.color=%23e3e3e3&icon.style=Rounded)
*/
val MeshtasticIcons.BatteryEmpty: ImageVector
get() {
if (batteryEmpty != null) {
return batteryEmpty!!
}
batteryEmpty =
ImageVector.Builder(
name = "BatteryEmpty",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 960f,
viewportHeight = 960f,
)
.apply {
path(fill = SolidColor(Color.Black)) {
moveTo(160f, 720f)
quadToRelative(-50f, 0f, -85f, -35f)
reflectiveQuadToRelative(-35f, -85f)
verticalLineToRelative(-240f)
quadToRelative(0f, -50f, 35f, -85f)
reflectiveQuadToRelative(85f, -35f)
horizontalLineToRelative(540f)
quadToRelative(50f, 0f, 85f, 35f)
reflectiveQuadToRelative(35f, 85f)
verticalLineToRelative(240f)
quadToRelative(0f, 50f, -35f, 85f)
reflectiveQuadToRelative(-85f, 35f)
lineTo(160f, 720f)
close()
moveTo(160f, 640f)
horizontalLineToRelative(540f)
quadToRelative(17f, 0f, 28.5f, -11.5f)
reflectiveQuadTo(740f, 600f)
verticalLineToRelative(-240f)
quadToRelative(0f, -17f, -11.5f, -28.5f)
reflectiveQuadTo(700f, 320f)
lineTo(160f, 320f)
quadToRelative(-17f, 0f, -28.5f, 11.5f)
reflectiveQuadTo(120f, 360f)
verticalLineToRelative(240f)
quadToRelative(0f, 17f, 11.5f, 28.5f)
reflectiveQuadTo(160f, 640f)
close()
moveTo(860f, 580f)
verticalLineToRelative(-200f)
horizontalLineToRelative(20f)
quadToRelative(17f, 0f, 28.5f, 11.5f)
reflectiveQuadTo(920f, 420f)
verticalLineToRelative(120f)
quadToRelative(0f, 17f, -11.5f, 28.5f)
reflectiveQuadTo(880f, 580f)
horizontalLineToRelative(-20f)
close()
moveTo(120f, 640f)
verticalLineToRelative(-320f)
verticalLineToRelative(320f)
close()
}
}
.build()
return batteryEmpty!!
}
private var batteryEmpty: ImageVector? = null
/**
* This is from Material Symbols.
*
* @see
* [battery_android_question](https://fonts.google.com/icons?icon.query=battery+android+question&icon.size=24&icon.color=%23e3e3e3&icon.style=Rounded)
*/
val MeshtasticIcons.BatteryUnknown: ImageVector
get() {
if (batteryUnknown != null) {
return batteryUnknown!!
}
batteryUnknown =
ImageVector.Builder(
name = "BatteryUnknown",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 960f,
viewportHeight = 960f,
)
.apply {
path(fill = SolidColor(Color.Black)) {
moveTo(120f, 640f)
verticalLineToRelative(-320f)
verticalLineToRelative(320f)
close()
moveTo(726f, 720f)
lineTo(160f, 720f)
quadToRelative(-50f, 0f, -85f, -35f)
reflectiveQuadToRelative(-35f, -85f)
verticalLineToRelative(-240f)
quadToRelative(0f, -50f, 35f, -85f)
reflectiveQuadToRelative(85f, -35f)
horizontalLineToRelative(521f)
quadToRelative(-20f, 16f, -35f, 36f)
reflectiveQuadToRelative(-25f, 44f)
lineTo(160f, 320f)
quadToRelative(-17f, 0f, -28.5f, 11.5f)
reflectiveQuadTo(120f, 360f)
verticalLineToRelative(240f)
quadToRelative(0f, 17f, 11.5f, 28.5f)
reflectiveQuadTo(160f, 640f)
horizontalLineToRelative(520f)
quadToRelative(2f, 25f, 14.5f, 45.5f)
reflectiveQuadTo(726f, 720f)
close()
moveTo(800f, 660f)
quadToRelative(17f, 0f, 28.5f, -11.5f)
reflectiveQuadTo(840f, 620f)
quadToRelative(0f, -17f, -11.5f, -28.5f)
reflectiveQuadTo(800f, 580f)
quadToRelative(-17f, 0f, -28.5f, 11.5f)
reflectiveQuadTo(760f, 620f)
quadToRelative(0f, 17f, 11.5f, 28.5f)
reflectiveQuadTo(800f, 660f)
close()
moveTo(772f, 538f)
horizontalLineToRelative(57f)
verticalLineToRelative(-21f)
quadToRelative(0f, -10f, 5f, -19f)
quadToRelative(6f, -13f, 15.5f, -22f)
reflectiveQuadToRelative(19.5f, -19f)
quadToRelative(17f, -17f, 28.5f, -37f)
reflectiveQuadToRelative(11.5f, -43f)
quadToRelative(0f, -42f, -32.5f, -69.5f)
reflectiveQuadTo(800f, 280f)
quadToRelative(-38f, 0f, -68f, 22f)
reflectiveQuadToRelative(-40f, 58f)
lineToRelative(51f, 21f)
quadToRelative(6f, -20f, 21.5f, -33f)
reflectiveQuadToRelative(35.5f, -13f)
quadToRelative(21f, 0f, 36.5f, 12f)
reflectiveQuadToRelative(15.5f, 32f)
quadToRelative(0f, 17f, -10f, 30.5f)
reflectiveQuadTo(820f, 434f)
quadToRelative(-11f, 11f, -22.5f, 21.5f)
reflectiveQuadTo(779f, 480f)
quadToRelative(-6f, 14f, -6.5f, 28.5f)
reflectiveQuadTo(772f, 538f)
close()
}
}
.build()
return batteryUnknown!!
}
private var batteryUnknown: ImageVector? = null

View file

@ -0,0 +1,151 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ui.icon
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.unit.dp
/**
* This is from Material Symbols.
*
* @see
* [router](https://fonts.google.com/icons?selected=Material+Symbols+Rounded:router:FILL@0;wght@400;GRAD@0;opsz@24&icon.query=router&icon.size=24&icon.color=%23e3e3e3&icon.platform=android&icon.style=Rounded)
*/
val MeshtasticIcons.Device: ImageVector
get() {
if (device != null) {
return device!!
}
device =
ImageVector.Builder(
name = "Outlined.Device",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 960f,
viewportHeight = 960f,
)
.apply {
path(fill = SolidColor(Color(0xFFE3E3E3))) {
moveTo(200f, 840f)
quadToRelative(-33f, 0f, -56.5f, -23.5f)
reflectiveQuadTo(120f, 760f)
verticalLineToRelative(-160f)
quadToRelative(0f, -33f, 23.5f, -56.5f)
reflectiveQuadTo(200f, 520f)
horizontalLineToRelative(400f)
verticalLineToRelative(-120f)
quadToRelative(0f, -17f, 11.5f, -28.5f)
reflectiveQuadTo(640f, 360f)
quadToRelative(17f, 0f, 28.5f, 11.5f)
reflectiveQuadTo(680f, 400f)
verticalLineToRelative(120f)
horizontalLineToRelative(80f)
quadToRelative(33f, 0f, 56.5f, 23.5f)
reflectiveQuadTo(840f, 600f)
verticalLineToRelative(160f)
quadToRelative(0f, 33f, -23.5f, 56.5f)
reflectiveQuadTo(760f, 840f)
lineTo(200f, 840f)
close()
moveTo(200f, 760f)
horizontalLineToRelative(560f)
verticalLineToRelative(-160f)
lineTo(200f, 600f)
verticalLineToRelative(160f)
close()
moveTo(280f, 720f)
quadToRelative(17f, 0f, 28.5f, -11.5f)
reflectiveQuadTo(320f, 680f)
quadToRelative(0f, -17f, -11.5f, -28.5f)
reflectiveQuadTo(280f, 640f)
quadToRelative(-17f, 0f, -28.5f, 11.5f)
reflectiveQuadTo(240f, 680f)
quadToRelative(0f, 17f, 11.5f, 28.5f)
reflectiveQuadTo(280f, 720f)
close()
moveTo(420f, 720f)
quadToRelative(17f, 0f, 28.5f, -11.5f)
reflectiveQuadTo(460f, 680f)
quadToRelative(0f, -17f, -11.5f, -28.5f)
reflectiveQuadTo(420f, 640f)
quadToRelative(-17f, 0f, -28.5f, 11.5f)
reflectiveQuadTo(380f, 680f)
quadToRelative(0f, 17f, 11.5f, 28.5f)
reflectiveQuadTo(420f, 720f)
close()
moveTo(560f, 720f)
quadToRelative(17f, 0f, 28.5f, -11.5f)
reflectiveQuadTo(600f, 680f)
quadToRelative(0f, -17f, -11.5f, -28.5f)
reflectiveQuadTo(560f, 640f)
quadToRelative(-17f, 0f, -28.5f, 11.5f)
reflectiveQuadTo(520f, 680f)
quadToRelative(0f, 17f, 11.5f, 28.5f)
reflectiveQuadTo(560f, 720f)
close()
moveTo(640f, 300f)
quadToRelative(-11f, 0f, -20f, 2f)
reflectiveQuadToRelative(-18f, 6f)
quadToRelative(-16f, 7f, -32.5f, 6f)
reflectiveQuadTo(541f, 301f)
quadToRelative(-12f, -12f, -11.5f, -29f)
reflectiveQuadToRelative(14.5f, -25f)
quadToRelative(21f, -13f, 45.5f, -20f)
reflectiveQuadToRelative(50.5f, -7f)
quadToRelative(27f, 0f, 51f, 7f)
reflectiveQuadToRelative(45f, 20f)
quadToRelative(14f, 8f, 14.5f, 25f)
reflectiveQuadTo(739f, 301f)
quadToRelative(-12f, 12f, -29f, 13f)
reflectiveQuadToRelative(-33f, -6f)
quadToRelative(-8f, -4f, -17.5f, -6f)
reflectiveQuadToRelative(-19.5f, -2f)
close()
moveTo(640f, 160f)
quadToRelative(-39f, 0f, -74.5f, 11.5f)
reflectiveQuadTo(500f, 205f)
quadToRelative(-14f, 10f, -30.5f, 9f)
reflectiveQuadTo(442f, 202f)
quadToRelative(-12f, -12f, -12f, -28f)
reflectiveQuadToRelative(13f, -26f)
quadToRelative(41f, -32f, 91f, -50f)
reflectiveQuadToRelative(106f, -18f)
quadToRelative(56f, 0f, 106f, 18f)
reflectiveQuadToRelative(91f, 50f)
quadToRelative(13f, 10f, 13f, 26f)
reflectiveQuadToRelative(-12f, 28f)
quadToRelative(-11f, 11f, -27.5f, 12f)
reflectiveQuadToRelative(-30.5f, -9f)
quadToRelative(-30f, -22f, -65.5f, -33.5f)
reflectiveQuadTo(640f, 160f)
close()
moveTo(200f, 760f)
verticalLineToRelative(-160f)
verticalLineToRelative(160f)
close()
}
}
.build()
return device!!
}
private var device: ImageVector? = 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 org.meshtastic.core.ui.icon
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.unit.dp
/**
* This is from Material Symbols.
*
* @see
* [map](https://fonts.google.com/icons?selected=Material+Symbols+Rounded:map:FILL@0;wght@400;GRAD@0;opsz@24&icon.query=map&icon.size=24&icon.color=%23e3e3e3&icon.platform=android&icon.style=Rounded)
*/
val MeshtasticIcons.Map: ImageVector
get() {
if (map != null) {
return map!!
}
map =
ImageVector.Builder(
name = "Outlined.Map",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 960f,
viewportHeight = 960f,
)
.apply {
path(fill = SolidColor(Color.Black)) {
moveToRelative(574f, 831f)
lineToRelative(-214f, -75f)
lineToRelative(-186f, 72f)
quadToRelative(-10f, 4f, -19.5f, 2.5f)
reflectiveQuadTo(137f, 824f)
quadToRelative(-8f, -5f, -12.5f, -13.5f)
reflectiveQuadTo(120f, 791f)
verticalLineToRelative(-561f)
quadToRelative(0f, -13f, 7.5f, -23f)
reflectiveQuadToRelative(20.5f, -15f)
lineToRelative(186f, -63f)
quadToRelative(6f, -2f, 12.5f, -3f)
reflectiveQuadToRelative(13.5f, -1f)
quadToRelative(7f, 0f, 13.5f, 1f)
reflectiveQuadToRelative(12.5f, 3f)
lineToRelative(214f, 75f)
lineToRelative(186f, -72f)
quadToRelative(10f, -4f, 19.5f, -2.5f)
reflectiveQuadTo(823f, 136f)
quadToRelative(8f, 5f, 12.5f, 13.5f)
reflectiveQuadTo(840f, 169f)
verticalLineToRelative(561f)
quadToRelative(0f, 13f, -7.5f, 23f)
reflectiveQuadTo(812f, 768f)
lineToRelative(-186f, 63f)
quadToRelative(-6f, 2f, -12.5f, 3f)
reflectiveQuadToRelative(-13.5f, 1f)
quadToRelative(-7f, 0f, -13.5f, -1f)
reflectiveQuadToRelative(-12.5f, -3f)
close()
moveTo(560f, 742f)
verticalLineToRelative(-468f)
lineToRelative(-160f, -56f)
verticalLineToRelative(468f)
lineToRelative(160f, 56f)
close()
moveTo(640f, 742f)
lineTo(760f, 702f)
verticalLineToRelative(-474f)
lineToRelative(-120f, 46f)
verticalLineToRelative(468f)
close()
moveTo(200f, 732f)
lineTo(320f, 686f)
verticalLineToRelative(-468f)
lineToRelative(-120f, 40f)
verticalLineToRelative(474f)
close()
moveTo(640f, 274f)
verticalLineToRelative(468f)
verticalLineToRelative(-468f)
close()
moveTo(320f, 218f)
verticalLineToRelative(468f)
verticalLineToRelative(-468f)
close()
}
}
.build()
return map!!
}
private var map: ImageVector? = null

View file

@ -0,0 +1,20 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ui.icon
object MeshtasticIcons

View file

@ -0,0 +1,101 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ui.icon
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.unit.dp
/**
* This is from Material Symbols.
*
* @see
* [forum](https://fonts.google.com/icons?selected=Material+Symbols+Rounded:forum:FILL@0;wght@400;GRAD@0;opsz@24&icon.query=forum&icon.size=24&icon.color=%23e3e3e3&icon.platform=android&icon.style=Rounded)
*/
val MeshtasticIcons.Conversations: ImageVector
get() {
if (conversations != null) {
return conversations!!
}
conversations =
ImageVector.Builder(
name = "Outlined.Conversations",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 960f,
viewportHeight = 960f,
)
.apply {
path(fill = SolidColor(Color.Black)) {
moveTo(840f, 824f)
quadToRelative(-8f, 0f, -15f, -3f)
reflectiveQuadToRelative(-13f, -9f)
lineToRelative(-92f, -92f)
lineTo(320f, 720f)
quadToRelative(-33f, 0f, -56.5f, -23.5f)
reflectiveQuadTo(240f, 640f)
verticalLineToRelative(-40f)
horizontalLineToRelative(440f)
quadToRelative(33f, 0f, 56.5f, -23.5f)
reflectiveQuadTo(760f, 520f)
verticalLineToRelative(-280f)
horizontalLineToRelative(40f)
quadToRelative(33f, 0f, 56.5f, 23.5f)
reflectiveQuadTo(880f, 320f)
verticalLineToRelative(463f)
quadToRelative(0f, 18f, -12f, 29.5f)
reflectiveQuadTo(840f, 824f)
close()
moveTo(160f, 487f)
lineToRelative(47f, -47f)
horizontalLineToRelative(393f)
verticalLineToRelative(-280f)
lineTo(160f, 160f)
verticalLineToRelative(327f)
close()
moveTo(120f, 624f)
quadToRelative(-16f, 0f, -28f, -11.5f)
reflectiveQuadTo(80f, 583f)
verticalLineToRelative(-423f)
quadToRelative(0f, -33f, 23.5f, -56.5f)
reflectiveQuadTo(160f, 80f)
horizontalLineToRelative(440f)
quadToRelative(33f, 0f, 56.5f, 23.5f)
reflectiveQuadTo(680f, 160f)
verticalLineToRelative(280f)
quadToRelative(0f, 33f, -23.5f, 56.5f)
reflectiveQuadTo(600f, 520f)
lineTo(240f, 520f)
lineToRelative(-92f, 92f)
quadToRelative(-6f, 6f, -13f, 9f)
reflectiveQuadToRelative(-15f, 3f)
close()
moveTo(160f, 440f)
verticalLineToRelative(-280f)
verticalLineToRelative(280f)
close()
}
}
.build()
return conversations!!
}
private var conversations: ImageVector? = null

View file

@ -0,0 +1,165 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ui.icon
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.unit.dp
/**
* This is from Material Symbols.
*
* @see
* [router_off](https://fonts.google.com/icons?icon.query=router+off&icon.size=24&icon.color=%23e3e3e3&icon.style=Rounded)
*/
val MeshtasticIcons.NoDevice: ImageVector
get() {
if (noDevice != null) {
return noDevice!!
}
noDevice =
ImageVector.Builder(
name = "Outlined.NoDevice",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 960f,
viewportHeight = 960f,
)
.apply {
path(fill = SolidColor(Color.Black)) {
moveTo(806f, 692f)
lineTo(600f, 486f)
verticalLineToRelative(-86f)
quadToRelative(0f, -17f, 11.5f, -28.5f)
reflectiveQuadTo(640f, 360f)
quadToRelative(17f, 0f, 28.5f, 11.5f)
reflectiveQuadTo(680f, 400f)
verticalLineToRelative(120f)
horizontalLineToRelative(80f)
quadToRelative(33f, 0f, 56.5f, 23.5f)
reflectiveQuadTo(840f, 600f)
verticalLineToRelative(78f)
quadToRelative(0f, 14f, -12f, 19f)
reflectiveQuadToRelative(-22f, -5f)
close()
moveTo(200f, 760f)
horizontalLineToRelative(446f)
lineTo(486f, 600f)
lineTo(200f, 600f)
verticalLineToRelative(160f)
close()
moveTo(200f, 840f)
quadToRelative(-33f, 0f, -56.5f, -23.5f)
reflectiveQuadTo(120f, 760f)
verticalLineToRelative(-160f)
quadToRelative(0f, -33f, 23.5f, -56.5f)
reflectiveQuadTo(200f, 520f)
horizontalLineToRelative(206f)
lineTo(83f, 197f)
quadToRelative(-12f, -12f, -12f, -28.5f)
reflectiveQuadTo(83f, 140f)
quadToRelative(12f, -12f, 28.5f, -12f)
reflectiveQuadToRelative(28.5f, 12f)
lineToRelative(680f, 680f)
quadToRelative(12f, 12f, 12f, 28f)
reflectiveQuadToRelative(-12f, 28f)
quadToRelative(-12f, 12f, -28.5f, 12f)
reflectiveQuadTo(763f, 876f)
lineToRelative(-37f, -36f)
lineTo(200f, 840f)
close()
moveTo(280f, 720f)
quadToRelative(-17f, 0f, -28.5f, -11.5f)
reflectiveQuadTo(240f, 680f)
quadToRelative(0f, -17f, 11.5f, -28.5f)
reflectiveQuadTo(280f, 640f)
quadToRelative(17f, 0f, 28.5f, 11.5f)
reflectiveQuadTo(320f, 680f)
quadToRelative(0f, 17f, -11.5f, 28.5f)
reflectiveQuadTo(280f, 720f)
close()
moveTo(420f, 720f)
quadToRelative(-17f, 0f, -28.5f, -11.5f)
reflectiveQuadTo(380f, 680f)
quadToRelative(0f, -17f, 11.5f, -28.5f)
reflectiveQuadTo(420f, 640f)
quadToRelative(17f, 0f, 28.5f, 11.5f)
reflectiveQuadTo(460f, 680f)
quadToRelative(0f, 17f, -11.5f, 28.5f)
reflectiveQuadTo(420f, 720f)
close()
moveTo(560f, 720f)
quadToRelative(-17f, 0f, -28.5f, -11.5f)
reflectiveQuadTo(520f, 680f)
quadToRelative(0f, -17f, 11.5f, -28.5f)
reflectiveQuadTo(560f, 640f)
quadToRelative(17f, 0f, 28.5f, 11.5f)
reflectiveQuadTo(600f, 680f)
quadToRelative(0f, 17f, -11.5f, 28.5f)
reflectiveQuadTo(560f, 720f)
close()
moveTo(200f, 760f)
verticalLineToRelative(-160f)
verticalLineToRelative(160f)
close()
moveTo(640f, 300f)
quadToRelative(-11f, 0f, -20f, 2f)
reflectiveQuadToRelative(-18f, 6f)
quadToRelative(-16f, 7f, -32.5f, 6f)
reflectiveQuadTo(541f, 301f)
quadToRelative(-12f, -12f, -11.5f, -29f)
reflectiveQuadToRelative(14.5f, -25f)
quadToRelative(21f, -13f, 45.5f, -20f)
reflectiveQuadToRelative(50.5f, -7f)
quadToRelative(27f, 0f, 51f, 7f)
reflectiveQuadToRelative(45f, 20f)
quadToRelative(14f, 8f, 14.5f, 25f)
reflectiveQuadTo(739f, 301f)
quadToRelative(-12f, 12f, -29f, 13f)
reflectiveQuadToRelative(-33f, -6f)
quadToRelative(-8f, -4f, -17.5f, -6f)
reflectiveQuadToRelative(-19.5f, -2f)
close()
moveTo(640f, 160f)
quadToRelative(-39f, 0f, -74.5f, 11.5f)
reflectiveQuadTo(500f, 205f)
quadToRelative(-14f, 10f, -30.5f, 9f)
reflectiveQuadTo(442f, 202f)
quadToRelative(-12f, -12f, -12f, -28f)
reflectiveQuadToRelative(13f, -26f)
quadToRelative(41f, -32f, 91f, -50f)
reflectiveQuadToRelative(106f, -18f)
quadToRelative(56f, 0f, 106f, 18f)
reflectiveQuadToRelative(91f, 50f)
quadToRelative(13f, 10f, 13f, 26f)
reflectiveQuadToRelative(-12f, 28f)
quadToRelative(-11f, 11f, -27.5f, 12f)
reflectiveQuadToRelative(-30.5f, -9f)
quadToRelative(-30f, -22f, -65.5f, -33.5f)
reflectiveQuadTo(640f, 160f)
close()
}
}
.build()
return noDevice!!
}
private var noDevice: ImageVector? = null

View file

@ -0,0 +1,166 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ui.icon
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.unit.dp
/**
* This is from Material Symbols.
*
* @see
* [graph_3](https://fonts.google.com/icons?icon.query=graph+3&icon.size=24&icon.color=%23e3e3e3&icon.style=Rounded)
*/
val MeshtasticIcons.Nodes: ImageVector
get() {
if (nodes != null) {
return nodes!!
}
nodes =
ImageVector.Builder(
name = "Outlined.Nodes",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 960f,
viewportHeight = 960f,
)
.apply {
path(fill = SolidColor(Color.Black)) {
moveTo(480f, 880f)
quadToRelative(-50f, 0f, -85f, -35f)
reflectiveQuadToRelative(-35f, -85f)
quadToRelative(0f, -5f, 0.5f, -11f)
reflectiveQuadToRelative(1.5f, -11f)
lineToRelative(-83f, -47f)
quadToRelative(-16f, 14f, -36f, 21.5f)
reflectiveQuadToRelative(-43f, 7.5f)
quadToRelative(-50f, 0f, -85f, -35f)
reflectiveQuadToRelative(-35f, -85f)
quadToRelative(0f, -50f, 35f, -85f)
reflectiveQuadToRelative(85f, -35f)
quadToRelative(24f, 0f, 45f, 9f)
reflectiveQuadToRelative(38f, 25f)
lineToRelative(119f, -60f)
quadToRelative(-3f, -23f, 2.5f, -45f)
reflectiveQuadToRelative(19.5f, -41f)
lineToRelative(-34f, -52f)
quadToRelative(-7f, 2f, -14.5f, 3f)
reflectiveQuadToRelative(-15.5f, 1f)
quadToRelative(-50f, 0f, -85f, -35f)
reflectiveQuadToRelative(-35f, -85f)
quadToRelative(0f, -50f, 35f, -85f)
reflectiveQuadToRelative(85f, -35f)
quadToRelative(50f, 0f, 85f, 35f)
reflectiveQuadToRelative(35f, 85f)
quadToRelative(0f, 20f, -6.5f, 38.5f)
reflectiveQuadTo(456f, 272f)
lineToRelative(35f, 52f)
quadToRelative(8f, -2f, 15f, -3f)
reflectiveQuadToRelative(15f, -1f)
quadToRelative(17f, 0f, 32f, 4f)
reflectiveQuadToRelative(29f, 12f)
lineToRelative(66f, -54f)
quadToRelative(-4f, -10f, -6f, -20.5f)
reflectiveQuadToRelative(-2f, -21.5f)
quadToRelative(0f, -50f, 35f, -85f)
reflectiveQuadToRelative(85f, -35f)
quadToRelative(50f, 0f, 85f, 35f)
reflectiveQuadToRelative(35f, 85f)
quadToRelative(0f, 50f, -35f, 85f)
reflectiveQuadToRelative(-85f, 35f)
quadToRelative(-17f, 0f, -32f, -4.5f)
reflectiveQuadTo(699f, 343f)
lineToRelative(-66f, 55f)
quadToRelative(4f, 10f, 6f, 20.5f)
reflectiveQuadToRelative(2f, 21.5f)
quadToRelative(0f, 50f, -35f, 85f)
reflectiveQuadToRelative(-85f, 35f)
quadToRelative(-24f, 0f, -45.5f, -9f)
reflectiveQuadTo(437f, 526f)
lineToRelative(-118f, 59f)
quadToRelative(2f, 9f, 1.5f, 18f)
reflectiveQuadToRelative(-2.5f, 18f)
lineToRelative(84f, 48f)
quadToRelative(16f, -14f, 35.5f, -21.5f)
reflectiveQuadTo(480f, 640f)
quadToRelative(50f, 0f, 85f, 35f)
reflectiveQuadToRelative(35f, 85f)
quadToRelative(0f, 50f, -35f, 85f)
reflectiveQuadToRelative(-85f, 35f)
close()
moveTo(200f, 640f)
quadToRelative(17f, 0f, 28.5f, -11.5f)
reflectiveQuadTo(240f, 600f)
quadToRelative(0f, -17f, -11.5f, -28.5f)
reflectiveQuadTo(200f, 560f)
quadToRelative(-17f, 0f, -28.5f, 11.5f)
reflectiveQuadTo(160f, 600f)
quadToRelative(0f, 17f, 11.5f, 28.5f)
reflectiveQuadTo(200f, 640f)
close()
moveTo(360f, 240f)
quadToRelative(17f, 0f, 28.5f, -11.5f)
reflectiveQuadTo(400f, 200f)
quadToRelative(0f, -17f, -11.5f, -28.5f)
reflectiveQuadTo(360f, 160f)
quadToRelative(-17f, 0f, -28.5f, 11.5f)
reflectiveQuadTo(320f, 200f)
quadToRelative(0f, 17f, 11.5f, 28.5f)
reflectiveQuadTo(360f, 240f)
close()
moveTo(480f, 800f)
quadToRelative(17f, 0f, 28.5f, -11.5f)
reflectiveQuadTo(520f, 760f)
quadToRelative(0f, -17f, -11.5f, -28.5f)
reflectiveQuadTo(480f, 720f)
quadToRelative(-17f, 0f, -28.5f, 11.5f)
reflectiveQuadTo(440f, 760f)
quadToRelative(0f, 17f, 11.5f, 28.5f)
reflectiveQuadTo(480f, 800f)
close()
moveTo(520f, 480f)
quadToRelative(17f, 0f, 28.5f, -11.5f)
reflectiveQuadTo(560f, 440f)
quadToRelative(0f, -17f, -11.5f, -28.5f)
reflectiveQuadTo(520f, 400f)
quadToRelative(-17f, 0f, -28.5f, 11.5f)
reflectiveQuadTo(480f, 440f)
quadToRelative(0f, 17f, 11.5f, 28.5f)
reflectiveQuadTo(520f, 480f)
close()
moveTo(760f, 280f)
quadToRelative(17f, 0f, 28.5f, -11.5f)
reflectiveQuadTo(800f, 240f)
quadToRelative(0f, -17f, -11.5f, -28.5f)
reflectiveQuadTo(760f, 200f)
quadToRelative(-17f, 0f, -28.5f, 11.5f)
reflectiveQuadTo(720f, 240f)
quadToRelative(0f, 17f, 11.5f, 28.5f)
reflectiveQuadTo(760f, 280f)
close()
}
}
.build()
return nodes!!
}
private var nodes: ImageVector? = null

View file

@ -0,0 +1,160 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ui.icon
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.unit.dp
/**
* This is from Material Symbols.
*
* @see
* [settings](https://fonts.google.com/icons?selected=Material+Symbols+Rounded:settings:FILL@0;wght@400;GRAD@0;opsz@24&icon.style=Rounded&icon.query=settings&icon.set=Material+Symbols&icon.size=24&icon.color=%23e3e3e3&icon.platform=android)
*/
val MeshtasticIcons.Settings: ImageVector
get() {
if (settings != null) {
return settings!!
}
settings =
ImageVector.Builder(
name = "Settings",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 960f,
viewportHeight = 960f,
)
.apply {
path(fill = SolidColor(Color(0xFFE3E3E3))) {
moveTo(433f, 880f)
quadToRelative(-27f, 0f, -46.5f, -18f)
reflectiveQuadTo(363f, 818f)
lineToRelative(-9f, -66f)
quadToRelative(-13f, -5f, -24.5f, -12f)
reflectiveQuadTo(307f, 725f)
lineToRelative(-62f, 26f)
quadToRelative(-25f, 11f, -50f, 2f)
reflectiveQuadToRelative(-39f, -32f)
lineToRelative(-47f, -82f)
quadToRelative(-14f, -23f, -8f, -49f)
reflectiveQuadToRelative(27f, -43f)
lineToRelative(53f, -40f)
quadToRelative(-1f, -7f, -1f, -13.5f)
verticalLineToRelative(-27f)
quadToRelative(0f, -6.5f, 1f, -13.5f)
lineToRelative(-53f, -40f)
quadToRelative(-21f, -17f, -27f, -43f)
reflectiveQuadToRelative(8f, -49f)
lineToRelative(47f, -82f)
quadToRelative(14f, -23f, 39f, -32f)
reflectiveQuadToRelative(50f, 2f)
lineToRelative(62f, 26f)
quadToRelative(11f, -8f, 23f, -15f)
reflectiveQuadToRelative(24f, -12f)
lineToRelative(9f, -66f)
quadToRelative(4f, -26f, 23.5f, -44f)
reflectiveQuadToRelative(46.5f, -18f)
horizontalLineToRelative(94f)
quadToRelative(27f, 0f, 46.5f, 18f)
reflectiveQuadToRelative(23.5f, 44f)
lineToRelative(9f, 66f)
quadToRelative(13f, 5f, 24.5f, 12f)
reflectiveQuadToRelative(22.5f, 15f)
lineToRelative(62f, -26f)
quadToRelative(25f, -11f, 50f, -2f)
reflectiveQuadToRelative(39f, 32f)
lineToRelative(47f, 82f)
quadToRelative(14f, 23f, 8f, 49f)
reflectiveQuadToRelative(-27f, 43f)
lineToRelative(-53f, 40f)
quadToRelative(1f, 7f, 1f, 13.5f)
verticalLineToRelative(27f)
quadToRelative(0f, 6.5f, -2f, 13.5f)
lineToRelative(53f, 40f)
quadToRelative(21f, 17f, 27f, 43f)
reflectiveQuadToRelative(-8f, 49f)
lineToRelative(-48f, 82f)
quadToRelative(-14f, 23f, -39f, 32f)
reflectiveQuadToRelative(-50f, -2f)
lineToRelative(-60f, -26f)
quadToRelative(-11f, 8f, -23f, 15f)
reflectiveQuadToRelative(-24f, 12f)
lineToRelative(-9f, 66f)
quadToRelative(-4f, 26f, -23.5f, 44f)
reflectiveQuadTo(527f, 880f)
horizontalLineToRelative(-94f)
close()
moveTo(440f, 800f)
horizontalLineToRelative(79f)
lineToRelative(14f, -106f)
quadToRelative(31f, -8f, 57.5f, -23.5f)
reflectiveQuadTo(639f, 633f)
lineToRelative(99f, 41f)
lineToRelative(39f, -68f)
lineToRelative(-86f, -65f)
quadToRelative(5f, -14f, 7f, -29.5f)
reflectiveQuadToRelative(2f, -31.5f)
quadToRelative(0f, -16f, -2f, -31.5f)
reflectiveQuadToRelative(-7f, -29.5f)
lineToRelative(86f, -65f)
lineToRelative(-39f, -68f)
lineToRelative(-99f, 42f)
quadToRelative(-22f, -23f, -48.5f, -38.5f)
reflectiveQuadTo(533f, 266f)
lineToRelative(-13f, -106f)
horizontalLineToRelative(-79f)
lineToRelative(-14f, 106f)
quadToRelative(-31f, 8f, -57.5f, 23.5f)
reflectiveQuadTo(321f, 327f)
lineToRelative(-99f, -41f)
lineToRelative(-39f, 68f)
lineToRelative(86f, 64f)
quadToRelative(-5f, 15f, -7f, 30f)
reflectiveQuadToRelative(-2f, 32f)
quadToRelative(0f, 16f, 2f, 31f)
reflectiveQuadToRelative(7f, 30f)
lineToRelative(-86f, 65f)
lineToRelative(39f, 68f)
lineToRelative(99f, -42f)
quadToRelative(22f, 23f, 48.5f, 38.5f)
reflectiveQuadTo(427f, 694f)
lineToRelative(13f, 106f)
close()
moveTo(482f, 620f)
quadToRelative(58f, 0f, 99f, -41f)
reflectiveQuadToRelative(41f, -99f)
quadToRelative(0f, -58f, -41f, -99f)
reflectiveQuadToRelative(-99f, -41f)
quadToRelative(-59f, 0f, -99.5f, 41f)
reflectiveQuadTo(342f, 480f)
quadToRelative(0f, 58f, 40.5f, 99f)
reflectiveQuadToRelative(99.5f, 41f)
close()
moveTo(480f, 480f)
close()
}
}
.build()
return settings!!
}
private var settings: ImageVector? = null

View file

@ -0,0 +1,236 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ui.theme
import androidx.compose.ui.graphics.Color
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,105 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.ColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
val MeshtasticGreen = Color(0xFF67EA94)
val MeshtasticAlt = Color(0xFF2C2D3C)
val HyperlinkBlue = Color(0xFF43C3B0)
val AnnotationColor = Color(0xFF039BE5)
object IAQColors {
val IAQExcellent = Color(0xFF00E400)
val IAQGood = Color(0xFF92D050)
val IAQLightlyPolluted = Color(0xFFFFFF00)
val IAQModeratelyPolluted = Color(0xFFFF7300)
val IAQHeavilyPolluted = Color(0xFFFF0000)
val IAQSeverelyPolluted = Color(0xFF99004C)
val IAQExtremelyPolluted = Color(0xFF663300)
val IAQDangerouslyPolluted = Color(0xFF663300)
}
object GraphColors {
val InfantryBlue = Color(red = 75, green = 119, blue = 190)
val LightGreen = Color(0xFF4BF0BE)
val Purple = Color(0xFF9C27B0)
val Pink = Color(red = 255, green = 102, blue = 204)
val Orange = Color(0xFFFF8800)
val Green = Color.Green
val Red = Color.Red
val Blue = Color.Blue
val Yellow = Color.Yellow
val Magenta = Color.Magenta
val Cyan = Color.Cyan
}
object StatusColors {
val ColorScheme.StatusGreen: Color
@Composable
get() = // If it might change based on theme
if (isSystemInDarkTheme()) {
Color(0xFF28A03B) // Example dark green
} else {
Color(0xFF30C047)
}
val ColorScheme.StatusYellow: Color
@Composable
get() =
if (isSystemInDarkTheme()) {
Color(0xFFFFC107)
} else {
Color(0xFFFFD54F)
}
val ColorScheme.StatusOrange: Color
@Composable
get() =
if (isSystemInDarkTheme()) {
Color(0xFFE07000)
} else {
Color(0xFFFF8800)
}
val ColorScheme.StatusRed: Color
@Composable
get() = // If it might change based on theme
if (isSystemInDarkTheme()) {
Color(0xFFB00020)
} else {
Color(0xFFF44336)
}
val ColorScheme.StatusBlue: Color
@Composable
get() = // If it might change based on theme
if (isSystemInDarkTheme()) {
Color(0xFF2196F3)
} else {
Color(0xFF42A5F5)
}
}
object MessageItemColors {
val Red = Color(0x4DFF0000)
}

View file

@ -0,0 +1,303 @@
/*
* 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 org.meshtastic.core.ui.theme
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialExpressiveTheme
import androidx.compose.material3.MotionScheme.Companion.expressive
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)
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun AppTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
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
}
MaterialExpressiveTheme(
colorScheme = colorScheme,
motionScheme = expressive(),
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 org.meshtastic.core.ui.theme
import androidx.compose.material3.Typography
val AppTypography = Typography()

View file

@ -0,0 +1,14 @@
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal"
>
<path
android:fillColor="@android:color/white"
android:pathData="M14,20H6V6H14M14.67,4H13V2H7V4H5.33C4.6,4 4,4.6 4,5.33V20.67C4,21.4 4.6,22 5.33,22H14.67C15.4,22 16,21.4 16,20.67V5.33C16,4.6 15.4,4 14.67,4M21,7H19V13H21V8M21,15H19V17H21V15Z"
android:fillAlpha="0.5"
/>
</vector>

View file

@ -0,0 +1,14 @@
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal"
>
<path
android:fillColor="@android:color/white"
android:pathData="M16,20H8V6H16M16.67,4H15V2H9V4H7.33C6.6,4 6,4.6 6,5.33V20.67C6,21.4 6.6,22 7.33,22H16.67C17.41,22 18,21.41 18,20.67V5.33C18,4.6 17.4,4 16.67,4M15,16H9V19H15V16M15,7H9V10H15V7M15,11.5H9V14.5H15V11.5Z"
android:fillAlpha="0.5"
/>
</vector>

View file

@ -0,0 +1,14 @@
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal"
>
<path
android:fillColor="@android:color/white"
android:pathData="M16,20H8V6H16M16.67,4H15V2H9V4H7.33C6.6,4 6,4.6 6,5.33V20.67C6,21.4 6.6,22 7.33,22H16.67C17.41,22 18,21.41 18,20.67V5.33C18,4.6 17.4,4 16.67,4M15,16H9V19H15V16"
android:fillAlpha="0.5"
/>
</vector>

View file

@ -0,0 +1,14 @@
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal"
>
<path
android:fillColor="@android:color/white"
android:pathData="M16,20H8V6H16M16.67,4H15V2H9V4H7.33C6.6,4 6,4.6 6,5.33V20.67C6,21.4 6.6,22 7.33,22H16.67C17.41,22 18,21.41 18,20.67V5.33C18,4.6 17.4,4 16.67,4M15,16H9V19H15V16M15,11.5H9V14.5H15V11.5Z"
android:fillAlpha="0.5"
/>
</vector>

View file

@ -0,0 +1,14 @@
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal"
>
<path
android:fillColor="@android:color/white"
android:pathData="M16,20H8V6H16M16.67,4H15V2H9V4H7.33A1.33,1.33 0,0 0,6 5.33V20.67C6,21.4 6.6,22 7.33,22H16.67A1.33,1.33 0,0 0,18 20.67V5.33C18,4.6 17.4,4 16.67,4Z"
android:fillAlpha="0.5"
/>
</vector>

View file

@ -0,0 +1,14 @@
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal"
>
<path
android:fillColor="@android:color/white"
android:pathData="M15.07,12.25L14.17,13.17C13.63,13.71 13.25,14.18 13.09,15H11.05C11.16,14.1 11.56,13.28 12.17,12.67L13.41,11.41C13.78,11.05 14,10.55 14,10C14,8.89 13.1,8 12,8A2,2 0,0 0,10 10H8A4,4 0,0 1,12 6A4,4 0,0 1,16 10C16,10.88 15.64,11.68 15.07,12.25M13,19H11V17H13M16.67,4H15V2H9V4H7.33A1.33,1.33 0,0 0,6 5.33V20.66C6,21.4 6.6,22 7.33,22H16.67C17.4,22 18,21.4 18,20.66V5.33C18,4.59 17.4,4 16.67,4Z"
android:fillAlpha="0.5"
/>
</vector>

View file

@ -0,0 +1,28 @@
<!--
~ 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/>.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:height="24dp"
android:width="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:fillAlpha=".5"
android:pathData="M16,7V3H14V7H10V3H8V7H8C7,7 6,8 6,9V14.5L9.5,18V21H14.5V18L18,14.5V9C18,8 17,7 16,7Z" />
</vector>