Meshtastic-Android/app/src/main/java/com/geeksville/mesh/ui/ChannelFragment.kt

558 lines
22 KiB
Kotlin
Raw Normal View History

2020-02-17 20:00:11 -08:00
package com.geeksville.mesh.ui
2021-11-15 10:54:10 -03:00
import android.net.Uri
2020-04-07 16:04:58 -07:00
import android.os.Bundle
import android.os.RemoteException
2020-04-07 16:04:58 -07:00
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.activity.compose.rememberLauncherForActivityResult
2022-05-17 17:29:21 -03:00
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
2023-10-01 10:30:21 -03:00
import androidx.compose.material.ContentAlpha
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
2023-10-01 10:30:21 -03:00
import androidx.compose.material.OutlinedButton
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Scaffold
import androidx.compose.material.SnackbarHost
import androidx.compose.material.SnackbarHostState
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.twotone.Check
import androidx.compose.material.icons.twotone.Close
2024-07-28 02:58:41 -07:00
import androidx.compose.material.ButtonDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
2024-07-02 05:53:37 -07:00
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
2024-07-02 05:53:37 -07:00
import androidx.compose.runtime.saveable.listSaver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.SnapshotStateList
2024-07-02 05:53:37 -07:00
import androidx.compose.runtime.toMutableStateList
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.PreviewScreenSizes
import androidx.compose.ui.unit.dp
2020-04-07 17:42:31 -07:00
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.geeksville.mesh.AppOnlyProtos.ChannelSet
2022-09-04 22:52:40 -03:00
import com.geeksville.mesh.analytics.DataPair
import com.geeksville.mesh.android.GeeksvilleApplication
import com.geeksville.mesh.android.Logging
2021-02-27 13:43:55 +08:00
import com.geeksville.mesh.ChannelProtos
2022-05-26 16:23:47 -03:00
import com.geeksville.mesh.ConfigProtos
2020-02-17 20:00:11 -08:00
import com.geeksville.mesh.R
import com.geeksville.mesh.android.BuildUtils.debug
import com.geeksville.mesh.android.BuildUtils.errormsg
2022-06-01 00:04:31 -03:00
import com.geeksville.mesh.android.getCameraPermissions
2021-11-19 01:20:54 -03:00
import com.geeksville.mesh.android.hasCameraPermission
2022-09-16 18:17:47 -03:00
import com.geeksville.mesh.channelSet
2023-04-29 07:14:30 -03:00
import com.geeksville.mesh.channelSettings
2022-09-16 18:17:47 -03:00
import com.geeksville.mesh.copy
import com.geeksville.mesh.model.Channel
2020-06-14 00:11:08 -04:00
import com.geeksville.mesh.model.ChannelOption
2020-04-07 17:42:31 -07:00
import com.geeksville.mesh.model.UIViewModel
import com.geeksville.mesh.model.getChannelUrl
import com.geeksville.mesh.model.qrCode
import com.geeksville.mesh.model.toChannelSet
import com.geeksville.mesh.service.MeshService
import com.geeksville.mesh.ui.components.AdaptiveTwoPane
import com.geeksville.mesh.ui.components.DropDownPreference
import com.geeksville.mesh.ui.components.PreferenceFooter
import com.geeksville.mesh.ui.components.ScannedQrCodeDialog
2023-10-01 10:30:21 -03:00
import com.geeksville.mesh.ui.components.config.ChannelCard
import com.geeksville.mesh.ui.components.config.ChannelSelection
2023-04-29 07:14:30 -03:00
import com.geeksville.mesh.ui.components.config.EditChannelDialog
import com.geeksville.mesh.ui.components.dragContainer
import com.geeksville.mesh.ui.components.dragDropItemsIndexed
import com.geeksville.mesh.ui.components.rememberDragDropState
import com.google.accompanist.themeadapter.appcompat.AppCompatTheme
import com.google.android.material.dialog.MaterialAlertDialogBuilder
2022-05-03 17:32:01 -03:00
import com.journeyapps.barcodescanner.ScanContract
import com.journeyapps.barcodescanner.ScanOptions
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
@AndroidEntryPoint
2020-04-07 16:04:58 -07:00
class ChannelFragment : ScreenFragment("Channel"), Logging {
2020-04-07 17:42:31 -07:00
private val model: UIViewModel by activityViewModels()
2020-04-07 16:04:58 -07:00
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
2020-04-07 16:04:58 -07:00
savedInstanceState: Bundle?
2022-05-03 17:32:01 -03:00
): View {
return ComposeView(requireContext()).apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent {
val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
AppCompatTheme {
Scaffold(
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
) { paddingValues ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
) {
ChannelScreen(model) { text ->
scope.launch { snackbarHostState.showSnackbar(text) }
}
}
}
}
}
}
2020-04-07 17:42:31 -07:00
}
}
2020-04-07 16:04:58 -07:00
@Composable
fun ChannelScreen(
viewModel: UIViewModel = viewModel(),
showSnackbar: (String) -> Unit = {},
) {
val context = LocalContext.current
val focusManager = LocalFocusManager.current
val clipboardManager = LocalClipboardManager.current
val connectionState by viewModel.connectionState.observeAsState()
2023-10-01 10:30:21 -03:00
val enabled = connectionState == MeshService.ConnectionState.CONNECTED && !viewModel.isManaged
val channels by viewModel.channels.collectAsStateWithLifecycle()
var channelSet by remember(channels) { mutableStateOf(channels) }
var showChannelEditor by rememberSaveable { mutableStateOf(false) }
val isEditing = channelSet != channels || showChannelEditor
2024-07-02 05:53:37 -07:00
/* Holds selections made by the user for QR generation. */
val channelSelections = rememberSaveable(
saver = listSaver(
save = { stateList -> stateList.toList() },
restore = { it.toMutableStateList() }
)
) { mutableStateListOf(elements = Array(size = 8, init = { true })) }
val channelUrl = channelSet.getChannelUrl()
val modemPresetName = Channel(loraConfig = channelSet.loraConfig).name
var scannedChannelSet by remember { mutableStateOf<ChannelSet?>(null) }
val barcodeLauncher = rememberLauncherForActivityResult(ScanContract()) { result ->
if (result.contents != null) {
try {
scannedChannelSet = Uri.parse(result.contents).toChannelSet()
} catch (ex: Throwable) {
errormsg("Channel url error: ${ex.message}")
showSnackbar("${context.getString(R.string.channel_invalid)}: ${ex.message}")
}
}
}
fun updateSettingsList(update: MutableList<ChannelProtos.ChannelSettings>.() -> Unit) {
try {
val list = channelSet.settingsList.toMutableList()
list.update()
channelSet = channelSet.copy {
settings.clear()
settings.addAll(list)
}
} catch (ex: Exception) {
errormsg("Error updating ChannelSettings list:", ex)
}
}
fun zxingScan() {
debug("Starting zxing QR code scanner")
val zxingScan = ScanOptions()
zxingScan.setCameraId(0)
zxingScan.setPrompt("")
zxingScan.setBeepEnabled(false)
zxingScan.setDesiredBarcodeFormats(ScanOptions.QR_CODE)
barcodeLauncher.launch(zxingScan)
}
val requestPermissionAndScanLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions ->
if (permissions.entries.all { it.value }) zxingScan()
}
fun requestPermissionAndScan() {
MaterialAlertDialogBuilder(context)
.setTitle(R.string.camera_required)
.setMessage(R.string.why_camera_required)
.setNeutralButton(R.string.cancel) { _, _ ->
debug("Camera permission denied")
}
.setPositiveButton(R.string.accept) { _, _ ->
requestPermissionAndScanLauncher.launch(context.getCameraPermissions())
}
.show()
}
2021-03-24 13:48:26 +08:00
/// Send new channel settings to the device
fun installSettings(
newChannelSet: ChannelSet
) {
2022-05-26 16:23:47 -03:00
// Try to change the radio, if it fails, tell the user why and throw away their edits
2021-03-24 13:48:26 +08:00
try {
viewModel.setChannels(newChannelSet)
2022-05-26 16:23:47 -03:00
// Since we are writing to DeviceConfig, that will trigger the rest of the GUI update (QR code etc)
2021-03-24 13:48:26 +08:00
} catch (ex: RemoteException) {
errormsg("ignoring channel problem", ex)
channelSet = channels // Throw away user edits
2021-03-24 13:48:26 +08:00
// Tell the user to try again
showSnackbar(context.getString(R.string.radio_sleeping))
2023-10-01 10:30:21 -03:00
} finally {
showChannelEditor = false
2021-03-24 13:48:26 +08:00
}
}
2023-04-29 07:14:30 -03:00
fun installSettings(
newChannel: ChannelProtos.ChannelSettings,
newLoRaConfig: ConfigProtos.Config.LoRaConfig
) {
val newSet = channelSet {
settings.add(newChannel)
loraConfig = newLoRaConfig
}
installSettings(newSet)
}
fun resetButton() {
// User just locked it, we should warn and then apply changes to radio
MaterialAlertDialogBuilder(context)
.setTitle(R.string.reset_to_defaults)
.setMessage(R.string.are_you_sure_change_default)
.setNeutralButton(R.string.cancel) { _, _ ->
channelSet = channels // throw away any edits
2022-05-17 17:29:21 -03:00
}
.setPositiveButton(R.string.apply) { _, _ ->
debug("Switching back to default channel")
installSettings(
Channel.default.settings,
Channel.default.loraConfig.copy {
region = viewModel.region
txEnabled = viewModel.txEnabled
}
)
}
.show()
}
2022-05-17 17:29:21 -03:00
fun sendButton() {
MaterialAlertDialogBuilder(context)
.setTitle(R.string.change_channel)
.setMessage(R.string.are_you_sure_channel)
.setNeutralButton(R.string.cancel) { _, _ ->
showChannelEditor = false
channelSet = channels
}
.setPositiveButton(R.string.accept) { _, _ ->
installSettings(channelSet)
}
.show()
}
2021-03-24 13:48:26 +08:00
if (scannedChannelSet != null) {
val incoming = scannedChannelSet ?: return
2024-07-28 02:58:41 -07:00
/* Prompt the user to modify channels after scanning a QR code. */
ScannedQrCodeDialog(
channels = channels,
incoming = incoming,
onDismiss = { scannedChannelSet = null },
2024-07-28 02:58:41 -07:00
onConfirm = { newChannelSet -> installSettings(newChannelSet) }
)
}
2024-07-28 02:58:41 -07:00
2023-04-29 07:14:30 -03:00
var showEditChannelDialog: Int? by remember { mutableStateOf(null) }
if (showEditChannelDialog != null) {
val index = showEditChannelDialog ?: return
EditChannelDialog(
channelSettings = with(channelSet) {
if (settingsCount > index) getSettings(index) else channelSettings { }
},
modemPresetName = modemPresetName,
2023-04-29 07:14:30 -03:00
onAddClick = {
with(channelSet) {
if (settingsCount > index) channelSet = copy { settings[index] = it }
else channelSet = copy { settings.add(it) }
}
showEditChannelDialog = null
},
onDismissRequest = { showEditChannelDialog = null }
)
}
val listState = rememberLazyListState()
val dragDropState = rememberDragDropState(listState) { fromIndex, toIndex ->
updateSettingsList { add(toIndex, removeAt(fromIndex)) }
}
2023-10-01 10:30:21 -03:00
LazyColumn(
modifier = Modifier.dragContainer(
dragDropState = dragDropState,
haptics = LocalHapticFeedback.current,
),
state = listState,
contentPadding = PaddingValues(horizontal = 24.dp, vertical = 16.dp),
) {
2024-07-02 05:53:37 -07:00
if (!showChannelEditor) {
item {
ChannelListView(
2024-07-02 05:53:37 -07:00
enabled = enabled,
channelSet = channelSet,
modemPresetName = modemPresetName,
channelSelections = channelSelections,
onClick = { showChannelEditor = true }
)
2024-07-02 05:53:37 -07:00
}
2023-10-01 10:30:21 -03:00
} else {
dragDropItemsIndexed(
items = channelSet.settingsList,
dragDropState = dragDropState,
) { index, channel, isDragging ->
val elevation by animateDpAsState(if (isDragging) 8.dp else 4.dp, label = "drag")
2023-10-01 10:30:21 -03:00
ChannelCard(
elevation = elevation,
2023-10-01 10:30:21 -03:00
index = index,
title = channel.name.ifEmpty { modemPresetName },
enabled = enabled,
onEditClick = { showEditChannelDialog = index },
onDeleteClick = { updateSettingsList { removeAt(index) } }
2023-10-01 10:30:21 -03:00
)
}
item {
OutlinedButton(
modifier = Modifier.fillMaxWidth(),
onClick = {
channelSet = channelSet.copy {
settings.add(channelSettings { psk = Channel.default.settings.psk })
}
showEditChannelDialog = channelSet.settingsList.lastIndex
},
2024-07-02 05:53:37 -07:00
enabled = enabled && viewModel.maxChannels > channelSet.settingsCount,
2023-10-01 10:30:21 -03:00
colors = ButtonDefaults.buttonColors(
disabledContentColor = MaterialTheme.colors.onSurface.copy(alpha = ContentAlpha.disabled)
)
) { Text(text = stringResource(R.string.add)) }
}
2021-11-15 10:54:10 -03:00
}
item {
var valueState by remember(channelUrl) { mutableStateOf(channelUrl) }
val isError = valueState != channelUrl
OutlinedTextField(
value = valueState.toString(),
onValueChange = {
try {
valueState = Uri.parse(it)
channelSet = valueState.toChannelSet()
} catch (ex: Throwable) {
// channelSet failed to update, isError true
}
},
2023-04-29 07:14:30 -03:00
modifier = Modifier.fillMaxWidth(),
2023-05-13 18:18:49 -03:00
enabled = enabled,
label = { Text("URL") },
isError = isError,
trailingIcon = {
val isUrlEqual = channelUrl == channels.getChannelUrl()
IconButton(onClick = {
when {
isError -> valueState = channelUrl
!isUrlEqual -> viewModel.setRequestChannelUrl(channelUrl)
else -> {
// track how many times users share channels
GeeksvilleApplication.analytics.track(
"share",
DataPair("content_type", "channel")
)
clipboardManager.setText(AnnotatedString(channelUrl.toString()))
}
}
}) {
Icon(
painter = when {
isError -> rememberVectorPainter(Icons.TwoTone.Close)
!isUrlEqual -> rememberVectorPainter(Icons.TwoTone.Check)
else -> painterResource(R.drawable.ic_twotone_content_copy_24)
},
contentDescription = when {
isError -> "Error"
!isUrlEqual -> stringResource(R.string.send)
else -> "Copy"
},
tint = if (isError) MaterialTheme.colors.error
else LocalContentColor.current.copy(alpha = LocalContentAlpha.current)
)
}
},
maxLines = 1,
singleLine = true,
keyboardOptions = KeyboardOptions.Default.copy(
keyboardType = KeyboardType.Uri, imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
)
2022-04-24 12:12:13 -03:00
}
item {
DropDownPreference(title = stringResource(id = R.string.channel_options),
2023-05-13 18:18:49 -03:00
enabled = enabled,
items = ChannelOption.entries
.map { it.modemPreset to stringResource(it.configRes) },
selectedItem = channelSet.loraConfig.modemPreset,
onItemSelected = {
val lora = channelSet.loraConfig.copy { modemPreset = it }
channelSet = channelSet.copy { loraConfig = lora }
})
2022-04-24 12:12:13 -03:00
}
2020-06-14 00:11:08 -04:00
if (isEditing) item {
PreferenceFooter(
2023-05-13 18:18:49 -03:00
enabled = enabled,
onCancelClicked = {
focusManager.clearFocus()
2023-10-01 10:30:21 -03:00
showChannelEditor = false
channelSet = channels
},
onSaveClicked = {
focusManager.clearFocus()
// viewModel.setRequestChannelUrl(channelUrl)
sendButton()
})
} else {
item {
PreferenceFooter(
2023-05-13 18:18:49 -03:00
enabled = enabled,
negativeText = R.string.reset,
onNegativeClicked = {
focusManager.clearFocus()
resetButton()
},
positiveText = R.string.scan,
onPositiveClicked = {
focusManager.clearFocus()
// viewModel.setRequestChannelUrl(channelUrl)
if (context.hasCameraPermission()) zxingScan() else requestPermissionAndScan()
})
}
2020-06-14 00:11:08 -04:00
}
}
}
@Composable
private fun QrCodeImage(
enabled: Boolean,
channelSet: ChannelSet,
modifier: Modifier = Modifier,
) = Image(
painter = channelSet.qrCode
?.let { BitmapPainter(it.asImageBitmap()) }
?: painterResource(id = R.drawable.qrcode),
contentDescription = stringResource(R.string.qr_code),
modifier = modifier,
contentScale = ContentScale.Inside,
alpha = if (enabled) 1.0f else ContentAlpha.disabled,
// colorFilter = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }),
)
2024-07-02 05:53:37 -07:00
@Composable
private fun ChannelListView(
2024-07-02 05:53:37 -07:00
enabled: Boolean,
channelSet: ChannelSet,
modemPresetName: String,
channelSelections: SnapshotStateList<Boolean>,
onClick: () -> Unit = {},
2024-07-02 05:53:37 -07:00
) {
val selectedChannelSet = channelSet.copy {
val result = settings.filterIndexed { i, _ -> channelSelections.getOrNull(i) == true }
settings.clear()
settings.addAll(result)
}
AdaptiveTwoPane(
first = {
channelSet.settingsList.forEachIndexed { index, channel ->
ChannelSelection(
index = index,
title = channel.name.ifEmpty { modemPresetName },
enabled = enabled,
isSelected = channelSelections[index],
onSelected = {
if (it || selectedChannelSet.settingsCount > 1) {
channelSelections[index] = it
}
},
)
}
OutlinedButton(
modifier = Modifier.fillMaxWidth(),
onClick = onClick,
enabled = enabled,
) { Text(text = stringResource(R.string.edit)) }
},
second = {
QrCodeImage(
2024-07-02 05:53:37 -07:00
enabled = enabled,
channelSet = selectedChannelSet,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
2024-07-02 05:53:37 -07:00
)
},
)
2024-07-02 05:53:37 -07:00
}
@PreviewScreenSizes
2023-04-29 07:14:30 -03:00
@Composable
private fun ChannelScreenPreview() {
ChannelListView(
enabled = true,
channelSet = channelSet {
settings.add(Channel.default.settings)
loraConfig = Channel.default.loraConfig
},
modemPresetName = Channel.default.name,
channelSelections = listOf(true).toMutableStateList(),
)
2023-04-29 07:14:30 -03:00
}