mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
refactor: migrate ChannelFragment to Composable (#615)
This commit is contained in:
parent
a560555a01
commit
5bf4c9c184
7 changed files with 353 additions and 450 deletions
|
|
@ -167,6 +167,7 @@ dependencies {
|
|||
androidTestImplementation composeBom
|
||||
|
||||
implementation 'androidx.compose.material:material'
|
||||
implementation 'androidx.activity:activity-compose'
|
||||
implementation 'androidx.compose.runtime:runtime-livedata'
|
||||
implementation "com.google.accompanist:accompanist-themeadapter-appcompat:0.29.2-rc"
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ enum class ChannelOption(
|
|||
MEDIUM_FAST(ModemPreset.MEDIUM_FAST, R.string.modem_config_medium),
|
||||
MEDIUM_SLOW(ModemPreset.MEDIUM_SLOW, R.string.modem_config_slow_medium),
|
||||
LONG_FAST(ModemPreset.LONG_FAST, R.string.modem_config_long),
|
||||
LONG_MODERATE(ModemPreset.LONG_MODERATE, R.string.modem_config_mod_long),
|
||||
LONG_SLOW(ModemPreset.LONG_SLOW, R.string.modem_config_slow_long),
|
||||
VERY_LONG_SLOW(ModemPreset.VERY_LONG_SLOW, R.string.modem_config_very_long);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,177 +1,164 @@
|
|||
package com.geeksville.mesh.ui
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Intent
|
||||
import android.graphics.ColorMatrix
|
||||
import android.graphics.ColorMatrixColorFilter
|
||||
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 android.view.inputmethod.EditorInfo
|
||||
import android.widget.ArrayAdapter
|
||||
import android.widget.ImageView
|
||||
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.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
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.unit.dp
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.lifecycle.asLiveData
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.geeksville.mesh.analytics.DataPair
|
||||
import com.geeksville.mesh.android.GeeksvilleApplication
|
||||
import com.geeksville.mesh.android.Logging
|
||||
import com.geeksville.mesh.android.hideKeyboard
|
||||
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.copy
|
||||
import com.geeksville.mesh.databinding.ChannelFragmentBinding
|
||||
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.util.onEditorAction
|
||||
import com.geeksville.mesh.service.MeshService
|
||||
import com.geeksville.mesh.ui.components.DropDownPreference
|
||||
import com.geeksville.mesh.ui.components.EditTextPreference
|
||||
import com.geeksville.mesh.ui.components.PreferenceFooter
|
||||
import com.google.accompanist.themeadapter.appcompat.AppCompatTheme
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.google.protobuf.ByteString
|
||||
import com.journeyapps.barcodescanner.ScanContract
|
||||
import com.journeyapps.barcodescanner.ScanOptions
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.launch
|
||||
import java.security.SecureRandom
|
||||
|
||||
|
||||
// Make an image view dim
|
||||
fun ImageView.setDim() {
|
||||
val matrix = ColorMatrix()
|
||||
matrix.setSaturation(0f) //0 means grayscale
|
||||
val cf = ColorMatrixColorFilter(matrix)
|
||||
colorFilter = cf
|
||||
imageAlpha = 64 // 128 = 0.5
|
||||
}
|
||||
|
||||
/// Return image view to normal
|
||||
fun ImageView.setOpaque() {
|
||||
colorFilter = null
|
||||
imageAlpha = 255
|
||||
}
|
||||
|
||||
@AndroidEntryPoint
|
||||
class ChannelFragment : ScreenFragment("Channel"), Logging {
|
||||
|
||||
private var _binding: ChannelFragmentBinding? = null
|
||||
|
||||
// This property is only valid between onCreateView and onDestroyView.
|
||||
private val binding get() = _binding!!
|
||||
|
||||
private val model: UIViewModel by activityViewModels()
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater, container: ViewGroup?,
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
_binding = ChannelFragmentBinding.inflate(inflater, container, false)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
/// Called when the lock/unlock icon has changed
|
||||
private fun onEditingChanged() {
|
||||
val isEditing = binding.editableCheckbox.isChecked
|
||||
|
||||
binding.channelOptions.isEnabled = isEditing
|
||||
binding.shareButton.isEnabled = !isEditing
|
||||
binding.resetButton.isEnabled = isEditing
|
||||
binding.scanButton.isEnabled = isEditing
|
||||
binding.channelNameView.isEnabled = isEditing
|
||||
if (isEditing) // Dim the (stale) QR code while editing...
|
||||
binding.qrView.setDim()
|
||||
else
|
||||
binding.qrView.setOpaque()
|
||||
}
|
||||
|
||||
/// Pull the latest data from the model (discarding any user edits)
|
||||
private fun setGUIfromModel() {
|
||||
val channels = model.channels.value
|
||||
val channel = channels.primaryChannel
|
||||
val connected = model.isConnected()
|
||||
|
||||
// Only let buttons work if we are connected to the radio
|
||||
binding.editableCheckbox.isChecked = false // start locked
|
||||
onEditingChanged() // we just locked the gui
|
||||
binding.shareButton.isEnabled = connected
|
||||
|
||||
if (channel != null) {
|
||||
binding.qrView.visibility = View.VISIBLE
|
||||
binding.channelNameEdit.visibility = View.VISIBLE
|
||||
binding.channelNameEdit.setText(channel.humanName)
|
||||
|
||||
// For now, we only let the user edit/save channels while the radio is awake - because the service
|
||||
// doesn't cache DeviceConfig writes.
|
||||
binding.editableCheckbox.isEnabled = connected
|
||||
|
||||
val bitmap = channels.qrCode
|
||||
if (bitmap != null)
|
||||
binding.qrView.setImageBitmap(bitmap)
|
||||
|
||||
val modemPreset = channel.loraConfig.modemPreset
|
||||
val channelOption = ChannelOption.fromConfig(modemPreset)
|
||||
binding.filledExposedDropdown.setText(
|
||||
getString(
|
||||
channelOption?.configRes ?: R.string.modem_config_unrecognized
|
||||
), false
|
||||
)
|
||||
|
||||
} else {
|
||||
binding.qrView.visibility = View.INVISIBLE
|
||||
binding.channelNameEdit.visibility = View.INVISIBLE
|
||||
binding.editableCheckbox.isEnabled = false
|
||||
}
|
||||
|
||||
val modemPresets = ChannelOption.values()
|
||||
val modemPresetList = modemPresets.map { getString(it.configRes) }
|
||||
val adapter = ArrayAdapter(
|
||||
requireContext(),
|
||||
R.layout.dropdown_menu_popup_item,
|
||||
modemPresetList
|
||||
)
|
||||
|
||||
binding.filledExposedDropdown.setAdapter(adapter)
|
||||
}
|
||||
|
||||
private fun shareChannel() {
|
||||
model.channels.value.let { channels ->
|
||||
|
||||
GeeksvilleApplication.analytics.track(
|
||||
"share",
|
||||
DataPair("content_type", "channel")
|
||||
) // track how many times users share channels
|
||||
|
||||
val sendIntent: Intent = Intent().apply {
|
||||
action = Intent.ACTION_SEND
|
||||
putExtra(Intent.EXTRA_TEXT, channels.getChannelUrl().toString())
|
||||
putExtra(
|
||||
Intent.EXTRA_TITLE,
|
||||
getString(R.string.url_for_join)
|
||||
)
|
||||
type = "text/plain"
|
||||
}
|
||||
|
||||
try {
|
||||
val shareIntent = Intent.createChooser(sendIntent, null)
|
||||
requireActivity().startActivity(shareIntent)
|
||||
} catch (ex: ActivityNotFoundException) {
|
||||
Snackbar.make(
|
||||
requireView(),
|
||||
R.string.no_app_found,
|
||||
Snackbar.LENGTH_SHORT
|
||||
).show()
|
||||
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 channels by viewModel.channels.collectAsState()
|
||||
var channelSet by remember(channels.protobuf) { mutableStateOf(channels.protobuf) }
|
||||
|
||||
val primaryChannel = ChannelSet(channelSet).primaryChannel
|
||||
val channelUrl = ChannelSet(channelSet).getChannelUrl()
|
||||
|
||||
var 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
|
||||
private fun installSettings(
|
||||
fun installSettings(
|
||||
newChannel: ChannelProtos.ChannelSettings,
|
||||
newLoRaConfig: ConfigProtos.Config.LoRaConfig
|
||||
) {
|
||||
|
|
@ -182,197 +169,249 @@ class ChannelFragment : ScreenFragment("Channel"), Logging {
|
|||
})
|
||||
// Try to change the radio, if it fails, tell the user why and throw away their edits
|
||||
try {
|
||||
model.setChannels(newSet)
|
||||
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)
|
||||
|
||||
setGUIfromModel() // Throw away user edits
|
||||
channelSet = channels.protobuf // Throw away user edits
|
||||
|
||||
// Tell the user to try again
|
||||
Snackbar.make(
|
||||
requireView(),
|
||||
R.string.radio_sleeping,
|
||||
Snackbar.LENGTH_SHORT
|
||||
).show()
|
||||
scope.launch {
|
||||
snackbarHostState.showSnackbar(context.getString(R.string.radio_sleeping))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
val barcodeLauncher = registerForActivityResult(ScanContract()) { result ->
|
||||
if (result.contents != null) {
|
||||
model.setRequestChannelUrl(Uri.parse(result.contents))
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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 =
|
||||
registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions ->
|
||||
if (permissions.entries.all { it.value }) zxingScan()
|
||||
.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 requestPermissionAndScan() {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.camera_required)
|
||||
.setMessage(R.string.why_camera_required)
|
||||
.setNeutralButton(R.string.cancel) { _, _ ->
|
||||
debug("Camera permission denied")
|
||||
}
|
||||
.setPositiveButton(getString(R.string.accept)) { _, _ ->
|
||||
requestPermissionAndScanLauncher.launch(requireContext().getCameraPermissions())
|
||||
}
|
||||
.show()
|
||||
}
|
||||
fun sendButton() {
|
||||
channels.primaryChannel?.let { oldPrimary ->
|
||||
var newSettings = oldPrimary.settings
|
||||
val newName = primaryChannel!!.name.trim()
|
||||
|
||||
binding.channelNameEdit.onEditorAction(EditorInfo.IME_ACTION_DONE) {
|
||||
requireActivity().hideKeyboard()
|
||||
}
|
||||
// Find the new modem config
|
||||
var newModemPreset = channelSet.loraConfig.modemPreset
|
||||
if (newModemPreset == ConfigProtos.Config.LoRaConfig.ModemPreset.UNRECOGNIZED) // Huh? didn't find it - keep same
|
||||
newModemPreset = oldPrimary.loraConfig.modemPreset
|
||||
|
||||
binding.resetButton.setOnClickListener {
|
||||
// User just locked it, we should warn and then apply changes to radio
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.reset_to_defaults)
|
||||
.setMessage(R.string.are_you_sure_change_default)
|
||||
.setNeutralButton(R.string.cancel) { _, _ ->
|
||||
setGUIfromModel() // throw away any edits
|
||||
}
|
||||
.setPositiveButton(R.string.apply) { _, _ ->
|
||||
debug("Switching back to default channel")
|
||||
installSettings(
|
||||
Channel.default.settings,
|
||||
Channel.default.loraConfig.copy {
|
||||
region = model.region
|
||||
txEnabled = model.txEnabled
|
||||
}
|
||||
)
|
||||
}
|
||||
.show()
|
||||
}
|
||||
// Generate a new AES256 key if the user changes channel name or the name is non-default and the settings changed
|
||||
val shouldUseRandomKey =
|
||||
newName != oldPrimary.name || (newName.isNotEmpty() && newModemPreset != oldPrimary.loraConfig.modemPreset)
|
||||
if (shouldUseRandomKey) {
|
||||
|
||||
binding.scanButton.setOnClickListener {
|
||||
if (requireContext().hasCameraPermission()) zxingScan()
|
||||
else requestPermissionAndScan()
|
||||
}
|
||||
|
||||
// Note: Do not use setOnCheckedChanged here because we don't want to be called when we programmatically disable editing
|
||||
binding.editableCheckbox.setOnClickListener {
|
||||
|
||||
/// We use this to determine if the user tried to install a custom name
|
||||
var originalName = ""
|
||||
|
||||
val checked = binding.editableCheckbox.isChecked
|
||||
if (checked) {
|
||||
// User just unlocked for editing - remove the # goo around the channel name
|
||||
model.channels.value.primaryChannel?.let { ch ->
|
||||
// Note: We are careful to show the empty string here if the user was on a default channel, so the user knows they should it for any changes
|
||||
originalName = ch.settings.name
|
||||
binding.channelNameEdit.setText(originalName)
|
||||
// Install a new customized channel
|
||||
debug("ASSIGNING NEW AES256 KEY")
|
||||
val random = SecureRandom()
|
||||
val bytes = ByteArray(32)
|
||||
random.nextBytes(bytes)
|
||||
newSettings = newSettings.copy {
|
||||
name = newName
|
||||
psk = ByteString.copyFrom(bytes)
|
||||
}
|
||||
} else {
|
||||
// User just locked it, we should warn and then apply changes to radio
|
||||
|
||||
model.channels.value.primaryChannel?.let { oldPrimary ->
|
||||
var newSettings = oldPrimary.settings
|
||||
val newName = binding.channelNameEdit.text.toString().trim()
|
||||
|
||||
// Find the new modem config
|
||||
val selectedModemPresetString =
|
||||
binding.filledExposedDropdown.editableText.toString()
|
||||
var newModemPreset = getModemPreset(selectedModemPresetString)
|
||||
if (newModemPreset == ConfigProtos.Config.LoRaConfig.ModemPreset.UNRECOGNIZED) // Huh? didn't find it - keep same
|
||||
newModemPreset = oldPrimary.loraConfig.modemPreset
|
||||
|
||||
// Generate a new AES256 key if the user changes channel name or the name is non-default and the settings changed
|
||||
val shouldUseRandomKey =
|
||||
newName != originalName || (newName.isNotEmpty() && newModemPreset != oldPrimary.loraConfig.modemPreset)
|
||||
if (shouldUseRandomKey) {
|
||||
|
||||
// Install a new customized channel
|
||||
debug("ASSIGNING NEW AES256 KEY")
|
||||
val random = SecureRandom()
|
||||
val bytes = ByteArray(32)
|
||||
random.nextBytes(bytes)
|
||||
newSettings = newSettings.copy {
|
||||
name = newName.take(11) // proto max_size:12
|
||||
psk = ByteString.copyFrom(bytes)
|
||||
}
|
||||
} else {
|
||||
debug("Switching back to default channel")
|
||||
newSettings = Channel.default.settings
|
||||
}
|
||||
|
||||
// No matter what apply the speed selection from the user
|
||||
val newLoRaConfig = model.config.lora.copy {
|
||||
usePreset = true
|
||||
modemPreset = newModemPreset
|
||||
bandwidth = 0
|
||||
spreadFactor = 0
|
||||
codingRate = 0
|
||||
}
|
||||
|
||||
val humanName = Channel(newSettings, newLoRaConfig).humanName
|
||||
binding.channelNameEdit.setText(humanName)
|
||||
|
||||
val message = buildString {
|
||||
append(getString(R.string.are_you_sure_channel))
|
||||
if (!shouldUseRandomKey)
|
||||
append("\n\n" + getString(R.string.warning_default_psk).format(humanName))
|
||||
}
|
||||
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.change_channel)
|
||||
.setMessage(message)
|
||||
.setNeutralButton(R.string.cancel) { _, _ ->
|
||||
setGUIfromModel()
|
||||
}
|
||||
.setPositiveButton(getString(R.string.accept)) { _, _ ->
|
||||
// Generate a new channel with only the changes the user can change in the GUI
|
||||
|
||||
installSettings(newSettings, newLoRaConfig)
|
||||
}
|
||||
.show()
|
||||
}
|
||||
debug("Switching back to default channel")
|
||||
newSettings = Channel.default.settings
|
||||
}
|
||||
|
||||
onEditingChanged() // update GUI on what user is allowed to edit/share
|
||||
}
|
||||
// No matter what apply the speed selection from the user
|
||||
val newLoRaConfig = viewModel.config.lora.copy {
|
||||
usePreset = true
|
||||
modemPreset = newModemPreset
|
||||
bandwidth = 0
|
||||
spreadFactor = 0
|
||||
codingRate = 0
|
||||
}
|
||||
|
||||
// Share this particular channel if someone clicks share
|
||||
binding.shareButton.setOnClickListener {
|
||||
shareChannel()
|
||||
}
|
||||
val humanName = Channel(newSettings, newLoRaConfig).humanName
|
||||
|
||||
model.channels.asLiveData().observe(viewLifecycleOwner) {
|
||||
setGUIfromModel()
|
||||
}
|
||||
val message = buildString {
|
||||
append(context.getString(R.string.are_you_sure_channel))
|
||||
if (!shouldUseRandomKey)
|
||||
append("\n\n" + context.getString(R.string.warning_default_psk).format(humanName))
|
||||
}
|
||||
|
||||
// If connection state changes, we might need to enable/disable buttons
|
||||
model.connectionState.observe(viewLifecycleOwner) {
|
||||
setGUIfromModel()
|
||||
MaterialAlertDialogBuilder(context)
|
||||
.setTitle(R.string.change_channel)
|
||||
.setMessage(message)
|
||||
.setNeutralButton(R.string.cancel) { _, _ ->
|
||||
channelSet = channels.protobuf
|
||||
}
|
||||
.setPositiveButton(context.getString(R.string.accept)) { _, _ ->
|
||||
// Generate a new channel with only the changes the user can change in the GUI
|
||||
installSettings(newSettings, newLoRaConfig)
|
||||
}
|
||||
.show()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
}
|
||||
|
||||
private fun getModemPreset(selectedChannelOptionString: String): ConfigProtos.Config.LoRaConfig.ModemPreset {
|
||||
for (item in ChannelOption.values()) {
|
||||
if (getString(item.configRes) == selectedChannelOptionString)
|
||||
return item.modemPreset
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 24.dp, vertical = 16.dp),
|
||||
) {
|
||||
item {
|
||||
val isFocused = remember { mutableStateOf(false) }
|
||||
EditTextPreference(
|
||||
title = stringResource(R.string.channel_name),
|
||||
value = if (isFocused.value) primaryChannel?.name ?: ""
|
||||
else primaryChannel?.humanName ?: "",
|
||||
maxSize = 11, // name max_size:12
|
||||
enabled = connected,
|
||||
isError = false,
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
keyboardType = KeyboardType.Text, imeAction = ImeAction.Done
|
||||
),
|
||||
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
|
||||
onValueChanged = {
|
||||
val newSettings = channelSet.getSettings(0).copy { name = it }
|
||||
channelSet = channelSet.copy { settings[0] = newSettings }
|
||||
},
|
||||
onFocusChanged = { isFocused.value = it.isFocused }
|
||||
)
|
||||
}
|
||||
|
||||
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 (connected) 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 = connected,
|
||||
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 = connected,
|
||||
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 = connected,
|
||||
onCancelClicked = {
|
||||
focusManager.clearFocus()
|
||||
channelSet = channels.protobuf
|
||||
isEditing = false
|
||||
},
|
||||
onSaveClicked = {
|
||||
focusManager.clearFocus()
|
||||
// viewModel.setRequestChannelUrl(channelUrl)
|
||||
sendButton()
|
||||
})
|
||||
} else {
|
||||
item {
|
||||
PreferenceFooter(
|
||||
enabled = connected,
|
||||
negativeText = R.string.reset,
|
||||
onNegativeClicked = {
|
||||
focusManager.clearFocus()
|
||||
resetButton()
|
||||
},
|
||||
positiveText = R.string.scan,
|
||||
onPositiveClicked = {
|
||||
focusManager.clearFocus()
|
||||
// viewModel.setRequestChannelUrl(channelUrl)
|
||||
if (context.hasCameraPermission()) zxingScan() else requestPermissionAndScan()
|
||||
})
|
||||
}
|
||||
}
|
||||
return ConfigProtos.Config.LoRaConfig.ModemPreset.UNRECOGNIZED
|
||||
}
|
||||
SnackbarHost(hostState = snackbarHostState)
|
||||
}
|
||||
|
||||
//@Preview(showBackground = true)
|
||||
//@Composable
|
||||
//private fun ChannelScreenPreview() {
|
||||
// ChannelScreen()
|
||||
//}
|
||||
|
|
|
|||
15
app/src/main/res/drawable/ic_twotone_content_copy_24.xml
Normal file
15
app/src/main/res/drawable/ic_twotone_content_copy_24.xml
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="?attr/colorControlNormal"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillAlpha="0.3"
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M8,7h11v14H8z"
|
||||
android:strokeAlpha="0.3" />
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M16,1L4,1c-1.1,0 -2,0.9 -2,2v14h2L4,3h12L16,1zM19,5L8,5c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h11c1.1,0 2,-0.9 2,-2L21,7c0,-1.1 -0.9,-2 -2,-2zM19,21L8,21L8,7h11v14z" />
|
||||
</vector>
|
||||
|
|
@ -1,145 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:id="@+id/channelNameView"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="64dp"
|
||||
android:layout_marginTop="16dp"
|
||||
android:layout_marginEnd="64dp"
|
||||
android:hint="@string/channel_name"
|
||||
app:counterEnabled="true"
|
||||
app:counterMaxLength="11"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/channelNameEdit"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:digits="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz1234567890- "
|
||||
android:imeOptions="actionDone"
|
||||
android:singleLine="true"
|
||||
android:text="@string/unset" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/qrView"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:layout_marginStart="16dp"
|
||||
android:layout_marginTop="8dp"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:contentDescription="@string/qr_code"
|
||||
app:layout_constraintBottom_toTopOf="@+id/channelOptions"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/channelNameView"
|
||||
app:srcCompat="@drawable/qrcode" />
|
||||
|
||||
<!--
|
||||
geeksville: no longer used but keeping as a good example of a button group. instead I use
|
||||
a toggleable icon.
|
||||
|
||||
<com.google.android.material.button.MaterialButtonToggleGroup
|
||||
android:id="@+id/editGroup"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="96dp"
|
||||
android:layout_marginTop="16dp"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:singleSelection="true"
|
||||
app:selectionRequired="true"
|
||||
app:layout_constraintTop_toBottomOf="@+id/channelOptions">
|
||||
|
||||
<Button
|
||||
android:id="@+id/locked"
|
||||
style="@style/Widget.App.Button.OutlinedButton.IconOnly"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
app:icon="@drawable/ic_twotone_lock_24" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/unlocked"
|
||||
style="@style/Widget.App.Button.OutlinedButton.IconOnly"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
app:icon="@drawable/ic_twotone_lock_open_24" />
|
||||
</com.google.android.material.button.MaterialButtonToggleGroup>
|
||||
-->
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:id="@+id/channelOptions"
|
||||
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox.ExposedDropdownMenu"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="64dp"
|
||||
android:layout_marginEnd="64dp"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:hint="@string/channel_options"
|
||||
app:layout_constraintBottom_toTopOf="@+id/editableCheckbox"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent">
|
||||
|
||||
<AutoCompleteTextView
|
||||
android:id="@+id/filled_exposed_dropdown"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<Button
|
||||
android:id="@+id/resetButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="10dp"
|
||||
android:text="@string/reset"
|
||||
app:layout_constraintBottom_toBottomOf="@id/bottomButtonsGuideline"
|
||||
app:layout_constraintEnd_toStartOf="@id/editableCheckbox" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/scanButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="10dp"
|
||||
android:text="@string/scan"
|
||||
app:layout_constraintBottom_toBottomOf="@id/bottomButtonsGuideline"
|
||||
app:layout_constraintStart_toEndOf="@id/editableCheckbox" />
|
||||
|
||||
<CheckBox
|
||||
android:id="@+id/editableCheckbox"
|
||||
android:minWidth="0dp"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:button="@drawable/sl_lock_24dp"
|
||||
app:layout_constraintBottom_toBottomOf="@id/bottomButtonsGuideline"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:layout_constraintRight_toRightOf="parent" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/shareButton"
|
||||
style="@android:style/Widget.Material.ImageButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:contentDescription="@string/share"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="@id/bottomButtonsGuideline"
|
||||
app:srcCompat="@drawable/ic_twotone_share_24" />
|
||||
|
||||
<androidx.constraintlayout.widget.Guideline
|
||||
android:id="@+id/bottomButtonsGuideline"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="32dp"
|
||||
android:orientation="horizontal"
|
||||
app:layout_constraintGuide_end="16dp" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:padding="16dp"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:textAppearance="?attr/textAppearanceSubtitle1"
|
||||
/>
|
||||
|
|
@ -61,6 +61,7 @@
|
|||
<string name="modem_config_short">Short Range / Fast</string>
|
||||
<string name="modem_config_medium">Medium Range / Fast</string>
|
||||
<string name="modem_config_long">Long Range / Fast</string>
|
||||
<string name="modem_config_mod_long">Long Range / Moderate</string>
|
||||
<string name="modem_config_very_long">Very Long Range / Slow</string>
|
||||
<string name="modem_config_unrecognized">UNRECOGNIZED</string>
|
||||
<string name="meshtastic_service_notifications">Service notifications</string>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue