feat: add channel editor (#627)

This commit is contained in:
Andre K 2023-04-29 07:14:30 -03:00 committed by GitHub
parent c821eb3681
commit e5a860cb36
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 561 additions and 104 deletions

View file

@ -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 = { },
)
}

View file

@ -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 = { },
)
}