mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
feat: add channel editor (#627)
This commit is contained in:
parent
c821eb3681
commit
e5a860cb36
8 changed files with 561 additions and 104 deletions
|
|
@ -0,0 +1,200 @@
|
|||
package com.geeksville.mesh.ui.components.config
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.wrapContentSize
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material.Card
|
||||
import androidx.compose.material.Chip
|
||||
import androidx.compose.material.ContentAlpha
|
||||
import androidx.compose.material.ExperimentalMaterialApi
|
||||
import androidx.compose.material.FloatingActionButton
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.IconButton
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.twotone.Add
|
||||
import androidx.compose.material.icons.twotone.Close
|
||||
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.focus.FocusManager
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.geeksville.mesh.ChannelProtos.ChannelSettings
|
||||
import com.geeksville.mesh.R
|
||||
import com.geeksville.mesh.channelSettings
|
||||
import com.geeksville.mesh.model.Channel
|
||||
import com.geeksville.mesh.ui.components.PreferenceCategory
|
||||
import com.geeksville.mesh.ui.components.PreferenceFooter
|
||||
|
||||
@OptIn(ExperimentalMaterialApi::class)
|
||||
@Composable
|
||||
fun ChannelCard(
|
||||
index: Int,
|
||||
title: String,
|
||||
enabled: Boolean,
|
||||
onEditClick: () -> Unit,
|
||||
onDeleteClick: () -> Unit,
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 2.dp)
|
||||
.clickable(enabled = enabled) { onEditClick() },
|
||||
elevation = 4.dp
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(vertical = 4.dp, horizontal = 4.dp)
|
||||
) {
|
||||
Chip(onClick = onEditClick) { Text("$index") }
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.body1,
|
||||
color = if (!enabled) MaterialTheme.colors.onSurface.copy(alpha = ContentAlpha.disabled) else Color.Unspecified,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
IconButton(onClick = { onDeleteClick() }) {
|
||||
Icon(
|
||||
Icons.TwoTone.Close,
|
||||
stringResource(R.string.delete),
|
||||
modifier = Modifier.wrapContentSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChannelSettingsItemList(
|
||||
settingsList: List<ChannelSettings>,
|
||||
maxChannels: Int = 8,
|
||||
enabled: Boolean,
|
||||
focusManager: FocusManager,
|
||||
onSaveClicked: (List<ChannelSettings>) -> Unit,
|
||||
) {
|
||||
ChannelSettingsItemList(
|
||||
settingsList = settingsList,
|
||||
maxChannels = maxChannels,
|
||||
enabled = enabled,
|
||||
focusManager = focusManager,
|
||||
onPositiveClicked = onSaveClicked,
|
||||
onNegativeClicked = { }
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChannelSettingsItemList(
|
||||
settingsList: List<ChannelSettings>,
|
||||
maxChannels: Int = 8,
|
||||
enabled: Boolean,
|
||||
focusManager: FocusManager,
|
||||
onNegativeClicked: () -> Unit,
|
||||
@StringRes positiveText: Int = R.string.send,
|
||||
onPositiveClicked: (List<ChannelSettings>) -> Unit,
|
||||
) {
|
||||
val settingsListInput = remember {
|
||||
mutableStateListOf<ChannelSettings>().apply { addAll(settingsList) }
|
||||
}
|
||||
var showEditChannelDialog: Int? by remember { mutableStateOf(null) }
|
||||
|
||||
if (showEditChannelDialog != null) {
|
||||
val index = showEditChannelDialog ?: return
|
||||
EditChannelDialog(
|
||||
channelSettings = with(settingsListInput) {
|
||||
if (size > index) get(index) else channelSettings { }
|
||||
},
|
||||
onAddClick = {
|
||||
if (settingsListInput.size > index) settingsListInput[index] = it
|
||||
else settingsListInput.add(it)
|
||||
showEditChannelDialog = null
|
||||
},
|
||||
onDismissRequest = { showEditChannelDialog = null }
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
if (maxChannels > settingsListInput.size) FloatingActionButton(
|
||||
onClick = {
|
||||
settingsListInput.add(channelSettings {
|
||||
psk = Channel.default.settings.psk
|
||||
})
|
||||
showEditChannelDialog = settingsListInput.size - 1
|
||||
},
|
||||
modifier = Modifier
|
||||
.padding(16.dp)
|
||||
.align(Alignment.BottomEnd),
|
||||
) { Icon(Icons.TwoTone.Add, stringResource(R.string.add)) }
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.padding(horizontal = 16.dp)
|
||||
) {
|
||||
item { PreferenceCategory(text = "Channels") }
|
||||
|
||||
itemsIndexed(settingsListInput) { index, channel ->
|
||||
ChannelCard(
|
||||
index = index,
|
||||
title = channel.name,
|
||||
enabled = enabled,
|
||||
onEditClick = { showEditChannelDialog = index },
|
||||
onDeleteClick = { settingsListInput.removeAt(index) }
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
PreferenceFooter(
|
||||
// FIXME workaround until we use navigation in ChannelFragment
|
||||
enabled = positiveText != R.string.send
|
||||
|| !settingsListInput.containsAll(settingsList)
|
||||
|| !settingsList.containsAll(settingsListInput),
|
||||
negativeText = R.string.cancel,
|
||||
onNegativeClicked = {
|
||||
focusManager.clearFocus()
|
||||
settingsListInput.clear()
|
||||
settingsListInput.addAll(settingsList)
|
||||
onNegativeClicked()
|
||||
},
|
||||
positiveText = positiveText,
|
||||
onPositiveClicked = { onPositiveClicked(settingsListInput) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
fun ChannelSettingsPreview() {
|
||||
ChannelSettingsItemList(
|
||||
settingsList = listOf(
|
||||
channelSettings {
|
||||
psk = Channel.default.settings.psk
|
||||
name = Channel.default.name
|
||||
},
|
||||
channelSettings {
|
||||
name = stringResource(R.string.channel_name)
|
||||
},
|
||||
),
|
||||
enabled = true,
|
||||
focusManager = LocalFocusManager.current,
|
||||
onSaveClicked = { },
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,191 @@
|
|||
package com.geeksville.mesh.ui.components.config
|
||||
|
||||
import android.util.Base64
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
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.AlertDialog
|
||||
import androidx.compose.material.Button
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.IconButton
|
||||
import androidx.compose.material.LocalContentAlpha
|
||||
import androidx.compose.material.LocalContentColor
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.OutlinedTextField
|
||||
import androidx.compose.material.Switch
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.twotone.Close
|
||||
import androidx.compose.material.icons.twotone.Refresh
|
||||
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.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.ChannelProtos
|
||||
import com.geeksville.mesh.R
|
||||
import com.geeksville.mesh.channelSettings
|
||||
import com.geeksville.mesh.model.Channel
|
||||
import com.geeksville.mesh.ui.components.EditTextPreference
|
||||
import com.google.accompanist.themeadapter.appcompat.AppCompatTheme
|
||||
import com.google.protobuf.ByteString
|
||||
import com.google.protobuf.kotlin.toByteString
|
||||
import java.security.SecureRandom
|
||||
|
||||
@Composable
|
||||
fun EditChannelDialog(
|
||||
channelSettings: ChannelProtos.ChannelSettings,
|
||||
onAddClick: (ChannelProtos.ChannelSettings) -> Unit,
|
||||
onDismissRequest: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val base64Flags = Base64.URL_SAFE + Base64.NO_WRAP
|
||||
fun encodeToString(input: ByteString) =
|
||||
Base64.encodeToString(input.toByteArray() ?: ByteArray(0), base64Flags)
|
||||
|
||||
var pskInput by remember { mutableStateOf(channelSettings.psk) }
|
||||
var pskString by remember(pskInput) { mutableStateOf(encodeToString(pskInput)) }
|
||||
val pskError = pskString != encodeToString(pskInput)
|
||||
|
||||
var nameInput by remember { mutableStateOf(channelSettings.name) }
|
||||
var uplinkInput by remember { mutableStateOf(channelSettings.uplinkEnabled) }
|
||||
var downlinkInput by remember { mutableStateOf(channelSettings.downlinkEnabled) }
|
||||
|
||||
fun getRandomKey() {
|
||||
val random = SecureRandom()
|
||||
val bytes = ByteArray(32)
|
||||
random.nextBytes(bytes)
|
||||
pskInput = ByteString.copyFrom(bytes)
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
text = {
|
||||
AppCompatTheme {
|
||||
Column(modifier.fillMaxWidth()) {
|
||||
EditTextPreference(
|
||||
title = stringResource(R.string.channel_name),
|
||||
value = nameInput,
|
||||
maxSize = 11, // name max_size:12
|
||||
enabled = true,
|
||||
isError = false,
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
keyboardType = KeyboardType.Text, imeAction = ImeAction.Done
|
||||
),
|
||||
keyboardActions = KeyboardActions(onDone = { }),
|
||||
onValueChanged = { nameInput = it },
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = pskString,
|
||||
onValueChange = {
|
||||
try {
|
||||
pskString = it
|
||||
val decoded = Base64.decode(it, base64Flags).toByteString()
|
||||
if (decoded.size() == 32) pskInput = decoded // 256 bit only
|
||||
} catch (ex: Throwable) {
|
||||
// Base64 decode failed, pskError true
|
||||
}
|
||||
},
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
enabled = true,
|
||||
label = { Text("PSK") },
|
||||
isError = pskError,
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
keyboardType = KeyboardType.Password, imeAction = ImeAction.Done
|
||||
),
|
||||
keyboardActions = KeyboardActions(onDone = { }),
|
||||
trailingIcon = {
|
||||
IconButton(
|
||||
onClick = {
|
||||
if (pskError) {
|
||||
pskInput = channelSettings.psk
|
||||
pskString = encodeToString(pskInput)
|
||||
} else getRandomKey()
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
if (pskError) Icons.TwoTone.Close else Icons.TwoTone.Refresh,
|
||||
contentDescription = stringResource(R.string.reset),
|
||||
tint = if (pskError) MaterialTheme.colors.error
|
||||
else LocalContentColor.current.copy(alpha = LocalContentAlpha.current)
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("Uplink enabled", // TODO move to resource strings
|
||||
modifier = modifier.weight(1f)
|
||||
)
|
||||
Switch(
|
||||
checked = uplinkInput,
|
||||
onCheckedChange = { uplinkInput = it },
|
||||
)
|
||||
}
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("Downlink enabled", // TODO move to resource strings
|
||||
modifier = modifier.weight(1f)
|
||||
)
|
||||
Switch(
|
||||
checked = downlinkInput,
|
||||
onCheckedChange = { downlinkInput = it },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
buttons = {
|
||||
Row(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Button(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 24.dp)
|
||||
.weight(1f),
|
||||
onClick = onDismissRequest
|
||||
) { Text(stringResource(R.string.cancel)) }
|
||||
Button(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(end = 24.dp)
|
||||
.weight(1f),
|
||||
onClick = {
|
||||
onAddClick(channelSettings {
|
||||
psk = pskInput
|
||||
name = nameInput.trim()
|
||||
uplinkEnabled = uplinkInput
|
||||
downlinkEnabled = downlinkInput
|
||||
})
|
||||
},
|
||||
enabled = !pskError,
|
||||
) { Text(stringResource(R.string.save)) }
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
fun EditChannelDialogPreview() {
|
||||
EditChannelDialog(
|
||||
channelSettings = channelSettings {
|
||||
psk = Channel.default.settings.psk
|
||||
name = Channel.default.name
|
||||
},
|
||||
onAddClick = { },
|
||||
onDismissRequest = { },
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue