diff --git a/app/build.gradle b/app/build.gradle
index 3eaa7b139..f2ba3e6bd 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -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"
diff --git a/app/src/main/java/com/geeksville/mesh/model/ChannelOption.kt b/app/src/main/java/com/geeksville/mesh/model/ChannelOption.kt
index cd50750b3..d451da790 100644
--- a/app/src/main/java/com/geeksville/mesh/model/ChannelOption.kt
+++ b/app/src/main/java/com/geeksville/mesh/model/ChannelOption.kt
@@ -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);
diff --git a/app/src/main/java/com/geeksville/mesh/ui/ChannelFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/ChannelFragment.kt
index a30e492d4..99a13cf18 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/ChannelFragment.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/ChannelFragment.kt
@@ -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()
+//}
diff --git a/app/src/main/res/drawable/ic_twotone_content_copy_24.xml b/app/src/main/res/drawable/ic_twotone_content_copy_24.xml
new file mode 100644
index 000000000..bf15de1ea
--- /dev/null
+++ b/app/src/main/res/drawable/ic_twotone_content_copy_24.xml
@@ -0,0 +1,15 @@
+
+
+
+
diff --git a/app/src/main/res/layout/channel_fragment.xml b/app/src/main/res/layout/channel_fragment.xml
deleted file mode 100644
index fc66fe285..000000000
--- a/app/src/main/res/layout/channel_fragment.xml
+++ /dev/null
@@ -1,145 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/layout/dropdown_menu_popup_item.xml b/app/src/main/res/layout/dropdown_menu_popup_item.xml
deleted file mode 100644
index b305adce9..000000000
--- a/app/src/main/res/layout/dropdown_menu_popup_item.xml
+++ /dev/null
@@ -1,9 +0,0 @@
-
-
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index c89bd6232..4f0001857 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -61,6 +61,7 @@
Short Range / Fast
Medium Range / Fast
Long Range / Fast
+ Long Range / Moderate
Very Long Range / Slow
UNRECOGNIZED
Service notifications