mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
568 lines
21 KiB
Kotlin
568 lines
21 KiB
Kotlin
/*
|
|
* Copyright (c) 2024 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
|
|
|
|
import android.net.Uri
|
|
import android.os.Bundle
|
|
import android.os.RemoteException
|
|
import android.view.LayoutInflater
|
|
import android.view.View
|
|
import android.view.ViewGroup
|
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
|
import androidx.activity.result.contract.ActivityResultContracts
|
|
import androidx.compose.animation.core.animateDpAsState
|
|
import androidx.compose.foundation.Image
|
|
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.rememberLazyListState
|
|
import androidx.compose.foundation.text.KeyboardActions
|
|
import androidx.compose.foundation.text.KeyboardOptions
|
|
import androidx.compose.material.ButtonDefaults
|
|
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
|
|
import androidx.compose.material.OutlinedButton
|
|
import androidx.compose.material.OutlinedTextField
|
|
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
|
|
import androidx.compose.material.icons.twotone.ContentCopy
|
|
import androidx.compose.runtime.Composable
|
|
import androidx.compose.runtime.LaunchedEffect
|
|
import androidx.compose.runtime.getValue
|
|
import androidx.compose.runtime.mutableStateListOf
|
|
import androidx.compose.runtime.mutableStateOf
|
|
import androidx.compose.runtime.remember
|
|
import androidx.compose.runtime.saveable.listSaver
|
|
import androidx.compose.runtime.saveable.rememberSaveable
|
|
import androidx.compose.runtime.setValue
|
|
import androidx.compose.runtime.snapshots.SnapshotStateList
|
|
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.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
|
|
import androidx.fragment.app.activityViewModels
|
|
import androidx.hilt.navigation.compose.hiltViewModel
|
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|
import com.geeksville.mesh.AppOnlyProtos.ChannelSet
|
|
import com.geeksville.mesh.ChannelProtos
|
|
import com.geeksville.mesh.ConfigProtos
|
|
import com.geeksville.mesh.R
|
|
import com.geeksville.mesh.analytics.DataPair
|
|
import com.geeksville.mesh.android.BuildUtils.debug
|
|
import com.geeksville.mesh.android.BuildUtils.errormsg
|
|
import com.geeksville.mesh.android.GeeksvilleApplication
|
|
import com.geeksville.mesh.android.Logging
|
|
import com.geeksville.mesh.android.getCameraPermissions
|
|
import com.geeksville.mesh.android.hasCameraPermission
|
|
import com.geeksville.mesh.channelSet
|
|
import com.geeksville.mesh.channelSettings
|
|
import com.geeksville.mesh.copy
|
|
import com.geeksville.mesh.model.Channel
|
|
import com.geeksville.mesh.model.ChannelOption
|
|
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.config.ChannelCard
|
|
import com.geeksville.mesh.ui.components.config.ChannelSelection
|
|
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
|
|
import com.journeyapps.barcodescanner.ScanContract
|
|
import com.journeyapps.barcodescanner.ScanOptions
|
|
import dagger.hilt.android.AndroidEntryPoint
|
|
|
|
@AndroidEntryPoint
|
|
class ChannelFragment : ScreenFragment("Channel"), Logging {
|
|
|
|
private val model: UIViewModel by activityViewModels()
|
|
|
|
override fun onCreateView(
|
|
inflater: LayoutInflater,
|
|
container: ViewGroup?,
|
|
savedInstanceState: Bundle?
|
|
): View {
|
|
return ComposeView(requireContext()).apply {
|
|
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
|
|
setContent {
|
|
AppCompatTheme {
|
|
ChannelScreen(model)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@Suppress("LongMethod", "CyclomaticComplexMethod")
|
|
@Composable
|
|
fun ChannelScreen(
|
|
viewModel: UIViewModel = hiltViewModel(),
|
|
) {
|
|
val context = LocalContext.current
|
|
val focusManager = LocalFocusManager.current
|
|
|
|
val connectionState by viewModel.connectionState.collectAsStateWithLifecycle()
|
|
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
|
|
|
|
/* Holds selections made by the user for QR generation. */
|
|
val channelSelections = rememberSaveable(
|
|
saver = listSaver(
|
|
save = { it.toList() },
|
|
restore = { it.toMutableStateList() }
|
|
)
|
|
) { mutableStateListOf(elements = Array(size = 8, init = { true })) }
|
|
|
|
val selectedChannelSet = channelSet.copy {
|
|
val result = settings.filterIndexed { i, _ -> channelSelections.getOrNull(i) == true }
|
|
settings.clear()
|
|
settings.addAll(result)
|
|
}
|
|
val modemPresetName = Channel(loraConfig = channelSet.loraConfig).name
|
|
|
|
val barcodeLauncher = rememberLauncherForActivityResult(ScanContract()) { result ->
|
|
if (result.contents != null) {
|
|
viewModel.requestChannelUrl(Uri.parse(result.contents))
|
|
}
|
|
}
|
|
|
|
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()
|
|
}
|
|
|
|
// Send new channel settings to the device
|
|
fun installSettings(
|
|
newChannelSet: ChannelSet
|
|
) {
|
|
// Try to change the radio, if it fails, tell the user why and throw away their edits
|
|
try {
|
|
viewModel.setChannels(newChannelSet)
|
|
// Since we are writing to DeviceConfig, that will trigger the rest of the GUI update (QR code etc)
|
|
} catch (ex: RemoteException) {
|
|
errormsg("ignoring channel problem", ex)
|
|
|
|
channelSet = channels // Throw away user edits
|
|
|
|
// Tell the user to try again
|
|
viewModel.showSnackbar(R.string.cant_change_no_radio)
|
|
} finally {
|
|
showChannelEditor = false
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
.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()
|
|
}
|
|
|
|
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()
|
|
}
|
|
|
|
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,
|
|
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)) }
|
|
}
|
|
|
|
LazyColumn(
|
|
modifier = Modifier.dragContainer(
|
|
dragDropState = dragDropState,
|
|
haptics = LocalHapticFeedback.current,
|
|
),
|
|
state = listState,
|
|
contentPadding = PaddingValues(horizontal = 24.dp, vertical = 16.dp),
|
|
) {
|
|
if (!showChannelEditor) {
|
|
item {
|
|
ChannelListView(
|
|
enabled = enabled,
|
|
channelSet = channelSet,
|
|
modemPresetName = modemPresetName,
|
|
channelSelections = channelSelections,
|
|
onClick = { showChannelEditor = true }
|
|
)
|
|
EditChannelUrl(
|
|
enabled = enabled,
|
|
channelUrl = selectedChannelSet.getChannelUrl(),
|
|
onConfirm = viewModel::requestChannelUrl
|
|
)
|
|
}
|
|
} else {
|
|
dragDropItemsIndexed(
|
|
items = channelSet.settingsList,
|
|
dragDropState = dragDropState,
|
|
) { index, channel, isDragging ->
|
|
val elevation by animateDpAsState(if (isDragging) 8.dp else 4.dp, label = "drag")
|
|
ChannelCard(
|
|
elevation = elevation,
|
|
index = index,
|
|
title = channel.name.ifEmpty { modemPresetName },
|
|
enabled = enabled,
|
|
onEditClick = { showEditChannelDialog = index },
|
|
onDeleteClick = { updateSettingsList { removeAt(index) } }
|
|
)
|
|
}
|
|
item {
|
|
OutlinedButton(
|
|
modifier = Modifier.fillMaxWidth(),
|
|
onClick = {
|
|
channelSet = channelSet.copy {
|
|
settings.add(channelSettings { psk = Channel.default.settings.psk })
|
|
}
|
|
showEditChannelDialog = channelSet.settingsList.lastIndex
|
|
},
|
|
enabled = enabled && viewModel.maxChannels > channelSet.settingsCount,
|
|
colors = ButtonDefaults.buttonColors(
|
|
disabledContentColor = MaterialTheme.colors.onSurface.copy(alpha = ContentAlpha.disabled)
|
|
)
|
|
) { Text(text = stringResource(R.string.add)) }
|
|
}
|
|
}
|
|
|
|
item {
|
|
DropDownPreference(title = stringResource(id = R.string.channel_options),
|
|
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 }
|
|
})
|
|
}
|
|
|
|
item {
|
|
if (isEditing) {
|
|
PreferenceFooter(
|
|
enabled = enabled,
|
|
onCancelClicked = {
|
|
focusManager.clearFocus()
|
|
showChannelEditor = false
|
|
channelSet = channels
|
|
},
|
|
onSaveClicked = {
|
|
focusManager.clearFocus()
|
|
sendButton()
|
|
})
|
|
} else {
|
|
PreferenceFooter(
|
|
enabled = enabled,
|
|
negativeText = R.string.reset,
|
|
onNegativeClicked = {
|
|
focusManager.clearFocus()
|
|
resetButton()
|
|
},
|
|
positiveText = R.string.scan,
|
|
onPositiveClicked = {
|
|
focusManager.clearFocus()
|
|
if (context.hasCameraPermission()) zxingScan() else requestPermissionAndScan()
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@Suppress("LongMethod")
|
|
@Composable
|
|
private fun EditChannelUrl(
|
|
enabled: Boolean,
|
|
channelUrl: Uri,
|
|
modifier: Modifier = Modifier,
|
|
onConfirm: (Uri) -> Unit
|
|
) {
|
|
val focusManager = LocalFocusManager.current
|
|
val clipboardManager = LocalClipboardManager.current
|
|
|
|
var valueState by remember(channelUrl) { mutableStateOf(channelUrl) }
|
|
var isError by remember { mutableStateOf(false) }
|
|
|
|
// Trigger dialog automatically when users paste a new valid URL
|
|
LaunchedEffect(valueState, isError) {
|
|
if (!isError && valueState != channelUrl) {
|
|
onConfirm(valueState)
|
|
}
|
|
}
|
|
|
|
OutlinedTextField(
|
|
value = valueState.toString(),
|
|
onValueChange = {
|
|
isError = runCatching {
|
|
valueState = Uri.parse(it)
|
|
valueState.toChannelSet()
|
|
}.isFailure
|
|
},
|
|
modifier = modifier.fillMaxWidth(),
|
|
enabled = enabled,
|
|
label = { Text("URL") },
|
|
isError = isError,
|
|
trailingIcon = {
|
|
val isUrlEqual = valueState == channelUrl
|
|
IconButton(onClick = {
|
|
when {
|
|
isError -> {
|
|
isError = false
|
|
valueState = channelUrl
|
|
}
|
|
|
|
!isUrlEqual -> {
|
|
onConfirm(valueState)
|
|
valueState = channelUrl
|
|
}
|
|
|
|
else -> {
|
|
// track how many times users share channels
|
|
GeeksvilleApplication.analytics.track(
|
|
"share", DataPair("content_type", "channel")
|
|
)
|
|
clipboardManager.setText(AnnotatedString(valueState.toString()))
|
|
}
|
|
}
|
|
}) {
|
|
Icon(
|
|
imageVector = when {
|
|
isError -> Icons.TwoTone.Close
|
|
!isUrlEqual -> Icons.TwoTone.Check
|
|
else -> Icons.TwoTone.ContentCopy
|
|
},
|
|
contentDescription = when {
|
|
isError -> stringResource(R.string.share)
|
|
!isUrlEqual -> stringResource(R.string.send)
|
|
else -> stringResource(R.string.share)
|
|
},
|
|
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() }),
|
|
)
|
|
}
|
|
|
|
@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) }),
|
|
)
|
|
|
|
@Composable
|
|
private fun ChannelListView(
|
|
enabled: Boolean,
|
|
channelSet: ChannelSet,
|
|
modemPresetName: String,
|
|
channelSelections: SnapshotStateList<Boolean>,
|
|
onClick: () -> Unit = {},
|
|
) {
|
|
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(
|
|
onClick = onClick,
|
|
modifier = Modifier.fillMaxWidth(),
|
|
enabled = enabled,
|
|
colors = ButtonDefaults.outlinedButtonColors(
|
|
contentColor = MaterialTheme.colors.onSurface,
|
|
),
|
|
) { Text(text = stringResource(R.string.edit)) }
|
|
},
|
|
second = {
|
|
QrCodeImage(
|
|
enabled = enabled,
|
|
channelSet = selectedChannelSet,
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.padding(vertical = 4.dp)
|
|
)
|
|
},
|
|
)
|
|
}
|
|
|
|
@PreviewScreenSizes
|
|
@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(),
|
|
)
|
|
}
|