From 90cdb2c48929511472964fc7bde5c1b59ea6974b Mon Sep 17 00:00:00 2001 From: tekstrand Date: Thu, 3 Sep 2026 16:05:28 -0500 Subject: [PATCH 1/2] Replaced the Monitor status card with a compact status strip. The card took the lower half of the screen while stopped and said the same thing twice: a header band held the word STOPPED and a full width button below it held the word Start. The strip spans the width under the waterfall as its control bar, two rows tall. A colored dot and a state word sit on the left, and a switch replaces the Start button, because the engine is the only thing on this screen that can be switched: transmit is not a mode, since messages go out per frame, and the rig connects when the engine starts, so both appear as state rather than as controls. The dot and the word read Transmitting while a send is on the air, fed by the transmit state the queue already publishes. The second row carries power, transmit offset and time drift on one line, with the audio device picker, sync to next decode, and drift reset behind an overflow menu. Frequency became a compact text button that opens a single choice dialog. No exposed dropdown remains on this screen, so the filter on restore bug that emptied the frequency list after a tab switch is gone by construction rather than suppressed. The release version moved into a new About category in Settings, the SNR tile is gone, and the strings and the status text style that nothing references any more went with them. The audio picker is AudioDevices.showPicker, added beside the list and selection that landed with the extraction. --- .../com/js8call/example/ui/AudioDevices.kt | 26 + .../com/js8call/example/ui/MonitorFragment.kt | 489 ++++++++---------- .../js8call/example/ui/SettingsFragment.kt | 11 +- .../main/res/drawable/ic_arrow_drop_down.xml | 10 + .../src/main/res/drawable/ic_more_vert.xml | 13 + .../app/src/main/res/drawable/status_dot.xml | 9 + .../src/main/res/layout/fragment_monitor.xml | 243 +-------- .../main/res/layout/monitor_status_card.xml | 102 ++++ .../src/main/res/menu/monitor_overflow.xml | 16 + android/app/src/main/res/values/strings.xml | 36 +- android/app/src/main/res/values/themes.xml | 6 - android/app/src/main/res/xml/preferences.xml | 13 + 12 files changed, 446 insertions(+), 528 deletions(-) create mode 100644 android/app/src/main/res/drawable/ic_arrow_drop_down.xml create mode 100644 android/app/src/main/res/drawable/ic_more_vert.xml create mode 100644 android/app/src/main/res/drawable/status_dot.xml create mode 100644 android/app/src/main/res/layout/monitor_status_card.xml create mode 100644 android/app/src/main/res/menu/monitor_overflow.xml 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 5bcc3b712..725a42d4e 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 8ccd73cbc..6c430c00a 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,96 @@ 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 + + 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)) + + val shouldBeOn = engineState == EngineState.RUNNING || engineState == EngineState.STARTING + applyingSwitchState = true + powerSwitch.isChecked = shouldBeOn + applyingSwitchState = false } - 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 +278,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 +289,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 +317,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 +364,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 20faa26da..724ced000 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 000000000..7ce4d8df9 --- /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 000000000..78038f8f4 --- /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 000000000..987d75ec7 --- /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 6e9492b9b..8027683c2 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" /> - - + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -