diff --git a/android/app/src/main/java/com/js8call/example/ui/AudioDevices.kt b/android/app/src/main/java/com/js8call/example/ui/AudioDevices.kt index 5bcc3b71..725a42d4 100644 --- a/android/app/src/main/java/com/js8call/example/ui/AudioDevices.kt +++ b/android/app/src/main/java/com/js8call/example/ui/AudioDevices.kt @@ -6,6 +6,8 @@ import android.media.AudioDeviceInfo import android.media.AudioManager import android.os.Build import androidx.preference.PreferenceManager +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.js8call.example.R import com.js8call.example.service.JS8EngineService /** @@ -102,6 +104,30 @@ object AudioDevices { return true } + /** + * Show the picker. [onSelected] runs after the choice is stored, so the + * caller only has to refresh whatever it shows the device on. + */ + fun showPicker( + context: Context, + engineRunning: Boolean, + onSelected: (Device) -> Unit + ) { + val devices = list(context) + val current = selected(context, devices) + val checked = devices.indexOf(current) + + MaterialAlertDialogBuilder(context) + .setTitle(R.string.monitor_menu_audio_device) + .setSingleChoiceItems(devices.map { it.name }.toTypedArray(), checked) { dialog, which -> + dialog.dismiss() + val device = devices[which] + select(context, device, engineRunning) + onSelected(device) + } + .show() + } + private const val PREF_SELECTED_ID = "last_audio_device_id" private const val DEFAULT_DEVICE_ID = -1 } diff --git a/android/app/src/main/java/com/js8call/example/ui/MonitorFragment.kt b/android/app/src/main/java/com/js8call/example/ui/MonitorFragment.kt index 8ccd73cb..e4cdd94a 100644 --- a/android/app/src/main/java/com/js8call/example/ui/MonitorFragment.kt +++ b/android/app/src/main/java/com/js8call/example/ui/MonitorFragment.kt @@ -3,26 +3,30 @@ package com.js8call.example.ui import android.Manifest import android.content.Intent import android.content.pm.PackageManager +import android.content.res.ColorStateList import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.AdapterView -import android.widget.ArrayAdapter -import android.widget.Button -import android.widget.Spinner +import android.widget.ImageView +import android.widget.PopupMenu import android.widget.TextView import androidx.core.content.ContextCompat import androidx.fragment.app.Fragment import androidx.lifecycle.ViewModelProvider +import com.google.android.material.button.MaterialButton +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.materialswitch.MaterialSwitch import com.google.android.material.snackbar.Snackbar import com.js8call.example.R import com.js8call.example.model.EngineState +import com.js8call.example.model.MonitorStatus +import com.js8call.example.model.TransmitState import com.js8call.example.service.JS8EngineService /** * Fragment for monitoring/receiving screen. - * Shows waterfall display and engine status. + * Shows the waterfall and the status strip below it. */ class MonitorFragment : Fragment() { @@ -30,28 +34,24 @@ class MonitorFragment : Fragment() { private lateinit var transmitViewModel: TransmitViewModel private lateinit var waterfallView: WaterfallView + private lateinit var stateDot: ImageView private lateinit var statusText: TextView - private lateinit var snrValue: TextView - private lateinit var powerValue: TextView - private lateinit var txOffsetValue: TextView - private lateinit var timeDriftValue: TextView - private lateinit var timeSyncButton: Button - private lateinit var timeDriftResetButton: Button - private lateinit var audioDeviceSpinner: Spinner - private lateinit var frequencySpinner: Spinner - private lateinit var startStopButton: Button - private lateinit var monitorVersionText: TextView - - // Audio device management - private var audioDeviceAdapter: ArrayAdapter? = null - private var availableDevices = mutableListOf() - private var userInitiatedAudioSelection = false + private lateinit var frequencyButton: MaterialButton + private lateinit var powerSwitch: MaterialSwitch + private lateinit var telemetryText: TextView // Frequency management - // Spinner position last applied programmatically; onItemSelected skips it - // because setSelection() fires the listener asynchronously. + private var frequencyEntries = listOf() + private var frequencyValues = listOf() private var appliedFrequencyIndex = -1 + private var lastLabelRes = 0 + private var lastColorRes = 0 + + // Set true while the switch is moved in code, so the listener can tell a + // state update apart from a tap. + private var applyingSwitchState = false + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -69,25 +69,11 @@ class MonitorFragment : Fragment() { // Find views waterfallView = view.findViewById(R.id.waterfall_view) + stateDot = view.findViewById(R.id.state_dot) statusText = view.findViewById(R.id.status_text) - snrValue = view.findViewById(R.id.snr_value) - powerValue = view.findViewById(R.id.power_value) - txOffsetValue = view.findViewById(R.id.tx_offset_value) - timeDriftValue = view.findViewById(R.id.time_drift_value) - timeSyncButton = view.findViewById(R.id.time_sync_button) - timeDriftResetButton = view.findViewById(R.id.time_drift_reset_button) - monitorVersionText = view.findViewById(R.id.monitor_version) - audioDeviceSpinner = view.findViewById(R.id.audio_device_spinner) - frequencySpinner = view.findViewById(R.id.frequency_spinner) - startStopButton = view.findViewById(R.id.start_stop_button) - - // Set version text dynamically from package info - val versionName = try { - requireContext().packageManager.getPackageInfo(requireContext().packageName, 0).versionName - } catch (e: PackageManager.NameNotFoundException) { - "unknown" - } - monitorVersionText.text = "Version: $versionName" + frequencyButton = view.findViewById(R.id.frequency_button) + powerSwitch = view.findViewById(R.id.power_switch) + telemetryText = view.findViewById(R.id.telemetry_text) // Set up waterfall offset callback waterfallView.bindRenderer(viewModel.getWaterfallRenderer()) @@ -104,82 +90,26 @@ class MonitorFragment : Fragment() { requireContext().startService(intent) } - // Set up audio device spinner - setupAudioDeviceSpinner() - - // Set up frequency spinner - setupFrequencySpinner() - - // Set up observers + loadFrequencies() observeViewModel() - // Set up click listeners - startStopButton.setOnClickListener { - toggleMonitoring() - } - - timeSyncButton.setOnClickListener { - val intent = Intent(requireContext(), JS8EngineService::class.java).apply { - action = JS8EngineService.ACTION_TIME_SYNC_ONCE - } - requireContext().startService(intent) - Snackbar.make(requireView(), getString(R.string.monitor_time_sync_armed), Snackbar.LENGTH_SHORT).show() + powerSwitch.setOnCheckedChangeListener { _, isChecked -> + if (applyingSwitchState) return@setOnCheckedChangeListener + if (isChecked) startMonitoring() else stopMonitoring() } - timeDriftResetButton.setOnClickListener { - val intent = Intent(requireContext(), JS8EngineService::class.java).apply { - action = JS8EngineService.ACTION_SET_TIME_DRIFT - putExtra(JS8EngineService.EXTRA_TIME_DRIFT_MS, 0L) - } - requireContext().startService(intent) - } - - } - - override fun onPause() { - super.onPause() - userInitiatedAudioSelection = false - } - - override fun onResume() { - super.onResume() - refreshAudioDevices() - } - - override fun onDestroyView() { - super.onDestroyView() + frequencyButton.setOnClickListener { showFrequencyDialog() } + view.findViewById(R.id.monitor_overflow) + .setOnClickListener { showOverflowMenu(it) } } private fun observeViewModel() { // Observe status viewModel.status.observe(viewLifecycleOwner) { status -> - updateStatus(status.state) - - // Update SNR - snrValue.text = if (status.snr != 0) { - getString(R.string.format_snr, status.snr) - } else { - "--" - } - - // Update power - powerValue.text = if (status.powerDb != 0f) { - String.format("%.1f dB", status.powerDb) - } else { - "--" - } - - // Update TX offset - txOffsetValue.text = "${status.txOffsetHz.toInt()} Hz" + renderState() + renderTelemetry(status) waterfallView.txOffsetHz = status.txOffsetHz - // Update time drift - timeDriftValue.text = if (status.timeDriftMs != 0L) { - String.format("%+d ms", status.timeDriftMs) - } else { - "0 ms" - } - // Show error if present status.errorMessage?.let { error -> Snackbar.make(requireView(), error, Snackbar.LENGTH_LONG).show() @@ -187,10 +117,7 @@ class MonitorFragment : Fragment() { } } - // Observe running state - viewModel.isRunning.observe(viewLifecycleOwner) { isRunning -> - updateButtonState(isRunning) - } + transmitViewModel.txState.observe(viewLifecycleOwner) { renderState() } viewModel.radioFrequency.observe(viewLifecycleOwner) { frequencyHz -> if (frequencyHz != null && frequencyHz > 0) { @@ -199,34 +126,98 @@ class MonitorFragment : Fragment() { } } - private fun updateStatus(state: EngineState) { - statusText.text = when (state) { - EngineState.STOPPED -> getString(R.string.monitor_status_stopped) - EngineState.STARTING -> getString(R.string.monitor_status_starting) - EngineState.RUNNING -> getString(R.string.monitor_status_running) - EngineState.ERROR -> "ERROR" + private fun renderState() { + val engineState = viewModel.status.value?.state ?: EngineState.STOPPED + val transmitting = engineState == EngineState.RUNNING && + transmitViewModel.txState.value == TransmitState.TRANSMITTING + + // Ahead of the repaint skip: a denied permission leaves the state + // unchanged but the switch moved, and it has to come back + val shouldBeOn = engineState == EngineState.RUNNING || engineState == EngineState.STARTING + applyingSwitchState = true + powerSwitch.isChecked = shouldBeOn + applyingSwitchState = false + + val (labelRes, colorRes) = when { + transmitting -> R.string.monitor_state_transmitting to R.color.tx_button_transmitting + engineState == EngineState.RUNNING -> R.string.monitor_state_receiving to R.color.snr_excellent + engineState == EngineState.STARTING -> R.string.monitor_state_starting to R.color.tx_button_queued + engineState == EngineState.ERROR -> R.string.monitor_state_error to R.color.message_failed + else -> R.string.monitor_state_off to R.color.message_pending } + // The status LiveData ticks at the spectrum rate; skip unchanged paints + if (labelRes == lastLabelRes && colorRes == lastColorRes) return + lastLabelRes = labelRes + lastColorRes = colorRes + + statusText.setText(labelRes) + stateDot.imageTintList = + ColorStateList.valueOf(ContextCompat.getColor(requireContext(), colorRes)) } - private fun updateButtonState(isRunning: Boolean) { - if (isRunning) { - startStopButton.text = getString(R.string.monitor_stop) - startStopButton.setCompoundDrawablesWithIntrinsicBounds(android.R.drawable.ic_media_pause, 0, 0, 0) + private fun renderTelemetry(status: MonitorStatus) { + val drift = if (status.timeDriftMs != 0L) { + String.format("%+d ms", status.timeDriftMs) } else { - startStopButton.text = getString(R.string.monitor_start) - startStopButton.setCompoundDrawablesWithIntrinsicBounds(android.R.drawable.ic_media_play, 0, 0, 0) + "0 ms" + } + val offsetAndDrift = getString( + R.string.monitor_telemetry, + status.txOffsetHz.toInt(), + drift + ) + // A stopped engine reads no power, and a leading placeholder just adds noise + telemetryText.text = if (status.powerDb != 0f) { + getString(R.string.monitor_telemetry_power, status.powerDb, offsetAndDrift) + } else { + offsetAndDrift } } - private fun toggleMonitoring() { - if (viewModel.isRunning.value == true) { - stopMonitoring() - } else { - startMonitoring() + private fun showOverflowMenu(anchor: View) { + val popup = PopupMenu(requireContext(), anchor) + popup.menuInflater.inflate(R.menu.monitor_overflow, popup.menu) + popup.setOnMenuItemClickListener { item -> + when (item.itemId) { + R.id.action_audio_device -> { + showAudioDeviceDialog() + true + } + R.id.action_time_sync -> { + armTimeSync() + true + } + R.id.action_time_drift_reset -> { + resetTimeDrift() + true + } + else -> false + } + } + popup.show() + } + + private fun armTimeSync() { + val intent = Intent(requireContext(), JS8EngineService::class.java).apply { + action = JS8EngineService.ACTION_TIME_SYNC_ONCE + } + requireContext().startService(intent) + Snackbar.make(requireView(), getString(R.string.monitor_time_sync_armed), Snackbar.LENGTH_SHORT).show() + } + + private fun resetTimeDrift() { + val intent = Intent(requireContext(), JS8EngineService::class.java).apply { + action = JS8EngineService.ACTION_SET_TIME_DRIFT + putExtra(JS8EngineService.EXTRA_TIME_DRIFT_MS, 0L) } + requireContext().startService(intent) } private fun startMonitoring() { + // Starting an engine that is already up tears down its audio capture + val state = viewModel.status.value?.state + if (state == EngineState.RUNNING || state == EngineState.STARTING) return + val skipMicPermission = AudioDevices.usesSerialAudio(requireContext()) // Check permission @@ -289,6 +280,8 @@ class MonitorFragment : Fragment() { // Permission granted, try starting again startMonitoring() } else { + // The switch moved on the tap that asked for the permission + renderState() Snackbar.make( requireView(), R.string.permission_audio_denied, @@ -298,99 +291,23 @@ class MonitorFragment : Fragment() { } } - private fun setupAudioDeviceSpinner() { - // Create adapter - audioDeviceAdapter = ArrayAdapter( - requireContext(), - android.R.layout.simple_spinner_item, - availableDevices - ) - audioDeviceAdapter?.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) - audioDeviceSpinner.adapter = audioDeviceAdapter - - audioDeviceSpinner.setOnTouchListener { _, _ -> - userInitiatedAudioSelection = true - false - } - audioDeviceSpinner.setOnFocusChangeListener { _, hasFocus -> - if (!hasFocus) { - userInitiatedAudioSelection = false - } - } - - // Set up selection listener - audioDeviceSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { - override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) { - val userInitiated = userInitiatedAudioSelection - userInitiatedAudioSelection = false - if (!userInitiated) return - if (position < 0 || position >= availableDevices.size) return - - val selectedDevice = availableDevices[position] - android.util.Log.d("MonitorFragment", "Audio device selected: ${selectedDevice.name} (ID: ${selectedDevice.id})") - - // Stored even while stopped, so the next start uses the pick. - if (AudioDevices.select(requireContext(), selectedDevice, viewModel.isRunning.value == true)) { - Snackbar.make(requireView(), "Switching audio device...", Snackbar.LENGTH_SHORT).show() - } - } - - override fun onNothingSelected(parent: AdapterView<*>?) { - // Do nothing - } - } - } - - private fun refreshAudioDevices() { - availableDevices.clear() - availableDevices.addAll(AudioDevices.list(requireContext())) - audioDeviceAdapter?.notifyDataSetChanged() - val selected = AudioDevices.selected(requireContext(), availableDevices) - audioDeviceSpinner.setSelection(availableDevices.indexOf(selected)) - } - - private fun updateFrequencyFromRadio(frequencyHz: Long) { - val frequencyValues = resources.getStringArray(R.array.js8_frequency_values) - val frequencyEntries = resources.getStringArray(R.array.js8_frequency_entries) - - // Find the closest matching frequency in our list - var closestIndex = 0 - var closestDiff = Long.MAX_VALUE - - for (i in frequencyValues.indices) { - val freq = frequencyValues[i].toLongOrNull() ?: continue - val diff = kotlin.math.abs(freq - frequencyHz) - if (diff < closestDiff) { - closestDiff = diff - closestIndex = i + private fun showAudioDeviceDialog() { + val running = viewModel.isRunning.value == true + AudioDevices.showPicker(requireContext(), running) { device -> + // A stopped engine has nothing to move, so say what will be used + val message = if (running) { + getString(R.string.monitor_audio_device_switching, device.name) + } else { + getString(R.string.monitor_audio_device_selected, device.name) } - } - - // Update spinner if we found a reasonable match (within 100 kHz) - if (closestDiff < 100000) { - val currentIndex = frequencySpinner.selectedItemPosition - if (currentIndex == closestIndex) { - return - } - appliedFrequencyIndex = closestIndex - frequencySpinner.setSelection(closestIndex) - - val prefs = androidx.preference.PreferenceManager.getDefaultSharedPreferences(requireContext()) - prefs.edit().putString("last_frequency", frequencyValues[closestIndex]).apply() - - android.util.Log.i("MonitorFragment", "Set frequency to ${frequencyEntries[closestIndex]} based on radio frequency $frequencyHz Hz") - Snackbar.make(requireView(), "Radio tuned to ${frequencyEntries[closestIndex]}", Snackbar.LENGTH_SHORT).show() - } else { - android.util.Log.d("MonitorFragment", "Radio frequency $frequencyHz Hz doesn't match any preset (closest: ${frequencyValues[closestIndex]} Hz, diff: $closestDiff Hz)") + Snackbar.make(requireView(), message, Snackbar.LENGTH_SHORT).show() } } - private fun setupFrequencySpinner() { - // Get frequency arrays from resources + private fun loadFrequencies() { val baseEntries = resources.getStringArray(R.array.js8_frequency_entries) val baseValues = resources.getStringArray(R.array.js8_frequency_values) - // Load saved frequency preference val prefs = androidx.preference.PreferenceManager.getDefaultSharedPreferences(requireContext()) val customFrequencyMhz = prefs.getString("custom_frequency_mhz", "")?.trim().orEmpty() @@ -402,80 +319,34 @@ class MonitorFragment : Fragment() { } val presetPairs = baseEntries.indices.map { baseEntries[it] to baseValues[it] } .filter { rigType != "qmx_serial" || qmxBands.any { band -> it.first.startsWith(band) } } - val frequencyEntries = presetPairs.map { it.first }.toMutableList() - val frequencyValues = presetPairs.map { it.second }.toMutableList() + val entries = presetPairs.map { it.first }.toMutableList() + val values = presetPairs.map { it.second }.toMutableList() val customFrequencyHz = customFrequencyMhz.toDoubleOrNull()?.let { mhz -> if (mhz > 0) (mhz * 1_000_000.0).toLong() else null } if (customFrequencyHz != null && (rigType != "qmx_serial" || isQmxFrequencySupported(customFrequencyHz, qmxBands))) { - frequencyEntries.add("Custom - ${customFrequencyMhz}MHz") - frequencyValues.add(customFrequencyHz.toString()) + entries.add("Custom - ${customFrequencyMhz}MHz") + values.add(customFrequencyHz.toString()) } - // Create adapter - val adapter = ArrayAdapter( - requireContext(), - android.R.layout.simple_spinner_item, - frequencyEntries - ) - adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) - frequencySpinner.adapter = adapter + frequencyEntries = entries + frequencyValues = values // Preserve the established 20 m default for normal radios. QMX may not // support 20 m in every hardware profile, so use its first valid preset. val defaultFrequency = if (rigType == "qmx_serial") { - frequencyValues.firstOrNull() ?: "14078000" + values.firstOrNull() ?: "14078000" } else { baseValues.getOrNull(3) ?: "14078000" } val savedFrequency = prefs.getString("last_frequency", defaultFrequency) ?: defaultFrequency - val savedIndex = frequencyValues.indexOf(savedFrequency).takeIf { it >= 0 } - ?: frequencyValues.indexOf(defaultFrequency).takeIf { it >= 0 } + val savedIndex = values.indexOf(savedFrequency).takeIf { it >= 0 } + ?: values.indexOf(defaultFrequency).takeIf { it >= 0 } ?: 0 - // Set initial selection - appliedFrequencyIndex = savedIndex - frequencySpinner.setSelection(savedIndex, false) - - // Set up selection listener - frequencySpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { - override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) { - if (position == appliedFrequencyIndex) return - if (position < 0 || position >= frequencyValues.size) return - appliedFrequencyIndex = position - - val frequencyHz = frequencyValues[position].toLongOrNull() ?: return - android.util.Log.d("MonitorFragment", "Frequency selected: ${frequencyEntries[position]} ($frequencyHz Hz)") - - // Save frequency preference - prefs.edit().putString("last_frequency", frequencyValues[position]).apply() - - // Check if rig control is enabled - val rigControlEnabled = prefs.getBoolean("rig_control_enabled", false) - val rigType = prefs.getString("rig_type", "none") - - if (rigControlEnabled && (rigType == "network" || rigType == "hamlib_usb" || rigType == "trusdx_serial" || rigType == "qmx_serial")) { - // Send frequency change to service - val intent = Intent(requireContext(), JS8EngineService::class.java).apply { - action = JS8EngineService.ACTION_SET_FREQUENCY - putExtra(JS8EngineService.EXTRA_FREQUENCY_HZ, frequencyHz) - } - requireContext().startService(intent) - - Snackbar.make(requireView(), "Setting frequency to ${frequencyEntries[position]}", Snackbar.LENGTH_SHORT).show() - } else if (rigControlEnabled && rigType == "rts_ptt") { - android.util.Log.d("MonitorFragment", "RTS PTT mode does not support frequency control") - } else { - android.util.Log.d("MonitorFragment", "Rig control not enabled or not supported type, skipping frequency change") - } - } - - override fun onNothingSelected(parent: AdapterView<*>?) { - // Do nothing - } - } + applyFrequency(savedIndex, persist = false) } private fun isQmxFrequencySupported(frequencyHz: Long, bands: Set): Boolean { @@ -495,6 +366,90 @@ class MonitorFragment : Fragment() { return band in bands } + private fun applyFrequency(index: Int, persist: Boolean = true) { + appliedFrequencyIndex = index + frequencyButton.text = shortFrequencyLabel(frequencyEntries[index]) + if (persist) { + androidx.preference.PreferenceManager.getDefaultSharedPreferences(requireContext()) + .edit().putString("last_frequency", frequencyValues[index]).apply() + } + } + + /** "20m - 14.078 MHz" is too wide for the strip, so show it as "20m · 14.078". */ + private fun shortFrequencyLabel(entry: String): String { + return entry.removeSuffix(" MHz").replace(" - ", " · ") + } + + private fun showFrequencyDialog() { + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.monitor_radio_frequency) + .setSingleChoiceItems( + frequencyEntries.toTypedArray(), + appliedFrequencyIndex + ) { dialog, which -> + dialog.dismiss() + selectFrequency(which) + } + .show() + } + + private fun selectFrequency(position: Int) { + if (position == appliedFrequencyIndex) return + if (position < 0 || position >= frequencyValues.size) return + applyFrequency(position) + + val frequencyHz = frequencyValues[position].toLongOrNull() ?: return + android.util.Log.d("MonitorFragment", "Frequency selected: ${frequencyEntries[position]} ($frequencyHz Hz)") + + // Check if rig control is enabled + val prefs = androidx.preference.PreferenceManager.getDefaultSharedPreferences(requireContext()) + val rigControlEnabled = prefs.getBoolean("rig_control_enabled", false) + val rigType = prefs.getString("rig_type", "none") + + if (rigControlEnabled && (rigType == "network" || rigType == "hamlib_usb" || rigType == "trusdx_serial" || rigType == "qmx_serial")) { + // Send frequency change to service + val intent = Intent(requireContext(), JS8EngineService::class.java).apply { + action = JS8EngineService.ACTION_SET_FREQUENCY + putExtra(JS8EngineService.EXTRA_FREQUENCY_HZ, frequencyHz) + } + requireContext().startService(intent) + + Snackbar.make(requireView(), "Setting frequency to ${frequencyEntries[position]}", Snackbar.LENGTH_SHORT).show() + } else if (rigControlEnabled && rigType == "rts_ptt") { + android.util.Log.d("MonitorFragment", "RTS PTT mode does not support frequency control") + } else { + android.util.Log.d("MonitorFragment", "Rig control not enabled or not supported type, skipping frequency change") + } + } + + private fun updateFrequencyFromRadio(frequencyHz: Long) { + // Find the closest matching frequency in our list + var closestIndex = 0 + var closestDiff = Long.MAX_VALUE + + for (i in frequencyValues.indices) { + val freq = frequencyValues[i].toLongOrNull() ?: continue + val diff = kotlin.math.abs(freq - frequencyHz) + if (diff < closestDiff) { + closestDiff = diff + closestIndex = i + } + } + + // Update the label if we found a reasonable match (within 100 kHz) + if (closestDiff < 100000) { + if (appliedFrequencyIndex == closestIndex) { + return + } + applyFrequency(closestIndex) + + android.util.Log.i("MonitorFragment", "Set frequency to ${frequencyEntries[closestIndex]} based on radio frequency $frequencyHz Hz") + Snackbar.make(requireView(), "Radio tuned to ${frequencyEntries[closestIndex]}", Snackbar.LENGTH_SHORT).show() + } else { + android.util.Log.d("MonitorFragment", "Radio frequency $frequencyHz Hz doesn't match any preset (closest: ${frequencyValues[closestIndex]} Hz, diff: $closestDiff Hz)") + } + } + companion object { private const val REQUEST_AUDIO_PERMISSION = 1 } diff --git a/android/app/src/main/java/com/js8call/example/ui/SettingsFragment.kt b/android/app/src/main/java/com/js8call/example/ui/SettingsFragment.kt index 20faa26d..724ced00 100644 --- a/android/app/src/main/java/com/js8call/example/ui/SettingsFragment.kt +++ b/android/app/src/main/java/com/js8call/example/ui/SettingsFragment.kt @@ -14,12 +14,14 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.core.content.ContextCompat import androidx.preference.EditTextPreference import androidx.preference.ListPreference +import androidx.preference.Preference import androidx.preference.PreferenceFragmentCompat import androidx.preference.SwitchPreferenceCompat import com.google.android.material.snackbar.Snackbar import com.js8call.core.BluetoothSerialPortCatalog import com.js8call.core.HamlibRigCatalog import com.js8call.core.UsbSerialPortCatalog +import com.js8call.example.BuildConfig import com.js8call.example.R import java.util.Locale @@ -66,15 +68,12 @@ class SettingsFragment : PreferenceFragmentCompat() { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { setPreferencesFromResource(R.xml.preferences, rootKey) + findPreference("app_version")?.summary = BuildConfig.VERSION_NAME + val prefs = preferenceManager.sharedPreferences if (prefs != null && !prefs.contains("my_status")) { val statusPref = findPreference("my_status") - val versionName = try { - requireContext().packageManager.getPackageInfo(requireContext().packageName, 0).versionName - } catch (e: PackageManager.NameNotFoundException) { - "unknown" - } - statusPref?.text = "JS8Android-$versionName" + statusPref?.text = "JS8Android-${BuildConfig.VERSION_NAME}" } val callsignPref = findPreference("callsign") diff --git a/android/app/src/main/res/drawable/ic_arrow_drop_down.xml b/android/app/src/main/res/drawable/ic_arrow_drop_down.xml new file mode 100644 index 00000000..7ce4d8df --- /dev/null +++ b/android/app/src/main/res/drawable/ic_arrow_drop_down.xml @@ -0,0 +1,10 @@ + + + + diff --git a/android/app/src/main/res/drawable/ic_more_vert.xml b/android/app/src/main/res/drawable/ic_more_vert.xml new file mode 100644 index 00000000..78038f8f --- /dev/null +++ b/android/app/src/main/res/drawable/ic_more_vert.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/android/app/src/main/res/drawable/status_dot.xml b/android/app/src/main/res/drawable/status_dot.xml new file mode 100644 index 00000000..987d75ec --- /dev/null +++ b/android/app/src/main/res/drawable/status_dot.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/android/app/src/main/res/layout/fragment_monitor.xml b/android/app/src/main/res/layout/fragment_monitor.xml index 6e9492b9..8027683c 100644 --- a/android/app/src/main/res/layout/fragment_monitor.xml +++ b/android/app/src/main/res/layout/fragment_monitor.xml @@ -1,240 +1,21 @@ - + android:layout_height="match_parent" + android:orientation="vertical"> + android:layout_weight="1" + android:background="@color/waterfall_background" /> - - + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -