meshtastic-android/app/src/main/java/com/geeksville/mesh/ui/ChannelFragment.kt

414 wiersze
16 KiB
Kotlin

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.foundation.Image
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.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
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.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
import androidx.compose.material.icons.twotone.Edit
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
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.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.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.Preview
import androidx.compose.ui.unit.dp
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.geeksville.mesh.AppOnlyProtos
import com.geeksville.mesh.analytics.DataPair
import com.geeksville.mesh.android.GeeksvilleApplication
import com.geeksville.mesh.android.Logging
import com.geeksville.mesh.ChannelProtos
import com.geeksville.mesh.ConfigProtos
import com.geeksville.mesh.R
import com.geeksville.mesh.android.BuildUtils.debug
import com.geeksville.mesh.android.BuildUtils.errormsg
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.ChannelSet
import com.geeksville.mesh.model.UIViewModel
import com.geeksville.mesh.service.MeshService
import com.geeksville.mesh.ui.components.DropDownPreference
import com.geeksville.mesh.ui.components.PreferenceFooter
import com.geeksville.mesh.ui.components.RegularPreference
import com.geeksville.mesh.ui.components.config.ChannelSettingsItemList
import com.geeksville.mesh.ui.components.config.EditChannelDialog
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
import kotlinx.coroutines.launch
@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)
}
}
}
}
}
@Composable
fun ChannelScreen(viewModel: UIViewModel = viewModel()) {
val context = LocalContext.current
val focusManager = LocalFocusManager.current
val clipboardManager = LocalClipboardManager.current
val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
val connectionState by viewModel.connectionState.observeAsState()
val connected = connectionState == MeshService.ConnectionState.CONNECTED
val enabled = connected && !viewModel.isManaged
val channels by viewModel.channels.collectAsStateWithLifecycle()
var channelSet by remember(channels) { mutableStateOf(channels.protobuf) }
val primaryChannel = ChannelSet(channelSet).primaryChannel
val channelUrl = ChannelSet(channelSet).getChannelUrl()
val isEditing by remember(channelSet) { mutableStateOf(channelSet != channels.protobuf) }
val barcodeLauncher = rememberLauncherForActivityResult(ScanContract()) { result ->
if (result.contents != null) {
viewModel.setRequestChannelUrl(Uri.parse(result.contents))
}
}
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: AppOnlyProtos.ChannelSet
) {
val newSet = ChannelSet(newChannelSet)
// Try to change the radio, if it fails, tell the user why and throw away their edits
try {
viewModel.setChannels(newSet)
// 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.protobuf // Throw away user edits
// Tell the user to try again
scope.launch {
snackbarHostState.showSnackbar(context.getString(R.string.radio_sleeping))
}
}
}
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.protobuf // 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() {
primaryChannel?.let { primaryChannel ->
val humanName = primaryChannel.humanName
val message = buildString {
append(context.getString(R.string.are_you_sure_channel))
if (primaryChannel.settings == Channel.default.settings)
append("\n\n" + context.getString(R.string.warning_default_psk, humanName))
}
MaterialAlertDialogBuilder(context)
.setTitle(R.string.change_channel)
.setMessage(message)
.setNeutralButton(R.string.cancel) { _, _ ->
channelSet = channels.protobuf
}
.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 { }
},
onAddClick = {
with(channelSet) {
if (settingsCount > index) channelSet = copy { settings[index] = it }
else channelSet = copy { settings.add(it) }
}
showEditChannelDialog = null
},
onDismissRequest = { showEditChannelDialog = null }
)
}
var showChannelEditor by remember { mutableStateOf(false) }
if (showChannelEditor) ChannelSettingsItemList(
settingsList = channelSet.settingsList,
enabled = enabled,
focusManager = focusManager,
onNegativeClicked = {
focusManager.clearFocus()
showChannelEditor = false
},
positiveText = R.string.save,
onPositiveClicked = {
focusManager.clearFocus()
showChannelEditor = false
channelSet = channelSet.toBuilder().clearSettings().addAllSettings(it).build()
}
)
if (!showChannelEditor) LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 24.dp, vertical = 16.dp),
) {
item {
RegularPreference(
title = stringResource(R.string.channel_name),
subtitle = primaryChannel?.humanName.orEmpty(),
onClick = { showChannelEditor = true },
enabled = enabled,
trailingIcon = Icons.TwoTone.Edit
)
}
if (!isEditing) item {
Image(
painter = ChannelSet(channelSet).qrCode?.let { BitmapPainter(it.asImageBitmap()) }
?: painterResource(id = R.drawable.qrcode),
contentDescription = stringResource(R.string.qr_code),
contentScale = ContentScale.FillWidth,
alpha = if (enabled) 1f else 0.25f,
// colorFilter = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }),
modifier = Modifier
.fillMaxWidth()
.padding(top = 16.dp, bottom = 16.dp)
)
}
item {
var valueState by remember(channelUrl) { mutableStateOf(channelUrl) }
val isError = valueState != channelUrl
OutlinedTextField(
value = valueState.toString(),
onValueChange = {
try {
valueState = Uri.parse(it)
channelSet = ChannelSet(valueState).protobuf
} catch (ex: Throwable) {
// channelSet failed to update, isError true
}
},
modifier = Modifier.fillMaxWidth(),
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() }),
)
}
item {
DropDownPreference(title = stringResource(id = R.string.channel_options),
enabled = enabled,
items = ChannelOption.values()
.map { it.modemPreset to stringResource(it.configRes) },
selectedItem = channelSet.loraConfig.modemPreset,
onItemSelected = {
val lora = channelSet.loraConfig.copy { modemPreset = it }
channelSet = channelSet.copy { loraConfig = lora }
})
}
if (isEditing) item {
PreferenceFooter(
enabled = enabled,
onCancelClicked = {
focusManager.clearFocus()
channelSet = channels.protobuf
},
onSaveClicked = {
focusManager.clearFocus()
// viewModel.setRequestChannelUrl(channelUrl)
sendButton()
})
} else {
item {
PreferenceFooter(
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()
})
}
}
}
SnackbarHost(hostState = snackbarHostState)
}
@Preview(showBackground = true)
@Composable
fun ChannelScreenPreview() {
// ChannelScreen()
}