mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
Refactor: organize ui screens to separate packages (#1982)
This commit is contained in:
parent
32d9f29d7e
commit
ad1897c564
108 changed files with 475 additions and 569 deletions
|
|
@ -0,0 +1,49 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.common.components
|
||||
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun AdaptiveTwoPane(
|
||||
first: @Composable ColumnScope.() -> Unit,
|
||||
second: @Composable ColumnScope.() -> Unit,
|
||||
) = BoxWithConstraints {
|
||||
val compactWidth = maxWidth < 600.dp
|
||||
Row {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
first()
|
||||
|
||||
if (compactWidth) {
|
||||
second()
|
||||
}
|
||||
}
|
||||
|
||||
if (!compactWidth) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
second()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.common.components
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextLinkStyles
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.fromHtml
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.geeksville.mesh.R
|
||||
|
||||
@Composable
|
||||
fun SimpleAlertDialog(
|
||||
title: String,
|
||||
message: String?,
|
||||
html: String? = null,
|
||||
onDismissRequest: () -> Unit,
|
||||
onConfirmRequest: () -> Unit = onDismissRequest // Default confirm to dismiss
|
||||
) {
|
||||
|
||||
val annotatedString = html?.let {
|
||||
AnnotatedString.fromHtml(
|
||||
html,
|
||||
linkStyles = TextLinkStyles(
|
||||
style = SpanStyle(
|
||||
textDecoration = TextDecoration.Underline,
|
||||
fontStyle = FontStyle.Italic,
|
||||
color = Color.Blue
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
title = { Text(text = title) },
|
||||
text = {
|
||||
if (annotatedString != null) {
|
||||
Text(
|
||||
text = annotatedString,
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = message.orEmpty()
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onConfirmRequest) {
|
||||
Text(stringResource(id = R.string.okay))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// For Rationale Dialogs
|
||||
@Composable
|
||||
fun MultipleChoiceAlertDialog(
|
||||
title: String,
|
||||
message: String?,
|
||||
choices: Map<String, () -> Unit>,
|
||||
onDismissRequest: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
title = { Text(text = title) },
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
message?.let {
|
||||
Text(
|
||||
text = it,
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
}
|
||||
choices.forEach { (choice, action) ->
|
||||
Button(
|
||||
modifier = Modifier.fillMaxWidth().padding(8.dp),
|
||||
onClick = {
|
||||
action()
|
||||
onDismissRequest()
|
||||
},
|
||||
) {
|
||||
Text(text = choice)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.common.components
|
||||
|
||||
import android.text.Spannable
|
||||
import android.text.Spannable.Factory
|
||||
import android.text.style.URLSpan
|
||||
import android.text.util.Linkify
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.LinkAnnotation
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextLinkStyles
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.withLink
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.core.text.util.LinkifyCompat
|
||||
import com.geeksville.mesh.ui.common.theme.HyperlinkBlue
|
||||
|
||||
private val DefaultTextLinkStyles = TextLinkStyles(
|
||||
style = SpanStyle(
|
||||
color = HyperlinkBlue,
|
||||
textDecoration = TextDecoration.Underline,
|
||||
)
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun AutoLinkText(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
style: TextStyle = TextStyle.Default,
|
||||
linkStyles: TextLinkStyles = DefaultTextLinkStyles,
|
||||
) {
|
||||
val spannable = remember(text) {
|
||||
linkify(text)
|
||||
}
|
||||
Text(
|
||||
text = spannable.toAnnotatedString(linkStyles),
|
||||
modifier = modifier,
|
||||
style = style,
|
||||
)
|
||||
}
|
||||
|
||||
private fun linkify(text: String) = Factory.getInstance().newSpannable(text).also {
|
||||
LinkifyCompat.addLinks(it, Linkify.WEB_URLS or Linkify.EMAIL_ADDRESSES or Linkify.PHONE_NUMBERS)
|
||||
}
|
||||
|
||||
private fun Spannable.toAnnotatedString(
|
||||
linkStyles: TextLinkStyles,
|
||||
): AnnotatedString = buildAnnotatedString {
|
||||
val spannable = this@toAnnotatedString
|
||||
var lastEnd = 0
|
||||
spannable.getSpans(0, spannable.length, Any::class.java).forEach { span ->
|
||||
val start = spannable.getSpanStart(span)
|
||||
val end = spannable.getSpanEnd(span)
|
||||
append(spannable.subSequence(lastEnd, start))
|
||||
when (span) {
|
||||
is URLSpan -> withLink(LinkAnnotation.Url(url = span.url, styles = linkStyles)) {
|
||||
append(spannable.subSequence(start, end))
|
||||
}
|
||||
|
||||
else -> append(spannable.subSequence(start, end))
|
||||
}
|
||||
lastEnd = end
|
||||
}
|
||||
append(spannable.subSequence(lastEnd, spannable.length))
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun AutoLinkTextPreview() {
|
||||
AutoLinkText("A text containing a link https://example.com")
|
||||
}
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.common.components
|
||||
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewLightDark
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.geeksville.mesh.R
|
||||
import com.geeksville.mesh.ui.common.theme.AppTheme
|
||||
|
||||
@Composable
|
||||
fun BatteryInfo(
|
||||
modifier: Modifier = Modifier,
|
||||
batteryLevel: Int?,
|
||||
voltage: Float?
|
||||
) {
|
||||
val infoString = "%d%% %.2fV".format(batteryLevel, voltage)
|
||||
val (image, level) = when (batteryLevel) {
|
||||
in 0 .. 4 -> R.drawable.ic_battery_alert to " $infoString"
|
||||
in 5 .. 14 -> R.drawable.ic_battery_outline to infoString
|
||||
in 15..34 -> R.drawable.ic_battery_low to infoString
|
||||
in 35..79 -> R.drawable.ic_battery_medium to infoString
|
||||
in 80..100 -> R.drawable.ic_battery_high to infoString
|
||||
101 -> R.drawable.ic_power_plug_24 to "%.2fV".format(voltage)
|
||||
else -> R.drawable.ic_battery_unknown to (voltage?.let { "%.2fV".format(it) } ?: "")
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = modifier,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
modifier = Modifier.height(18.dp),
|
||||
imageVector = ImageVector.vectorResource(id = image),
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Text(
|
||||
text = level,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewLightDark
|
||||
@Composable
|
||||
fun BatteryInfoPreview(
|
||||
@PreviewParameter(BatteryInfoPreviewParameterProvider::class)
|
||||
batteryInfo: Pair<Int?, Float?>
|
||||
) {
|
||||
AppTheme {
|
||||
BatteryInfo(
|
||||
batteryLevel = batteryInfo.first,
|
||||
voltage = batteryInfo.second
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview
|
||||
fun BatteryInfoPreviewSimple() {
|
||||
AppTheme {
|
||||
BatteryInfo(
|
||||
batteryLevel = 85,
|
||||
voltage = 3.7F
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class BatteryInfoPreviewParameterProvider : PreviewParameterProvider<Pair<Int?, Float?>> {
|
||||
override val values: Sequence<Pair<Int?, Float?>>
|
||||
get() = sequenceOf(
|
||||
85 to 3.7F,
|
||||
2 to 3.7F,
|
||||
12 to 3.7F,
|
||||
28 to 3.7F,
|
||||
50 to 3.7F,
|
||||
101 to 4.9F,
|
||||
null to 4.5F,
|
||||
null to null
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.common.components
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.twotone.KeyboardArrowDown
|
||||
import androidx.compose.material.icons.twotone.KeyboardArrowUp
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.geeksville.mesh.R
|
||||
|
||||
@Composable
|
||||
fun BitwisePreference(
|
||||
title: String,
|
||||
value: Int,
|
||||
enabled: Boolean,
|
||||
items: List<Pair<Int, String>>,
|
||||
onItemSelected: (Int) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var dropDownExpanded by remember { mutableStateOf(value = false) }
|
||||
|
||||
RegularPreference(
|
||||
title = title,
|
||||
subtitle = value.toString(),
|
||||
onClick = { dropDownExpanded = !dropDownExpanded },
|
||||
enabled = enabled,
|
||||
trailingIcon = if (dropDownExpanded) {
|
||||
Icons.TwoTone.KeyboardArrowUp
|
||||
} else {
|
||||
Icons.TwoTone.KeyboardArrowDown
|
||||
},
|
||||
)
|
||||
|
||||
Box {
|
||||
DropdownMenu(
|
||||
expanded = dropDownExpanded,
|
||||
onDismissRequest = { dropDownExpanded = !dropDownExpanded },
|
||||
) {
|
||||
items.forEach { item ->
|
||||
DropdownMenuItem(
|
||||
onClick = { onItemSelected(value xor item.first) },
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
text = {
|
||||
Text(
|
||||
text = item.second,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Checkbox(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.wrapContentWidth(Alignment.End),
|
||||
checked = value and item.first != 0,
|
||||
onCheckedChange = { onItemSelected(value xor item.first) },
|
||||
enabled = enabled,
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
PreferenceFooter(
|
||||
enabled = enabled,
|
||||
negativeText = R.string.clear,
|
||||
onNegativeClicked = { onItemSelected(0) },
|
||||
positiveText = R.string.close,
|
||||
onPositiveClicked = { dropDownExpanded = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun BitwisePreferencePreview() {
|
||||
BitwisePreference(
|
||||
title = "Settings",
|
||||
value = 3,
|
||||
enabled = true,
|
||||
items = listOf(1 to "TEST1", 2 to "TEST2"),
|
||||
onItemSelected = {}
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.common.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
|
||||
@Composable
|
||||
fun BottomSheetDialog(
|
||||
onDismiss: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable ColumnScope.() -> Unit
|
||||
) = Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Transparent)
|
||||
.clickable(
|
||||
onClick = onDismiss,
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() }
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.background(
|
||||
color = MaterialTheme.colorScheme.surface.copy(alpha = 1f),
|
||||
shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp)
|
||||
)
|
||||
.padding(16.dp),
|
||||
content = content
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.common.components
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
|
||||
@Composable
|
||||
fun ClickableTextField(
|
||||
@StringRes label: Int,
|
||||
enabled: Boolean,
|
||||
trailingIcon: ImageVector,
|
||||
value: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
isError: Boolean = false,
|
||||
) {
|
||||
val source = remember { MutableInteractionSource() }
|
||||
val isPressed by source.collectIsPressedAsState()
|
||||
if (isPressed) onClick()
|
||||
|
||||
OutlinedTextField(
|
||||
value,
|
||||
onValueChange = {},
|
||||
enabled = enabled,
|
||||
readOnly = true,
|
||||
label = { Text(stringResource(label)) },
|
||||
trailingIcon = { Icon(trailingIcon, null) },
|
||||
isError = isError,
|
||||
interactionSource = source,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.common.components
|
||||
|
||||
import android.content.ClipData
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.twotone.ContentCopy
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.ClipEntry
|
||||
import androidx.compose.ui.platform.LocalClipboard
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.geeksville.mesh.R
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun CopyIconButton(
|
||||
valueToCopy: String,
|
||||
modifier: Modifier = Modifier,
|
||||
label: String = stringResource(id = R.string.copy),
|
||||
) {
|
||||
val clipboardManager = LocalClipboard.current
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
IconButton(
|
||||
modifier = modifier,
|
||||
onClick = {
|
||||
coroutineScope.launch {
|
||||
val clipData = ClipData.newPlainText(label, valueToCopy)
|
||||
val clipEntry = ClipEntry(clipData)
|
||||
clipboardManager.setClipEntry(clipEntry)
|
||||
}
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.TwoTone.ContentCopy,
|
||||
contentDescription = label
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,148 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.common.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.twotone.KeyboardArrowDown
|
||||
import androidx.compose.material.icons.twotone.KeyboardArrowUp
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.geeksville.mesh.R
|
||||
import com.google.protobuf.ProtocolMessageEnum
|
||||
|
||||
@Composable
|
||||
fun <T : Enum<T>> DropDownPreference(
|
||||
title: String,
|
||||
enabled: Boolean,
|
||||
selectedItem: T,
|
||||
onItemSelected: (T) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
summary: String? = null,
|
||||
) {
|
||||
DropDownPreference(
|
||||
title = title,
|
||||
enabled = enabled,
|
||||
items = selectedItem.declaringJavaClass.enumConstants
|
||||
?.filter { it.name != "UNRECOGNIZED" }?.map { it to it.name } ?: emptyList(),
|
||||
selectedItem = selectedItem,
|
||||
onItemSelected = onItemSelected,
|
||||
modifier = modifier,
|
||||
summary = summary,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun <T> DropDownPreference(
|
||||
title: String,
|
||||
enabled: Boolean,
|
||||
items: List<Pair<T, String>>,
|
||||
selectedItem: T,
|
||||
onItemSelected: (T) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
summary: String? = null,
|
||||
) {
|
||||
var dropDownExpanded by remember { mutableStateOf(value = false) }
|
||||
|
||||
val deprecatedItems: List<T> = remember {
|
||||
if (selectedItem is ProtocolMessageEnum) {
|
||||
val enum = (selectedItem as? Enum<*>)?.declaringJavaClass?.enumConstants
|
||||
val descriptor = (selectedItem as ProtocolMessageEnum).descriptorForType
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
enum?.filter { entries ->
|
||||
descriptor.values.any { it.name == entries.name && it.options.deprecated }
|
||||
} as? List<T> ?: emptyList() // Safe cast to List<T> or return emptyList if cast fails
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
RegularPreference(
|
||||
title = title,
|
||||
subtitle = items.find { it.first == selectedItem }?.second
|
||||
?: stringResource(id = R.string.unrecognized),
|
||||
onClick = {
|
||||
dropDownExpanded = true
|
||||
},
|
||||
enabled = enabled,
|
||||
trailingIcon = if (dropDownExpanded) {
|
||||
Icons.TwoTone.KeyboardArrowUp
|
||||
} else {
|
||||
Icons.TwoTone.KeyboardArrowDown
|
||||
},
|
||||
summary = summary,
|
||||
)
|
||||
|
||||
Box {
|
||||
DropdownMenu(
|
||||
expanded = dropDownExpanded,
|
||||
onDismissRequest = { dropDownExpanded = !dropDownExpanded },
|
||||
) {
|
||||
items.filterNot { it.first in deprecatedItems }.forEach { item ->
|
||||
DropdownMenuItem(
|
||||
onClick = {
|
||||
dropDownExpanded = false
|
||||
onItemSelected(item.first)
|
||||
},
|
||||
modifier = modifier
|
||||
.background(
|
||||
color = if (selectedItem == item.first) {
|
||||
MaterialTheme.colorScheme.primary.copy(alpha = 0.3f)
|
||||
} else {
|
||||
Color.Unspecified
|
||||
},
|
||||
),
|
||||
text = {
|
||||
Text(
|
||||
text = item.second,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun DropDownPreferencePreview() {
|
||||
DropDownPreference(
|
||||
title = "Settings",
|
||||
summary = "Lorem ipsum dolor sit amet",
|
||||
enabled = true,
|
||||
items = listOf("TEST1" to "text1", "TEST2" to "text2"),
|
||||
selectedItem = "TEST2",
|
||||
onItemSelected = {}
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,142 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.common.components
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.twotone.Close
|
||||
import androidx.compose.material.icons.twotone.Refresh
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.geeksville.mesh.R
|
||||
import com.geeksville.mesh.model.Channel
|
||||
import com.geeksville.mesh.util.encodeToString
|
||||
import com.geeksville.mesh.util.toByteString
|
||||
import com.google.protobuf.ByteString
|
||||
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
fun EditBase64Preference(
|
||||
modifier: Modifier = Modifier,
|
||||
title: String,
|
||||
value: ByteString,
|
||||
enabled: Boolean,
|
||||
readOnly: Boolean = false,
|
||||
keyboardActions: KeyboardActions,
|
||||
onValueChange: (ByteString) -> Unit,
|
||||
onGenerateKey: (() -> Unit)? = null,
|
||||
trailingIcon: (@Composable () -> Unit)? = null,
|
||||
) {
|
||||
|
||||
var valueState by remember { mutableStateOf(value.encodeToString()) }
|
||||
val isError = value.encodeToString() != valueState
|
||||
|
||||
// don't update values while the user is editing
|
||||
var isFocused by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(value) {
|
||||
if (!isFocused) {
|
||||
valueState = value.encodeToString()
|
||||
}
|
||||
}
|
||||
|
||||
val (icon, description) = when {
|
||||
isError -> Icons.TwoTone.Close to stringResource(R.string.error)
|
||||
onGenerateKey != null && !isFocused -> Icons.TwoTone.Refresh to stringResource(R.string.reset)
|
||||
else -> null to null
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = valueState,
|
||||
onValueChange = {
|
||||
valueState = it
|
||||
runCatching { it.toByteString() }.onSuccess(onValueChange)
|
||||
},
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.onFocusChanged { focusState -> isFocused = focusState.isFocused },
|
||||
enabled = enabled,
|
||||
readOnly = readOnly,
|
||||
label = { Text(text = title) },
|
||||
isError = isError,
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
keyboardType = KeyboardType.Password, imeAction = ImeAction.Done
|
||||
),
|
||||
keyboardActions = keyboardActions,
|
||||
trailingIcon = {
|
||||
if (icon != null) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
if (isError) {
|
||||
valueState = value.encodeToString()
|
||||
onValueChange(value)
|
||||
} else if (onGenerateKey != null && !isFocused) {
|
||||
onGenerateKey()
|
||||
}
|
||||
},
|
||||
enabled = enabled,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = description,
|
||||
tint = if (isError) {
|
||||
MaterialTheme.colorScheme.error
|
||||
} else {
|
||||
LocalContentColor.current
|
||||
}
|
||||
)
|
||||
}
|
||||
} else if (trailingIcon != null) {
|
||||
trailingIcon()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun EditBase64PreferencePreview() {
|
||||
EditBase64Preference(
|
||||
title = "Title",
|
||||
value = Channel.getRandomKey(),
|
||||
enabled = true,
|
||||
keyboardActions = KeyboardActions {},
|
||||
onValueChange = {},
|
||||
onGenerateKey = {},
|
||||
modifier = Modifier.padding(16.dp)
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.common.components
|
||||
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
|
||||
@Composable
|
||||
fun EditIPv4Preference(
|
||||
title: String,
|
||||
value: Int,
|
||||
enabled: Boolean,
|
||||
keyboardActions: KeyboardActions,
|
||||
onValueChanged: (Int) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val pattern = """\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b""".toRegex()
|
||||
|
||||
fun convertIntToIpAddress(int: Int): String {
|
||||
return "${int and 0xff}.${int shr 8 and 0xff}.${int shr 16 and 0xff}.${int shr 24 and 0xff}"
|
||||
}
|
||||
|
||||
fun convertIpAddressToInt(ipAddress: String): Int? = ipAddress.split(".")
|
||||
.map { it.toIntOrNull() }.reversed() // little-endian byte order
|
||||
.fold(0) { total, next ->
|
||||
if (next == null) return null else total shl 8 or next
|
||||
}
|
||||
|
||||
var valueState by remember(value) { mutableStateOf(convertIntToIpAddress(value)) }
|
||||
|
||||
EditTextPreference(
|
||||
title = title,
|
||||
value = valueState,
|
||||
enabled = enabled,
|
||||
isError = convertIntToIpAddress(value) != valueState,
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
keyboardType = KeyboardType.Number, imeAction = ImeAction.Done
|
||||
),
|
||||
keyboardActions = keyboardActions,
|
||||
onValueChanged = {
|
||||
valueState = it
|
||||
if (pattern.matches(it)) convertIpAddressToInt(it)?.let { int -> onValueChanged(int) }
|
||||
},
|
||||
onFocusChanged = {},
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun EditIPv4PreferencePreview() {
|
||||
EditIPv4Preference(
|
||||
title = "IP Address",
|
||||
value = 16820416,
|
||||
enabled = true,
|
||||
keyboardActions = KeyboardActions {},
|
||||
onValueChanged = {}
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,202 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.common.components
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.wrapContentSize
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.twotone.Close
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.geeksville.mesh.ModuleConfigProtos.RemoteHardwarePin
|
||||
import com.geeksville.mesh.ModuleConfigProtos.RemoteHardwarePinType
|
||||
import com.geeksville.mesh.R
|
||||
import com.geeksville.mesh.copy
|
||||
import com.geeksville.mesh.remoteHardwarePin
|
||||
import com.google.protobuf.ByteString
|
||||
|
||||
@Composable
|
||||
inline fun <reified T> EditListPreference(
|
||||
title: String,
|
||||
list: List<T>,
|
||||
maxCount: Int,
|
||||
enabled: Boolean,
|
||||
keyboardActions: KeyboardActions,
|
||||
crossinline onValuesChanged: (List<T>) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val focusManager = LocalFocusManager.current
|
||||
val listState = remember(list) { mutableStateListOf<T>().apply { addAll(list) } }
|
||||
|
||||
Column(modifier = modifier) {
|
||||
Text(
|
||||
modifier = modifier.padding(16.dp),
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
listState.forEachIndexed { index, value ->
|
||||
val trailingIcon = @Composable {
|
||||
IconButton(
|
||||
onClick = {
|
||||
focusManager.clearFocus()
|
||||
listState.removeAt(index)
|
||||
onValuesChanged(listState)
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.TwoTone.Close,
|
||||
contentDescription = stringResource(R.string.delete),
|
||||
modifier = Modifier.wrapContentSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// handle lora.ignoreIncoming: List<Int>
|
||||
if (value is Int) EditTextPreference(
|
||||
title = "${index + 1}/$maxCount",
|
||||
value = value,
|
||||
enabled = enabled,
|
||||
keyboardActions = keyboardActions,
|
||||
onValueChanged = {
|
||||
listState[index] = it as T
|
||||
onValuesChanged(listState)
|
||||
},
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
trailingIcon = trailingIcon,
|
||||
)
|
||||
|
||||
// handle security.adminKey: List<ByteString>
|
||||
if (value is ByteString) EditBase64Preference(
|
||||
title = "${index + 1}/$maxCount",
|
||||
value = value,
|
||||
enabled = enabled,
|
||||
keyboardActions = keyboardActions,
|
||||
onValueChange = {
|
||||
listState[index] = it as T
|
||||
onValuesChanged(listState)
|
||||
},
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
trailingIcon = trailingIcon,
|
||||
)
|
||||
|
||||
// handle remoteHardware.availablePins: List<RemoteHardwarePin>
|
||||
if (value is RemoteHardwarePin) {
|
||||
EditTextPreference(
|
||||
title = stringResource(R.string.gpio_pin),
|
||||
value = value.gpioPin,
|
||||
enabled = enabled,
|
||||
keyboardActions = keyboardActions,
|
||||
onValueChanged = {
|
||||
if (it in 0..255) {
|
||||
listState[index] = value.copy { gpioPin = it } as T
|
||||
onValuesChanged(listState)
|
||||
}
|
||||
},
|
||||
)
|
||||
EditTextPreference(
|
||||
title = stringResource(R.string.name),
|
||||
value = value.name,
|
||||
maxSize = 14, // name max_size:15
|
||||
enabled = enabled,
|
||||
isError = false,
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
keyboardType = KeyboardType.Text, imeAction = ImeAction.Done
|
||||
),
|
||||
keyboardActions = keyboardActions,
|
||||
onValueChanged = {
|
||||
listState[index] = value.copy { name = it } as T
|
||||
onValuesChanged(listState)
|
||||
},
|
||||
trailingIcon = trailingIcon,
|
||||
)
|
||||
DropDownPreference(
|
||||
title = stringResource(R.string.type),
|
||||
enabled = enabled,
|
||||
items = RemoteHardwarePinType.entries
|
||||
.filter { it != RemoteHardwarePinType.UNRECOGNIZED }
|
||||
.map { it to it.name },
|
||||
selectedItem = value.type,
|
||||
onItemSelected = {
|
||||
listState[index] = value.copy { type = it } as T
|
||||
onValuesChanged(listState)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
OutlinedButton(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
onClick = {
|
||||
// Add element based on the type T
|
||||
val newElement = when (T::class) {
|
||||
Int::class -> 0 as T
|
||||
ByteString::class -> ByteString.EMPTY as T
|
||||
RemoteHardwarePin::class -> remoteHardwarePin {} as T
|
||||
else -> throw IllegalArgumentException("Unsupported type: ${T::class}")
|
||||
}
|
||||
listState.add(listState.size, newElement)
|
||||
},
|
||||
enabled = maxCount > listState.size,
|
||||
) { Text(text = stringResource(R.string.add)) }
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun EditListPreferencePreview() {
|
||||
Column {
|
||||
EditListPreference(
|
||||
title = "Ignore incoming",
|
||||
list = listOf(12345, 67890),
|
||||
maxCount = 4,
|
||||
enabled = true,
|
||||
keyboardActions = KeyboardActions {},
|
||||
onValuesChanged = {},
|
||||
)
|
||||
EditListPreference(
|
||||
title = "Available pins",
|
||||
list = listOf(
|
||||
remoteHardwarePin {
|
||||
gpioPin = 12
|
||||
name = "Front door"
|
||||
type = RemoteHardwarePinType.DIGITAL_READ
|
||||
},
|
||||
),
|
||||
maxCount = 4,
|
||||
enabled = true,
|
||||
keyboardActions = KeyboardActions {},
|
||||
onValuesChanged = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.common.components
|
||||
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.twotone.VisibilityOff
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.geeksville.mesh.R
|
||||
|
||||
@Composable
|
||||
fun EditPasswordPreference(
|
||||
title: String,
|
||||
value: String,
|
||||
maxSize: Int,
|
||||
enabled: Boolean,
|
||||
keyboardActions: KeyboardActions,
|
||||
onValueChanged: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var isPasswordVisible by remember { mutableStateOf(false) }
|
||||
|
||||
EditTextPreference(
|
||||
title = title,
|
||||
value = value,
|
||||
maxSize = maxSize,
|
||||
enabled = enabled,
|
||||
isError = false,
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
keyboardType = KeyboardType.Password, imeAction = ImeAction.Done
|
||||
),
|
||||
keyboardActions = keyboardActions,
|
||||
onValueChanged = {
|
||||
onValueChanged(it)
|
||||
},
|
||||
onFocusChanged = {},
|
||||
visualTransformation = if (isPasswordVisible) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { isPasswordVisible = !isPasswordVisible }) {
|
||||
Icon(
|
||||
imageVector = if (isPasswordVisible) Icons.TwoTone.VisibilityOff else Icons.TwoTone.VisibilityOff,
|
||||
contentDescription = if (isPasswordVisible) {
|
||||
stringResource(R.string.hide_password)
|
||||
} else {
|
||||
stringResource(R.string.show_password)
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun EditPasswordPreferencePreview() {
|
||||
EditPasswordPreference(
|
||||
title = "Password",
|
||||
value = "top secret",
|
||||
maxSize = 63,
|
||||
enabled = true,
|
||||
keyboardActions = KeyboardActions {},
|
||||
onValueChanged = {}
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,270 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.common.components
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.twotone.Info
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusState
|
||||
import androidx.compose.ui.focus.onFocusEvent
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.geeksville.mesh.R
|
||||
|
||||
@Composable
|
||||
fun SignedIntegerEditTextPreference(
|
||||
title: String,
|
||||
value: Int,
|
||||
enabled: Boolean,
|
||||
keyboardActions: KeyboardActions,
|
||||
onValueChanged: (Int) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
onFocusChanged: (FocusState) -> Unit = {},
|
||||
trailingIcon: (@Composable () -> Unit)? = null,
|
||||
) {
|
||||
var valueState by remember(value) { mutableStateOf(value.toString()) }
|
||||
|
||||
EditTextPreference(
|
||||
title = title,
|
||||
value = valueState,
|
||||
enabled = enabled,
|
||||
isError = valueState.toIntOrNull() == null,
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
keyboardType = KeyboardType.Number, imeAction = ImeAction.Done
|
||||
),
|
||||
keyboardActions = keyboardActions,
|
||||
onValueChanged = {
|
||||
valueState = it
|
||||
it.toIntOrNull()?.let { int ->
|
||||
onValueChanged(int)
|
||||
}
|
||||
},
|
||||
onFocusChanged = onFocusChanged,
|
||||
modifier = modifier,
|
||||
trailingIcon = trailingIcon
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EditTextPreference(
|
||||
title: String,
|
||||
value: Int,
|
||||
enabled: Boolean,
|
||||
keyboardActions: KeyboardActions,
|
||||
onValueChanged: (Int) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
onFocusChanged: (FocusState) -> Unit = {},
|
||||
trailingIcon: (@Composable () -> Unit)? = null,
|
||||
) {
|
||||
var valueState by remember(value) { mutableStateOf(value.toUInt().toString()) }
|
||||
|
||||
EditTextPreference(
|
||||
title = title,
|
||||
value = valueState,
|
||||
enabled = enabled,
|
||||
isError = value.toUInt().toString() != valueState,
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
keyboardType = KeyboardType.Number, imeAction = ImeAction.Done
|
||||
),
|
||||
keyboardActions = keyboardActions,
|
||||
onValueChanged = {
|
||||
if (it.isEmpty()) valueState = it
|
||||
else it.toUIntOrNull()?.toInt()?.let { int ->
|
||||
valueState = it
|
||||
onValueChanged(int)
|
||||
}
|
||||
},
|
||||
onFocusChanged = onFocusChanged,
|
||||
modifier = modifier,
|
||||
trailingIcon = trailingIcon
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EditTextPreference(
|
||||
title: String,
|
||||
value: Float,
|
||||
enabled: Boolean,
|
||||
keyboardActions: KeyboardActions,
|
||||
onValueChanged: (Float) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
onFocusChanged: (FocusState) -> Unit = {},
|
||||
) {
|
||||
var valueState by remember(value) { mutableStateOf(value.toString()) }
|
||||
|
||||
EditTextPreference(
|
||||
title = title,
|
||||
value = valueState,
|
||||
enabled = enabled,
|
||||
isError = value.toString() != valueState,
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
keyboardType = KeyboardType.Number, imeAction = ImeAction.Done
|
||||
),
|
||||
keyboardActions = keyboardActions,
|
||||
onValueChanged = {
|
||||
if (it.isEmpty()) valueState = it
|
||||
else it.toFloatOrNull()?.let { float ->
|
||||
valueState = it
|
||||
onValueChanged(float)
|
||||
}
|
||||
},
|
||||
onFocusChanged = onFocusChanged,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EditTextPreference(
|
||||
title: String,
|
||||
value: Double,
|
||||
enabled: Boolean,
|
||||
keyboardActions: KeyboardActions,
|
||||
onValueChanged: (Double) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var valueState by remember(value) { mutableStateOf(value.toString()) }
|
||||
val decimalSeparators = setOf('.', ',', '٫', '、', '·') // set of possible decimal separators
|
||||
|
||||
EditTextPreference(
|
||||
title = title,
|
||||
value = valueState,
|
||||
enabled = enabled,
|
||||
isError = value.toString() != valueState,
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
keyboardType = KeyboardType.Number, imeAction = ImeAction.Done
|
||||
),
|
||||
keyboardActions = keyboardActions,
|
||||
onValueChanged = {
|
||||
if (it.length <= 1 || it.first() in decimalSeparators) valueState = it
|
||||
else it.toDoubleOrNull()?.let { double ->
|
||||
valueState = it
|
||||
onValueChanged(double)
|
||||
}
|
||||
},
|
||||
onFocusChanged = {},
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EditTextPreference(
|
||||
title: String,
|
||||
value: String,
|
||||
enabled: Boolean,
|
||||
isError: Boolean,
|
||||
keyboardOptions: KeyboardOptions,
|
||||
keyboardActions: KeyboardActions,
|
||||
onValueChanged: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
maxSize: Int = 0, // max_size - 1 (in bytes)
|
||||
onFocusChanged: (FocusState) -> Unit = {},
|
||||
trailingIcon: (@Composable () -> Unit)? = null,
|
||||
visualTransformation: VisualTransformation = VisualTransformation.None,
|
||||
) {
|
||||
var isFocused by remember { mutableStateOf(false) }
|
||||
|
||||
TextField(
|
||||
value = value,
|
||||
singleLine = true,
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.onFocusEvent { isFocused = it.isFocused; onFocusChanged(it) },
|
||||
enabled = enabled,
|
||||
isError = isError,
|
||||
onValueChange = {
|
||||
if (maxSize > 0) {
|
||||
if (it.toByteArray().size <= maxSize) {
|
||||
onValueChanged(it)
|
||||
}
|
||||
} else onValueChanged(it)
|
||||
},
|
||||
label = { Text(title) },
|
||||
keyboardOptions = keyboardOptions,
|
||||
keyboardActions = keyboardActions,
|
||||
visualTransformation = visualTransformation,
|
||||
trailingIcon = {
|
||||
if (trailingIcon != null) {
|
||||
trailingIcon()
|
||||
} else if (isError) {
|
||||
Icon(
|
||||
imageVector = Icons.TwoTone.Info,
|
||||
contentDescription = stringResource(id = R.string.error),
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
if (maxSize > 0 && isFocused) {
|
||||
Box(
|
||||
contentAlignment = Alignment.BottomEnd,
|
||||
modifier = modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = "${value.toByteArray().size}/$maxSize",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onBackground,
|
||||
modifier = Modifier.padding(end = 8.dp, bottom = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun EditTextPreferencePreview() {
|
||||
Column {
|
||||
EditTextPreference(
|
||||
title = "String",
|
||||
value = "Meshtastic",
|
||||
maxSize = 39,
|
||||
enabled = true,
|
||||
isError = false,
|
||||
keyboardOptions = KeyboardOptions.Default,
|
||||
keyboardActions = KeyboardActions {},
|
||||
onValueChanged = {},
|
||||
)
|
||||
EditTextPreference(
|
||||
title = "Advanced Settings",
|
||||
value = UInt.MAX_VALUE.toInt(),
|
||||
enabled = true,
|
||||
keyboardActions = KeyboardActions {},
|
||||
onValueChanged = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.common.components
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.emoji2.emojipicker.RecentEmojiProviderAdapter
|
||||
import com.geeksville.mesh.util.CustomRecentEmojiProvider
|
||||
|
||||
@Composable
|
||||
fun EmojiPicker(
|
||||
onDismiss: () -> Unit = {},
|
||||
onConfirm: (String) -> Unit
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.Bottom
|
||||
) {
|
||||
BackHandler {
|
||||
onDismiss()
|
||||
}
|
||||
AndroidView(
|
||||
factory = { context ->
|
||||
androidx.emoji2.emojipicker.EmojiPickerView(context).apply {
|
||||
clipToOutline = true
|
||||
setRecentEmojiProvider(
|
||||
RecentEmojiProviderAdapter(CustomRecentEmojiProvider(context))
|
||||
)
|
||||
setOnEmojiPickedListener { emoji ->
|
||||
onDismiss()
|
||||
onConfirm(emoji.emoji)
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EmojiPickerDialog(
|
||||
onDismiss: () -> Unit = {},
|
||||
onConfirm: (String) -> Unit
|
||||
) = BottomSheetDialog(
|
||||
onDismiss = onDismiss,
|
||||
modifier = Modifier.fillMaxHeight(fraction = .4f),
|
||||
) {
|
||||
EmojiPicker(
|
||||
onConfirm = onConfirm,
|
||||
onDismiss = onDismiss,
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,321 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.common.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ThumbUp
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.geeksville.mesh.R
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
enum class Iaq(val color: Color, val description: String, val range: IntRange) {
|
||||
Excellent(Color(0xFF00E400), "Excellent", 0..50),
|
||||
Good(Color(0xFF92D050), "Good", 51..100),
|
||||
LightlyPolluted(Color(0xFFFFFF00), "Lightly Polluted", 101..150),
|
||||
ModeratelyPolluted(Color(0xFFFF7300), "Moderately Polluted", 151..200),
|
||||
HeavilyPolluted(Color(0xFFFF0000), "Heavily Polluted", 201..300),
|
||||
SeverelyPolluted(Color(0xFF99004C), "Severely Polluted", 301..400),
|
||||
ExtremelyPolluted(Color(0xFF663300), "Extremely Polluted", 401..500),
|
||||
DangerouslyPolluted(Color(0xFF663300), "Dangerously Polluted", 501..Int.MAX_VALUE)
|
||||
}
|
||||
|
||||
fun getIaq(iaq: Int): Iaq {
|
||||
return when {
|
||||
iaq in Iaq.Excellent.range -> Iaq.Excellent
|
||||
iaq in Iaq.Good.range -> Iaq.Good
|
||||
iaq in Iaq.LightlyPolluted.range -> Iaq.LightlyPolluted
|
||||
iaq in Iaq.ModeratelyPolluted.range -> Iaq.ModeratelyPolluted
|
||||
iaq in Iaq.HeavilyPolluted.range -> Iaq.HeavilyPolluted
|
||||
iaq in Iaq.SeverelyPolluted.range -> Iaq.SeverelyPolluted
|
||||
iaq in Iaq.ExtremelyPolluted.range -> Iaq.ExtremelyPolluted
|
||||
else -> Iaq.DangerouslyPolluted
|
||||
}
|
||||
}
|
||||
|
||||
private fun getIaqDescriptionWithRange(iaqEnum: Iaq): String {
|
||||
return if (iaqEnum.range.last == Int.MAX_VALUE) {
|
||||
"${iaqEnum.description} (${iaqEnum.range.first}+)"
|
||||
} else {
|
||||
"${iaqEnum.description} (${iaqEnum.range.first}-${iaqEnum.range.last})"
|
||||
}
|
||||
}
|
||||
|
||||
enum class IaqDisplayMode {
|
||||
Pill, Dot, Text, Gauge, Gradient
|
||||
}
|
||||
|
||||
@Suppress("LongMethod", "UnusedPrivateProperty")
|
||||
@Composable
|
||||
fun IndoorAirQuality(iaq: Int, displayMode: IaqDisplayMode = IaqDisplayMode.Pill) {
|
||||
var isLegendOpen by remember { mutableStateOf(false) }
|
||||
val iaqEnum = getIaq(iaq)
|
||||
val gradient = Brush.linearGradient(
|
||||
colors = Iaq.entries.map { it.color },
|
||||
)
|
||||
|
||||
Column {
|
||||
when (displayMode) {
|
||||
IaqDisplayMode.Pill -> {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(iaqEnum.color)
|
||||
.width(125.dp)
|
||||
.height(30.dp)
|
||||
.clickable { isLegendOpen = true }
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(4.dp)
|
||||
.align(Alignment.CenterStart),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "IAQ $iaq",
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Icon(
|
||||
imageVector = if (iaq < 100) Icons.Default.ThumbUp else Icons.Filled.Warning,
|
||||
contentDescription = "AQI Icon",
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
IaqDisplayMode.Dot -> {
|
||||
Column(modifier = Modifier.clickable { isLegendOpen = true }) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(text = "$iaq")
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(10.dp)
|
||||
.background(iaqEnum.color, shape = CircleShape)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
IaqDisplayMode.Text -> {
|
||||
Text(
|
||||
text = getIaqDescriptionWithRange(iaqEnum),
|
||||
fontSize = 12.sp,
|
||||
modifier = Modifier.clickable { isLegendOpen = true }
|
||||
)
|
||||
}
|
||||
|
||||
IaqDisplayMode.Gauge -> {
|
||||
CircularProgressIndicator(
|
||||
progress = iaq / 500f,
|
||||
modifier = Modifier
|
||||
.size(60.dp)
|
||||
.clickable { isLegendOpen = true },
|
||||
strokeWidth = 8.dp,
|
||||
color = iaqEnum.color
|
||||
)
|
||||
Text(text = "$iaq")
|
||||
}
|
||||
|
||||
IaqDisplayMode.Gradient -> {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier.clickable { isLegendOpen = true }
|
||||
) {
|
||||
LinearProgressIndicator(
|
||||
progress = iaq / 500f,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(20.dp),
|
||||
color = iaqEnum.color,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(text = iaqEnum.description, fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isLegendOpen) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { isLegendOpen = false },
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
text = {
|
||||
IAQScale()
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { isLegendOpen = false }) {
|
||||
Text(text = stringResource(id = R.string.close))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Assuming Iaq is an enum class with color and description properties
|
||||
// and that it conforms to CaseIterable.
|
||||
// Replace with your actual implementation
|
||||
|
||||
@Composable
|
||||
fun IAQScale(modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.Start
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.indoor_air_quality_iaq),
|
||||
style = MaterialTheme.typography.titleLarge.copy(
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center,
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
for (iaq in Iaq.entries) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(20.dp, 15.dp)
|
||||
.clip(RoundedCornerShape(5.dp))
|
||||
.background(iaq.color)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(getIaqDescriptionWithRange(iaq), style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
fun IAQScalePreview() {
|
||||
IAQScale()
|
||||
}
|
||||
|
||||
@Suppress("LongMethod")
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun IndoorAirQualityPreview() {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text("Pill", style = MaterialTheme.typography.titleLarge)
|
||||
Row {
|
||||
IndoorAirQuality(iaq = 6)
|
||||
IndoorAirQuality(iaq = 51)
|
||||
}
|
||||
Row {
|
||||
IndoorAirQuality(iaq = 101)
|
||||
IndoorAirQuality(iaq = 201)
|
||||
}
|
||||
Row {
|
||||
IndoorAirQuality(iaq = 350)
|
||||
IndoorAirQuality(iaq = 351)
|
||||
}
|
||||
|
||||
Text("Dot", style = MaterialTheme.typography.titleLarge)
|
||||
Row {
|
||||
IndoorAirQuality(iaq = 6, displayMode = IaqDisplayMode.Dot)
|
||||
IndoorAirQuality(iaq = 51, displayMode = IaqDisplayMode.Dot)
|
||||
IndoorAirQuality(iaq = 101, displayMode = IaqDisplayMode.Dot)
|
||||
IndoorAirQuality(iaq = 201, displayMode = IaqDisplayMode.Dot)
|
||||
IndoorAirQuality(iaq = 350, displayMode = IaqDisplayMode.Dot)
|
||||
IndoorAirQuality(iaq = 351, displayMode = IaqDisplayMode.Dot)
|
||||
}
|
||||
|
||||
Text("Text", style = MaterialTheme.typography.titleLarge)
|
||||
Row {
|
||||
IndoorAirQuality(iaq = 6, displayMode = IaqDisplayMode.Text)
|
||||
IndoorAirQuality(iaq = 51, displayMode = IaqDisplayMode.Text)
|
||||
IndoorAirQuality(iaq = 101, displayMode = IaqDisplayMode.Text)
|
||||
}
|
||||
Row {
|
||||
IndoorAirQuality(iaq = 201, displayMode = IaqDisplayMode.Text)
|
||||
IndoorAirQuality(iaq = 350, displayMode = IaqDisplayMode.Text)
|
||||
IndoorAirQuality(iaq = 500, displayMode = IaqDisplayMode.Text)
|
||||
}
|
||||
|
||||
Text("Gauge", style = MaterialTheme.typography.titleLarge)
|
||||
Row {
|
||||
IndoorAirQuality(iaq = 6, displayMode = IaqDisplayMode.Gauge)
|
||||
IndoorAirQuality(iaq = 51, displayMode = IaqDisplayMode.Gauge)
|
||||
IndoorAirQuality(iaq = 101, displayMode = IaqDisplayMode.Gauge)
|
||||
IndoorAirQuality(iaq = 151, displayMode = IaqDisplayMode.Gauge)
|
||||
}
|
||||
Row {
|
||||
IndoorAirQuality(iaq = 201, displayMode = IaqDisplayMode.Gauge)
|
||||
IndoorAirQuality(iaq = 251, displayMode = IaqDisplayMode.Gauge)
|
||||
IndoorAirQuality(iaq = 301, displayMode = IaqDisplayMode.Gauge)
|
||||
IndoorAirQuality(iaq = 351, displayMode = IaqDisplayMode.Gauge)
|
||||
}
|
||||
Row {
|
||||
IndoorAirQuality(iaq = 401, displayMode = IaqDisplayMode.Gauge)
|
||||
IndoorAirQuality(iaq = 500, displayMode = IaqDisplayMode.Gauge)
|
||||
}
|
||||
|
||||
Text("Gradient", style = MaterialTheme.typography.titleLarge)
|
||||
IndoorAirQuality(iaq = 6, displayMode = IaqDisplayMode.Gradient)
|
||||
IndoorAirQuality(iaq = 51, displayMode = IaqDisplayMode.Gradient)
|
||||
IndoorAirQuality(iaq = 101, displayMode = IaqDisplayMode.Gradient)
|
||||
IndoorAirQuality(iaq = 201, displayMode = IaqDisplayMode.Gradient)
|
||||
IndoorAirQuality(iaq = 351, displayMode = IaqDisplayMode.Gradient)
|
||||
IndoorAirQuality(iaq = 401, displayMode = IaqDisplayMode.Gradient)
|
||||
IndoorAirQuality(iaq = 500, displayMode = IaqDisplayMode.Gradient)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,285 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.common.components
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
|
||||
import androidx.compose.foundation.gestures.scrollBy
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyItemScope
|
||||
import androidx.compose.foundation.lazy.LazyListItemInfo
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedback
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.zIndex
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
// Derived in part from: https://github.com/androidx/androidx/blob/c92ad2941368202b2d78b8d14c71bf81e9525944/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/LazyColumnDragAndDropDemo.kt
|
||||
@Preview
|
||||
@Composable
|
||||
fun LazyColumnDragAndDropDemo() {
|
||||
var list by remember { mutableStateOf(List(50) { it }) }
|
||||
|
||||
val listState = rememberLazyListState()
|
||||
val dragDropState = rememberDragDropState(listState, headerCount = 1) { fromIndex, toIndex ->
|
||||
if (fromIndex in list.indices && toIndex in list.indices) {
|
||||
list = list.toMutableList().apply { add(toIndex, removeAt(fromIndex)) }
|
||||
}
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.dragContainer(
|
||||
dragDropState = dragDropState,
|
||||
haptics = LocalHapticFeedback.current,
|
||||
),
|
||||
state = listState,
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
item {
|
||||
Text("Header", Modifier.fillMaxWidth().padding(20.dp))
|
||||
}
|
||||
|
||||
itemsIndexed(list, key = { _, item -> item }) { index, item ->
|
||||
DraggableItem(dragDropState, index + 1) { isDragging ->
|
||||
Card {
|
||||
Text("Item $item", Modifier.fillMaxWidth().padding(20.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Text("Footer", Modifier.fillMaxWidth().padding(20.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberDragDropState(
|
||||
lazyListState: LazyListState,
|
||||
headerCount: Int = 0,
|
||||
onMove: (Int, Int) -> Unit
|
||||
): DragDropState {
|
||||
val scope = rememberCoroutineScope()
|
||||
val state = remember(lazyListState) { DragDropState(lazyListState, headerCount, scope, onMove) }
|
||||
LaunchedEffect(state) {
|
||||
while (true) {
|
||||
val diff = state.scrollChannel.receive()
|
||||
lazyListState.scrollBy(diff)
|
||||
}
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
class DragDropState
|
||||
internal constructor(
|
||||
private val state: LazyListState,
|
||||
private val headerCount: Int,
|
||||
private val scope: CoroutineScope,
|
||||
private val onMove: (Int, Int) -> Unit
|
||||
) {
|
||||
private var draggingItemIndex by mutableStateOf<Int?>(null)
|
||||
val adjustedItemIndex get() = draggingItemIndex?.minus(headerCount)
|
||||
|
||||
internal val scrollChannel = Channel<Float>()
|
||||
|
||||
private var draggingItemDraggedDelta by mutableFloatStateOf(0f)
|
||||
private var draggingItemInitialOffset by mutableIntStateOf(0)
|
||||
internal val draggingItemOffset: Float
|
||||
get() = draggingItemLayoutInfo?.let { item ->
|
||||
draggingItemInitialOffset + draggingItemDraggedDelta - item.offset
|
||||
} ?: 0f
|
||||
|
||||
private val draggingItemLayoutInfo: LazyListItemInfo?
|
||||
get() = state.layoutInfo.visibleItemsInfo.firstOrNull { it.index == draggingItemIndex }
|
||||
|
||||
internal var previousIndexOfDraggedItem by mutableStateOf<Int?>(null)
|
||||
private set
|
||||
|
||||
internal var previousItemOffset = Animatable(0f)
|
||||
private set
|
||||
|
||||
internal fun onDragStart(offset: Offset): LazyListItemInfo? = state.layoutInfo.visibleItemsInfo
|
||||
.filter { it.contentType == DragDropContentType }
|
||||
.firstOrNull { item -> offset.y.toInt() in item.offset..(item.offset + item.size) }
|
||||
?.also {
|
||||
draggingItemIndex = it.index
|
||||
draggingItemInitialOffset = it.offset
|
||||
}
|
||||
|
||||
internal fun onDragInterrupted() {
|
||||
if (draggingItemIndex != null) {
|
||||
previousIndexOfDraggedItem = draggingItemIndex
|
||||
val startOffset = draggingItemOffset
|
||||
scope.launch {
|
||||
previousItemOffset.snapTo(startOffset)
|
||||
previousItemOffset.animateTo(
|
||||
0f,
|
||||
spring(stiffness = Spring.StiffnessMediumLow, visibilityThreshold = 1f)
|
||||
)
|
||||
previousIndexOfDraggedItem = null
|
||||
}
|
||||
}
|
||||
draggingItemDraggedDelta = 0f
|
||||
draggingItemIndex = null
|
||||
draggingItemInitialOffset = 0
|
||||
}
|
||||
|
||||
internal fun onDrag(offset: Offset) {
|
||||
draggingItemDraggedDelta += offset.y
|
||||
|
||||
val draggingItem = draggingItemLayoutInfo ?: return
|
||||
val startOffset = draggingItem.offset + draggingItemOffset
|
||||
val endOffset = startOffset + draggingItem.size
|
||||
val middleOffset = startOffset + (endOffset - startOffset) / 2f
|
||||
|
||||
val targetItem = state.layoutInfo.visibleItemsInfo
|
||||
.find { item ->
|
||||
middleOffset.toInt() in item.offset..item.offsetEnd &&
|
||||
draggingItem.index != item.index
|
||||
}
|
||||
if (targetItem != null) {
|
||||
if (
|
||||
draggingItem.index == state.firstVisibleItemIndex ||
|
||||
targetItem.index == state.firstVisibleItemIndex
|
||||
) {
|
||||
state.requestScrollToItem(
|
||||
state.firstVisibleItemIndex,
|
||||
state.firstVisibleItemScrollOffset
|
||||
)
|
||||
}
|
||||
onMove.invoke(draggingItem.index - headerCount, targetItem.index - headerCount)
|
||||
draggingItemIndex = targetItem.index
|
||||
} else {
|
||||
val overscroll = when {
|
||||
draggingItemDraggedDelta > 0 ->
|
||||
(endOffset - state.layoutInfo.viewportEndOffset).coerceAtLeast(0f)
|
||||
|
||||
draggingItemDraggedDelta < 0 ->
|
||||
(startOffset - state.layoutInfo.viewportStartOffset).coerceAtMost(0f)
|
||||
|
||||
else -> 0f
|
||||
}
|
||||
if (overscroll != 0f) {
|
||||
scrollChannel.trySend(overscroll)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val LazyListItemInfo.offsetEnd: Int
|
||||
get() = this.offset + this.size
|
||||
}
|
||||
|
||||
fun Modifier.dragContainer(
|
||||
dragDropState: DragDropState,
|
||||
haptics: HapticFeedback,
|
||||
): Modifier {
|
||||
return this.pointerInput(dragDropState) {
|
||||
detectDragGesturesAfterLongPress(
|
||||
onDrag = { change, offset ->
|
||||
change.consume()
|
||||
dragDropState.onDrag(offset = offset)
|
||||
},
|
||||
onDragStart = { offset ->
|
||||
dragDropState.onDragStart(offset) ?: return@detectDragGesturesAfterLongPress
|
||||
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
},
|
||||
onDragEnd = { dragDropState.onDragInterrupted() },
|
||||
onDragCancel = { dragDropState.onDragInterrupted() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LazyItemScope.DraggableItem(
|
||||
dragDropState: DragDropState,
|
||||
index: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable ColumnScope.(isDragging: Boolean) -> Unit
|
||||
) {
|
||||
val dragging = index == dragDropState.adjustedItemIndex
|
||||
val draggingModifier = if (dragging) {
|
||||
Modifier
|
||||
.zIndex(1f)
|
||||
.graphicsLayer { translationY = dragDropState.draggingItemOffset }
|
||||
} else if (index == dragDropState.previousIndexOfDraggedItem) {
|
||||
Modifier
|
||||
.zIndex(1f)
|
||||
.graphicsLayer { translationY = dragDropState.previousItemOffset.value }
|
||||
} else {
|
||||
Modifier.animateItem(fadeInSpec = null, fadeOutSpec = null)
|
||||
}
|
||||
Column(modifier = modifier.then(draggingModifier)) { content(dragging) }
|
||||
}
|
||||
|
||||
const val DragDropContentType = "drag-and-drop"
|
||||
|
||||
/**
|
||||
* Extension function for [LazyListScope] with drag-and-drop functionality for indexed items.
|
||||
*
|
||||
* Wraps [itemsIndexed] function with [detectDragGesturesAfterLongPress] to enable long-press
|
||||
* drag gestures and allow items in the list to be reordered using the provided [DragDropState].
|
||||
*/
|
||||
inline fun <T> LazyListScope.dragDropItemsIndexed(
|
||||
items: List<T>,
|
||||
dragDropState: DragDropState,
|
||||
noinline key: ((index: Int, item: T) -> Any)? = null,
|
||||
crossinline itemContent: @Composable LazyItemScope.(index: Int, item: T, isDragging: Boolean) -> Unit
|
||||
) = itemsIndexed(
|
||||
items = items,
|
||||
key = key,
|
||||
contentType = { _, _ -> DragDropContentType },
|
||||
itemContent = { index, item ->
|
||||
DraggableItem(
|
||||
dragDropState = dragDropState,
|
||||
index = index,
|
||||
content = { isDragging -> itemContent(index, item, isDragging) }
|
||||
)
|
||||
}
|
||||
)
|
||||
|
|
@ -0,0 +1,173 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
@file:Suppress("MagicNumber")
|
||||
|
||||
package com.geeksville.mesh.ui.common.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.SignalCellular4Bar
|
||||
import androidx.compose.material.icons.filled.SignalCellularAlt
|
||||
import androidx.compose.material.icons.filled.SignalCellularAlt1Bar
|
||||
import androidx.compose.material.icons.filled.SignalCellularAlt2Bar
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.geeksville.mesh.R
|
||||
|
||||
private const val SNR_GOOD_THRESHOLD = -7f
|
||||
private const val SNR_FAIR_THRESHOLD = -15f
|
||||
|
||||
private const val RSSI_GOOD_THRESHOLD = -115
|
||||
private const val RSSI_FAIR_THRESHOLD = -126
|
||||
|
||||
private enum class Quality(
|
||||
val nameRes: Int,
|
||||
val imageVector: ImageVector,
|
||||
val color: Color
|
||||
) {
|
||||
NONE(R.string.none_quality, Icons.Default.SignalCellularAlt1Bar, Color.Red),
|
||||
BAD(R.string.bad, Icons.Default.SignalCellularAlt2Bar, Color(red = 247, green = 147, blue = 26)),
|
||||
FAIR(R.string.fair, Icons.Default.SignalCellularAlt, Color(red = 255, green = 230, blue = 0)),
|
||||
GOOD(R.string.good, Icons.Default.SignalCellular4Bar, Color(0xFF30C047))
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays the `snr` and `rssi` color coded based on the signal quality, along with
|
||||
* a human readable description and related icon.
|
||||
*/
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun NodeSignalQuality(snr: Float, rssi: Int, modifier: Modifier = Modifier) {
|
||||
val quality = determineSignalQuality(snr, rssi)
|
||||
FlowRow(
|
||||
modifier = modifier,
|
||||
maxLines = 1,
|
||||
) {
|
||||
Snr(snr)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Rssi(rssi)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
text = "${stringResource(R.string.signal)} ${stringResource(quality.nameRes)}",
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize,
|
||||
maxLines = 1,
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Icon(
|
||||
imageVector = quality.imageVector,
|
||||
contentDescription = stringResource(R.string.signal_quality),
|
||||
tint = quality.color
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays the `snr` and `rssi` with color depending on the values respectively.
|
||||
*/
|
||||
@Composable
|
||||
fun SnrAndRssi(snr: Float, rssi: Int) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Snr(snr)
|
||||
Rssi(rssi)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays a human readable description and icon representing the signal quality.
|
||||
*/
|
||||
@Composable
|
||||
fun LoraSignalIndicator(snr: Float, rssi: Int) {
|
||||
|
||||
val quality = determineSignalQuality(snr, rssi)
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(8.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = quality.imageVector,
|
||||
contentDescription = stringResource(R.string.signal_quality),
|
||||
tint = quality.color
|
||||
)
|
||||
Text(text = "${stringResource(R.string.signal)} ${stringResource(quality.nameRes)}")
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Snr(snr: Float) {
|
||||
val color: Color = if (snr > SNR_GOOD_THRESHOLD) {
|
||||
Quality.GOOD.color
|
||||
} else if (snr > SNR_FAIR_THRESHOLD) {
|
||||
Quality.FAIR.color
|
||||
} else {
|
||||
Quality.BAD.color
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "%s %.2fdB".format(stringResource(id = R.string.snr), snr),
|
||||
color = color,
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Rssi(rssi: Int) {
|
||||
val color: Color = if (rssi > RSSI_GOOD_THRESHOLD) {
|
||||
Quality.GOOD.color
|
||||
} else if (rssi > RSSI_FAIR_THRESHOLD) {
|
||||
Quality.FAIR.color
|
||||
} else {
|
||||
Quality.BAD.color
|
||||
}
|
||||
Text(
|
||||
text = "%s %ddBm".format(stringResource(id = R.string.rssi), rssi),
|
||||
color = color,
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize
|
||||
)
|
||||
}
|
||||
|
||||
private fun determineSignalQuality(snr: Float, rssi: Int): Quality = when {
|
||||
snr > SNR_GOOD_THRESHOLD && rssi > RSSI_GOOD_THRESHOLD -> Quality.GOOD
|
||||
snr > SNR_GOOD_THRESHOLD && rssi > RSSI_FAIR_THRESHOLD -> Quality.FAIR
|
||||
snr > SNR_FAIR_THRESHOLD && rssi > RSSI_GOOD_THRESHOLD -> Quality.FAIR
|
||||
snr <= SNR_FAIR_THRESHOLD && rssi <= RSSI_FAIR_THRESHOLD -> Quality.NONE
|
||||
else -> Quality.BAD
|
||||
}
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.common.components
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.geeksville.mesh.util.DistanceUnit
|
||||
import com.geeksville.mesh.util.toDistanceString
|
||||
import kotlin.math.pow
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private const val PositionEnabled = 32
|
||||
private const val PositionDisabled = 0
|
||||
|
||||
const val PositionPrecisionMin = 10
|
||||
const val PositionPrecisionMax = 19
|
||||
const val PositionPrecisionDefault = 13
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
fun precisionBitsToMeters(bits: Int): Double = 23905787.925008 * 0.5.pow(bits.toDouble())
|
||||
|
||||
@Composable
|
||||
fun PositionPrecisionPreference(
|
||||
title: String,
|
||||
value: Int,
|
||||
enabled: Boolean,
|
||||
onValueChanged: (Int) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val unit = remember { DistanceUnit.getFromLocale() }
|
||||
|
||||
Column(modifier = modifier) {
|
||||
SwitchPreference(
|
||||
title = title,
|
||||
checked = value != PositionDisabled,
|
||||
enabled = enabled,
|
||||
onCheckedChange = { enabled ->
|
||||
val newValue = if (enabled) PositionEnabled else PositionDisabled
|
||||
onValueChanged(newValue)
|
||||
},
|
||||
padding = PaddingValues(0.dp)
|
||||
)
|
||||
AnimatedVisibility(visible = value != PositionDisabled) {
|
||||
SwitchPreference(
|
||||
title = "Precise location",
|
||||
checked = value == PositionEnabled,
|
||||
enabled = enabled,
|
||||
onCheckedChange = { enabled ->
|
||||
val newValue = if (enabled) PositionEnabled else PositionPrecisionDefault
|
||||
onValueChanged(newValue)
|
||||
},
|
||||
padding = PaddingValues(0.dp)
|
||||
)
|
||||
}
|
||||
AnimatedVisibility(visible = value in (PositionDisabled + 1)..<PositionEnabled) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Slider(
|
||||
value = value.toFloat(),
|
||||
onValueChange = { onValueChanged(it.roundToInt()) },
|
||||
enabled = enabled,
|
||||
valueRange = PositionPrecisionMin.toFloat()..PositionPrecisionMax.toFloat(),
|
||||
steps = PositionPrecisionMax - PositionPrecisionMin - 1,
|
||||
)
|
||||
|
||||
val precisionMeters = precisionBitsToMeters(value).toInt()
|
||||
Text(
|
||||
text = precisionMeters.toDistanceString(unit),
|
||||
modifier = Modifier.padding(bottom = 16.dp),
|
||||
fontSize = MaterialTheme.typography.bodyLarge.fontSize,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun PositionPrecisionPreferencePreview() {
|
||||
PositionPrecisionPreference(
|
||||
title = "Position enabled",
|
||||
value = PositionPrecisionDefault,
|
||||
enabled = true,
|
||||
onValueChanged = {},
|
||||
modifier = Modifier.padding(horizontal = 16.dp)
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.common.components
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ProvideTextStyle
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun PreferenceCategory(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
content: (@Composable ColumnScope.() -> Unit)? = null
|
||||
) {
|
||||
Text(
|
||||
text,
|
||||
modifier = modifier.padding(start = 16.dp, top = 24.dp, bottom = 8.dp, end = 16.dp),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
)
|
||||
if (content != null) {
|
||||
Card(
|
||||
modifier = modifier.padding(bottom = 8.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
ProvideTextStyle(MaterialTheme.typography.bodyLarge) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun PreferenceCategoryPreview() {
|
||||
PreferenceCategory(
|
||||
text = "Advanced settings"
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.common.components
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.geeksville.mesh.R
|
||||
|
||||
@Composable
|
||||
fun PreferenceFooter(
|
||||
enabled: Boolean,
|
||||
onCancelClicked: () -> Unit,
|
||||
onSaveClicked: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
PreferenceFooter(
|
||||
enabled = enabled,
|
||||
negativeText = R.string.cancel,
|
||||
onNegativeClicked = onCancelClicked,
|
||||
positiveText = R.string.send,
|
||||
onPositiveClicked = onSaveClicked,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PreferenceFooter(
|
||||
enabled: Boolean,
|
||||
@StringRes negativeText: Int,
|
||||
onNegativeClicked: () -> Unit,
|
||||
@StringRes positiveText: Int,
|
||||
onPositiveClicked: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.height(64.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
OutlinedButton(
|
||||
modifier = modifier
|
||||
.height(48.dp)
|
||||
.weight(1f),
|
||||
enabled = enabled,
|
||||
onClick = onNegativeClicked,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = negativeText),
|
||||
)
|
||||
}
|
||||
OutlinedButton(
|
||||
modifier = modifier
|
||||
.height(48.dp)
|
||||
.weight(1f),
|
||||
enabled = enabled,
|
||||
onClick = onPositiveClicked,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = positiveText),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun PreferenceFooterPreview() {
|
||||
PreferenceFooter(enabled = true, onCancelClicked = {}, onSaveClicked = {})
|
||||
}
|
||||
|
|
@ -0,0 +1,138 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.common.components
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun RegularPreference(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
summary: String? = null,
|
||||
trailingIcon: ImageVector? = null,
|
||||
) {
|
||||
RegularPreference(
|
||||
title = title,
|
||||
subtitle = AnnotatedString(text = subtitle),
|
||||
onClick = onClick,
|
||||
modifier = modifier,
|
||||
enabled = enabled,
|
||||
summary = summary,
|
||||
trailingIcon = trailingIcon,
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun RegularPreference(
|
||||
title: String,
|
||||
subtitle: AnnotatedString,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
summary: String? = null,
|
||||
trailingIcon: ImageVector? = null,
|
||||
) {
|
||||
val color = if (enabled) {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(enabled = enabled, onClick = onClick)
|
||||
.padding(all = 16.dp),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
FlowRow(
|
||||
modifier = Modifier.weight(1f),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = if (enabled) {
|
||||
Color.Unspecified
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
||||
},
|
||||
)
|
||||
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = color,
|
||||
)
|
||||
}
|
||||
if (trailingIcon != null) {
|
||||
Icon(
|
||||
imageVector = trailingIcon,
|
||||
contentDescription = "trailingIcon",
|
||||
modifier = Modifier
|
||||
.padding(start = 8.dp)
|
||||
.wrapContentWidth(Alignment.End),
|
||||
tint = color,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (summary != null) {
|
||||
Text(
|
||||
text = summary,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = color,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun RegularPreferencePreview() {
|
||||
RegularPreference(
|
||||
title = "Advanced settings",
|
||||
subtitle = "Text2",
|
||||
onClick = { },
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,239 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.common.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.PreviewScreenSizes
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.geeksville.mesh.AppOnlyProtos.ChannelSet
|
||||
import com.geeksville.mesh.R
|
||||
import com.geeksville.mesh.channelSet
|
||||
import com.geeksville.mesh.copy
|
||||
import com.geeksville.mesh.model.Channel
|
||||
import com.geeksville.mesh.model.UIViewModel
|
||||
import com.geeksville.mesh.ui.radioconfig.components.ChannelSelection
|
||||
|
||||
@Composable
|
||||
fun ScannedQrCodeDialog(
|
||||
viewModel: UIViewModel,
|
||||
incoming: ChannelSet,
|
||||
) {
|
||||
val channels by viewModel.channels.collectAsStateWithLifecycle()
|
||||
|
||||
ScannedQrCodeDialog(
|
||||
channels = channels,
|
||||
incoming = incoming,
|
||||
onDismiss = viewModel::clearRequestChannelUrl,
|
||||
onConfirm = viewModel::setChannels,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Enables the user to select which channels to accept after scanning a QR code.
|
||||
*/
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
fun ScannedQrCodeDialog(
|
||||
channels: ChannelSet,
|
||||
incoming: ChannelSet,
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: (ChannelSet) -> Unit
|
||||
) {
|
||||
var shouldReplace by remember { mutableStateOf(incoming.hasLoraConfig()) }
|
||||
|
||||
val channelSet = remember(shouldReplace) {
|
||||
if (shouldReplace) {
|
||||
incoming
|
||||
} else {
|
||||
channels.copy {
|
||||
// To guarantee consistent ordering, using a LinkedHashSet which iterates through
|
||||
// it's entries according to the order an item was *first* inserted.
|
||||
// https://kotlinlang.org/api/latest/jvm/stdlib/kotlin.collections/-linked-hash-set/
|
||||
val result = LinkedHashSet(settings + incoming.settingsList)
|
||||
settings.clear()
|
||||
settings.addAll(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val modemPresetName = Channel(loraConfig = channelSet.loraConfig).name
|
||||
|
||||
/* Holds selections made by the user */
|
||||
val channelSelections = remember(channelSet) {
|
||||
mutableStateListOf(elements = Array(size = channelSet.settingsCount, init = { true }))
|
||||
}
|
||||
|
||||
val selectedChannelSet = channelSet.copy {
|
||||
val result = settings.filterIndexed { i, _ -> channelSelections.getOrNull(i) == true }
|
||||
settings.clear()
|
||||
settings.addAll(result)
|
||||
}
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = { onDismiss() },
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false, dismissOnBackPress = true)
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.widthIn(max = 600.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(horizontal = 24.dp, vertical = 16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(id = R.string.new_channel_rcvd),
|
||||
modifier = Modifier.padding(20.dp),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
)
|
||||
}
|
||||
itemsIndexed(channelSet.settingsList) { index, channel ->
|
||||
ChannelSelection(
|
||||
index = index,
|
||||
title = channel.name.ifEmpty { modemPresetName },
|
||||
enabled = true,
|
||||
isSelected = channelSelections[index],
|
||||
onSelected = {
|
||||
if (it || selectedChannelSet.settingsCount > 1) {
|
||||
channelSelections[index] = it
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier.padding(vertical = 20.dp),
|
||||
) {
|
||||
val selectedColors = ButtonDefaults.buttonColors()
|
||||
val unselectedColors = ButtonDefaults.outlinedButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
|
||||
OutlinedButton(
|
||||
onClick = { shouldReplace = false },
|
||||
modifier = Modifier
|
||||
.height(48.dp)
|
||||
.weight(1f),
|
||||
colors = if (!shouldReplace) selectedColors else unselectedColors,
|
||||
) { Text(text = stringResource(R.string.add)) }
|
||||
|
||||
OutlinedButton(
|
||||
onClick = { shouldReplace = true },
|
||||
modifier = Modifier
|
||||
.height(48.dp)
|
||||
.weight(1f),
|
||||
enabled = incoming.hasLoraConfig(),
|
||||
colors = if (shouldReplace) selectedColors else unselectedColors,
|
||||
) { Text(text = stringResource(R.string.replace)) }
|
||||
}
|
||||
}
|
||||
|
||||
/* User Actions via buttons */
|
||||
item {
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp)
|
||||
) {
|
||||
TextButton(
|
||||
onClick = {
|
||||
onDismiss()
|
||||
},
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.cancel),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 1,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
}
|
||||
|
||||
TextButton(
|
||||
onClick = {
|
||||
onDismiss()
|
||||
onConfirm(selectedChannelSet)
|
||||
},
|
||||
enabled = selectedChannelSet.settingsCount in 1..8,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.accept),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 1,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewScreenSizes
|
||||
@Composable
|
||||
private fun ScannedQrCodeDialogPreview() {
|
||||
ScannedQrCodeDialog(
|
||||
channels = channelSet {
|
||||
settings.add(Channel.default.settings)
|
||||
loraConfig = Channel.default.loraConfig
|
||||
},
|
||||
incoming = channelSet {
|
||||
settings.add(Channel.default.settings)
|
||||
loraConfig = Channel.default.loraConfig
|
||||
},
|
||||
onDismiss = {},
|
||||
onConfirm = {},
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.common.components
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewLightDark
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import com.geeksville.mesh.R
|
||||
import com.geeksville.mesh.model.Node
|
||||
import com.geeksville.mesh.ui.common.preview.NodePreviewParameterProvider
|
||||
import com.geeksville.mesh.ui.common.theme.AppTheme
|
||||
|
||||
const val MAX_VALID_SNR = 100F
|
||||
const val MAX_VALID_RSSI = 0
|
||||
|
||||
@Composable
|
||||
fun SignalInfo(
|
||||
modifier: Modifier = Modifier,
|
||||
node: Node,
|
||||
isThisNode: Boolean
|
||||
) {
|
||||
val text = if (isThisNode) {
|
||||
stringResource(R.string.channel_air_util).format(
|
||||
node.deviceMetrics.channelUtilization,
|
||||
node.deviceMetrics.airUtilTx
|
||||
)
|
||||
} else {
|
||||
buildList {
|
||||
val hopsString = "%s: %s".format(
|
||||
stringResource(R.string.hops_away),
|
||||
if (node.hopsAway == -1) {
|
||||
"?"
|
||||
} else {
|
||||
node.hopsAway.toString()
|
||||
}
|
||||
)
|
||||
if (node.channel > 0) {
|
||||
add("ch:${node.channel}")
|
||||
}
|
||||
if (node.hopsAway != 0) add(hopsString)
|
||||
}.joinToString(" ")
|
||||
}
|
||||
Column {
|
||||
if (text.isNotEmpty()) {
|
||||
Text(
|
||||
modifier = modifier,
|
||||
text = text,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontSize = MaterialTheme.typography.bodySmall.fontSize
|
||||
)
|
||||
}
|
||||
/* We only know the Signal Quality from direct nodes aka 0 hop. */
|
||||
if (node.hopsAway <= 0) {
|
||||
if (node.snr < MAX_VALID_SNR && node.rssi < MAX_VALID_RSSI) {
|
||||
NodeSignalQuality(node.snr, node.rssi)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview(showBackground = true)
|
||||
fun SignalInfoSimplePreview() {
|
||||
AppTheme {
|
||||
SignalInfo(
|
||||
node = Node(
|
||||
num = 1,
|
||||
lastHeard = 0,
|
||||
channel = 0,
|
||||
snr = 12.5F,
|
||||
rssi = -42,
|
||||
hopsAway = 0
|
||||
),
|
||||
isThisNode = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewLightDark
|
||||
@Composable
|
||||
fun SignalInfoPreview(
|
||||
@PreviewParameter(NodePreviewParameterProvider::class)
|
||||
node: Node
|
||||
) {
|
||||
AppTheme {
|
||||
SignalInfo(
|
||||
node = node,
|
||||
isThisNode = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@PreviewLightDark
|
||||
fun SignalInfoSelfPreview(
|
||||
@PreviewParameter(NodePreviewParameterProvider::class)
|
||||
node: Node
|
||||
) {
|
||||
AppTheme {
|
||||
SignalInfo(
|
||||
node = node,
|
||||
isThisNode = true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,128 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.common.components
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.PreviewLightDark
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.geeksville.mesh.R
|
||||
import com.geeksville.mesh.ui.common.theme.AppTheme
|
||||
|
||||
@Composable
|
||||
fun SimpleAlertDialog(
|
||||
@StringRes title: Int,
|
||||
text: @Composable (() -> Unit)? = null,
|
||||
confirmText: String? = null,
|
||||
onConfirm: (() -> Unit)? = null,
|
||||
dismissText: String? = null,
|
||||
onDismiss: () -> Unit = {},
|
||||
) = AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
dismissButton = {
|
||||
TextButton(
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp),
|
||||
colors = ButtonDefaults.textButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||
),
|
||||
) { Text(text = dismissText ?: stringResource(id = R.string.cancel)) }
|
||||
},
|
||||
confirmButton = {
|
||||
onConfirm?.let {
|
||||
TextButton(
|
||||
onClick = onConfirm,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp),
|
||||
colors = ButtonDefaults.textButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||
),
|
||||
) { Text(text = confirmText ?: stringResource(id = R.string.okay)) }
|
||||
}
|
||||
},
|
||||
title = {
|
||||
Text(
|
||||
text = stringResource(id = title),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
},
|
||||
text = text,
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun SimpleAlertDialog(
|
||||
@StringRes title: Int,
|
||||
@StringRes text: Int,
|
||||
onConfirm: (() -> Unit)? = null,
|
||||
onDismiss: () -> Unit = {},
|
||||
) = SimpleAlertDialog(
|
||||
onConfirm = onConfirm,
|
||||
onDismiss = onDismiss,
|
||||
title = title,
|
||||
text = {
|
||||
Text(
|
||||
text = stringResource(id = text),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun SimpleAlertDialog(
|
||||
@StringRes title: Int,
|
||||
text: String,
|
||||
onConfirm: (() -> Unit)? = null,
|
||||
onDismiss: () -> Unit = {},
|
||||
) = SimpleAlertDialog(
|
||||
onConfirm = onConfirm,
|
||||
onDismiss = onDismiss,
|
||||
title = title,
|
||||
text = {
|
||||
Text(
|
||||
text = text,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
@PreviewLightDark
|
||||
@Composable
|
||||
private fun SimpleAlertDialogPreview() {
|
||||
AppTheme {
|
||||
SimpleAlertDialog(
|
||||
title = R.string.message,
|
||||
text = R.string.sample_message,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,429 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.common.components
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.gestures.horizontalDrag
|
||||
import androidx.compose.foundation.layout.Arrangement.spacedBy
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.foundation.selection.selectableGroup
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.composed
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.TransformOrigin
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.AwaitPointerEventScope
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.PointerInputChange
|
||||
import androidx.compose.ui.input.pointer.changedToUp
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.Layout
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.onClick
|
||||
import androidx.compose.ui.semantics.role
|
||||
import androidx.compose.ui.semantics.selected
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.semantics.stateDescription
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow.Companion.Ellipsis
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.geeksville.mesh.model.TimeFrame
|
||||
|
||||
private const val NO_OPTION_INDEX = -1
|
||||
|
||||
private val TRACK_PADDING = 2.dp
|
||||
|
||||
private val TRACK_COLOR = Color.LightGray.copy(alpha = .5f)
|
||||
|
||||
private val PRESSED_TRACK_PADDING = 1.dp
|
||||
|
||||
private val OPTION_PADDING = 5.dp
|
||||
|
||||
private const val PRESSED_UNSELECTED_ALPHA = .6f
|
||||
|
||||
private val BACKGROUND_SHAPE = RoundedCornerShape(8.dp)
|
||||
|
||||
/**
|
||||
* Provides the user with a set of options they can choose from.
|
||||
*
|
||||
* (Inspired by https://gist.github.com/zach-klippenstein/7ae8874db304f957d6bb91263e292117)
|
||||
*/
|
||||
@Composable
|
||||
fun <T : Any> SlidingSelector(
|
||||
options: List<T>,
|
||||
selectedOption: T,
|
||||
onOptionSelected: (T) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable (T) -> Unit
|
||||
) {
|
||||
val state = remember { SelectorState() }
|
||||
state.optionCount = options.size
|
||||
state.selectedOption = options.indexOf(selectedOption)
|
||||
state.onOptionSelected = { onOptionSelected(options[it]) }
|
||||
|
||||
/* Animate between whole-number indices so we don't need to do pixel calculations. */
|
||||
val selectedIndexOffset by animateFloatAsState(
|
||||
state.selectedOption.toFloat(),
|
||||
label = "Selected Index Offset"
|
||||
)
|
||||
|
||||
Layout(
|
||||
content = {
|
||||
SelectedIndicator(state)
|
||||
Dividers(state)
|
||||
Options(state, options, content)
|
||||
},
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.then(state.inputModifier)
|
||||
.background(TRACK_COLOR, BACKGROUND_SHAPE)
|
||||
.padding(TRACK_PADDING)
|
||||
) { measurables, constraints ->
|
||||
val (indicatorMeasurable, dividersMeasurable, optionsMeasurable) = measurables
|
||||
|
||||
/* Measure the options first so we know how tall to make the indicator. */
|
||||
val optionsPlaceable = optionsMeasurable.measure(constraints)
|
||||
state.updatePressedScale(optionsPlaceable.height, this)
|
||||
|
||||
/* Measure the indicator and dividers to be the right size. */
|
||||
val indicatorPlaceable = indicatorMeasurable.measure(
|
||||
Constraints.fixed(
|
||||
width = optionsPlaceable.width / options.size,
|
||||
height = optionsPlaceable.height
|
||||
)
|
||||
)
|
||||
|
||||
val dividersPlaceable = dividersMeasurable.measure(
|
||||
Constraints.fixed(
|
||||
width = optionsPlaceable.width,
|
||||
height = optionsPlaceable.height
|
||||
)
|
||||
)
|
||||
|
||||
layout(optionsPlaceable.width, optionsPlaceable.height) {
|
||||
val optionWidth = optionsPlaceable.width / options.size
|
||||
|
||||
/* Place the indicator first so that it's below the option labels. */
|
||||
indicatorPlaceable.placeRelative(
|
||||
x = (selectedIndexOffset * optionWidth).toInt(),
|
||||
y = 0
|
||||
)
|
||||
dividersPlaceable.placeRelative(IntOffset.Zero)
|
||||
optionsPlaceable.placeRelative(IntOffset.Zero)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Visual representation of the option the user may select.
|
||||
*/
|
||||
@Composable
|
||||
fun OptionLabel(text: String) {
|
||||
Text(text, maxLines = 1, overflow = Ellipsis)
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws the selected indicator on the [SlidingSelector] track.
|
||||
*/
|
||||
@Composable
|
||||
private fun SelectedIndicator(state: SelectorState) {
|
||||
Box(
|
||||
Modifier
|
||||
.then(
|
||||
state.optionScaleModifier(
|
||||
pressed = state.pressedOption == state.selectedOption,
|
||||
option = state.selectedOption
|
||||
)
|
||||
)
|
||||
.shadow(4.dp, BACKGROUND_SHAPE)
|
||||
.background(MaterialTheme.colorScheme.background, BACKGROUND_SHAPE)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws dividers between [OptionLabel]s.
|
||||
*/
|
||||
@Composable
|
||||
private fun Dividers(state: SelectorState) {
|
||||
/* Animate each divider independently. */
|
||||
val alphas = (0 until state.optionCount).map { i ->
|
||||
val selectionAdjacent = i == state.selectedOption || i - 1 == state.selectedOption
|
||||
animateFloatAsState(if (selectionAdjacent) 0f else 1f, label = "Dividers")
|
||||
}
|
||||
|
||||
Canvas(Modifier.fillMaxSize()) {
|
||||
val optionWidth = size.width / state.optionCount
|
||||
val dividerPadding = TRACK_PADDING + PRESSED_TRACK_PADDING
|
||||
|
||||
alphas.forEachIndexed { i, alpha ->
|
||||
val x = i * optionWidth
|
||||
drawLine(
|
||||
Color.White,
|
||||
alpha = alpha.value,
|
||||
start = Offset(x, dividerPadding.toPx()),
|
||||
end = Offset(x, size.height - dividerPadding.toPx())
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws the options available to the user.
|
||||
*/
|
||||
@Composable
|
||||
private fun <T> Options(
|
||||
state: SelectorState,
|
||||
options: List<T>,
|
||||
content: @Composable (T) -> Unit
|
||||
) {
|
||||
CompositionLocalProvider(
|
||||
LocalTextStyle provides TextStyle(fontWeight = FontWeight.Medium)
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = spacedBy(TRACK_PADDING),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.selectableGroup()
|
||||
) {
|
||||
options.forEachIndexed { i, timeFrame ->
|
||||
val isSelected = i == state.selectedOption
|
||||
val isPressed = i == state.pressedOption
|
||||
|
||||
/* Unselected presses are represented by fading. */
|
||||
val alpha by animateFloatAsState(
|
||||
if (!isSelected && isPressed) PRESSED_UNSELECTED_ALPHA else 1f,
|
||||
label = "Unselected"
|
||||
)
|
||||
|
||||
val semanticsModifier = Modifier.semantics(mergeDescendants = true) {
|
||||
selected = isSelected
|
||||
role = Role.Button
|
||||
onClick { state.onOptionSelected(i); true }
|
||||
stateDescription = if (isSelected) "Selected" else "Not selected"
|
||||
}
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
/* Divide space evenly between all options. */
|
||||
.weight(1f)
|
||||
.then(semanticsModifier)
|
||||
.padding(OPTION_PADDING)
|
||||
/* Draw pressed indication when not selected. */
|
||||
.alpha(alpha)
|
||||
/* Selected presses are represented by scaling. */
|
||||
.then(state.optionScaleModifier(isPressed && isSelected, i))
|
||||
/* Center the option content. */
|
||||
.wrapContentWidth()
|
||||
) {
|
||||
content(timeFrame)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Contains and handles the state necessary to present the [SlidingSelector] to the user.
|
||||
*/
|
||||
private class SelectorState {
|
||||
var optionCount by mutableIntStateOf(0)
|
||||
var selectedOption by mutableIntStateOf(0)
|
||||
var onOptionSelected: (Int) -> Unit by mutableStateOf({})
|
||||
var pressedOption by mutableIntStateOf(NO_OPTION_INDEX)
|
||||
|
||||
/**
|
||||
* Scale factor that should be used to scale pressed option. When this scale is applied,
|
||||
* exactly [PRESSED_TRACK_PADDING] will be added around the element's usual size.
|
||||
*/
|
||||
var pressedSelectedScale by mutableFloatStateOf(1f)
|
||||
private set
|
||||
|
||||
/**
|
||||
* Calculates the scale factor we need to use for pressed options to get the desired padding.
|
||||
*/
|
||||
fun updatePressedScale(controlHeight: Int, density: Density) {
|
||||
with(density) {
|
||||
val pressedPadding = PRESSED_TRACK_PADDING * 2
|
||||
val pressedHeight = controlHeight - pressedPadding.toPx()
|
||||
pressedSelectedScale = pressedHeight / controlHeight
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a [Modifier] that will scale an element so that it gets [PRESSED_TRACK_PADDING] extra
|
||||
* padding around it. The scale will be animated.
|
||||
*
|
||||
* The scale is also performed around either the left or right edge of the element if the option
|
||||
* is the first or last option, respectively. In those cases, the scale will also be translated so
|
||||
* that [PRESSED_TRACK_PADDING] will be added on the left or right edge.
|
||||
*/
|
||||
@SuppressLint("ModifierFactoryExtensionFunction")
|
||||
fun optionScaleModifier(
|
||||
pressed: Boolean,
|
||||
option: Int,
|
||||
): Modifier = Modifier.composed {
|
||||
val scale by animateFloatAsState(if (pressed) pressedSelectedScale else 1f, label = "Scale")
|
||||
val xOffset by animateDpAsState(
|
||||
if (pressed) PRESSED_TRACK_PADDING else 0.dp,
|
||||
label = "x Offset"
|
||||
)
|
||||
|
||||
graphicsLayer {
|
||||
this.scaleX = scale
|
||||
this.scaleY = scale
|
||||
|
||||
/* Scales on the ends should gravitate to that edge. */
|
||||
this.transformOrigin = TransformOrigin(
|
||||
pivotFractionX = when (option) {
|
||||
0 -> 0f
|
||||
optionCount - 1 -> 1f
|
||||
else -> .5f
|
||||
},
|
||||
pivotFractionY = .5f
|
||||
)
|
||||
|
||||
/* But should still move inwards to keep the pressed padding consistent with top and bottom. */
|
||||
this.translationX = when (option) {
|
||||
0 -> xOffset.toPx()
|
||||
optionCount - 1 -> -xOffset.toPx()
|
||||
else -> 0f
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A [Modifier] that will listen for touch gestures and update the selected and pressed properties
|
||||
* of this state appropriately.
|
||||
*/
|
||||
val inputModifier = Modifier.pointerInput(optionCount) {
|
||||
val optionWidth = size.width / optionCount
|
||||
|
||||
/* Helper to calculate which option an event occurred in. */
|
||||
fun optionIndex(change: PointerInputChange): Int =
|
||||
((change.position.x / size.width.toFloat()) * optionCount)
|
||||
.toInt()
|
||||
.coerceIn(0, optionCount - 1)
|
||||
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown()
|
||||
|
||||
pressedOption = optionIndex(down)
|
||||
val downOnSelected = pressedOption == selectedOption
|
||||
val optionBounds = Rect(
|
||||
left = pressedOption * optionWidth.toFloat(),
|
||||
right = (pressedOption + 1) * optionWidth.toFloat(),
|
||||
top = 0f,
|
||||
bottom = size.height.toFloat()
|
||||
)
|
||||
|
||||
if (downOnSelected) {
|
||||
horizontalDrag(down.id) { change ->
|
||||
pressedOption = optionIndex(change)
|
||||
|
||||
if (pressedOption != selectedOption) {
|
||||
onOptionSelected(pressedOption)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
waitForUpOrCancellation(inBounds = optionBounds)
|
||||
/* Null means the gesture was cancelled (e.g. dragged out of bounds). */
|
||||
?.let { onOptionSelected(pressedOption) }
|
||||
}
|
||||
pressedOption = NO_OPTION_INDEX
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Works with bounds that may not be at 0,0.
|
||||
*/
|
||||
@Suppress("ReturnCount")
|
||||
private suspend fun AwaitPointerEventScope.waitForUpOrCancellation(inBounds: Rect): PointerInputChange? {
|
||||
while (true) {
|
||||
val event = awaitPointerEvent(PointerEventPass.Main)
|
||||
if (event.changes.all { it.changedToUp() }) {
|
||||
/* All pointers are up */
|
||||
return event.changes[0]
|
||||
}
|
||||
|
||||
if (event.changes.any { it.isConsumed || !inBounds.contains(it.position) }) {
|
||||
/* Canceled */
|
||||
return null
|
||||
}
|
||||
|
||||
val consumeCheck = awaitPointerEvent(PointerEventPass.Final)
|
||||
if (consumeCheck.changes.any { it.isConsumed }) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun SlidingSelectorPreview() {
|
||||
MaterialTheme {
|
||||
Surface {
|
||||
Column(Modifier.padding(8.dp)) {
|
||||
|
||||
var selectedOption by remember { mutableStateOf(TimeFrame.TWENTY_FOUR_HOURS) }
|
||||
SlidingSelector(
|
||||
TimeFrame.entries.toList(),
|
||||
selectedOption,
|
||||
onOptionSelected = { selectedOption = it }
|
||||
) {
|
||||
OptionLabel(stringResource(it.strRes))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.common.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun SwitchPreference(
|
||||
title: String,
|
||||
summary: String,
|
||||
checked: Boolean,
|
||||
enabled: Boolean,
|
||||
onCheckedChange: (Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val color = if (enabled) {
|
||||
Color.Unspecified
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
||||
}
|
||||
|
||||
ListItem(
|
||||
modifier = modifier,
|
||||
trailingContent = {
|
||||
Switch(
|
||||
enabled = enabled,
|
||||
checked = checked,
|
||||
onCheckedChange = onCheckedChange,
|
||||
)
|
||||
},
|
||||
supportingContent = {
|
||||
Text(
|
||||
text = summary,
|
||||
modifier = Modifier.padding(bottom = 16.dp),
|
||||
color = color,
|
||||
)
|
||||
},
|
||||
headlineContent = {
|
||||
Text(
|
||||
text = title,
|
||||
color = color,
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SwitchPreference(
|
||||
title: String,
|
||||
checked: Boolean,
|
||||
enabled: Boolean,
|
||||
onCheckedChange: (Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
padding: PaddingValues = PaddingValues(horizontal = 16.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.size(48.dp)
|
||||
.padding(padding),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = if (enabled) {
|
||||
Color.Unspecified
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
||||
},
|
||||
)
|
||||
Switch(
|
||||
enabled = enabled,
|
||||
checked = checked,
|
||||
onCheckedChange = onCheckedChange,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun SwitchPreferencePreview() {
|
||||
SwitchPreference(title = "Setting", checked = true, enabled = true, onCheckedChange = {})
|
||||
}
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.common.components
|
||||
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun TextDividerPreference(
|
||||
title: String,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
trailingIcon: ImageVector? = null,
|
||||
) {
|
||||
TextDividerPreference(
|
||||
title = AnnotatedString(text = title),
|
||||
enabled = enabled,
|
||||
modifier = modifier,
|
||||
trailingIcon = trailingIcon,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TextDividerPreference(
|
||||
title: AnnotatedString,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
trailingIcon: ImageVector? = null,
|
||||
) {
|
||||
Card(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(all = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = if (!enabled) {
|
||||
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
||||
} else {
|
||||
Color.Unspecified
|
||||
},
|
||||
)
|
||||
if (trailingIcon != null) {
|
||||
Icon(
|
||||
trailingIcon, "trailingIcon",
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.wrapContentWidth(Alignment.End),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun TextDividerPreferencePreview() {
|
||||
TextDividerPreference(title = "Advanced settings")
|
||||
}
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.common.components
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||
|
||||
@Composable
|
||||
fun rememberTimeTickWithLifecycle(): Long {
|
||||
val context = LocalContext.current
|
||||
var value by remember { mutableLongStateOf(System.currentTimeMillis()) }
|
||||
val receiver = TimeBroadcastReceiver { value = System.currentTimeMillis() }
|
||||
|
||||
LifecycleResumeEffect(Unit) {
|
||||
receiver.register(context)
|
||||
value = System.currentTimeMillis()
|
||||
|
||||
onPauseOrDispose {
|
||||
receiver.unregister(context)
|
||||
}
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
private class TimeBroadcastReceiver(
|
||||
val onTimeChanged: () -> Unit,
|
||||
) : BroadcastReceiver() {
|
||||
private var registered = false
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
onTimeChanged()
|
||||
}
|
||||
|
||||
fun register(context: Context) {
|
||||
if (!registered) {
|
||||
val filter = IntentFilter(Intent.ACTION_TIME_TICK)
|
||||
context.registerReceiver(this, filter)
|
||||
registered = true
|
||||
}
|
||||
}
|
||||
|
||||
fun unregister(context: Context) {
|
||||
if (registered) {
|
||||
context.unregisterReceiver(this)
|
||||
registered = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.common.preview
|
||||
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
|
||||
@Preview(name = "Large Font", fontScale = 2f)
|
||||
annotation class LargeFontPreview
|
||||
|
|
@ -0,0 +1,145 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.common.preview
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import com.geeksville.mesh.ConfigProtos
|
||||
import com.geeksville.mesh.DeviceMetrics.Companion.currentTime
|
||||
import com.geeksville.mesh.MeshProtos
|
||||
import com.geeksville.mesh.deviceMetrics
|
||||
import com.geeksville.mesh.environmentMetrics
|
||||
import com.geeksville.mesh.model.Node
|
||||
import com.geeksville.mesh.paxcount
|
||||
import com.geeksville.mesh.position
|
||||
import com.geeksville.mesh.user
|
||||
import com.google.protobuf.ByteString
|
||||
import kotlin.random.Random
|
||||
|
||||
class NodePreviewParameterProvider : PreviewParameterProvider<Node> {
|
||||
val mickeyMouse = Node(
|
||||
num = 1955,
|
||||
user = user {
|
||||
id = "mickeyMouseId"
|
||||
longName = "Mickey Mouse"
|
||||
shortName = "MM"
|
||||
hwModel = MeshProtos.HardwareModel.TBEAM
|
||||
role = ConfigProtos.Config.DeviceConfig.Role.ROUTER
|
||||
},
|
||||
position = position {
|
||||
latitudeI = 338125110
|
||||
longitudeI = -1179189760
|
||||
altitude = 138
|
||||
satsInView = 4
|
||||
},
|
||||
lastHeard = currentTime(),
|
||||
channel = 0,
|
||||
snr = 12.5F,
|
||||
rssi = -42,
|
||||
deviceMetrics = deviceMetrics {
|
||||
channelUtilization = 2.4F
|
||||
airUtilTx = 3.5F
|
||||
batteryLevel = 85
|
||||
voltage = 3.7F
|
||||
uptimeSeconds = 3600
|
||||
},
|
||||
isFavorite = true,
|
||||
hopsAway = 0
|
||||
)
|
||||
|
||||
private val minnieMouse = mickeyMouse.copy(
|
||||
num = Random.nextInt(),
|
||||
user = user {
|
||||
longName = "Minnie Mouse"
|
||||
shortName = "MiMo"
|
||||
id = "minnieMouseId"
|
||||
hwModel = MeshProtos.HardwareModel.HELTEC_V3
|
||||
},
|
||||
snr = 12.5F,
|
||||
rssi = -42,
|
||||
position = position {},
|
||||
hopsAway = 1
|
||||
)
|
||||
|
||||
private val donaldDuck = Node(
|
||||
num = Random.nextInt(),
|
||||
position = position {
|
||||
latitudeI = 338052347
|
||||
longitudeI = -1179208460
|
||||
altitude = 121
|
||||
satsInView = 66
|
||||
},
|
||||
lastHeard = currentTime() - 300,
|
||||
channel = 0,
|
||||
snr = 12.5F,
|
||||
rssi = -42,
|
||||
deviceMetrics = deviceMetrics {
|
||||
channelUtilization = 2.4F
|
||||
airUtilTx = 3.5F
|
||||
batteryLevel = 85
|
||||
voltage = 3.7F
|
||||
uptimeSeconds = 3600
|
||||
},
|
||||
user = user {
|
||||
id = "donaldDuckId"
|
||||
longName = "Donald Duck, the Grand Duck of the Ducks"
|
||||
shortName = "DoDu"
|
||||
hwModel = MeshProtos.HardwareModel.HELTEC_V3
|
||||
publicKey = ByteString.copyFrom(ByteArray(32) { 1 })
|
||||
},
|
||||
environmentMetrics = environmentMetrics {
|
||||
temperature = 28.0F
|
||||
relativeHumidity = 50.0F
|
||||
barometricPressure = 1013.25F
|
||||
gasResistance = 0.0F
|
||||
voltage = 3.7F
|
||||
current = 0.0F
|
||||
iaq = 100
|
||||
},
|
||||
paxcounter = paxcount {
|
||||
wifi = 30
|
||||
ble = 39
|
||||
uptime = 420
|
||||
},
|
||||
isFavorite = true,
|
||||
hopsAway = 2
|
||||
)
|
||||
|
||||
private val unknown = donaldDuck.copy(
|
||||
user = user {
|
||||
id = "myId"
|
||||
longName = "Meshtastic myId"
|
||||
shortName = "myId"
|
||||
hwModel = MeshProtos.HardwareModel.UNSET
|
||||
},
|
||||
environmentMetrics = environmentMetrics {},
|
||||
paxcounter = paxcount {},
|
||||
)
|
||||
|
||||
private val almostNothing = Node(
|
||||
num = Random.nextInt(),
|
||||
)
|
||||
|
||||
override val values: Sequence<Node>
|
||||
get() = sequenceOf(
|
||||
mickeyMouse, // "this" node
|
||||
unknown,
|
||||
almostNothing,
|
||||
minnieMouse,
|
||||
donaldDuck
|
||||
)
|
||||
}
|
||||
241
app/src/main/java/com/geeksville/mesh/ui/common/theme/Color.kt
Normal file
241
app/src/main/java/com/geeksville/mesh/ui/common/theme/Color.kt
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.common.theme
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
val MeshtasticGreen = Color(0xFF67EA94)
|
||||
val MeshtasticAlt = Color(0xFF2C2D3C)
|
||||
val HyperlinkBlue = Color(0xFF43C3B0)
|
||||
val InfantryBlue = Color(red = 75, green = 119, blue = 190)
|
||||
val Orange = Color(red = 247, green = 147, blue = 26)
|
||||
|
||||
val primaryLight = Color(0xFF306A42)
|
||||
val onPrimaryLight = Color(0xFFFFFFFF)
|
||||
val primaryContainerLight = Color(0xFFB3F1BF)
|
||||
val onPrimaryContainerLight = Color(0xFF00210D)
|
||||
val secondaryLight = Color(0xFF506353)
|
||||
val onSecondaryLight = Color(0xFFFFFFFF)
|
||||
val secondaryContainerLight = Color(0xFFD2E8D3)
|
||||
val onSecondaryContainerLight = Color(0xFF0D1F12)
|
||||
val tertiaryLight = Color(0xFF3A656E)
|
||||
val onTertiaryLight = Color(0xFFFFFFFF)
|
||||
val tertiaryContainerLight = Color(0xFFBEEAF6)
|
||||
val onTertiaryContainerLight = Color(0xFF001F25)
|
||||
val errorLight = Color(0xFFBA1A1A)
|
||||
val onErrorLight = Color(0xFFFFFFFF)
|
||||
val errorContainerLight = Color(0xFFFFDAD6)
|
||||
val onErrorContainerLight = Color(0xFF410002)
|
||||
val backgroundLight = Color(0xFFF6FBF3)
|
||||
val onBackgroundLight = Color(0xFF181D18)
|
||||
val surfaceLight = Color(0xFFF6FBF3)
|
||||
val onSurfaceLight = Color(0xFF181D18)
|
||||
val surfaceVariantLight = Color(0xFFDDE5DA)
|
||||
val onSurfaceVariantLight = Color(0xFF414941)
|
||||
val outlineLight = Color(0xFF717971)
|
||||
val outlineVariantLight = Color(0xFFC1C9BF)
|
||||
val scrimLight = Color(0xFF000000)
|
||||
val inverseSurfaceLight = Color(0xFF2D322D)
|
||||
val inverseOnSurfaceLight = Color(0xFFEEF2EA)
|
||||
val inversePrimaryLight = Color(0xFF97D5A5)
|
||||
val surfaceDimLight = Color(0xFFD7DBD4)
|
||||
val surfaceBrightLight = Color(0xFFF6FBF3)
|
||||
val surfaceContainerLowestLight = Color(0xFFFFFFFF)
|
||||
val surfaceContainerLowLight = Color(0xFFF0F5ED)
|
||||
val surfaceContainerLight = Color(0xFFEBEFE7)
|
||||
val surfaceContainerHighLight = Color(0xFFE5EAE2)
|
||||
val surfaceContainerHighestLight = Color(0xFFDFE4DC)
|
||||
|
||||
val primaryLightMediumContrast = Color(0xFF0F4D29)
|
||||
val onPrimaryLightMediumContrast = Color(0xFFFFFFFF)
|
||||
val primaryContainerLightMediumContrast = Color(0xFF478157)
|
||||
val onPrimaryContainerLightMediumContrast = Color(0xFFFFFFFF)
|
||||
val secondaryLightMediumContrast = Color(0xFF344738)
|
||||
val onSecondaryLightMediumContrast = Color(0xFFFFFFFF)
|
||||
val secondaryContainerLightMediumContrast = Color(0xFF657A68)
|
||||
val onSecondaryContainerLightMediumContrast = Color(0xFFFFFFFF)
|
||||
val tertiaryLightMediumContrast = Color(0xFF1C4952)
|
||||
val onTertiaryLightMediumContrast = Color(0xFFFFFFFF)
|
||||
val tertiaryContainerLightMediumContrast = Color(0xFF517B85)
|
||||
val onTertiaryContainerLightMediumContrast = Color(0xFFFFFFFF)
|
||||
val errorLightMediumContrast = Color(0xFF8C0009)
|
||||
val onErrorLightMediumContrast = Color(0xFFFFFFFF)
|
||||
val errorContainerLightMediumContrast = Color(0xFFDA342E)
|
||||
val onErrorContainerLightMediumContrast = Color(0xFFFFFFFF)
|
||||
val backgroundLightMediumContrast = Color(0xFFF6FBF3)
|
||||
val onBackgroundLightMediumContrast = Color(0xFF181D18)
|
||||
val surfaceLightMediumContrast = Color(0xFFF6FBF3)
|
||||
val onSurfaceLightMediumContrast = Color(0xFF181D18)
|
||||
val surfaceVariantLightMediumContrast = Color(0xFFDDE5DA)
|
||||
val onSurfaceVariantLightMediumContrast = Color(0xFF3D453D)
|
||||
val outlineLightMediumContrast = Color(0xFF596159)
|
||||
val outlineVariantLightMediumContrast = Color(0xFF757D74)
|
||||
val scrimLightMediumContrast = Color(0xFF000000)
|
||||
val inverseSurfaceLightMediumContrast = Color(0xFF2D322D)
|
||||
val inverseOnSurfaceLightMediumContrast = Color(0xFFEEF2EA)
|
||||
val inversePrimaryLightMediumContrast = Color(0xFF97D5A5)
|
||||
val surfaceDimLightMediumContrast = Color(0xFFD7DBD4)
|
||||
val surfaceBrightLightMediumContrast = Color(0xFFF6FBF3)
|
||||
val surfaceContainerLowestLightMediumContrast = Color(0xFFFFFFFF)
|
||||
val surfaceContainerLowLightMediumContrast = Color(0xFFF0F5ED)
|
||||
val surfaceContainerLightMediumContrast = Color(0xFFEBEFE7)
|
||||
val surfaceContainerHighLightMediumContrast = Color(0xFFE5EAE2)
|
||||
val surfaceContainerHighestLightMediumContrast = Color(0xFFDFE4DC)
|
||||
|
||||
val primaryLightHighContrast = Color(0xFF002911)
|
||||
val onPrimaryLightHighContrast = Color(0xFFFFFFFF)
|
||||
val primaryContainerLightHighContrast = Color(0xFF0F4D29)
|
||||
val onPrimaryContainerLightHighContrast = Color(0xFFFFFFFF)
|
||||
val secondaryLightHighContrast = Color(0xFF142619)
|
||||
val onSecondaryLightHighContrast = Color(0xFFFFFFFF)
|
||||
val secondaryContainerLightHighContrast = Color(0xFF344738)
|
||||
val onSecondaryContainerLightHighContrast = Color(0xFFFFFFFF)
|
||||
val tertiaryLightHighContrast = Color(0xFF00262E)
|
||||
val onTertiaryLightHighContrast = Color(0xFFFFFFFF)
|
||||
val tertiaryContainerLightHighContrast = Color(0xFF1C4952)
|
||||
val onTertiaryContainerLightHighContrast = Color(0xFFFFFFFF)
|
||||
val errorLightHighContrast = Color(0xFF4E0002)
|
||||
val onErrorLightHighContrast = Color(0xFFFFFFFF)
|
||||
val errorContainerLightHighContrast = Color(0xFF8C0009)
|
||||
val onErrorContainerLightHighContrast = Color(0xFFFFFFFF)
|
||||
val backgroundLightHighContrast = Color(0xFFF6FBF3)
|
||||
val onBackgroundLightHighContrast = Color(0xFF181D18)
|
||||
val surfaceLightHighContrast = Color(0xFFF6FBF3)
|
||||
val onSurfaceLightHighContrast = Color(0xFF000000)
|
||||
val surfaceVariantLightHighContrast = Color(0xFFDDE5DA)
|
||||
val onSurfaceVariantLightHighContrast = Color(0xFF1E261F)
|
||||
val outlineLightHighContrast = Color(0xFF3D453D)
|
||||
val outlineVariantLightHighContrast = Color(0xFF3D453D)
|
||||
val scrimLightHighContrast = Color(0xFF000000)
|
||||
val inverseSurfaceLightHighContrast = Color(0xFF2D322D)
|
||||
val inverseOnSurfaceLightHighContrast = Color(0xFFFFFFFF)
|
||||
val inversePrimaryLightHighContrast = Color(0xFFBCFBC8)
|
||||
val surfaceDimLightHighContrast = Color(0xFFD7DBD4)
|
||||
val surfaceBrightLightHighContrast = Color(0xFFF6FBF3)
|
||||
val surfaceContainerLowestLightHighContrast = Color(0xFFFFFFFF)
|
||||
val surfaceContainerLowLightHighContrast = Color(0xFFF0F5ED)
|
||||
val surfaceContainerLightHighContrast = Color(0xFFEBEFE7)
|
||||
val surfaceContainerHighLightHighContrast = Color(0xFFE5EAE2)
|
||||
val surfaceContainerHighestLightHighContrast = Color(0xFFDFE4DC)
|
||||
|
||||
val primaryDark = Color(0xFF97D5A5)
|
||||
val onPrimaryDark = Color(0xFF00391A)
|
||||
val primaryContainerDark = Color(0xFF15512C)
|
||||
val onPrimaryContainerDark = Color(0xFFB3F1BF)
|
||||
val secondaryDark = Color(0xFFB6CCB8)
|
||||
val onSecondaryDark = Color(0xFF223526)
|
||||
val secondaryContainerDark = Color(0xFF384B3C)
|
||||
val onSecondaryContainerDark = Color(0xFFD2E8D3)
|
||||
val tertiaryDark = Color(0xFFA2CED9)
|
||||
val onTertiaryDark = Color(0xFF01363F)
|
||||
val tertiaryContainerDark = Color(0xFF204D56)
|
||||
val onTertiaryContainerDark = Color(0xFFBEEAF6)
|
||||
val errorDark = Color(0xFFFFB4AB)
|
||||
val onErrorDark = Color(0xFF690005)
|
||||
val errorContainerDark = Color(0xFF93000A)
|
||||
val onErrorContainerDark = Color(0xFFFFDAD6)
|
||||
val backgroundDark = Color(0xFF101510)
|
||||
val onBackgroundDark = Color(0xFFDFE4DC)
|
||||
val surfaceDark = Color(0xFF101510)
|
||||
val onSurfaceDark = Color(0xFFDFE4DC)
|
||||
val surfaceVariantDark = Color(0xFF414941)
|
||||
val onSurfaceVariantDark = Color(0xFFC1C9BF)
|
||||
val outlineDark = Color(0xFF8B938A)
|
||||
val outlineVariantDark = Color(0xFF414941)
|
||||
val scrimDark = Color(0xFF000000)
|
||||
val inverseSurfaceDark = Color(0xFFDFE4DC)
|
||||
val inverseOnSurfaceDark = Color(0xFF2D322D)
|
||||
val inversePrimaryDark = Color(0xFF306A42)
|
||||
val surfaceDimDark = Color(0xFF101510)
|
||||
val surfaceBrightDark = Color(0xFF353A35)
|
||||
val surfaceContainerLowestDark = Color(0xFF0A0F0B)
|
||||
val surfaceContainerLowDark = Color(0xFF181D18)
|
||||
val surfaceContainerDark = Color(0xFF1C211C)
|
||||
val surfaceContainerHighDark = Color(0xFF262B26)
|
||||
val surfaceContainerHighestDark = Color(0xFF313631)
|
||||
|
||||
val primaryDarkMediumContrast = Color(0xFF9BD9A9)
|
||||
val onPrimaryDarkMediumContrast = Color(0xFF001B09)
|
||||
val primaryContainerDarkMediumContrast = Color(0xFF639D72)
|
||||
val onPrimaryContainerDarkMediumContrast = Color(0xFF000000)
|
||||
val secondaryDarkMediumContrast = Color(0xFFBBD0BC)
|
||||
val onSecondaryDarkMediumContrast = Color(0xFF081A0D)
|
||||
val secondaryContainerDarkMediumContrast = Color(0xFF819683)
|
||||
val onSecondaryContainerDarkMediumContrast = Color(0xFF000000)
|
||||
val tertiaryDarkMediumContrast = Color(0xFFA6D2DD)
|
||||
val onTertiaryDarkMediumContrast = Color(0xFF00191F)
|
||||
val tertiaryContainerDarkMediumContrast = Color(0xFF6D97A2)
|
||||
val onTertiaryContainerDarkMediumContrast = Color(0xFF000000)
|
||||
val errorDarkMediumContrast = Color(0xFFFFBAB1)
|
||||
val onErrorDarkMediumContrast = Color(0xFF370001)
|
||||
val errorContainerDarkMediumContrast = Color(0xFFFF5449)
|
||||
val onErrorContainerDarkMediumContrast = Color(0xFF000000)
|
||||
val backgroundDarkMediumContrast = Color(0xFF101510)
|
||||
val onBackgroundDarkMediumContrast = Color(0xFFDFE4DC)
|
||||
val surfaceDarkMediumContrast = Color(0xFF101510)
|
||||
val onSurfaceDarkMediumContrast = Color(0xFFF8FCF4)
|
||||
val surfaceVariantDarkMediumContrast = Color(0xFF414941)
|
||||
val onSurfaceVariantDarkMediumContrast = Color(0xFFC5CDC3)
|
||||
val outlineDarkMediumContrast = Color(0xFF9DA59C)
|
||||
val outlineVariantDarkMediumContrast = Color(0xFF7D857D)
|
||||
val scrimDarkMediumContrast = Color(0xFF000000)
|
||||
val inverseSurfaceDarkMediumContrast = Color(0xFFDFE4DC)
|
||||
val inverseOnSurfaceDarkMediumContrast = Color(0xFF262B26)
|
||||
val inversePrimaryDarkMediumContrast = Color(0xFF16522E)
|
||||
val surfaceDimDarkMediumContrast = Color(0xFF101510)
|
||||
val surfaceBrightDarkMediumContrast = Color(0xFF353A35)
|
||||
val surfaceContainerLowestDarkMediumContrast = Color(0xFF0A0F0B)
|
||||
val surfaceContainerLowDarkMediumContrast = Color(0xFF181D18)
|
||||
val surfaceContainerDarkMediumContrast = Color(0xFF1C211C)
|
||||
val surfaceContainerHighDarkMediumContrast = Color(0xFF262B26)
|
||||
val surfaceContainerHighestDarkMediumContrast = Color(0xFF313631)
|
||||
|
||||
val primaryDarkHighContrast = Color(0xFFEFFFEE)
|
||||
val onPrimaryDarkHighContrast = Color(0xFF000000)
|
||||
val primaryContainerDarkHighContrast = Color(0xFF9BD9A9)
|
||||
val onPrimaryContainerDarkHighContrast = Color(0xFF000000)
|
||||
val secondaryDarkHighContrast = Color(0xFFEFFFEE)
|
||||
val onSecondaryDarkHighContrast = Color(0xFF000000)
|
||||
val secondaryContainerDarkHighContrast = Color(0xFFBBD0BC)
|
||||
val onSecondaryContainerDarkHighContrast = Color(0xFF000000)
|
||||
val tertiaryDarkHighContrast = Color(0xFFF3FCFF)
|
||||
val onTertiaryDarkHighContrast = Color(0xFF000000)
|
||||
val tertiaryContainerDarkHighContrast = Color(0xFFA6D2DD)
|
||||
val onTertiaryContainerDarkHighContrast = Color(0xFF000000)
|
||||
val errorDarkHighContrast = Color(0xFFFFF9F9)
|
||||
val onErrorDarkHighContrast = Color(0xFF000000)
|
||||
val errorContainerDarkHighContrast = Color(0xFFFFBAB1)
|
||||
val onErrorContainerDarkHighContrast = Color(0xFF000000)
|
||||
val backgroundDarkHighContrast = Color(0xFF101510)
|
||||
val onBackgroundDarkHighContrast = Color(0xFFDFE4DC)
|
||||
val surfaceDarkHighContrast = Color(0xFF101510)
|
||||
val onSurfaceDarkHighContrast = Color(0xFFFFFFFF)
|
||||
val surfaceVariantDarkHighContrast = Color(0xFF414941)
|
||||
val onSurfaceVariantDarkHighContrast = Color(0xFFF5FDF2)
|
||||
val outlineDarkHighContrast = Color(0xFFC5CDC3)
|
||||
val outlineVariantDarkHighContrast = Color(0xFFC5CDC3)
|
||||
val scrimDarkHighContrast = Color(0xFF000000)
|
||||
val inverseSurfaceDarkHighContrast = Color(0xFFDFE4DC)
|
||||
val inverseOnSurfaceDarkHighContrast = Color(0xFF000000)
|
||||
val inversePrimaryDarkHighContrast = Color(0xFF003216)
|
||||
val surfaceDimDarkHighContrast = Color(0xFF101510)
|
||||
val surfaceBrightDarkHighContrast = Color(0xFF353A35)
|
||||
val surfaceContainerLowestDarkHighContrast = Color(0xFF0A0F0B)
|
||||
val surfaceContainerLowDarkHighContrast = Color(0xFF181D18)
|
||||
val surfaceContainerDarkHighContrast = Color(0xFF1C211C)
|
||||
val surfaceContainerHighDarkHighContrast = Color(0xFF262B26)
|
||||
val surfaceContainerHighestDarkHighContrast = Color(0xFF313631)
|
||||
298
app/src/main/java/com/geeksville/mesh/ui/common/theme/Theme.kt
Normal file
298
app/src/main/java/com/geeksville/mesh/ui/common/theme/Theme.kt
Normal file
|
|
@ -0,0 +1,298 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
@file:Suppress("UnusedPrivateProperty")
|
||||
|
||||
package com.geeksville.mesh.ui.common.theme
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
private val lightScheme = lightColorScheme(
|
||||
primary = primaryLight,
|
||||
onPrimary = onPrimaryLight,
|
||||
primaryContainer = primaryContainerLight,
|
||||
onPrimaryContainer = onPrimaryContainerLight,
|
||||
secondary = secondaryLight,
|
||||
onSecondary = onSecondaryLight,
|
||||
secondaryContainer = secondaryContainerLight,
|
||||
onSecondaryContainer = onSecondaryContainerLight,
|
||||
tertiary = tertiaryLight,
|
||||
onTertiary = onTertiaryLight,
|
||||
tertiaryContainer = tertiaryContainerLight,
|
||||
onTertiaryContainer = onTertiaryContainerLight,
|
||||
error = errorLight,
|
||||
onError = onErrorLight,
|
||||
errorContainer = errorContainerLight,
|
||||
onErrorContainer = onErrorContainerLight,
|
||||
background = backgroundLight,
|
||||
onBackground = onBackgroundLight,
|
||||
surface = surfaceLight,
|
||||
onSurface = onSurfaceLight,
|
||||
surfaceVariant = surfaceVariantLight,
|
||||
onSurfaceVariant = onSurfaceVariantLight,
|
||||
outline = outlineLight,
|
||||
outlineVariant = outlineVariantLight,
|
||||
scrim = scrimLight,
|
||||
inverseSurface = inverseSurfaceLight,
|
||||
inverseOnSurface = inverseOnSurfaceLight,
|
||||
inversePrimary = inversePrimaryLight,
|
||||
surfaceDim = surfaceDimLight,
|
||||
surfaceBright = surfaceBrightLight,
|
||||
surfaceContainerLowest = surfaceContainerLowestLight,
|
||||
surfaceContainerLow = surfaceContainerLowLight,
|
||||
surfaceContainer = surfaceContainerLight,
|
||||
surfaceContainerHigh = surfaceContainerHighLight,
|
||||
surfaceContainerHighest = surfaceContainerHighestLight,
|
||||
)
|
||||
|
||||
private val darkScheme = darkColorScheme(
|
||||
primary = primaryDark,
|
||||
onPrimary = onPrimaryDark,
|
||||
primaryContainer = primaryContainerDark,
|
||||
onPrimaryContainer = onPrimaryContainerDark,
|
||||
secondary = secondaryDark,
|
||||
onSecondary = onSecondaryDark,
|
||||
secondaryContainer = secondaryContainerDark,
|
||||
onSecondaryContainer = onSecondaryContainerDark,
|
||||
tertiary = tertiaryDark,
|
||||
onTertiary = onTertiaryDark,
|
||||
tertiaryContainer = tertiaryContainerDark,
|
||||
onTertiaryContainer = onTertiaryContainerDark,
|
||||
error = errorDark,
|
||||
onError = onErrorDark,
|
||||
errorContainer = errorContainerDark,
|
||||
onErrorContainer = onErrorContainerDark,
|
||||
background = backgroundDark,
|
||||
onBackground = onBackgroundDark,
|
||||
surface = surfaceDark,
|
||||
onSurface = onSurfaceDark,
|
||||
surfaceVariant = surfaceVariantDark,
|
||||
onSurfaceVariant = onSurfaceVariantDark,
|
||||
outline = outlineDark,
|
||||
outlineVariant = outlineVariantDark,
|
||||
scrim = scrimDark,
|
||||
inverseSurface = inverseSurfaceDark,
|
||||
inverseOnSurface = inverseOnSurfaceDark,
|
||||
inversePrimary = inversePrimaryDark,
|
||||
surfaceDim = surfaceDimDark,
|
||||
surfaceBright = surfaceBrightDark,
|
||||
surfaceContainerLowest = surfaceContainerLowestDark,
|
||||
surfaceContainerLow = surfaceContainerLowDark,
|
||||
surfaceContainer = surfaceContainerDark,
|
||||
surfaceContainerHigh = surfaceContainerHighDark,
|
||||
surfaceContainerHighest = surfaceContainerHighestDark,
|
||||
)
|
||||
|
||||
private val mediumContrastLightColorScheme = lightColorScheme(
|
||||
primary = primaryLightMediumContrast,
|
||||
onPrimary = onPrimaryLightMediumContrast,
|
||||
primaryContainer = primaryContainerLightMediumContrast,
|
||||
onPrimaryContainer = onPrimaryContainerLightMediumContrast,
|
||||
secondary = secondaryLightMediumContrast,
|
||||
onSecondary = onSecondaryLightMediumContrast,
|
||||
secondaryContainer = secondaryContainerLightMediumContrast,
|
||||
onSecondaryContainer = onSecondaryContainerLightMediumContrast,
|
||||
tertiary = tertiaryLightMediumContrast,
|
||||
onTertiary = onTertiaryLightMediumContrast,
|
||||
tertiaryContainer = tertiaryContainerLightMediumContrast,
|
||||
onTertiaryContainer = onTertiaryContainerLightMediumContrast,
|
||||
error = errorLightMediumContrast,
|
||||
onError = onErrorLightMediumContrast,
|
||||
errorContainer = errorContainerLightMediumContrast,
|
||||
onErrorContainer = onErrorContainerLightMediumContrast,
|
||||
background = backgroundLightMediumContrast,
|
||||
onBackground = onBackgroundLightMediumContrast,
|
||||
surface = surfaceLightMediumContrast,
|
||||
onSurface = onSurfaceLightMediumContrast,
|
||||
surfaceVariant = surfaceVariantLightMediumContrast,
|
||||
onSurfaceVariant = onSurfaceVariantLightMediumContrast,
|
||||
outline = outlineLightMediumContrast,
|
||||
outlineVariant = outlineVariantLightMediumContrast,
|
||||
scrim = scrimLightMediumContrast,
|
||||
inverseSurface = inverseSurfaceLightMediumContrast,
|
||||
inverseOnSurface = inverseOnSurfaceLightMediumContrast,
|
||||
inversePrimary = inversePrimaryLightMediumContrast,
|
||||
surfaceDim = surfaceDimLightMediumContrast,
|
||||
surfaceBright = surfaceBrightLightMediumContrast,
|
||||
surfaceContainerLowest = surfaceContainerLowestLightMediumContrast,
|
||||
surfaceContainerLow = surfaceContainerLowLightMediumContrast,
|
||||
surfaceContainer = surfaceContainerLightMediumContrast,
|
||||
surfaceContainerHigh = surfaceContainerHighLightMediumContrast,
|
||||
surfaceContainerHighest = surfaceContainerHighestLightMediumContrast,
|
||||
)
|
||||
|
||||
private val highContrastLightColorScheme = lightColorScheme(
|
||||
primary = primaryLightHighContrast,
|
||||
onPrimary = onPrimaryLightHighContrast,
|
||||
primaryContainer = primaryContainerLightHighContrast,
|
||||
onPrimaryContainer = onPrimaryContainerLightHighContrast,
|
||||
secondary = secondaryLightHighContrast,
|
||||
onSecondary = onSecondaryLightHighContrast,
|
||||
secondaryContainer = secondaryContainerLightHighContrast,
|
||||
onSecondaryContainer = onSecondaryContainerLightHighContrast,
|
||||
tertiary = tertiaryLightHighContrast,
|
||||
onTertiary = onTertiaryLightHighContrast,
|
||||
tertiaryContainer = tertiaryContainerLightHighContrast,
|
||||
onTertiaryContainer = onTertiaryContainerLightHighContrast,
|
||||
error = errorLightHighContrast,
|
||||
onError = onErrorLightHighContrast,
|
||||
errorContainer = errorContainerLightHighContrast,
|
||||
onErrorContainer = onErrorContainerLightHighContrast,
|
||||
background = backgroundLightHighContrast,
|
||||
onBackground = onBackgroundLightHighContrast,
|
||||
surface = surfaceLightHighContrast,
|
||||
onSurface = onSurfaceLightHighContrast,
|
||||
surfaceVariant = surfaceVariantLightHighContrast,
|
||||
onSurfaceVariant = onSurfaceVariantLightHighContrast,
|
||||
outline = outlineLightHighContrast,
|
||||
outlineVariant = outlineVariantLightHighContrast,
|
||||
scrim = scrimLightHighContrast,
|
||||
inverseSurface = inverseSurfaceLightHighContrast,
|
||||
inverseOnSurface = inverseOnSurfaceLightHighContrast,
|
||||
inversePrimary = inversePrimaryLightHighContrast,
|
||||
surfaceDim = surfaceDimLightHighContrast,
|
||||
surfaceBright = surfaceBrightLightHighContrast,
|
||||
surfaceContainerLowest = surfaceContainerLowestLightHighContrast,
|
||||
surfaceContainerLow = surfaceContainerLowLightHighContrast,
|
||||
surfaceContainer = surfaceContainerLightHighContrast,
|
||||
surfaceContainerHigh = surfaceContainerHighLightHighContrast,
|
||||
surfaceContainerHighest = surfaceContainerHighestLightHighContrast,
|
||||
)
|
||||
|
||||
private val mediumContrastDarkColorScheme = darkColorScheme(
|
||||
primary = primaryDarkMediumContrast,
|
||||
onPrimary = onPrimaryDarkMediumContrast,
|
||||
primaryContainer = primaryContainerDarkMediumContrast,
|
||||
onPrimaryContainer = onPrimaryContainerDarkMediumContrast,
|
||||
secondary = secondaryDarkMediumContrast,
|
||||
onSecondary = onSecondaryDarkMediumContrast,
|
||||
secondaryContainer = secondaryContainerDarkMediumContrast,
|
||||
onSecondaryContainer = onSecondaryContainerDarkMediumContrast,
|
||||
tertiary = tertiaryDarkMediumContrast,
|
||||
onTertiary = onTertiaryDarkMediumContrast,
|
||||
tertiaryContainer = tertiaryContainerDarkMediumContrast,
|
||||
onTertiaryContainer = onTertiaryContainerDarkMediumContrast,
|
||||
error = errorDarkMediumContrast,
|
||||
onError = onErrorDarkMediumContrast,
|
||||
errorContainer = errorContainerDarkMediumContrast,
|
||||
onErrorContainer = onErrorContainerDarkMediumContrast,
|
||||
background = backgroundDarkMediumContrast,
|
||||
onBackground = onBackgroundDarkMediumContrast,
|
||||
surface = surfaceDarkMediumContrast,
|
||||
onSurface = onSurfaceDarkMediumContrast,
|
||||
surfaceVariant = surfaceVariantDarkMediumContrast,
|
||||
onSurfaceVariant = onSurfaceVariantDarkMediumContrast,
|
||||
outline = outlineDarkMediumContrast,
|
||||
outlineVariant = outlineVariantDarkMediumContrast,
|
||||
scrim = scrimDarkMediumContrast,
|
||||
inverseSurface = inverseSurfaceDarkMediumContrast,
|
||||
inverseOnSurface = inverseOnSurfaceDarkMediumContrast,
|
||||
inversePrimary = inversePrimaryDarkMediumContrast,
|
||||
surfaceDim = surfaceDimDarkMediumContrast,
|
||||
surfaceBright = surfaceBrightDarkMediumContrast,
|
||||
surfaceContainerLowest = surfaceContainerLowestDarkMediumContrast,
|
||||
surfaceContainerLow = surfaceContainerLowDarkMediumContrast,
|
||||
surfaceContainer = surfaceContainerDarkMediumContrast,
|
||||
surfaceContainerHigh = surfaceContainerHighDarkMediumContrast,
|
||||
surfaceContainerHighest = surfaceContainerHighestDarkMediumContrast,
|
||||
)
|
||||
|
||||
private val highContrastDarkColorScheme = darkColorScheme(
|
||||
primary = primaryDarkHighContrast,
|
||||
onPrimary = onPrimaryDarkHighContrast,
|
||||
primaryContainer = primaryContainerDarkHighContrast,
|
||||
onPrimaryContainer = onPrimaryContainerDarkHighContrast,
|
||||
secondary = secondaryDarkHighContrast,
|
||||
onSecondary = onSecondaryDarkHighContrast,
|
||||
secondaryContainer = secondaryContainerDarkHighContrast,
|
||||
onSecondaryContainer = onSecondaryContainerDarkHighContrast,
|
||||
tertiary = tertiaryDarkHighContrast,
|
||||
onTertiary = onTertiaryDarkHighContrast,
|
||||
tertiaryContainer = tertiaryContainerDarkHighContrast,
|
||||
onTertiaryContainer = onTertiaryContainerDarkHighContrast,
|
||||
error = errorDarkHighContrast,
|
||||
onError = onErrorDarkHighContrast,
|
||||
errorContainer = errorContainerDarkHighContrast,
|
||||
onErrorContainer = onErrorContainerDarkHighContrast,
|
||||
background = backgroundDarkHighContrast,
|
||||
onBackground = onBackgroundDarkHighContrast,
|
||||
surface = surfaceDarkHighContrast,
|
||||
onSurface = onSurfaceDarkHighContrast,
|
||||
surfaceVariant = surfaceVariantDarkHighContrast,
|
||||
onSurfaceVariant = onSurfaceVariantDarkHighContrast,
|
||||
outline = outlineDarkHighContrast,
|
||||
outlineVariant = outlineVariantDarkHighContrast,
|
||||
scrim = scrimDarkHighContrast,
|
||||
inverseSurface = inverseSurfaceDarkHighContrast,
|
||||
inverseOnSurface = inverseOnSurfaceDarkHighContrast,
|
||||
inversePrimary = inversePrimaryDarkHighContrast,
|
||||
surfaceDim = surfaceDimDarkHighContrast,
|
||||
surfaceBright = surfaceBrightDarkHighContrast,
|
||||
surfaceContainerLowest = surfaceContainerLowestDarkHighContrast,
|
||||
surfaceContainerLow = surfaceContainerLowDarkHighContrast,
|
||||
surfaceContainer = surfaceContainerDarkHighContrast,
|
||||
surfaceContainerHigh = surfaceContainerHighDarkHighContrast,
|
||||
surfaceContainerHighest = surfaceContainerHighestDarkHighContrast,
|
||||
)
|
||||
|
||||
@Immutable
|
||||
data class ColorFamily(
|
||||
val color: Color,
|
||||
val onColor: Color,
|
||||
val colorContainer: Color,
|
||||
val onColorContainer: Color
|
||||
)
|
||||
|
||||
val unspecified_scheme = ColorFamily(
|
||||
Color.Unspecified, Color.Unspecified, Color.Unspecified, Color.Unspecified
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun AppTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
// Dynamic color is available on Android 12+
|
||||
dynamicColor: Boolean = true,
|
||||
content: @Composable() () -> Unit
|
||||
) {
|
||||
val colorScheme = when {
|
||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
val context = LocalContext.current
|
||||
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
}
|
||||
|
||||
darkTheme -> darkScheme
|
||||
else -> lightScheme
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = AppTypography,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
|
||||
const val MODE_DYNAMIC = 6969420
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.common.theme
|
||||
|
||||
import androidx.compose.material3.Typography
|
||||
|
||||
val AppTypography = Typography()
|
||||
Loading…
Add table
Add a link
Reference in a new issue