From 832cdd7e4daca41a0b8321fb6b308bf66fbb84c1 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Wed, 23 Sep 2026 17:22:46 -0500 Subject: [PATCH 1/8] fix: reconnect iOS TX player node after an audio config change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VOX TX crashed the app (SIGABRT) every time it should have keyed, once a DigiRig/USB audio interface was attached. Attaching the interface posts an AVAudioEngineConfigurationChange, which tears down every connection in the shared AVAudioEngine — including the TX playerNode -> mainMixerNode link. TxPlayerService cached `isAttached = true` for the app's lifetime and never reconnected, so at TX time AVAudioPlayerNode.play() ran on a disconnected node and AVFAudio raised an uncaught NSException -> abort. AudioCaptureService only rebuilds the capture (input) side on config change, so the TX side was left dead. Split the lifecycle into attach (once) vs connect (per graph rebuild) via a pure `TxConnectionState`, observe AVAudioEngineConfigurationChange in TxPlayerService, and invalidate the connection + drop the stale converter so the next play() reconnects and rebuilds the converter against the current mixer format. Diagnosed from three identical device crash reports (TxPlayerService.play -> AVAudioPlayerNode play -> StartImpl NSException). Co-Authored-By: Claude Opus 4.8 (1M context) --- ios/FT8AF/FT8AF.xcodeproj/project.pbxproj | 4 + ios/FT8AF/FT8AF/Engine/TxPlayerService.swift | 99 ++++++++++++++++--- .../FT8AFTests/TxConnectionStateTests.swift | 79 +++++++++++++++ 3 files changed, 167 insertions(+), 15 deletions(-) create mode 100644 ios/FT8AF/FT8AFTests/TxConnectionStateTests.swift diff --git a/ios/FT8AF/FT8AF.xcodeproj/project.pbxproj b/ios/FT8AF/FT8AF.xcodeproj/project.pbxproj index 741341142..e8ce1aa09 100644 --- a/ios/FT8AF/FT8AF.xcodeproj/project.pbxproj +++ b/ios/FT8AF/FT8AF.xcodeproj/project.pbxproj @@ -64,6 +64,7 @@ B4122A07A833C6746F51D5D6 /* PotaService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E27EE4248BF38A15E0F3B2D /* PotaService.swift */; }; B8FD5A2DF3B2A89740B3CB73 /* WorldMapCanvas.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BDDB723455CB7AE2BB68EF0 /* WorldMapCanvas.swift */; }; BCEB9305C3EA84C758FAFBA7 /* AzimuthalMapCanvas.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB943F6B81F9D36BA85504A3 /* AzimuthalMapCanvas.swift */; }; + C1B544AEB67D088C227528EC /* TxConnectionStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46834CB53DF42AA73774F7DA /* TxConnectionStateTests.swift */; }; CE1ECA0C5BB350A1EDDA7018 /* SlotTimerBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 800081E79EC795EBDE2E077E /* SlotTimerBar.swift */; }; CF1120C958F019240FB25A25 /* Typography.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0CA452882C4980BED953078 /* Typography.swift */; }; D36ECEB73B847787DE373072 /* SpectrumStrip.swift in Sources */ = {isa = PBXBuildFile; fileRef = C96D9C69CFC1480E6A5B64B5 /* SpectrumStrip.swift */; }; @@ -111,6 +112,7 @@ 3B94D7FBAC132C5854796F79 /* AudioRouteController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioRouteController.swift; sourceTree = ""; }; 4217D8419318D6A336873605 /* AudioRoutePickerButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioRoutePickerButton.swift; sourceTree = ""; }; 467569ADBF392233E7AA71DC /* TimeSyncSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeSyncSettings.swift; sourceTree = ""; }; + 46834CB53DF42AA73774F7DA /* TxConnectionStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TxConnectionStateTests.swift; sourceTree = ""; }; 47F0A25416CDB1102C9AD749 /* KeychainStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainStore.swift; sourceTree = ""; }; 4A8FFDE0644205A9AE050BC0 /* BlockedCallsignsSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockedCallsignsSettings.swift; sourceTree = ""; }; 4B08B1AA6951B99CEFAA521F /* TransmissionSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransmissionSettings.swift; sourceTree = ""; }; @@ -357,6 +359,7 @@ 7DB508B1F2E7EBB815B8C9B2 /* AudioCaptureSessionOptionsTests.swift */, F2EBE7CC0F661A6451447578 /* PttModeTests.swift */, BB78DA6983B10ABE10319476 /* RigModelTests.swift */, + 46834CB53DF42AA73774F7DA /* TxConnectionStateTests.swift */, ); path = FT8AFTests; sourceTree = ""; @@ -583,6 +586,7 @@ 434629940DB32DDFFA313E4F /* AudioCaptureSessionOptionsTests.swift in Sources */, 1418BC3ED37E2C61C4A09ED1 /* PttModeTests.swift in Sources */, 7FF97BE16C182502CA0E4F40 /* RigModelTests.swift in Sources */, + C1B544AEB67D088C227528EC /* TxConnectionStateTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/ios/FT8AF/FT8AF/Engine/TxPlayerService.swift b/ios/FT8AF/FT8AF/Engine/TxPlayerService.swift index 4cc77db4b..42897129a 100644 --- a/ios/FT8AF/FT8AF/Engine/TxPlayerService.swift +++ b/ios/FT8AF/FT8AF/Engine/TxPlayerService.swift @@ -1,6 +1,39 @@ import AVFoundation import FT8DSP +/// Pure connection-lifecycle state for the TX player node, extracted from +/// `TxPlayerService` so the (untestable) `AVAudioEngine` calls stay a thin +/// wrapper around testable decisions. +/// +/// The node is *attached* to the engine exactly once, but its *connection* +/// into the mixer graph is torn down by the engine on every +/// `AVAudioEngineConfigurationChange` (a USB audio interface such as a DigiRig +/// attaching/detaching, or any output-route change). Calling +/// `AVAudioPlayerNode.play()` on a node whose connection was invalidated raises +/// an uncaught Obj-C `NSException` inside AVFAudio and aborts the whole app — +/// which is exactly what happened at TX time after a DigiRig was plugged in. +/// Tracking attach and connect separately lets `play()` re-establish the +/// connection lazily after a config change instead of trusting a stale flag. +struct TxConnectionState: Equatable { + private(set) var isAttached = false + private(set) var isConnected = false + + /// The node must be attached to the engine before it can be connected. + var needsAttach: Bool { !isAttached } + /// The node must be (re)connected + the converter rebuilt before playback. + var needsConnect: Bool { !isConnected } + /// Playback preconditions are satisfied — node attached and connected. + var isReady: Bool { isAttached && isConnected } + + mutating func markAttached() { isAttached = true } + mutating func markConnected() { isConnected = true } + + /// A configuration change leaves the node attached but invalidates every + /// connection in the graph, so the next `play()` must reconnect and rebuild + /// the format converter against the (possibly new) mixer output format. + mutating func invalidateConnection() { isConnected = false } +} + /// Plays a mono 12 kHz FT8 waveform through the device speaker using an /// `AVAudioPlayerNode` attached to a shared `AVAudioEngine`. The TX scheduler /// calls `play(_:)` at the correct slot time; the service handles sample-rate @@ -8,14 +41,16 @@ import FT8DSP /// /// Node attachment is deferred to the first `play()` call so that the output /// hardware format has stabilized (on Simulator the output node may report -/// 0 Hz at engine-start time, which crashes `connect()`). +/// 0 Hz at engine-start time, which crashes `connect()`). The connection is +/// re-established after any `AVAudioEngineConfigurationChange` — see +/// `TxConnectionState`. final class TxPlayerService: @unchecked Sendable { private let playerNode = AVAudioPlayerNode() private var engine: AVAudioEngine? private var outputConverter: AVAudioConverter? private let lock = NSLock() private var _isPlaying = false - private var isAttached = false + private var conn = TxConnectionState() /// The 12 kHz mono format used by FT8Encoder output. private let ft8Format = AVAudioFormat( @@ -30,32 +65,63 @@ final class TxPlayerService: @unchecked Sendable { return _isPlaying } - /// Store the engine reference for deferred attachment. Does NOT connect - /// the player node yet — that happens lazily in `ensureAttached()`. + /// Store the engine reference for deferred attachment and observe + /// configuration changes so a route change (e.g. a DigiRig attaching) + /// forces the player node to reconnect before the next TX. Does NOT connect + /// the player node yet — that happens lazily in `ensureConnected()`. func configure(engine: AVAudioEngine) { self.engine = engine + NotificationCenter.default.addObserver( + self, + selector: #selector(handleConfigChange), + name: .AVAudioEngineConfigurationChange, + object: engine + ) + } + + deinit { + NotificationCenter.default.removeObserver(self) } - /// Attach and connect the player node, creating the format converter. - /// Returns `false` if the hardware output format is invalid (e.g. 0 Hz - /// on Simulator at startup) — the caller should skip playback. - private func ensureAttached() -> Bool { - if isAttached { return outputConverter != nil } + /// The engine tore down its graph (route/config change). The node stays + /// attached but its connection is gone; drop the cached converter so the + /// next `play()` reconnects against the current mixer format instead of + /// keying a disconnected node (which aborts the app). + @objc private func handleConfigChange() { + lock.lock() + conn.invalidateConnection() + outputConverter = nil + lock.unlock() + } + + /// Attach (once) and connect the player node, creating the format + /// converter. Reconnects and rebuilds the converter when a prior config + /// change invalidated the connection. Returns `false` if the hardware + /// output format is invalid (e.g. 0 Hz on Simulator at startup) — the + /// caller should skip playback. + private func ensureConnected() -> Bool { + lock.lock(); defer { lock.unlock() } + if !conn.needsConnect { return outputConverter != nil } guard let engine else { return false } // Use the mixer's output format — on real devices this is the // hardware rate (e.g. 48 kHz stereo); on Simulator it becomes valid - // once the engine is running. + // once the engine is running. After a config change it may be a + // different rate (e.g. the DigiRig's), so it must be re-read here. let mixerFormat = engine.mainMixerNode.outputFormat(forBus: 0) guard mixerFormat.sampleRate > 0, mixerFormat.channelCount > 0 else { return false } - engine.attach(playerNode) + if conn.needsAttach { + engine.attach(playerNode) + conn.markAttached() + } engine.connect(playerNode, to: engine.mainMixerNode, format: mixerFormat) outputConverter = AVAudioConverter(from: ft8Format, to: mixerFormat) - isAttached = true - return outputConverter != nil + guard outputConverter != nil else { return false } + conn.markConnected() + return true } /// Schedule and play `samples` (mono Float32 at 12 kHz). Interrupts any @@ -72,7 +138,7 @@ final class TxPlayerService: @unchecked Sendable { // interruption, Simulator startup) — AVAudioPlayerNode.play() on a // stopped engine logs "Cannot play yet!" and the TX is silent, and a // stopped engine can also report an invalid mixer format, which would - // make the deferred attach below bail. Restart it first. + // make the deferred connect below bail. Restart it first. if let engine, !engine.isRunning { do { try engine.start() @@ -82,7 +148,10 @@ final class TxPlayerService: @unchecked Sendable { } } - guard ensureAttached(), let converter = outputConverter else { + // Re-establish the node connection if a config change invalidated it. + // Skipping this is what aborts the app: play() on a disconnected node + // raises an uncaught NSException in AVFAudio. + guard ensureConnected(), let converter = outputConverter else { completion?() return false } diff --git a/ios/FT8AF/FT8AFTests/TxConnectionStateTests.swift b/ios/FT8AF/FT8AFTests/TxConnectionStateTests.swift new file mode 100644 index 000000000..00cd37fff --- /dev/null +++ b/ios/FT8AF/FT8AFTests/TxConnectionStateTests.swift @@ -0,0 +1,79 @@ +import XCTest +@testable import FT8AF + +/// Covers the connection-lifecycle decisions that guard `TxPlayerService.play`. +/// The regression these lock down: after an `AVAudioEngineConfigurationChange` +/// (e.g. a DigiRig USB interface attaching) the engine tears down the player +/// node's connection, and playing on a disconnected node aborts the app. The +/// state must report `needsConnect` again after such a change while keeping the +/// node attached, so `play()` reconnects instead of keying a dead node. +final class TxConnectionStateTests: XCTestCase { + func testFreshStateNeedsAttachAndConnect() { + let s = TxConnectionState() + XCTAssertTrue(s.needsAttach) + XCTAssertTrue(s.needsConnect) + XCTAssertFalse(s.isReady) + } + + func testAfterAttachAndConnectIsReady() { + var s = TxConnectionState() + s.markAttached() + s.markConnected() + XCTAssertFalse(s.needsAttach) + XCTAssertFalse(s.needsConnect) + XCTAssertTrue(s.isReady) + } + + func testAttachedButNotConnectedIsNotReady() { + var s = TxConnectionState() + s.markAttached() + XCTAssertFalse(s.needsAttach) + XCTAssertTrue(s.needsConnect) + XCTAssertFalse(s.isReady) + } + + /// The core regression: a config change must invalidate the connection but + /// leave the node attached, so the next play reconnects without re-attaching. + func testConfigChangeInvalidatesConnectionButKeepsAttachment() { + var s = TxConnectionState() + s.markAttached() + s.markConnected() + + s.invalidateConnection() + + XCTAssertFalse(s.needsAttach, "node stays attached across a config change") + XCTAssertTrue(s.needsConnect, "connection must be rebuilt after a config change") + XCTAssertFalse(s.isReady) + } + + func testReconnectAfterConfigChangeReturnsToReady() { + var s = TxConnectionState() + s.markAttached() + s.markConnected() + s.invalidateConnection() + + // Simulate ensureConnected()'s reconnect path: no re-attach needed, + // just a fresh connection. + XCTAssertFalse(s.needsAttach) + s.markConnected() + + XCTAssertTrue(s.isReady) + } + + /// Repeated config changes (unplug/replug) must keep forcing a reconnect, + /// never latching into a stale "ready" state. + func testRepeatedConfigChangesKeepRequiringReconnect() { + var s = TxConnectionState() + s.markAttached() + s.markConnected() + + for _ in 0..<5 { + s.invalidateConnection() + XCTAssertTrue(s.needsConnect) + XCTAssertFalse(s.needsAttach) + XCTAssertFalse(s.isReady) + s.markConnected() + XCTAssertTrue(s.isReady) + } + } +} From 5c7d01c2cc3af2d063d5ead3bf600634f3861020 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Wed, 23 Sep 2026 19:23:49 -0500 Subject: [PATCH 2/8] fix: address code-review findings on TX config-change fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the config-change reconnect fix, resolving three review findings: 1. Residual crash window (correctness): a config change delivered between ensureConnected() and playerNode.play() still tore down the node connection, and play() on a disconnected node raises an uncaught Obj-C NSException that aborts the app — Swift's `try` can't catch it. Added a minimal Obj-C exception-catch shim (ExceptionCatcher + bridging header) and route the stop/schedule/play keying through it; on a raised exception we invalidate the connection and bail (TX lost for that slot, app survives). Also made ensureConnected() return the converter directly so play() no longer re-reads outputConverter outside the lock (closes the data race with handleConfigChange). 2. Observer leak (correctness): configure() runs on every LiveEngine start (stop->start reuses the same long-lived engine), so a bare addObserver stacked a new config-change observer each cycle. removeObserver(self) first to keep it idempotent. 3. Dead code (cleanup): dropped TxConnectionState.isReady and its unused Equatable conformance (referenced only by tests); tests now assert needsAttach/needsConnect — the flags production actually branches on. Co-Authored-By: Claude Opus 4.8 (1M context) --- ios/FT8AF/FT8AF.xcodeproj/project.pbxproj | 10 +++ ios/FT8AF/FT8AF/Engine/ExceptionCatcher.h | 15 +++++ ios/FT8AF/FT8AF/Engine/ExceptionCatcher.m | 10 +++ ios/FT8AF/FT8AF/Engine/TxPlayerService.swift | 63 +++++++++++++------ ios/FT8AF/FT8AF/FT8AF-Bridging-Header.h | 6 ++ .../FT8AFTests/TxConnectionStateTests.swift | 20 +++--- ios/FT8AF/project.yml | 3 + 7 files changed, 97 insertions(+), 30 deletions(-) create mode 100644 ios/FT8AF/FT8AF/Engine/ExceptionCatcher.h create mode 100644 ios/FT8AF/FT8AF/Engine/ExceptionCatcher.m create mode 100644 ios/FT8AF/FT8AF/FT8AF-Bridging-Header.h diff --git a/ios/FT8AF/FT8AF.xcodeproj/project.pbxproj b/ios/FT8AF/FT8AF.xcodeproj/project.pbxproj index e8ce1aa09..08939892a 100644 --- a/ios/FT8AF/FT8AF.xcodeproj/project.pbxproj +++ b/ios/FT8AF/FT8AF.xcodeproj/project.pbxproj @@ -45,6 +45,7 @@ 7FC3449767DCA16F70133366 /* DecodeScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = C79C0E0096ADDC018120C361 /* DecodeScreen.swift */; }; 7FF97BE16C182502CA0E4F40 /* RigModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB78DA6983B10ABE10319476 /* RigModelTests.swift */; }; 80F7FFDDEE3AEE3E706C4A2B /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 076A186C88F9B5A2FFAAB073 /* Assets.xcassets */; }; + 882AA28CDD50C648EB0AB7C5 /* ExceptionCatcher.m in Sources */ = {isa = PBXBuildFile; fileRef = 05A1E61AD575F93424F65731 /* ExceptionCatcher.m */; }; 8DA0D562E25925F56B638B00 /* GridDistance.swift in Sources */ = {isa = PBXBuildFile; fileRef = E429D8A3556534F05374F12A /* GridDistance.swift */; }; 8EA75A450404FB948C0A6CDC /* SettingsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504C9AD4C6667267DFCABEF9 /* SettingsScreen.swift */; }; 8EF984B5816F012A2DF93FDF /* OnlineLogService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D131B05E27A96EC4BEB28782 /* OnlineLogService.swift */; }; @@ -98,6 +99,7 @@ /* Begin PBXFileReference section */ 0244EBDFC2ADDAE77418FF53 /* ActiveQsoPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveQsoPanel.swift; sourceTree = ""; }; 0290AFE608DC2FC9E1A83E16 /* QsoSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QsoSheet.swift; sourceTree = ""; }; + 05A1E61AD575F93424F65731 /* ExceptionCatcher.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = ExceptionCatcher.m; sourceTree = ""; }; 076A186C88F9B5A2FFAAB073 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 0FD3B4836E62F9DC32FAB0DE /* Colors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Colors.swift; sourceTree = ""; }; 11DF7123A737FE9F5236677F /* DecodeRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DecodeRow.swift; sourceTree = ""; }; @@ -106,6 +108,7 @@ 1E706B1D26B550FE4D798B68 /* Accelerate.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Accelerate.framework; path = System/Library/Frameworks/Accelerate.framework; sourceTree = SDKROOT; }; 29B5962B70F4510D76A5BC0A /* TxPlayerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TxPlayerService.swift; sourceTree = ""; }; 2A9EBD1E2440476261F01908 /* ClockSyncIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClockSyncIndicator.swift; sourceTree = ""; }; + 2B7D37874EDD64500E81BBD8 /* ExceptionCatcher.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ExceptionCatcher.h; sourceTree = ""; }; 2CFCB3285583184390D9E9BF /* AboutScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutScreen.swift; sourceTree = ""; }; 33CCA018AEEE169EDCA338FD /* LogbookAwards.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogbookAwards.swift; sourceTree = ""; }; 37860F113FBD71CA51712822 /* FT8AFTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = FT8AFTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -169,6 +172,7 @@ E0CA452882C4980BED953078 /* Typography.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Typography.swift; sourceTree = ""; }; E429D8A3556534F05374F12A /* GridDistance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GridDistance.swift; sourceTree = ""; }; F287F167508A8E4114F44116 /* FFTProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FFTProcessor.swift; sourceTree = ""; }; + F2D8A723CB45300FEDE7301B /* FT8AF-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "FT8AF-Bridging-Header.h"; sourceTree = ""; }; F2EBE7CC0F661A6451447578 /* PttModeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PttModeTests.swift; sourceTree = ""; }; F3C698656C5709343F4AEEF0 /* FrequencyPickerSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrequencyPickerSheet.swift; sourceTree = ""; }; FA66E8CE63C02CC35A2BC010 /* PotaActivationStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PotaActivationStore.swift; sourceTree = ""; }; @@ -328,6 +332,7 @@ children = ( BF21F5D95598018B8A46ED8D /* AppState.swift */, 076A186C88F9B5A2FFAAB073 /* Assets.xcassets */, + F2D8A723CB45300FEDE7301B /* FT8AF-Bridging-Header.h */, 6F1E760AEB31EF3855951728 /* FT8AFApp.swift */, BE0DCBA7E647113F08F32531 /* Info.plist */, 7DCF29540DF6FBE5F4ECD5FC /* Components */, @@ -381,6 +386,8 @@ children = ( C42FB097015693FF14B27553 /* AudioCaptureService.swift */, 3B94D7FBAC132C5854796F79 /* AudioRouteController.swift */, + 2B7D37874EDD64500E81BBD8 /* ExceptionCatcher.h */, + 05A1E61AD575F93424F65731 /* ExceptionCatcher.m */, F287F167508A8E4114F44116 /* FFTProcessor.swift */, 47F0A25416CDB1102C9AD749 /* KeychainStore.swift */, 4E09812700CD93E6187CC4BC /* LiveEngine.swift */, @@ -530,6 +537,7 @@ 3BA2BC0EFAF62534D39C3A4A /* DecodeFilterSettings.swift in Sources */, F3CB49EBF218C3BB9FAE02D6 /* DecodeRow.swift in Sources */, 7FC3449767DCA16F70133366 /* DecodeScreen.swift in Sources */, + 882AA28CDD50C648EB0AB7C5 /* ExceptionCatcher.m in Sources */, EEA0D2F1B9A7E0345F68E909 /* ExportLogSheet.swift in Sources */, 771EC057824C1F3698C6DC9B /* FFTProcessor.swift in Sources */, EC59A75E444DC6AB44913CB7 /* FT8AFApp.swift in Sources */, @@ -630,6 +638,7 @@ ); PRODUCT_BUNDLE_IDENTIFIER = radio.ks3ckc.ft8af; SDKROOT = iphoneos; + SWIFT_OBJC_BRIDGING_HEADER = "FT8AF/FT8AF-Bridging-Header.h"; SWIFT_VERSION = 5.9; TARGETED_DEVICE_FAMILY = "1,2"; }; @@ -720,6 +729,7 @@ ); PRODUCT_BUNDLE_IDENTIFIER = radio.ks3ckc.ft8af; SDKROOT = iphoneos; + SWIFT_OBJC_BRIDGING_HEADER = "FT8AF/FT8AF-Bridging-Header.h"; SWIFT_VERSION = 5.9; TARGETED_DEVICE_FAMILY = "1,2"; }; diff --git a/ios/FT8AF/FT8AF/Engine/ExceptionCatcher.h b/ios/FT8AF/FT8AF/Engine/ExceptionCatcher.h new file mode 100644 index 000000000..1b099894d --- /dev/null +++ b/ios/FT8AF/FT8AF/Engine/ExceptionCatcher.h @@ -0,0 +1,15 @@ +#import + +NS_ASSUME_NONNULL_BEGIN + +/// Runs `block` inside an Obj-C `@try/@catch` and returns the raised +/// `NSException`, or `nil` if none was raised. +/// +/// Swift cannot catch Obj-C exceptions natively (`try` only handles Swift +/// `Error`), so an AVFAudio call that raises one — e.g. +/// `AVAudioPlayerNode.play()` when the engine graph is torn down by a +/// configuration change mid-call — aborts the whole process. Wrapping that call +/// in this shim degrades the abort into a recoverable `nil`/exception result. +NSException *_Nullable ft8af_catchException(void (^_Nonnull block)(void)); + +NS_ASSUME_NONNULL_END diff --git a/ios/FT8AF/FT8AF/Engine/ExceptionCatcher.m b/ios/FT8AF/FT8AF/Engine/ExceptionCatcher.m new file mode 100644 index 000000000..5696e2f4a --- /dev/null +++ b/ios/FT8AF/FT8AF/Engine/ExceptionCatcher.m @@ -0,0 +1,10 @@ +#import "ExceptionCatcher.h" + +NSException *_Nullable ft8af_catchException(void (^_Nonnull block)(void)) { + @try { + block(); + return nil; + } @catch (NSException *exception) { + return exception; + } +} diff --git a/ios/FT8AF/FT8AF/Engine/TxPlayerService.swift b/ios/FT8AF/FT8AF/Engine/TxPlayerService.swift index 42897129a..a9faf3455 100644 --- a/ios/FT8AF/FT8AF/Engine/TxPlayerService.swift +++ b/ios/FT8AF/FT8AF/Engine/TxPlayerService.swift @@ -14,7 +14,7 @@ import FT8DSP /// which is exactly what happened at TX time after a DigiRig was plugged in. /// Tracking attach and connect separately lets `play()` re-establish the /// connection lazily after a config change instead of trusting a stale flag. -struct TxConnectionState: Equatable { +struct TxConnectionState { private(set) var isAttached = false private(set) var isConnected = false @@ -22,8 +22,6 @@ struct TxConnectionState: Equatable { var needsAttach: Bool { !isAttached } /// The node must be (re)connected + the converter rebuilt before playback. var needsConnect: Bool { !isConnected } - /// Playback preconditions are satisfied — node attached and connected. - var isReady: Bool { isAttached && isConnected } mutating func markAttached() { isAttached = true } mutating func markConnected() { isConnected = true } @@ -71,6 +69,13 @@ final class TxPlayerService: @unchecked Sendable { /// the player node yet — that happens lazily in `ensureConnected()`. func configure(engine: AVAudioEngine) { self.engine = engine + // `configure` is called on every LiveEngine start (stop→start reuses the + // same long-lived engine), so drop any prior registration first — a bare + // addObserver would stack a new observer each cycle and fire the handler + // N times per config change. + NotificationCenter.default.removeObserver( + self, name: .AVAudioEngineConfigurationChange, object: nil + ) NotificationCenter.default.addObserver( self, selector: #selector(handleConfigChange), @@ -96,13 +101,16 @@ final class TxPlayerService: @unchecked Sendable { /// Attach (once) and connect the player node, creating the format /// converter. Reconnects and rebuilds the converter when a prior config - /// change invalidated the connection. Returns `false` if the hardware - /// output format is invalid (e.g. 0 Hz on Simulator at startup) — the - /// caller should skip playback. - private func ensureConnected() -> Bool { + /// change invalidated the connection. Returns the converter, or `nil` if the + /// hardware output format is invalid (e.g. 0 Hz on Simulator at startup) — + /// the caller should skip playback. Returning the converter directly (rather + /// than letting `play()` re-read `outputConverter`) keeps the read under the + /// lock, so a concurrent `handleConfigChange` can't nil it between the check + /// and the use. + private func ensureConnected() -> AVAudioConverter? { lock.lock(); defer { lock.unlock() } - if !conn.needsConnect { return outputConverter != nil } - guard let engine else { return false } + if !conn.needsConnect { return outputConverter } + guard let engine else { return nil } // Use the mixer's output format — on real devices this is the // hardware rate (e.g. 48 kHz stereo); on Simulator it becomes valid @@ -110,7 +118,7 @@ final class TxPlayerService: @unchecked Sendable { // different rate (e.g. the DigiRig's), so it must be re-read here. let mixerFormat = engine.mainMixerNode.outputFormat(forBus: 0) guard mixerFormat.sampleRate > 0, mixerFormat.channelCount > 0 else { - return false + return nil } if conn.needsAttach { @@ -119,9 +127,9 @@ final class TxPlayerService: @unchecked Sendable { } engine.connect(playerNode, to: engine.mainMixerNode, format: mixerFormat) outputConverter = AVAudioConverter(from: ft8Format, to: mixerFormat) - guard outputConverter != nil else { return false } + guard outputConverter != nil else { return nil } conn.markConnected() - return true + return outputConverter } /// Schedule and play `samples` (mono Float32 at 12 kHz). Interrupts any @@ -151,7 +159,7 @@ final class TxPlayerService: @unchecked Sendable { // Re-establish the node connection if a config change invalidated it. // Skipping this is what aborts the app: play() on a disconnected node // raises an uncaught NSException in AVFAudio. - guard ensureConnected(), let converter = outputConverter else { + guard let converter = ensureConnected() else { completion?() return false } @@ -196,14 +204,31 @@ final class TxPlayerService: @unchecked Sendable { _isPlaying = true lock.unlock() - playerNode.stop() - playerNode.scheduleBuffer(outputBuffer, at: nil, options: .interrupts) { [weak self] in - self?.lock.lock() - self?._isPlaying = false - self?.lock.unlock() + // A config change can tear down the node's connection on another thread + // in the window between `ensureConnected()` and here; `play()` on a + // disconnected node then raises an uncaught Obj-C NSException that aborts + // the app. Swift's `try` can't catch it, so run the keying through an + // Obj-C @try shim and, on failure, invalidate the connection so the next + // TX rebuilds it. The audio is lost for this slot but the app survives. + let raised = ft8af_catchException { [self] in + playerNode.stop() + playerNode.scheduleBuffer(outputBuffer, at: nil, options: .interrupts) { [weak self] in + self?.lock.lock() + self?._isPlaying = false + self?.lock.unlock() + completion?() + } + playerNode.play() + } + if raised != nil { + lock.lock() + _isPlaying = false + conn.invalidateConnection() + outputConverter = nil + lock.unlock() completion?() + return false } - playerNode.play() return true } diff --git a/ios/FT8AF/FT8AF/FT8AF-Bridging-Header.h b/ios/FT8AF/FT8AF/FT8AF-Bridging-Header.h new file mode 100644 index 000000000..232a475b0 --- /dev/null +++ b/ios/FT8AF/FT8AF/FT8AF-Bridging-Header.h @@ -0,0 +1,6 @@ +// +// FT8AF-Bridging-Header.h +// Exposes the app's Objective-C helpers to Swift. +// + +#import "ExceptionCatcher.h" diff --git a/ios/FT8AF/FT8AFTests/TxConnectionStateTests.swift b/ios/FT8AF/FT8AFTests/TxConnectionStateTests.swift index 00cd37fff..be37b637a 100644 --- a/ios/FT8AF/FT8AFTests/TxConnectionStateTests.swift +++ b/ios/FT8AF/FT8AFTests/TxConnectionStateTests.swift @@ -7,29 +7,29 @@ import XCTest /// node's connection, and playing on a disconnected node aborts the app. The /// state must report `needsConnect` again after such a change while keeping the /// node attached, so `play()` reconnects instead of keying a dead node. +/// +/// Assertions use `needsAttach`/`needsConnect` — the exact flags +/// `ensureConnected()` branches on — so the tests exercise the production paths. final class TxConnectionStateTests: XCTestCase { func testFreshStateNeedsAttachAndConnect() { let s = TxConnectionState() XCTAssertTrue(s.needsAttach) XCTAssertTrue(s.needsConnect) - XCTAssertFalse(s.isReady) } - func testAfterAttachAndConnectIsReady() { + func testAfterAttachAndConnectNeedsNothing() { var s = TxConnectionState() s.markAttached() s.markConnected() XCTAssertFalse(s.needsAttach) XCTAssertFalse(s.needsConnect) - XCTAssertTrue(s.isReady) } - func testAttachedButNotConnectedIsNotReady() { + func testAttachedButNotConnectedStillNeedsConnect() { var s = TxConnectionState() s.markAttached() XCTAssertFalse(s.needsAttach) XCTAssertTrue(s.needsConnect) - XCTAssertFalse(s.isReady) } /// The core regression: a config change must invalidate the connection but @@ -43,10 +43,9 @@ final class TxConnectionStateTests: XCTestCase { XCTAssertFalse(s.needsAttach, "node stays attached across a config change") XCTAssertTrue(s.needsConnect, "connection must be rebuilt after a config change") - XCTAssertFalse(s.isReady) } - func testReconnectAfterConfigChangeReturnsToReady() { + func testReconnectAfterConfigChangeClearsNeedsConnect() { var s = TxConnectionState() s.markAttached() s.markConnected() @@ -57,11 +56,11 @@ final class TxConnectionStateTests: XCTestCase { XCTAssertFalse(s.needsAttach) s.markConnected() - XCTAssertTrue(s.isReady) + XCTAssertFalse(s.needsConnect) } /// Repeated config changes (unplug/replug) must keep forcing a reconnect, - /// never latching into a stale "ready" state. + /// never latching into a stale connected state. func testRepeatedConfigChangesKeepRequiringReconnect() { var s = TxConnectionState() s.markAttached() @@ -71,9 +70,8 @@ final class TxConnectionStateTests: XCTestCase { s.invalidateConnection() XCTAssertTrue(s.needsConnect) XCTAssertFalse(s.needsAttach) - XCTAssertFalse(s.isReady) s.markConnected() - XCTAssertTrue(s.isReady) + XCTAssertFalse(s.needsConnect) } } } diff --git a/ios/FT8AF/project.yml b/ios/FT8AF/project.yml index 24cf4fbbc..44f4da328 100644 --- a/ios/FT8AF/project.yml +++ b/ios/FT8AF/project.yml @@ -50,6 +50,9 @@ targets: base: SWIFT_VERSION: "5.9" PRODUCT_BUNDLE_IDENTIFIER: radio.ks3ckc.ft8af + # Exposes ExceptionCatcher.h so Swift can catch the Obj-C NSException + # that AVAudioPlayerNode.play() raises during a mid-call config change. + SWIFT_OBJC_BRIDGING_HEADER: FT8AF/FT8AF-Bridging-Header.h dependencies: - package: FT8AFKit product: FT8DSP From 80c7aaf1e8d9a98de1fcc4a4e63b9e58a32c07c0 Mon Sep 17 00:00:00 2001 From: Reid Crowe Date: Thu, 24 Sep 2026 10:53:04 -0500 Subject: [PATCH 3/8] Rename CLAUDE.md to AGENTS.md (content unchanged) A pure rename, kept separate so `git log --follow AGENTS.md` and blame keep the file's history. The next commit makes the wording agent-neutral and recreates CLAUDE.md as a pointer. Refs #843. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md => AGENTS.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename CLAUDE.md => AGENTS.md (100%) diff --git a/CLAUDE.md b/AGENTS.md similarity index 100% rename from CLAUDE.md rename to AGENTS.md From 5cb0a60ebaa61a7042f3a1b2b89fbf79ae52e5ef Mon Sep 17 00:00:00 2001 From: Reid Crowe Date: Thu, 24 Sep 2026 10:53:13 -0500 Subject: [PATCH 4/8] Make agent instructions tool-neutral: AGENTS.md + CLAUDE.md pointer AGENTS.md is now the single source of rules for every contributor, human or coding agent. CLAUDE.md shrinks to a pointer that imports it (@AGENTS.md), so Claude Code still loads the full rules. Every rule, command and gotcha is kept; only the title and the local-notes file name change (AGENTS.local.md, with CLAUDE.local.md still ignored and still working). - Testing gains iOS and desktop subsections with the verified commands, and notes the CI gaps. swift test needs full Xcode (XCTest). - Comments in 12 source files and the .gitignore note now point to AGENTS.md. - README links AGENTS.md from a new Contributing section. - .github/scripts/check-agents-md.sh (with --self-test) and the agents-md workflow fail if AGENTS.md is missing, if CLAUDE.md loses its import, or if any file still refers to CLAUDE.md. Closes #843. Co-Authored-By: Claude Opus 5.5 --- .github/scripts/check-agents-md.sh | 70 +++++++++++++++++++ .github/workflows/agents-md.yml | 27 +++++++ .gitignore | 5 +- AGENTS.md | 61 +++++++++++++++- CLAUDE.md | 5 ++ README.md | 6 ++ desktop/src-tauri/src/engine.rs | 2 +- desktop/src/logbook.ts | 2 +- .../main/cpp/ft8af_glue/test_golden_encode.c | 2 +- .../java/com/k1af/ft8af/MainViewModel.java | 2 +- .../ft8af/ft8transmit/FT8TransmitSignal.java | 2 +- .../ft8af/ft8transmit/TuneToneGenerator.java | 2 +- .../k1af/ft8af/wave/AudioChannelSelect.java | 2 +- .../ft8af/wave/UsbTransientErrorPolicy.java | 2 +- .../ft8af/wave/AudioChannelSelectTest.java | 2 +- ios/FT8AF/FT8AF/Engine/LiveEngine.swift | 2 +- ios/FT8AFKit/Sources/FT8Engine/TxTiming.swift | 2 +- .../Tests/FT8EngineTests/TxTimingTests.swift | 2 +- 18 files changed, 182 insertions(+), 16 deletions(-) create mode 100755 .github/scripts/check-agents-md.sh create mode 100644 .github/workflows/agents-md.yml create mode 100644 CLAUDE.md diff --git a/.github/scripts/check-agents-md.sh b/.github/scripts/check-agents-md.sh new file mode 100755 index 000000000..9218e8ef5 --- /dev/null +++ b/.github/scripts/check-agents-md.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# Guard the agent-neutral instructions layout: +# 1. AGENTS.md exists at the repo root (the single source of rules), +# 2. CLAUDE.md imports it with `@AGENTS.md`, +# 3. no other tracked text file refers to the old path CLAUDE.md. +# +# check-agents-md.sh [repo-root] # default: current directory +# check-agents-md.sh --self-test +# +# Used by .github/workflows/agents-md.yml. +set -euo pipefail + +check() { + local root="${1:-.}" + local failed=0 + if [[ ! -f "$root/AGENTS.md" ]]; then + echo "::error::AGENTS.md is missing at the repo root." + failed=1 + fi + if ! grep -qx '@AGENTS.md' "$root/CLAUDE.md" 2>/dev/null; then + echo "::error::CLAUDE.md must contain an '@AGENTS.md' import line." + failed=1 + fi + local stale + stale=$(git -C "$root" grep -I -n 'CLAUDE\.md' -- \ + ':!CLAUDE.md' ':!AGENTS.md' \ + ':!.github/scripts/check-agents-md.sh' ':!.github/workflows/agents-md.yml' || true) + if [[ -n "$stale" ]]; then + echo "::error::These files still refer to CLAUDE.md; point them at AGENTS.md:" + echo "$stale" + failed=1 + fi + return "$failed" +} + +self_test() { + local tmp + tmp=$(mktemp -d) + trap 'rm -rf "$tmp"' RETURN + git -C "$tmp" init -q + printf '# rules\n' > "$tmp/AGENTS.md" + printf '# FT8AF\n\n@AGENTS.md\n' > "$tmp/CLAUDE.md" + printf 'x = 1 # see AGENTS.md\n' > "$tmp/code.py" + git -C "$tmp" add -A + + check "$tmp" >/dev/null || { echo "self-test FAIL: valid layout rejected"; return 1; } + + printf 'x = 1 # see CLAUDE.md\n' > "$tmp/code.py" + git -C "$tmp" add -A + if check "$tmp" >/dev/null; then echo "self-test FAIL: stale reference accepted"; return 1; fi + printf 'x = 1\n' > "$tmp/code.py" + + printf '# FT8AF\n' > "$tmp/CLAUDE.md" + git -C "$tmp" add -A + if check "$tmp" >/dev/null; then echo "self-test FAIL: missing import accepted"; return 1; fi + printf '# FT8AF\n\n@AGENTS.md\n' > "$tmp/CLAUDE.md" + + git -C "$tmp" add -A + rm "$tmp/AGENTS.md" + git -C "$tmp" add -A + if check "$tmp" >/dev/null; then echo "self-test FAIL: missing AGENTS.md accepted"; return 1; fi + + echo "self-test OK" +} + +if [[ "${1:-}" == "--self-test" ]]; then + self_test +else + check "${1:-.}" +fi diff --git a/.github/workflows/agents-md.yml b/.github/workflows/agents-md.yml new file mode 100644 index 000000000..7b8f72f04 --- /dev/null +++ b/.github/workflows/agents-md.yml @@ -0,0 +1,27 @@ +name: Agent instructions check + +# AGENTS.md is the single source of agent/contributor rules; CLAUDE.md only +# imports it. Fail if that layout breaks or a file still points at CLAUDE.md. +# Cheap and always-run, so it can be made a required check. + +on: + pull_request: + push: + branches: [main, staging, dev] + +permissions: + contents: read + +jobs: + agents-md: + name: agents-md + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Self-test the check + run: bash .github/scripts/check-agents-md.sh --self-test + + - name: Check AGENTS.md layout and references + run: bash .github/scripts/check-agents-md.sh diff --git a/.gitignore b/.gitignore index 7bcf97a62..384c2776f 100644 --- a/.gitignore +++ b/.gitignore @@ -4,8 +4,9 @@ ft8af/.gitignore 2 .FT8CN软件设计初衷及使用说明0.88版.pdf.icloud ft8af/app/src/main/assets/.nightUSGS4Layer.sqlite.icloud -# Per-contributor Claude Code notes (machine paths, device serials) - see CLAUDE.md -CLAUDE.local.md +# Per-contributor agent notes (machine paths, device serials) - see AGENTS.md +AGENTS.local.md +CLAUDE.local.md # Accidental scratch/build-artifact trees (see PR #524) uiaar/ diff --git a/AGENTS.md b/AGENTS.md index 8ca7a9185..59780008b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,4 +1,8 @@ -# FT8AF Project Instructions +# FT8AF Agent & Contributor Instructions + +> **One source of rules.** This file is for every contributor, human or coding +> agent (Claude Code, Codex, Cursor, Copilot, Optio, …). `CLAUDE.md` only +> imports it. Put project rules here, not in a tool-specific file. > **Placeholders.** This file is written for any contributor's machine. > Substitute your own values wherever you see `<…>`. The recurring ones: @@ -7,7 +11,8 @@ > `` — your phone's serial from `adb devices`; > `` — your Android SDK root. > Machine-specific notes (your paths, device serials, rig quirks) belong in an -> untracked `CLAUDE.local.md`, not here. +> untracked `AGENTS.local.md`, not here. (`CLAUDE.local.md` is also git-ignored +> and still works for Claude Code users, since Claude Code loads it automatically.) ## Development environments @@ -74,6 +79,11 @@ extract the decision/geometry logic into a plain top-level `internal` function or class (e.g. `buildQsoLog`, `QsoPathProjection`) and test that. Keep the Composable a thin wrapper that just calls the extracted logic. +The same rule applies to the iOS and desktop apps: SwiftUI views and +`App.tsx` stay thin wrappers over plain, testable functions. + +### Android + Tests live in `ft8af/app/src/test/` (Kotlin under `.../kotlin`, Java under `.../java`), use JUnit4 + Truth (`assertThat`), and add `@RunWith(RobolectricTestRunner::class)` when the code under test touches @@ -99,6 +109,53 @@ export JAVA_HOME= ./gradlew testDebugUnitTest --tests ``` +### iOS (`ios/`, macOS only) + +Pure logic goes in the `FT8AFKit` Swift package (`ios/FT8AFKit/Sources/…`), +with XCTest suites under `ios/FT8AFKit/Tests/`. These run on the host Mac with +no simulator, but need full Xcode for XCTest: + +``` +cd ios/FT8AFKit && swift test +``` + +With only the Command Line Tools installed, `swift test` fails with +`unable to resolve module dependency: 'XCTest'`. `swift build` still works +there, and compiles everything except the tests. + +App-level tests (`ios/FT8AF/FT8AFTests`) need full Xcode (not just the +Command Line Tools) and XcodeGen (`brew install xcodegen`). Regenerate the +project after adding files or targets: + +``` +cd ios/FT8AF && xcodegen generate +xcodebuild test -project FT8AF.xcodeproj -scheme FT8AF \ + -destination 'platform=iOS Simulator,name=' \ + CODE_SIGNING_ALLOWED=NO +``` + +(Unverified: this `xcodebuild test` command follows CI's `xcodebuild build` +invocation but hasn't yet been run on a Mac with full Xcode.) + +CI (`.github/workflows/ios.yml`) runs `swift test` but only *builds* the app; +it does not run `FT8AFTests` yet, so run them locally. + +### Desktop (`desktop/`, Tauri) + +Frontend logic lives in plain modules like `desktop/src/rig.ts` with vitest +tests next to them (`*.test.ts`). Rust unit tests are `#[cfg(test)]` modules in +`desktop/src-tauri/src/`, and integration tests are in `desktop/src-tauri/tests/`. + +``` +cd desktop && npm ci && npm test # vitest +cd desktop && npm run build # build.rs needs the frontend dist +cd desktop/src-tauri && cargo test +``` + +On Linux, `cargo test` needs the system packages listed in +`.github/workflows/desktop.yml`. CI runs `cargo test` only in the non-gating +coverage job and doesn't run vitest yet, so run both locally. + ## Build & Deploy After making code changes, always build and install on the connected device diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..4297a1b51 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,5 @@ +# FT8AF + +All project instructions live in [AGENTS.md](AGENTS.md). Read it first. + +@AGENTS.md diff --git a/README.md b/README.md index 02d00984d..ad86c2f17 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,12 @@ cd ft8af --- +## Contributing + +Workflow, testing and build rules for humans and coding agents alike are in [AGENTS.md](AGENTS.md). + +--- + ## Native code The FT8 DSP core is a vendored copy of [kgoba's ft8_lib](https://github.com/kgoba/ft8_lib), pinned to a specific upstream commit — see [`ft8af/app/src/main/cpp/ft8_lib/FT8_LIB_PIN.txt`](ft8af/app/src/main/cpp/ft8_lib/FT8_LIB_PIN.txt) for the exact hash. App-specific JNI glue lives alongside it in `ft8af/app/src/main/cpp/ft8af_glue/`. All of it is built from source by the NDK/CMake toolchain into a single `libft8af.so`, so a fresh clone or worktree builds with no manual copying. diff --git a/desktop/src-tauri/src/engine.rs b/desktop/src-tauri/src/engine.rs index 0a62946d6..8a720a529 100644 --- a/desktop/src-tauri/src/engine.rs +++ b/desktop/src-tauri/src/engine.rs @@ -850,7 +850,7 @@ impl Engine { self.set_ptt(true); std::thread::sleep(Duration::from_millis(PTT_DELAY_MS)); - // Clip leading audio only if we'd overrun the cycle (CLAUDE.md gotcha: + // Clip leading audio only if we'd overrun the cycle (AGENTS.md gotcha: // ms_late = max(0, into_cycle - 2360), NOT into_cycle % 15000). Computed // here, right before audio starts, so it reflects the real lateness after // the PTT settle — `maybe_transmit`'s window check keeps this at 0 in diff --git a/desktop/src/logbook.ts b/desktop/src/logbook.ts index dc7ec88c1..9984a2fd8 100644 --- a/desktop/src/logbook.ts +++ b/desktop/src/logbook.ts @@ -1,6 +1,6 @@ // Pure, framework-free logbook helpers. The React `LogScreen` is a thin wrapper // around these so the decision/normalization logic stays unit-testable (see -// CLAUDE.md: Composable/JSX code can't be tested directly — extract the logic). +// AGENTS.md: Composable/JSX code can't be tested directly — extract the logic). import type { QsoRecord } from "./ipc"; diff --git a/ft8af/app/src/main/cpp/ft8af_glue/test_golden_encode.c b/ft8af/app/src/main/cpp/ft8af_glue/test_golden_encode.c index 49a7bc6f6..214f1a0cc 100644 --- a/ft8af/app/src/main/cpp/ft8af_glue/test_golden_encode.c +++ b/ft8af/app/src/main/cpp/ft8af_glue/test_golden_encode.c @@ -32,7 +32,7 @@ // // The Costas arrays at symbols 0-6 / 36-42 / 72-78 are also asserted directly — // those are what a receiver syncs on; corrupting them yields audible-but- -// undecodable TX (see CLAUDE.md "FT8 TX audio pipeline"). +// undecodable TX (see AGENTS.md "FT8 TX audio pipeline"). // // HOW TO RUN (host build, no device/emulator needed): // ft8af/app/src/main/cpp/ft8af_glue/run_host_tests.ps1 diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java b/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java index e15102ddd..ee3b3da94 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java @@ -1149,7 +1149,7 @@ public void doOnAfterQueryCallsignLocation(CallsignInfo callsignInfo) { // Voice assistant: TTS must never leak into a transmission (it would be // mixed into the rig audio and go out over the air — see the TX audio - // hazards in CLAUDE.md). Two layers: the announcer refuses to start an + // hazards in AGENTS.md). Two layers: the announcer refuses to start an // utterance while isTransmitting(), and this observer hard-stops any // in-flight speech the instant TX begins. observeForever is safe here: // the ViewModel constructor runs on the main thread (setValue above), diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/FT8TransmitSignal.java b/ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/FT8TransmitSignal.java index 5fc18369a..e2417580c 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/FT8TransmitSignal.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/FT8TransmitSignal.java @@ -1072,7 +1072,7 @@ private void playViaAudioTrack(float[] buffer) { // and, on a mono or unknown device, the only possibility — keeps the // historical MONO open: the framework duplicates it to every channel, so // that path is byte-for-byte what it always was. This matters more than - // it looks; see the FT8 TX audio pipeline notes in CLAUDE.md for how + // it looks; see the FT8 TX audio pipeline notes in AGENTS.md for how // little it takes to turn an audible transmission into an undecodable one. final TxChannelLayout layout = resolveTxLayout(preferredOutputDevice); diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/TuneToneGenerator.java b/ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/TuneToneGenerator.java index 9b59be27d..940b9067b 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/TuneToneGenerator.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/TuneToneGenerator.java @@ -14,7 +14,7 @@ * *

Pure arithmetic with injected frequency/sample-rate/amplitude — no * Android, no native code — so it is deterministic and unit-testable - * (CLAUDE.md testing rule). + * (AGENTS.md testing rule). */ public final class TuneToneGenerator { diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/wave/AudioChannelSelect.java b/ft8af/app/src/main/java/com/k1af/ft8af/wave/AudioChannelSelect.java index 6888b29bc..29a8ef559 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/wave/AudioChannelSelect.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/wave/AudioChannelSelect.java @@ -87,7 +87,7 @@ public static boolean needsStereoCapture(int selection) { * channels by the framework and by every UAC device, which is what * {@link #BOTH} means, so the default TX path keeps its historical mono * {@code AudioTrack} untouched. Changing that path is not free — see the FT8 - * TX audio pipeline notes in CLAUDE.md — so it only happens when the + * TX audio pipeline notes in AGENTS.md — so it only happens when the * operator actually asks for one side. */ public static boolean needsStereoPlayback(int selection) { diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/wave/UsbTransientErrorPolicy.java b/ft8af/app/src/main/java/com/k1af/ft8af/wave/UsbTransientErrorPolicy.java index b2d756427..e9409a7f7 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/wave/UsbTransientErrorPolicy.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/wave/UsbTransientErrorPolicy.java @@ -33,7 +33,7 @@ public enum Kind { /** * Most a single isochronous packet may be re-sent before the TX path gives - * up on it. FT8 has ~2.36 s of cycle slack (see {@code CLAUDE.md} → "FT8 + * up on it. FT8 has ~2.36 s of cycle slack (see {@code AGENTS.md} → "FT8 * TX audio pipeline"), so a handful of ~1 ms packet retries is well * within budget and never pushes audio off the WSJT-X grid. */ diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/wave/AudioChannelSelectTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/wave/AudioChannelSelectTest.java index 8f560b02e..19ed935a4 100644 --- a/ft8af/app/src/test/java/com/k1af/ft8af/wave/AudioChannelSelectTest.java +++ b/ft8af/app/src/test/java/com/k1af/ft8af/wave/AudioChannelSelectTest.java @@ -140,7 +140,7 @@ public void foldToMono_clampsSampleCountToTheBufferLength() { @Test public void needsStereoPlayback_onlyForASingleSide() { // BOTH must stay a mono open: that path is the historical TX pipeline and - // is not worth disturbing (see the CLAUDE.md TX audio hazards). + // is not worth disturbing (see the AGENTS.md TX audio hazards). assertThat(AudioChannelSelect.needsStereoPlayback(AudioChannelSelect.BOTH)).isFalse(); assertThat(AudioChannelSelect.needsStereoPlayback(AudioChannelSelect.LEFT)).isTrue(); assertThat(AudioChannelSelect.needsStereoPlayback(AudioChannelSelect.RIGHT)).isTrue(); diff --git a/ios/FT8AF/FT8AF/Engine/LiveEngine.swift b/ios/FT8AF/FT8AF/Engine/LiveEngine.swift index 612fa5661..edd5c085b 100644 --- a/ios/FT8AF/FT8AF/Engine/LiveEngine.swift +++ b/ios/FT8AF/FT8AF/Engine/LiveEngine.swift @@ -938,7 +938,7 @@ final class LiveEngine { /// boundary. The slack is the *physical* `TxTiming.defaultSlackMs` /// constant (15000 − 12640 = 2360 ms) — never the user setting: a smaller /// slack clips the leading Costas sync of ON-TIME transmissions and makes - /// them audible but undecodable (see CLAUDE.md, TX pipeline gotcha #2). + /// them audible but undecodable (see AGENTS.md, TX pipeline gotcha #2). /// The late-start-tolerance setting is a SKIP threshold instead: when more /// than that much leading audio would be clipped, the slot is skipped. private func beginTxPlayback(message: String, samples: [Float], profile: ModeProfile) { diff --git a/ios/FT8AFKit/Sources/FT8Engine/TxTiming.swift b/ios/FT8AFKit/Sources/FT8Engine/TxTiming.swift index a773d2df6..043c4a576 100644 --- a/ios/FT8AFKit/Sources/FT8Engine/TxTiming.swift +++ b/ios/FT8AFKit/Sources/FT8Engine/TxTiming.swift @@ -7,7 +7,7 @@ import Foundation /// transmission still ends on the cycle boundary — but ONLY the portion past /// the slack. Clipping anything on an on-time start (e.g. a naive /// `msIntoCycle % 15000`) chops the leading Costas sync array and produces an -/// audible-but-undecodable signal (see project CLAUDE.md, "FT8 TX audio +/// audible-but-undecodable signal (see project AGENTS.md, "FT8 TX audio /// pipeline" gotcha #2, fixed on Android in PR #93). public enum TxTiming { /// Slack between the 12.64 s FT8 waveform and the 15 s cycle. diff --git a/ios/FT8AFKit/Tests/FT8EngineTests/TxTimingTests.swift b/ios/FT8AFKit/Tests/FT8EngineTests/TxTimingTests.swift index 8ba1b6a55..9aaa81665 100644 --- a/ios/FT8AFKit/Tests/FT8EngineTests/TxTimingTests.swift +++ b/ios/FT8AFKit/Tests/FT8EngineTests/TxTimingTests.swift @@ -8,7 +8,7 @@ final class TxTimingTests: XCTestCase { XCTAssertEqual(TxTiming.defaultSlackMs, 2360) } - // The load-bearing rule (CLAUDE.md TX pipeline gotcha #2): a normal + // The load-bearing rule (AGENTS.md TX pipeline gotcha #2): a normal // on-time TX firing a few hundred ms into the cycle must clip NOTHING — // that is where the leading Costas sync lives. func testOnTimeStartClipsNothing() { From bb279b5fbd8ae5ac4878b2b97e36de75109ac07e Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Thu, 24 Sep 2026 12:58:43 -0500 Subject: [PATCH 5/8] fix: don't let SWR protection interrupt a user tune MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rig meter callback is live whenever PTT is keyed — during an FT8 over and during a user-initiated tune carrier alike. High SWR during a tune is the expected starting condition (the operator keys a carrier precisely to bring a match down), not a fault, but the SWR halt was firing anyway: it set the lockout, showed the banner, and the SWR_LOCKED gate then blocked the very retune the operator pressed Tune for. Thread a `tuning` flag through the pure `shouldHaltForSwr` gate so it returns false while a tune carrier is up, and pass `transmitSignal.isTuning()` from `onMeterUpdate`. The FT8-over halt path is unchanged. The tune worker keeps its own bounded max-on timeout for PA safety, and the diagnostic log now records "tune (halt suppressed)" so the reason is visible in debug.log. Adds shouldHalt_neverWhileTuning and updates the two existing callers for the new signature. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../MeterProtectionController.java | 28 +++++++++++++------ .../MeterProtectionControllerTest.java | 21 ++++++++++---- .../ft8af/rigs/DiscoveryTX500RigTest.java | 4 +-- 3 files changed, 38 insertions(+), 15 deletions(-) diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/MeterProtectionController.java b/ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/MeterProtectionController.java index 61d4b668a..6b81d9f58 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/MeterProtectionController.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/MeterProtectionController.java @@ -58,10 +58,16 @@ public void setTransmitSignal(FT8TransmitSignal signal) { /** * Whether SWR is high enough to halt TX: protection enabled, a valid reading, and over - * the threshold. Pure so the rule is unit-tested independent of the rig meter plumbing. + * the threshold — but never while a user tune carrier is up. A tune is the operator + * deliberately keying to bring SWR *down* (setting an antenna tuner, checking a match), + * so high SWR is the expected starting condition, not a fault; halting there would lock + * the operator out of the very retune they pressed the button for. The tune worker has + * its own bounded max-on timeout for PA safety. Pure so the rule is unit-tested + * independent of the rig meter plumbing. */ - public static boolean shouldHaltForSwr(int normalizedSwr, boolean enabled, int threshold) { - return normalizedSwr >= 0 && enabled && normalizedSwr > threshold; + public static boolean shouldHaltForSwr(int normalizedSwr, boolean enabled, int threshold, + boolean tuning) { + return !tuning && normalizedSwr >= 0 && enabled && normalizedSwr > threshold; } /** @@ -73,6 +79,11 @@ public void onMeterUpdate(int normalizedAlc, int normalizedSwr) { if (normalizedAlc >= 0) lastAlc.postValue(normalizedAlc); if (normalizedSwr >= 0) lastSwr.postValue(normalizedSwr); + // A user tune carrier is in progress: the meter callback is live whenever PTT is + // keyed (FT8 over OR tune), so suppress the SWR halt while tuning. See + // shouldHaltForSwr — halting here would set the lockout and block the retune. + boolean tuning = transmitSignal != null && transmitSignal.isTuning(); + // Diagnostics: when SWR protection is on, record each SWR reading we actually // receive so the debug.log shows whether meter data even reaches here during TX, the // values, and the halt decision. Without this a halt that never fires leaves no trace @@ -86,17 +97,18 @@ public void onMeterUpdate(int normalizedAlc, int normalizedSwr) { if (changed || now - lastSwrLogMs >= SWR_LOG_THROTTLE_MS) { lastSwrLogMs = now; lastLoggedSwr = normalizedSwr; + String decision = tuning ? "tune (halt suppressed)" + : (shouldHaltForSwr(normalizedSwr, GeneralVariables.swrHaltEnabled, + GeneralVariables.swrHaltThreshold, false) ? "HALT" : "ok"); GeneralVariables.fileLog(String.format( "MeterProtection: SWR reading swr=%d threshold=%d -> %s", - normalizedSwr, GeneralVariables.swrHaltThreshold, - shouldHaltForSwr(normalizedSwr, GeneralVariables.swrHaltEnabled, - GeneralVariables.swrHaltThreshold) ? "HALT" : "ok")); + normalizedSwr, GeneralVariables.swrHaltThreshold, decision)); } } - // --- SWR halt check --- + // --- SWR halt check (never during a user tune) --- if (shouldHaltForSwr(normalizedSwr, GeneralVariables.swrHaltEnabled, - GeneralVariables.swrHaltThreshold)) { + GeneralVariables.swrHaltThreshold, tuning)) { haltForSwr(normalizedSwr); return; // no point adjusting volume if we just killed TX } diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/ft8transmit/MeterProtectionControllerTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/ft8transmit/MeterProtectionControllerTest.java index d0520c656..e15027b6f 100644 --- a/ft8af/app/src/test/java/com/k1af/ft8af/ft8transmit/MeterProtectionControllerTest.java +++ b/ft8af/app/src/test/java/com/k1af/ft8af/ft8transmit/MeterProtectionControllerTest.java @@ -96,13 +96,24 @@ public void roundTrip_boundariesAreStable() { @Test public void shouldHalt_onlyWhenEnabledValidAndOverThreshold() { // Over threshold with protection on -> halt. - assertThat(MeterProtectionController.shouldHaltForSwr(150, true, 120)).isTrue(); + assertThat(MeterProtectionController.shouldHaltForSwr(150, true, 120, false)).isTrue(); // At/under threshold -> no halt (strictly greater-than). - assertThat(MeterProtectionController.shouldHaltForSwr(120, true, 120)).isFalse(); - assertThat(MeterProtectionController.shouldHaltForSwr(90, true, 120)).isFalse(); + assertThat(MeterProtectionController.shouldHaltForSwr(120, true, 120, false)).isFalse(); + assertThat(MeterProtectionController.shouldHaltForSwr(90, true, 120, false)).isFalse(); // Protection off -> never halt, even at high SWR. - assertThat(MeterProtectionController.shouldHaltForSwr(200, false, 120)).isFalse(); + assertThat(MeterProtectionController.shouldHaltForSwr(200, false, 120, false)).isFalse(); // No reading from the rig (-1) -> never halt. - assertThat(MeterProtectionController.shouldHaltForSwr(-1, true, 120)).isFalse(); + assertThat(MeterProtectionController.shouldHaltForSwr(-1, true, 120, false)).isFalse(); + } + + @Test + public void shouldHalt_neverWhileTuning() { + // A user tune deliberately keys a carrier to bring SWR down, so even an + // over-threshold reading with protection enabled must NOT halt while tuning + // — otherwise the operator is locked out of the retune they just started. + assertThat(MeterProtectionController.shouldHaltForSwr(150, true, 120, true)).isFalse(); + assertThat(MeterProtectionController.shouldHaltForSwr(255, true, 120, true)).isFalse(); + // The same reading DOES halt once the tune ends (tuning=false). + assertThat(MeterProtectionController.shouldHaltForSwr(150, true, 120, false)).isTrue(); } } diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/rigs/DiscoveryTX500RigTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/rigs/DiscoveryTX500RigTest.java index 7cede35b7..20f040976 100644 --- a/ft8af/app/src/test/java/com/k1af/ft8af/rigs/DiscoveryTX500RigTest.java +++ b/ft8af/app/src/test/java/com/k1af/ft8af/rigs/DiscoveryTX500RigTest.java @@ -58,10 +58,10 @@ public void catToNormalizedSwr_alignsWithHaltThresholdScale() { public void catToNormalizedSwr_tripsHaltAboveDefaultThreshold() { // Default swr-halt threshold is 120 (~3.0:1); a reading of 23 (~8.7:1) must halt. int normalized = DiscoveryTX500Rig.tx500CatToNormalizedSwr(23); - assertThat(MeterProtectionController.shouldHaltForSwr(normalized, true, 120)).isTrue(); + assertThat(MeterProtectionController.shouldHaltForSwr(normalized, true, 120, false)).isTrue(); // A low reading (cat 3 ~= 2.0:1) must NOT halt at the default threshold. int low = DiscoveryTX500Rig.tx500CatToNormalizedSwr(3); - assertThat(MeterProtectionController.shouldHaltForSwr(low, true, 120)).isFalse(); + assertThat(MeterProtectionController.shouldHaltForSwr(low, true, 120, false)).isFalse(); } } From 988e83a9c5c4b316772080eabb8e3ff92acd82cd Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Thu, 24 Sep 2026 13:35:34 -0500 Subject: [PATCH 6/8] feat: collapsing bottom-sheet controls drawer (design 3a/3b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turn the always-open operate control strip into a Material bottom-sheet drawer, per the "FT8AF app UX redesign" concept (options 3a/3b): - Collapsed peek (3a): rig/CAT status line docked above a rounded-top drawer surface with a drag handle, the primary Call CQ / Stop action, and the TX-period toggle. Tap the handle or swipe up to open. - Expanded sheet (3b): the full control set — Band & Mode, Call CQ (+ MORE) / Hunt, TX Period / Tune / DX, and the power slider — over a scrim, reusing FT8AFBottomSheet (swipe-down / scrim-tap / Back dismiss). The control widgets are extracted from TxStrip into shared internal composables (OperateStatusRow, BandModeRow, CqHuntRow, PeriodTuneDxRow, TxVolumeRow) so the peek, the expanded sheet, and the legacy strip all render identical controls and drive one set of hoisted action lambdas. Shipped behind a "Controls Drawer" setting (Radio & Audio, default ON, mirrors the showTxVolumeSlider pattern); off restores the original always-open strip. The drag-to-settle rule (drawerTargetExpanded) is extracted and unit-tested. Verified on emulator: peek, expand, collapse, and the settings toggle. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../java/com/k1af/ft8af/GeneralVariables.java | 3 + .../com/k1af/ft8af/database/DatabaseOpr.java | 4 + .../kotlin/radio/ks3ckc/ft8af/FT8AFApp.kt | 341 ++++++++----- .../ui/components/OperateControlsDrawer.kt | 292 +++++++++++ .../ks3ckc/ft8af/ui/components/TxStrip.kt | 469 +++++++++++------- .../ft8af/ui/settings/RadioAudioSettings.kt | 17 + .../src/main/res/values/strings_compose.xml | 4 + .../components/OperateControlsDrawerTest.kt | 70 +++ 8 files changed, 894 insertions(+), 306 deletions(-) create mode 100644 ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/OperateControlsDrawer.kt create mode 100644 ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/components/OperateControlsDrawerTest.kt diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java b/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java index 67287260a..d0ee31b75 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java @@ -161,6 +161,9 @@ public static boolean isConfiguredUsbAudioOutput(int vid, int pid) { public static boolean showTxVolumeSlider = true;//Show inline TX volume slider on main screen public static MutableLiveData mutableShowTxVolumeSlider = new MutableLiveData<>(true); + public static boolean controlsDrawerEnabled = true;//Operate controls as a collapsing bottom-sheet drawer (design 3a/3b) + public static MutableLiveData mutableControlsDrawerEnabled = new MutableLiveData<>(true); + //Save TX output level per band (issue #355), defaults off (global level only). // volatile: written from DatabaseOpr's background config-load thread and the // Settings toggle, read from UI + MeterProtectionController threads (same diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java b/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java index b80fb8670..6f69c32ae 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java @@ -2971,6 +2971,10 @@ protected Void doInBackground(Void... voids) { GeneralVariables.showTxVolumeSlider = !result.equals("0"); GeneralVariables.mutableShowTxVolumeSlider.postValue(GeneralVariables.showTxVolumeSlider); } + if (name.equalsIgnoreCase("controlsDrawerEnabled")) {//Collapsing bottom-sheet controls drawer, defaults on + GeneralVariables.controlsDrawerEnabled = !result.equals("0"); + GeneralVariables.mutableControlsDrawerEnabled.postValue(GeneralVariables.controlsDrawerEnabled); + } if (name.equalsIgnoreCase("perBandOutputLevel")) {//Save TX output level per band, defaults off GeneralVariables.savePerBandOutputLevel = result.equals("1"); } diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/FT8AFApp.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/FT8AFApp.kt index b010ab8f8..2fca525bf 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/FT8AFApp.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/FT8AFApp.kt @@ -63,6 +63,8 @@ import radio.ks3ckc.ft8af.ui.components.SlotTimerBar import radio.ks3ckc.ft8af.ui.components.TabBar import radio.ks3ckc.ft8af.ui.components.TransmitGlow import radio.ks3ckc.ft8af.ui.components.TxStrip +import radio.ks3ckc.ft8af.ui.components.OperateControlsPeek +import radio.ks3ckc.ft8af.ui.components.OperateControlsSheet import radio.ks3ckc.ft8af.ui.components.VoiceCommandButton import radio.ks3ckc.ft8af.ui.components.selectBandIndex import radio.ks3ckc.ft8af.ui.decode.DecodeScreen @@ -135,6 +137,18 @@ fun FT8AFApp(mainViewModel: MainViewModel) { showVolumeSlider = showVolumeSliderLive ?: GeneralVariables.showTxVolumeSlider } + // Operate-controls drawer (design 3a/3b) — observed so the Settings toggle takes effect + // immediately. When on, the always-open strip becomes a collapsing bottom-sheet drawer; + // controlsExpanded drives the open sheet and starts collapsed each session. + val controlsDrawerLive by GeneralVariables.mutableControlsDrawerEnabled.observeAsState( + GeneralVariables.controlsDrawerEnabled, + ) + var controlsDrawerEnabled by remember { mutableStateOf(GeneralVariables.controlsDrawerEnabled) } + LaunchedEffect(controlsDrawerLive) { + controlsDrawerEnabled = controlsDrawerLive ?: GeneralVariables.controlsDrawerEnabled + } + var controlsExpanded by rememberSaveable { mutableStateOf(false) } + // Consume the one-shot celebration signal so LiveData doesn't replay it // on recomposition / resubscription. LaunchedEffect(qsoCompletedAt) { @@ -339,6 +353,119 @@ fun FT8AFApp(mainViewModel: MainViewModel) { } } + // ---- Operating-controls: derived labels + shared action lambdas ---- + // Hoisted to function scope so the always-open TxStrip, the collapsing drawer peek (in the + // Column), and the expanded controls sheet (a sibling overlay in the outer Box) all drive + // identical rig behavior from one set of callbacks. + val slotMillis = ModeProfile.fromId(operatingMode).slotMillis.toLong() + val bandModeLabel = "$frequencyLabel · $modeName" + val huntOptionLabel = if (huntEnabled) { + stringResource( + when (huntPriority) { + HuntPriority.LATEST -> R.string.hunt_priority_latest + HuntPriority.STRONGEST -> R.string.hunt_priority_strongest + HuntPriority.WEAKEST -> R.string.hunt_priority_weakest + HuntPriority.FARTHEST -> R.string.hunt_priority_farthest + HuntPriority.POTA_FIRST -> R.string.hunt_priority_pota + HuntPriority.NEW_DXCC_FIRST -> R.string.hunt_priority_new_dxcc + HuntPriority.NEW_GRID_FIRST -> R.string.hunt_priority_new_grid + }, + ) + } else { + stringResource(R.string.tx_hunt_off) + } + + val onToggleTuneAction: () -> Unit = { + // Toggle (WSJT-X style latching Tune): tap to key the carrier, + // tap again to stop. startTune() toasts the reason when blocked. + // Per the tune-method setting the tap may instead fire the rig's + // internal ATU (issue #425) — a one-shot command, nothing to latch. + if (isTuning) { + mainViewModel.ft8TransmitSignal.stopTune() + } else if (!mainViewModel.tryStartTuneViaAtu()) { + mainViewModel.ft8TransmitSignal.startTune() + } + } + val onVolumeChangeAction: (Int) -> Unit = { newVolume -> + txVolume = newVolume + GeneralVariables.volumePercent = newVolume / 100f + GeneralVariables.mutableVolumePercent.postValue(newVolume / 100f) + } + val onVolumeChangeFinishedAction: () -> Unit = { + mainViewModel.databaseOpr.writeConfig("volumeValue", txVolume.toString(), null) + mainViewModel.baseRig?.connector?.setRFVolume(txVolume) + saveOutputLevelForCurrentBand(mainViewModel.databaseOpr, txVolume) + } + val onCallCQAction: () -> Unit = { + if (GeneralVariables.myCallsign.isNullOrEmpty()) { + Toast.makeText(context, context.getString(R.string.app_set_callsign_first), Toast.LENGTH_SHORT).show() + } else if (isFreeTextMode && freeTextMessage.isNotBlank()) { + // Free text is a one-shot (WSJT-X Tx5 style): send it once, + // immediately, then the engine auto-stops — it is an alternative + // to a 73, not a repeating CQ. Consume the armed free text so the + // next tap calls a normal CQ instead of re-sending it. + mainViewModel.ft8TransmitSignal.sendFreeTextOnce(freeTextMessage) + isFreeTextMode = false + freeTextMessage = "" + } else { + mainViewModel.ft8TransmitSignal.setTransmitFreeText(false) + mainViewModel.ft8TransmitSignal.userResetToCQ() + mainViewModel.ft8TransmitSignal.setActivated(true) + GeneralVariables.resetLaunchSupervision() + } + } + val onStopAction: () -> Unit = { + // In Hound mode the STOP button leaves Hound entirely; + // otherwise it just deactivates the normal sequencer. + if (GeneralVariables.houndMode) { + mainViewModel.stopHoundMode() + dxEnabled = false + } else { + mainViewModel.ft8TransmitSignal.setActivated(false) + // If Hunt armed this run, STOP ends Hunt too, so the buttons can't be + // left showing Hunt "on" while the sequencer is stopped (and idle). + if (huntEnabled) { + huntEnabled = false + GeneralVariables.autoFollowCQ = false + mainViewModel.databaseOpr.writeConfig("autoFollowCQ", "0", null) + } + } + // Clear free text and Field Day mode on stop + isFreeTextMode = false + freeTextMessage = "" + mainViewModel.ft8TransmitSignal.setTransmitFreeText(false) + if (fieldDayEnabled) { + fieldDayEnabled = false + GeneralVariables.fieldDayMode = false + mainViewModel.databaseOpr.writeConfig("fieldDayMode", "0", null) + cqModifier = "" + GeneralVariables.toModifier = "" + mainViewModel.databaseOpr.writeConfig("toModifier", "", null) + } + } + val onOpenCqOptionsAction: () -> Unit = { showCqOptions = true } + val onToggleDxAction: () -> Unit = { + if (dxEnabled || GeneralVariables.houndMode) { + mainViewModel.stopHoundMode() + dxEnabled = false + } else { + showHoundSetup = true + } + } + val onSelectTxPeriodAction: (Int) -> Unit = { newSlot -> + mainViewModel.ft8TransmitSignal.sequential = newSlot + mainViewModel.ft8TransmitSignal.mutableSequential.postValue(newSlot) + // Switching slots mid-QSO abandons the current contact. + val target = mainViewModel.ft8TransmitSignal.mutableToCallsign.value + if (FT8TransmitSignal.shouldResetTargetOnSlotToggle(target?.callsign)) { + mainViewModel.ft8TransmitSignal.userResetToCQ() + } + } + val onToggleHuntAction: () -> Unit = { setHuntEnabled(!huntEnabled) } + val onOpenHuntOptionsAction: () -> Unit = { showHuntOptions = true } + val onReconnectCatAction: () -> Unit = { mainViewModel.reconnectRig() } + val onOpenBandModeAction: () -> Unit = { showBandModeSheet = true } + Box(modifier = Modifier.fillMaxSize().background(BgApp)) { // Keep the content clear of the display cutout (and the status bar, should it // ever be visible) — the Box behind still paints BgApp into the padded band so a @@ -449,135 +576,64 @@ fun FT8AFApp(mainViewModel: MainViewModel) { offsetSec = avgDtSec, ) - // TX status strip — always visible above tab bar (redesigned, option 3a). The - // slot bar + clock-sync pill remain in the SlotTimerBar above; the strip's - // status row shows the plain-language "next transmit window" countdown. - val slotMillis = ModeProfile.fromId(operatingMode).slotMillis.toLong() - val bandModeLabel = "$frequencyLabel · $modeName" - val huntOptionLabel = if (huntEnabled) { - stringResource( - when (huntPriority) { - HuntPriority.LATEST -> R.string.hunt_priority_latest - HuntPriority.STRONGEST -> R.string.hunt_priority_strongest - HuntPriority.WEAKEST -> R.string.hunt_priority_weakest - HuntPriority.FARTHEST -> R.string.hunt_priority_farthest - HuntPriority.POTA_FIRST -> R.string.hunt_priority_pota - HuntPriority.NEW_DXCC_FIRST -> R.string.hunt_priority_new_dxcc - HuntPriority.NEW_GRID_FIRST -> R.string.hunt_priority_new_grid - }, + // TX operating controls above the tab bar. When the drawer setting is on (default) + // they collapse into a bottom-sheet drawer (design 3a/3b): the peek shows status + + // the primary action + TX period, and the full control set opens in the + // OperateControlsSheet overlay below. When off, the original always-open strip shows. + // The slot bar + clock-sync pill remain in the SlotTimerBar above. + if (controlsDrawerEnabled) { + OperateControlsPeek( + isTransmitting = isTransmitting, + isActivated = isActivated, + isTuning = isTuning, + slotMillis = slotMillis, + txSlot = txSlot, + huntEnabled = huntEnabled, + cqModifier = cqModifier, + isFreeTextMode = isFreeTextMode, + fieldDayEnabled = fieldDayEnabled, + showCatChip = showCatChip, + catState = catState, + onExpand = { controlsExpanded = true }, + onCallCQ = onCallCQAction, + onStop = onStopAction, + onOpenCqOptions = onOpenCqOptionsAction, + onSelectTxPeriod = onSelectTxPeriodAction, + onReconnectCat = onReconnectCatAction, ) } else { - stringResource(R.string.tx_hunt_off) + TxStrip( + isTransmitting = isTransmitting, + isActivated = isActivated, + bandModeLabel = bandModeLabel, + slotMillis = slotMillis, + txSlot = txSlot, + huntEnabled = huntEnabled, + huntOptionLabel = huntOptionLabel, + dxEnabled = dxEnabled, + catState = catState, + showCatChip = showCatChip, + txVolume = txVolume, + showVolumeSlider = showVolumeSlider, + cqModifier = cqModifier, + isFreeTextMode = isFreeTextMode, + fieldDayEnabled = fieldDayEnabled, + isTuning = isTuning, + tuneRemainingSec = tuneRemainingSec, + onToggleTune = onToggleTuneAction, + onVolumeChange = onVolumeChangeAction, + onVolumeChangeFinished = onVolumeChangeFinishedAction, + onCallCQ = onCallCQAction, + onStop = onStopAction, + onOpenCqOptions = onOpenCqOptionsAction, + onToggleDx = onToggleDxAction, + onSelectTxPeriod = onSelectTxPeriodAction, + onToggleHunt = onToggleHuntAction, + onOpenHuntOptions = onOpenHuntOptionsAction, + onReconnectCat = onReconnectCatAction, + onOpenBandMode = onOpenBandModeAction, + ) } - TxStrip( - isTransmitting = isTransmitting, - isActivated = isActivated, - bandModeLabel = bandModeLabel, - slotMillis = slotMillis, - txSlot = txSlot, - huntEnabled = huntEnabled, - huntOptionLabel = huntOptionLabel, - dxEnabled = dxEnabled, - catState = catState, - showCatChip = showCatChip, - txVolume = txVolume, - showVolumeSlider = showVolumeSlider, - cqModifier = cqModifier, - isFreeTextMode = isFreeTextMode, - fieldDayEnabled = fieldDayEnabled, - isTuning = isTuning, - tuneRemainingSec = tuneRemainingSec, - onToggleTune = { - // Toggle (WSJT-X style latching Tune): tap to key the carrier, - // tap again to stop. startTune() toasts the reason when blocked. - // Per the tune-method setting the tap may instead fire the rig's - // internal ATU (issue #425) — a one-shot command, nothing to latch. - if (isTuning) { - mainViewModel.ft8TransmitSignal.stopTune() - } else if (!mainViewModel.tryStartTuneViaAtu()) { - mainViewModel.ft8TransmitSignal.startTune() - } - }, - onVolumeChange = { newVolume -> - txVolume = newVolume - GeneralVariables.volumePercent = newVolume / 100f - GeneralVariables.mutableVolumePercent.postValue(newVolume / 100f) - }, - onVolumeChangeFinished = { - mainViewModel.databaseOpr.writeConfig("volumeValue", txVolume.toString(), null) - mainViewModel.baseRig?.connector?.setRFVolume(txVolume) - saveOutputLevelForCurrentBand(mainViewModel.databaseOpr, txVolume) - }, - onCallCQ = { - if (GeneralVariables.myCallsign.isNullOrEmpty()) { - Toast.makeText(context, context.getString(R.string.app_set_callsign_first), Toast.LENGTH_SHORT).show() - } else if (isFreeTextMode && freeTextMessage.isNotBlank()) { - // Free text is a one-shot (WSJT-X Tx5 style): send it once, - // immediately, then the engine auto-stops — it is an alternative - // to a 73, not a repeating CQ. Consume the armed free text so the - // next tap calls a normal CQ instead of re-sending it. - mainViewModel.ft8TransmitSignal.sendFreeTextOnce(freeTextMessage) - isFreeTextMode = false - freeTextMessage = "" - } else { - mainViewModel.ft8TransmitSignal.setTransmitFreeText(false) - mainViewModel.ft8TransmitSignal.userResetToCQ() - mainViewModel.ft8TransmitSignal.setActivated(true) - GeneralVariables.resetLaunchSupervision() - } - }, - onStop = { - // In Hound mode the STOP button leaves Hound entirely; - // otherwise it just deactivates the normal sequencer. - if (GeneralVariables.houndMode) { - mainViewModel.stopHoundMode() - dxEnabled = false - } else { - mainViewModel.ft8TransmitSignal.setActivated(false) - // If Hunt armed this run, STOP ends Hunt too, so the buttons can't be - // left showing Hunt "on" while the sequencer is stopped (and idle). - if (huntEnabled) { - huntEnabled = false - GeneralVariables.autoFollowCQ = false - mainViewModel.databaseOpr.writeConfig("autoFollowCQ", "0", null) - } - } - // Clear free text and Field Day mode on stop - isFreeTextMode = false - freeTextMessage = "" - mainViewModel.ft8TransmitSignal.setTransmitFreeText(false) - if (fieldDayEnabled) { - fieldDayEnabled = false - GeneralVariables.fieldDayMode = false - mainViewModel.databaseOpr.writeConfig("fieldDayMode", "0", null) - cqModifier = "" - GeneralVariables.toModifier = "" - mainViewModel.databaseOpr.writeConfig("toModifier", "", null) - } - }, - onOpenCqOptions = { showCqOptions = true }, - onToggleDx = { - if (dxEnabled || GeneralVariables.houndMode) { - mainViewModel.stopHoundMode() - dxEnabled = false - } else { - showHoundSetup = true - } - }, - onSelectTxPeriod = { newSlot -> - mainViewModel.ft8TransmitSignal.sequential = newSlot - mainViewModel.ft8TransmitSignal.mutableSequential.postValue(newSlot) - // Switching slots mid-QSO abandons the current contact. - val target = mainViewModel.ft8TransmitSignal.mutableToCallsign.value - if (FT8TransmitSignal.shouldResetTargetOnSlotToggle(target?.callsign)) { - mainViewModel.ft8TransmitSignal.userResetToCQ() - } - }, - onToggleHunt = { setHuntEnabled(!huntEnabled) }, - onOpenHuntOptions = { showHuntOptions = true }, - onReconnectCat = { mainViewModel.reconnectRig() }, - onOpenBandMode = { showBandModeSheet = true }, - ) // Bottom tab bar TabBar( @@ -595,6 +651,39 @@ fun FT8AFApp(mainViewModel: MainViewModel) { // One-shot particle burst when a QSO completes. QsoCelebration(triggerAt = qsoCompletedAt) + // Expanded operate-controls sheet (design 3b) — sibling overlay so its scrim + sheet + // sit above the tab bar and the collapsed peek. Only meaningful when the drawer setting + // is on; guarded so turning the setting off can't leave a stale sheet open. + OperateControlsSheet( + visible = controlsDrawerEnabled && controlsExpanded, + isTransmitting = isTransmitting, + isActivated = isActivated, + bandModeLabel = bandModeLabel, + txSlot = txSlot, + huntEnabled = huntEnabled, + huntOptionLabel = huntOptionLabel, + isTuning = isTuning, + dxEnabled = dxEnabled, + txVolume = txVolume, + showVolumeSlider = showVolumeSlider, + cqModifier = cqModifier, + isFreeTextMode = isFreeTextMode, + fieldDayEnabled = fieldDayEnabled, + tuneRemainingSec = tuneRemainingSec, + onDismiss = { controlsExpanded = false }, + onCallCQ = onCallCQAction, + onStop = onStopAction, + onSelectTxPeriod = onSelectTxPeriodAction, + onToggleHunt = onToggleHuntAction, + onOpenHuntOptions = onOpenHuntOptionsAction, + onOpenBandMode = onOpenBandModeAction, + onToggleTune = onToggleTuneAction, + onToggleDx = onToggleDxAction, + onOpenCqOptions = onOpenCqOptionsAction, + onVolumeChange = onVolumeChangeAction, + onVolumeChangeFinished = onVolumeChangeFinishedAction, + ) + // Frequency picker — sibling overlay so the scrim and sheet sit above the // tab bar and TxStrip. FrequencyPickerSheet( diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/OperateControlsDrawer.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/OperateControlsDrawer.kt new file mode 100644 index 000000000..3b45e0618 --- /dev/null +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/OperateControlsDrawer.kt @@ -0,0 +1,292 @@ +package radio.ks3ckc.ft8af.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectVerticalDragGestures +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import com.k1af.ft8af.R +import com.k1af.ft8af.rigs.CatConnectionState +import radio.ks3ckc.ft8af.theme.* + +/** + * Given the drawer's current state and the net vertical drag applied to the handle (negative = + * upward), decide the state to settle into when the drag ends. An upward drag past the threshold + * from the collapsed peek expands it; a downward drag past the threshold from expanded collapses + * it; anything short of the threshold leaves the state unchanged. Extracted so the gesture rule + * can be unit-tested without Compose. + */ +internal fun drawerTargetExpanded( + currentlyExpanded: Boolean, + dragDy: Float, + thresholdPx: Float, +): Boolean = when { + !currentlyExpanded && dragDy <= -thresholdPx -> true + currentlyExpanded && dragDy >= thresholdPx -> false + else -> currentlyExpanded +} + +/** The vertical drag distance on the peek handle that commits to expanding the drawer. */ +internal val DrawerDragThreshold = 48.dp + +/** + * The collapsed (peek) state of the operate-controls drawer — design option 3a. Renders inline + * above the tab bar: the rig/CAT status line docked on top, then a rounded-top drawer surface + * with a drag handle and only the primary action (Call CQ / Stop) alongside the TX-period toggle. + * Tapping the handle or swiping it up opens the full [OperateControlsSheet]; the rest of the + * controls live there. + */ +@Composable +fun OperateControlsPeek( + isTransmitting: Boolean, + isActivated: Boolean, + isTuning: Boolean, + slotMillis: Long, + txSlot: Int, + huntEnabled: Boolean, + cqModifier: String, + isFreeTextMode: Boolean, + fieldDayEnabled: Boolean, + showCatChip: Boolean, + catState: CatConnectionState, + onExpand: () -> Unit, + onCallCQ: () -> Unit, + onStop: () -> Unit, + onOpenCqOptions: () -> Unit, + onSelectTxPeriod: (Int) -> Unit, + onReconnectCat: () -> Unit, + modifier: Modifier = Modifier, +) { + val bgColor = if (isTransmitting) { + Brush.horizontalGradient( + listOf( + Color(0x1FFFAF5E), // rgba(255,175,94,0.12) + Color(0x0AFFAF5E), // rgba(255,175,94,0.04) + ) + ) + } else { + Brush.horizontalGradient(listOf(BgSurface, BgSurface)) + } + val actions = txStripActionState(isActivated, huntEnabled) + val cqIsStop = actions.cqIsStop + val variantSubtitle = cqStripSubtitle(cqIsStop, isFreeTextMode, fieldDayEnabled, cqModifier) + val cqSubtitle = variantSubtitle + ?: if (!cqIsStop) stringResource(R.string.tx_call_cq_subtitle) else null + + Column( + modifier = modifier + .fillMaxWidth() + .background(bgColor) + .drawBehind { + drawLine( + color = Border, + start = Offset(0f, 0f), + end = Offset(size.width, 0f), + strokeWidth = 1f, + ) + } + .padding(start = 16.dp, end = 16.dp, top = 10.dp, bottom = 12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + // Rig/CAT status line docked above the drawer surface (mockup 3a). + OperateStatusRow( + isTransmitting = isTransmitting, + isTuning = isTuning, + slotMillis = slotMillis, + txSlot = txSlot, + showCatChip = showCatChip, + catState = catState, + onReconnectCat = onReconnectCat, + ) + + // The peeking drawer surface: rounded-top card with a drag handle + primary controls. + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(topStart = 22.dp, topEnd = 22.dp)) + .background(BgSurface3), + ) { + DrawerHandle(currentlyExpanded = false, onSettle = { expand -> if (expand) onExpand() }) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 12.dp, end = 12.dp, bottom = 12.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + CallCqButton( + modifier = Modifier.weight(1.6f), + cqIsStop = cqIsStop, + cqDisabled = actions.cqDisabled, + subtitle = cqSubtitle, + onClick = { if (cqIsStop) onStop() else onCallCQ() }, + onOpenOptions = onOpenCqOptions, + optionsContentDescription = stringResource(R.string.tx_cq_options), + moreLabel = stringResource(R.string.tx_more), + ) + TxPeriodControl( + modifier = Modifier.weight(1f), + txSlot = txSlot, + onSelect = onSelectTxPeriod, + ) + } + } + } +} + +/** + * The expanded (open) state of the operate-controls drawer — design option 3b. Reuses + * [FT8AFBottomSheet] (scrim + drag handle + swipe-down / scrim-tap / Back dismissal) and lays out + * the full control set in the mockup order: Band & Mode, Call CQ (+ MORE) / Hunt, TX period / + * Tune / DX, and the power slider. Every control is the same widget the collapsed peek and the + * legacy [TxStrip] use, so behavior is identical wherever the operator taps it. + */ +@Composable +fun OperateControlsSheet( + visible: Boolean, + isTransmitting: Boolean, + isActivated: Boolean, + bandModeLabel: String, + txSlot: Int, + huntEnabled: Boolean, + huntOptionLabel: String, + isTuning: Boolean, + dxEnabled: Boolean, + txVolume: Int, + showVolumeSlider: Boolean, + cqModifier: String, + isFreeTextMode: Boolean, + fieldDayEnabled: Boolean, + tuneRemainingSec: Int, + onDismiss: () -> Unit, + onCallCQ: () -> Unit, + onStop: () -> Unit, + onSelectTxPeriod: (Int) -> Unit, + onToggleHunt: () -> Unit, + onOpenHuntOptions: () -> Unit, + onOpenBandMode: () -> Unit, + onToggleTune: () -> Unit, + onToggleDx: () -> Unit, + onOpenCqOptions: () -> Unit, + onVolumeChange: (Int) -> Unit, + onVolumeChangeFinished: () -> Unit, +) { + val actions = txStripActionState(isActivated, huntEnabled) + FT8AFBottomSheet(visible = visible, onDismiss = onDismiss) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, bottom = 20.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + BandModeRow(bandModeLabel = bandModeLabel, onOpenBandMode = onOpenBandMode) + CqHuntRow( + actions = actions, + huntOptionLabel = huntOptionLabel, + cqModifier = cqModifier, + isFreeTextMode = isFreeTextMode, + fieldDayEnabled = fieldDayEnabled, + onCallCQ = onCallCQ, + onStop = onStop, + onOpenCqOptions = onOpenCqOptions, + onToggleHunt = onToggleHunt, + onOpenHuntOptions = onOpenHuntOptions, + ) + PeriodTuneDxRow( + txSlot = txSlot, + isActivated = isActivated, + isTransmitting = isTransmitting, + isTuning = isTuning, + tuneRemainingSec = tuneRemainingSec, + dxEnabled = dxEnabled, + onSelectTxPeriod = onSelectTxPeriod, + onToggleTune = onToggleTune, + onToggleDx = onToggleDx, + ) + if (showVolumeSlider) { + TxVolumeRow( + txVolume = txVolume, + onVolumeChange = onVolumeChange, + onVolumeChangeFinished = onVolumeChangeFinished, + ) + } + } + } +} + +/** + * The drawer's grab handle: a centered 36×4 dp bar in a full-width, full-height touch target + * that both a tap and a vertical drag act on. On drag-end it settles via [drawerTargetExpanded]; + * a tap toggles. [onSettle] receives the desired expanded state. + */ +@Composable +private fun DrawerHandle( + currentlyExpanded: Boolean, + onSettle: (Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + val thresholdPx = with(LocalDensity.current) { DrawerDragThreshold.toPx() } + var dragAccum by remember { mutableFloatStateOf(0f) } + val expandLabel = stringResource(R.string.controls_drawer_expand) + val collapseLabel = stringResource(R.string.controls_drawer_collapse) + val label = if (currentlyExpanded) collapseLabel else expandLabel + + Box( + modifier = modifier + .fillMaxWidth() + .height(28.dp) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) { onSettle(!currentlyExpanded) } + .semantics { role = Role.Button; contentDescription = label } + .pointerInput(currentlyExpanded) { + detectVerticalDragGestures( + onDragStart = { dragAccum = 0f }, + onDragEnd = { + onSettle(drawerTargetExpanded(currentlyExpanded, dragAccum, thresholdPx)) + dragAccum = 0f + }, + onDragCancel = { dragAccum = 0f }, + onVerticalDrag = { _, dy -> dragAccum += dy }, + ) + }, + contentAlignment = Alignment.Center, + ) { + Box( + modifier = Modifier + .width(36.dp) + .height(4.dp) + .clip(RoundedCornerShape(99.dp)) + .background(Color(0x6694A3B8)), // rgba(148,163,184,0.40) + ) + } +} diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/TxStrip.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/TxStrip.kt index 24a238582..5d67e1b1a 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/TxStrip.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/TxStrip.kt @@ -189,207 +189,316 @@ fun TxStrip( verticalArrangement = Arrangement.spacedBy(10.dp), ) { // ---- Row 1: status + CAT chip ---- - val status = txStatusVisuals(isTransmitting, isTuning) - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Row( - modifier = Modifier.weight(1f, fill = false), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - PulseDot(color = if (status.listening) Signal else Accent) - Column { - Text( - text = stringResource(status.labelRes), - color = TextPrimary, - fontSize = 14.sp, - fontWeight = FontWeight.SemiBold, - fontFamily = InterFamily, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - if (status.listening) { - NextTxWindowLabel(slotMillis = slotMillis, txSlot = txSlot) - } - } - } - if (showCatChip) { - CatStatusChip(state = catState, onReconnect = onReconnectCat) - } - } + OperateStatusRow( + isTransmitting = isTransmitting, + isTuning = isTuning, + slotMillis = slotMillis, + txSlot = txSlot, + showCatChip = showCatChip, + catState = catState, + onReconnectCat = onReconnectCat, + ) // ---- Row 2: band & mode row (opens the Band & Mode sheet) ---- + BandModeRow(bandModeLabel = bandModeLabel, onOpenBandMode = onOpenBandMode) + + // ---- Row 3: Call CQ (+ MORE) · Hunt tile ---- + CqHuntRow( + actions = actions, + huntOptionLabel = huntOptionLabel, + cqModifier = cqModifier, + isFreeTextMode = isFreeTextMode, + fieldDayEnabled = fieldDayEnabled, + onCallCQ = onCallCQ, + onStop = onStop, + onOpenCqOptions = onOpenCqOptions, + onToggleHunt = onToggleHunt, + onOpenHuntOptions = onOpenHuntOptions, + ) + + // ---- Row 4: TX period segmented control · Tune · DX ---- + PeriodTuneDxRow( + txSlot = txSlot, + isActivated = isActivated, + isTransmitting = isTransmitting, + isTuning = isTuning, + tuneRemainingSec = tuneRemainingSec, + dxEnabled = dxEnabled, + onSelectTxPeriod = onSelectTxPeriod, + onToggleTune = onToggleTune, + onToggleDx = onToggleDx, + ) + + // ---- Inline TX volume slider (togglable from Settings) ---- + if (showVolumeSlider) { + TxVolumeRow( + txVolume = txVolume, + onVolumeChange = onVolumeChange, + onVolumeChangeFinished = onVolumeChangeFinished, + ) + } + } +} + +/** + * Row 1 — the status line (pulse dot + plain-language state, plus the "next transmit window" + * countdown while Listening) with the optional CAT chip on the right. Extracted so both the + * legacy [TxStrip] and the collapsing [OperateControlsDrawer] render an identical status line. + */ +@Composable +internal fun OperateStatusRow( + isTransmitting: Boolean, + isTuning: Boolean, + slotMillis: Long, + txSlot: Int, + showCatChip: Boolean, + catState: CatConnectionState, + onReconnectCat: () -> Unit, + modifier: Modifier = Modifier, +) { + val status = txStatusVisuals(isTransmitting, isTuning) + Row( + modifier = modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { Row( - modifier = Modifier - .fillMaxWidth() - .height(48.dp) - .clip(RoundedCornerShape(12.dp)) - .background(BgSurface3) - .border(1.dp, Border, RoundedCornerShape(12.dp)) - .clickable { onOpenBandMode() } - .padding(horizontal = 14.dp), + modifier = Modifier.weight(1f, fill = false), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - Text( - text = stringResource(R.string.tx_band_mode_label), - color = TextFaint, - fontSize = 10.sp, - fontWeight = FontWeight.SemiBold, - fontFamily = InterFamily, - letterSpacing = 0.8.sp, - ) - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { + PulseDot(color = if (status.listening) Signal else Accent) + Column { Text( - text = bandModeLabel, + text = stringResource(status.labelRes), color = TextPrimary, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, - fontFamily = GeistMonoFamily, + fontFamily = InterFamily, maxLines = 1, - softWrap = false, + overflow = TextOverflow.Ellipsis, ) - FT8AFIcons.ChevronDown(size = 14.dp, color = TextMuted, strokeWidth = 2f) + if (status.listening) { + NextTxWindowLabel(slotMillis = slotMillis, txSlot = txSlot) + } } } + if (showCatChip) { + CatStatusChip(state = catState, onReconnect = onReconnectCat) + } + } +} - // ---- Row 3: Call CQ (+ MORE) · Hunt tile ---- +/** Row 2 — the tappable Band & Mode card that opens the Band & Mode sheet. */ +@Composable +internal fun BandModeRow( + bandModeLabel: String, + onOpenBandMode: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .height(48.dp) + .clip(RoundedCornerShape(12.dp)) + .background(BgSurface3) + .border(1.dp, Border, RoundedCornerShape(12.dp)) + .clickable { onOpenBandMode() } + .padding(horizontal = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = stringResource(R.string.tx_band_mode_label), + color = TextFaint, + fontSize = 10.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = InterFamily, + letterSpacing = 0.8.sp, + ) Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - val cqIsStop = actions.cqIsStop - val variantSubtitle = cqStripSubtitle(cqIsStop, isFreeTextMode, fieldDayEnabled, cqModifier) - val cqSubtitle = variantSubtitle - ?: if (!cqIsStop) stringResource(R.string.tx_call_cq_subtitle) else null - CallCqButton( - modifier = Modifier.weight(1.6f), - cqIsStop = cqIsStop, - cqDisabled = actions.cqDisabled, - subtitle = cqSubtitle, - onClick = { if (cqIsStop) onStop() else onCallCQ() }, - onOpenOptions = onOpenCqOptions, - optionsContentDescription = stringResource(R.string.tx_cq_options), - moreLabel = stringResource(R.string.tx_more), - ) - - HuntTile( - modifier = Modifier.weight(1f), - huntEnabled = actions.huntActive, - huntDisabled = actions.huntDisabled, - optionLabel = huntOptionLabel, - onToggle = onToggleHunt, - onOpenOptions = onOpenHuntOptions, - optionsContentDescription = stringResource(R.string.tx_hunt_options), + Text( + text = bandModeLabel, + color = TextPrimary, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = GeistMonoFamily, + maxLines = 1, + softWrap = false, ) + FT8AFIcons.ChevronDown(size = 14.dp, color = TextMuted, strokeWidth = 2f) } + } +} - // ---- Row 4: TX period segmented control · Tune · DX ---- - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, +/** Row 3 — the primary Call CQ button (+ MORE) alongside the Hunt tile. */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +internal fun CqHuntRow( + actions: TxStripActionState, + huntOptionLabel: String, + cqModifier: String, + isFreeTextMode: Boolean, + fieldDayEnabled: Boolean, + onCallCQ: () -> Unit, + onStop: () -> Unit, + onOpenCqOptions: () -> Unit, + onToggleHunt: () -> Unit, + onOpenHuntOptions: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + val cqIsStop = actions.cqIsStop + val variantSubtitle = cqStripSubtitle(cqIsStop, isFreeTextMode, fieldDayEnabled, cqModifier) + val cqSubtitle = variantSubtitle + ?: if (!cqIsStop) stringResource(R.string.tx_call_cq_subtitle) else null + CallCqButton( + modifier = Modifier.weight(1.6f), + cqIsStop = cqIsStop, + cqDisabled = actions.cqDisabled, + subtitle = cqSubtitle, + onClick = { if (cqIsStop) onStop() else onCallCQ() }, + onOpenOptions = onOpenCqOptions, + optionsContentDescription = stringResource(R.string.tx_cq_options), + moreLabel = stringResource(R.string.tx_more), + ) + + HuntTile( + modifier = Modifier.weight(1f), + huntEnabled = actions.huntActive, + huntDisabled = actions.huntDisabled, + optionLabel = huntOptionLabel, + onToggle = onToggleHunt, + onOpenOptions = onOpenHuntOptions, + optionsContentDescription = stringResource(R.string.tx_hunt_options), + ) + } +} + +/** Row 4 — the TX-period segmented control alongside the Tune and DX secondary buttons. */ +@Composable +internal fun PeriodTuneDxRow( + txSlot: Int, + isActivated: Boolean, + isTransmitting: Boolean, + isTuning: Boolean, + tuneRemainingSec: Int, + dxEnabled: Boolean, + onSelectTxPeriod: (Int) -> Unit, + onToggleTune: () -> Unit, + onToggleDx: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + TxPeriodControl( + modifier = Modifier.weight(1.7f), + txSlot = txSlot, + onSelect = onSelectTxPeriod, + ) + val tuneEnabled = isTuning || (!isActivated && !isTransmitting) + SecondaryButton( + modifier = Modifier.weight(0.45f), + label = tuneChipLabel(stringResource(R.string.tune_button), isTuning, tuneRemainingSec), + active = isTuning, + enabled = tuneEnabled, + onClick = onToggleTune, + ) + SecondaryButton( + modifier = Modifier.weight(0.45f), + label = stringResource(R.string.tx_dx), + active = dxEnabled, + enabled = true, + onClick = onToggleDx, + ) + } +} + +/** The inline TX-volume slider (− / slider / +): togglable from Settings. */ +@Composable +internal fun TxVolumeRow( + txVolume: Int, + onVolumeChange: (Int) -> Unit, + onVolumeChangeFinished: () -> Unit, + modifier: Modifier = Modifier, +) { + val volumeDecrease = stringResource(R.string.tx_volume_decrease) + val volumeIncrease = stringResource(R.string.tx_volume_increase) + Row( + modifier = modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Box( + modifier = Modifier + .size(36.dp) + .clip(RoundedCornerShape(8.dp)) + .background(BgSurface3) + .semantics { role = Role.Button; contentDescription = volumeDecrease } + .clickable { + onVolumeChange(clampVolume(txVolume, -5)) + onVolumeChangeFinished() + }, + contentAlignment = Alignment.Center, ) { - TxPeriodControl( - modifier = Modifier.weight(1.7f), - txSlot = txSlot, - onSelect = onSelectTxPeriod, - ) - val tuneEnabled = isTuning || (!isActivated && !isTransmitting) - SecondaryButton( - modifier = Modifier.weight(0.45f), - label = tuneChipLabel(stringResource(R.string.tune_button), isTuning, tuneRemainingSec), - active = isTuning, - enabled = tuneEnabled, - onClick = onToggleTune, - ) - SecondaryButton( - modifier = Modifier.weight(0.45f), - label = stringResource(R.string.tx_dx), - active = dxEnabled, - enabled = true, - onClick = onToggleDx, + Text( + text = "−", + color = TextMuted, + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + fontFamily = GeistMonoFamily, ) } - // ---- Inline TX volume slider (togglable from Settings) ---- - val volumeDecrease = stringResource(R.string.tx_volume_decrease) - val volumeIncrease = stringResource(R.string.tx_volume_increase) - if (showVolumeSlider) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { - Box( - modifier = Modifier - .size(36.dp) - .clip(RoundedCornerShape(8.dp)) - .background(BgSurface3) - .semantics { role = Role.Button; contentDescription = volumeDecrease } - .clickable { - onVolumeChange(clampVolume(txVolume, -5)) - onVolumeChangeFinished() - }, - contentAlignment = Alignment.Center, - ) { - Text( - text = "−", - color = TextMuted, - fontSize = 16.sp, - fontWeight = FontWeight.Bold, - fontFamily = GeistMonoFamily, - ) - } - - IntSlider( - value = txVolume, - onValueChange = { v -> onVolumeChange(v.coerceIn(0, 100)) }, - onValueChangeFinished = onVolumeChangeFinished, - valueRange = 0f..100f, - modifier = Modifier.weight(1f), - thumbColor = Accent, - activeTrackColor = Accent, - ) - - Box( - modifier = Modifier - .size(36.dp) - .clip(RoundedCornerShape(8.dp)) - .background(BgSurface3) - .semantics { role = Role.Button; contentDescription = volumeIncrease } - .clickable { - onVolumeChange(clampVolume(txVolume, 5)) - onVolumeChangeFinished() - }, - contentAlignment = Alignment.Center, - ) { - Text( - text = "+", - color = TextMuted, - fontSize = 16.sp, - fontWeight = FontWeight.Bold, - fontFamily = GeistMonoFamily, - ) - } + IntSlider( + value = txVolume, + onValueChange = { v -> onVolumeChange(v.coerceIn(0, 100)) }, + onValueChangeFinished = onVolumeChangeFinished, + valueRange = 0f..100f, + modifier = Modifier.weight(1f), + thumbColor = Accent, + activeTrackColor = Accent, + ) - Text( - text = "${txVolume}%", - color = TextPrimary, - fontSize = 12.sp, - fontWeight = FontWeight.SemiBold, - fontFamily = GeistMonoFamily, - letterSpacing = 0.02.sp, - ) - } + Box( + modifier = Modifier + .size(36.dp) + .clip(RoundedCornerShape(8.dp)) + .background(BgSurface3) + .semantics { role = Role.Button; contentDescription = volumeIncrease } + .clickable { + onVolumeChange(clampVolume(txVolume, 5)) + onVolumeChangeFinished() + }, + contentAlignment = Alignment.Center, + ) { + Text( + text = "+", + color = TextMuted, + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + fontFamily = GeistMonoFamily, + ) } + + Text( + text = "${txVolume}%", + color = TextPrimary, + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = GeistMonoFamily, + letterSpacing = 0.02.sp, + ) } } @@ -400,7 +509,7 @@ fun TxStrip( */ @OptIn(ExperimentalFoundationApi::class) @Composable -private fun CallCqButton( +internal fun CallCqButton( cqIsStop: Boolean, cqDisabled: Boolean, subtitle: String?, @@ -508,7 +617,7 @@ private fun CallCqButton( * running (Hunt and calling CQ are mutually exclusive). */ @Composable -private fun HuntTile( +internal fun HuntTile( huntEnabled: Boolean, huntDisabled: Boolean, optionLabel: String, @@ -605,7 +714,7 @@ private fun HuntChip( * 2nd (odd) transmit slot. [txSlot] 0 selects "1st (even)", 1 selects "2nd (odd)". */ @Composable -private fun TxPeriodControl( +internal fun TxPeriodControl( txSlot: Int, onSelect: (Int) -> Unit, modifier: Modifier = Modifier, @@ -684,7 +793,7 @@ private fun PeriodSegment( /** A small 52dp secondary button (Tune / DX): amber-tinted when active, muted otherwise. */ @Composable -private fun SecondaryButton( +internal fun SecondaryButton( label: String, active: Boolean, enabled: Boolean, @@ -726,7 +835,7 @@ private fun SecondaryButton( } @Composable -private fun PulseDot(color: Color) { +internal fun PulseDot(color: Color) { val infiniteTransition = rememberInfiniteTransition(label = "pulse") val pulseAlpha by infiniteTransition.animateFloat( initialValue = 0.4f, diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/RadioAudioSettings.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/RadioAudioSettings.kt index 4b59347af..e8de0b4c6 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/RadioAudioSettings.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/RadioAudioSettings.kt @@ -826,6 +826,23 @@ fun RadioAudioSettings( ) } SectionDivider() + run { + var drawer by remember { mutableStateOf(GeneralVariables.controlsDrawerEnabled) } + SettingsRow( + label = stringResource(R.string.settings_controls_drawer), + description = stringResource(R.string.settings_controls_drawer_desc), + toggle = drawer, + onToggleChange = { enabled -> + drawer = enabled + GeneralVariables.controlsDrawerEnabled = enabled + GeneralVariables.mutableControlsDrawerEnabled.postValue(enabled) + mainViewModel.databaseOpr.writeConfig( + "controlsDrawerEnabled", if (enabled) "1" else "0", null, + ) + }, + ) + } + SectionDivider() run { var perBand by remember { mutableStateOf(GeneralVariables.savePerBandOutputLevel) diff --git a/ft8af/app/src/main/res/values/strings_compose.xml b/ft8af/app/src/main/res/values/strings_compose.xml index 331ec6e88..f05e1a292 100644 --- a/ft8af/app/src/main/res/values/strings_compose.xml +++ b/ft8af/app/src/main/res/values/strings_compose.xml @@ -694,6 +694,10 @@ Transmit audio level (hardware buttons ±5%) Show Volume Slider Display TX volume slider on main screen + Controls Drawer + Collapse the operate controls into a bottom sheet you pull up. Off shows the full control strip at all times. + Expand controls + Collapse controls Save Output Level per Band Remember the TX volume separately for each band and restore it on band change TX volume %1$d%% restored for %2$s diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/components/OperateControlsDrawerTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/components/OperateControlsDrawerTest.kt new file mode 100644 index 000000000..39a036eb8 --- /dev/null +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/components/OperateControlsDrawerTest.kt @@ -0,0 +1,70 @@ +package radio.ks3ckc.ft8af.ui.components + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +/** + * Unit tests for [drawerTargetExpanded] — the pure gesture rule deciding whether the + * operate-controls drawer (design 3a/3b) settles open or collapsed after a drag on its handle. + * No Android/Compose runtime needed. Sign convention: negative dy = upward drag. + */ +class OperateControlsDrawerTest { + + private val threshold = 48f + + @Test + fun `upward drag past threshold from collapsed expands`() { + assertThat(drawerTargetExpanded(currentlyExpanded = false, dragDy = -60f, thresholdPx = threshold)) + .isTrue() + } + + @Test + fun `upward drag exactly at threshold from collapsed expands`() { + assertThat(drawerTargetExpanded(currentlyExpanded = false, dragDy = -48f, thresholdPx = threshold)) + .isTrue() + } + + @Test + fun `small upward drag from collapsed stays collapsed`() { + assertThat(drawerTargetExpanded(currentlyExpanded = false, dragDy = -20f, thresholdPx = threshold)) + .isFalse() + } + + @Test + fun `downward drag from collapsed stays collapsed`() { + assertThat(drawerTargetExpanded(currentlyExpanded = false, dragDy = 200f, thresholdPx = threshold)) + .isFalse() + } + + @Test + fun `downward drag past threshold from expanded collapses`() { + assertThat(drawerTargetExpanded(currentlyExpanded = true, dragDy = 60f, thresholdPx = threshold)) + .isFalse() + } + + @Test + fun `downward drag exactly at threshold from expanded collapses`() { + assertThat(drawerTargetExpanded(currentlyExpanded = true, dragDy = 48f, thresholdPx = threshold)) + .isFalse() + } + + @Test + fun `small downward drag from expanded stays expanded`() { + assertThat(drawerTargetExpanded(currentlyExpanded = true, dragDy = 20f, thresholdPx = threshold)) + .isTrue() + } + + @Test + fun `upward drag from expanded stays expanded`() { + assertThat(drawerTargetExpanded(currentlyExpanded = true, dragDy = -200f, thresholdPx = threshold)) + .isTrue() + } + + @Test + fun `no drag leaves state unchanged`() { + assertThat(drawerTargetExpanded(currentlyExpanded = false, dragDy = 0f, thresholdPx = threshold)) + .isFalse() + assertThat(drawerTargetExpanded(currentlyExpanded = true, dragDy = 0f, thresholdPx = threshold)) + .isTrue() + } +} From ae9f1fe1000d390b498dc188a9cc57996e5f5bdd Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Thu, 24 Sep 2026 15:38:34 -0500 Subject: [PATCH 7/8] refactor: make the controls drawer one continuous sheet Previously the expanded controls were a separate modal (FT8AFBottomSheet) that slid up over the collapsed peek and reordered the controls, so it read as two stacked sheets rather than one drawer. Rework it into a single sheet that grows in place: one overlay whose bottom is anchored to a reserved peek slot in the main column and whose height animates between peek and expanded. The primary Call CQ / Stop + TX-period row is the top of the sheet in both states; Band & Mode, Hunt, Tune / DX, and the power slider expand into view below it. The handle drags live (follows the finger) and taps to toggle; a scrim over the content above (not the tab bar) fades in and taps to collapse. Also add a compact TX-period variant ("1st"/"2nd", no caption) so the segmented control reads cleanly in the narrow slot beside Call CQ, and tighten the peek height so no secondary row peeks through when collapsed. Verified on emulator: peek, drag/tap expand, collapse. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../kotlin/radio/ks3ckc/ft8af/FT8AFApp.kt | 104 ++--- .../ui/components/OperateControlsDrawer.kt | 359 ++++++++++-------- .../ks3ckc/ft8af/ui/components/TxStrip.kt | 38 +- .../src/main/res/values/strings_compose.xml | 2 + 4 files changed, 277 insertions(+), 226 deletions(-) diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/FT8AFApp.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/FT8AFApp.kt index 2fca525bf..877b736d1 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/FT8AFApp.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/FT8AFApp.kt @@ -22,6 +22,10 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.movableContentOf import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.foundation.layout.height +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInWindow import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState @@ -63,8 +67,9 @@ import radio.ks3ckc.ft8af.ui.components.SlotTimerBar import radio.ks3ckc.ft8af.ui.components.TabBar import radio.ks3ckc.ft8af.ui.components.TransmitGlow import radio.ks3ckc.ft8af.ui.components.TxStrip -import radio.ks3ckc.ft8af.ui.components.OperateControlsPeek -import radio.ks3ckc.ft8af.ui.components.OperateControlsSheet +import radio.ks3ckc.ft8af.ui.components.OperateControlsDrawer +import radio.ks3ckc.ft8af.ui.components.OperateStatusRow +import radio.ks3ckc.ft8af.ui.components.OperateDrawerPeekHeight import radio.ks3ckc.ft8af.ui.components.VoiceCommandButton import radio.ks3ckc.ft8af.ui.components.selectBandIndex import radio.ks3ckc.ft8af.ui.decode.DecodeScreen @@ -148,6 +153,10 @@ fun FT8AFApp(mainViewModel: MainViewModel) { controlsDrawerEnabled = controlsDrawerLive ?: GeneralVariables.controlsDrawerEnabled } var controlsExpanded by rememberSaveable { mutableStateOf(false) } + // Window-space Y of the reserved peek slot's bottom (== tab bar top). The drawer overlay + // anchors its bottom edge here so the collapsed sheet lines up exactly with the reserved + // inline space and grows upward from there. + var drawerAnchorBottomPx by remember { mutableFloatStateOf(0f) } // Consume the one-shot celebration signal so LiveData doesn't replay it // on recomposition / resubscription. @@ -582,24 +591,26 @@ fun FT8AFApp(mainViewModel: MainViewModel) { // OperateControlsSheet overlay below. When off, the original always-open strip shows. // The slot bar + clock-sync pill remain in the SlotTimerBar above. if (controlsDrawerEnabled) { - OperateControlsPeek( + // Docked status line (design 3a), then a reserved slot the drawer sheet sits in + // when collapsed. The actual sheet is the OperateControlsDrawer overlay in the + // outer Box; it anchors its bottom to this slot and grows upward on expand. + OperateStatusRow( isTransmitting = isTransmitting, - isActivated = isActivated, isTuning = isTuning, slotMillis = slotMillis, txSlot = txSlot, - huntEnabled = huntEnabled, - cqModifier = cqModifier, - isFreeTextMode = isFreeTextMode, - fieldDayEnabled = fieldDayEnabled, showCatChip = showCatChip, catState = catState, - onExpand = { controlsExpanded = true }, - onCallCQ = onCallCQAction, - onStop = onStopAction, - onOpenCqOptions = onOpenCqOptionsAction, - onSelectTxPeriod = onSelectTxPeriodAction, onReconnectCat = onReconnectCatAction, + modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp), + ) + Spacer( + modifier = Modifier + .fillMaxWidth() + .height(OperateDrawerPeekHeight) + .onGloballyPositioned { coords -> + drawerAnchorBottomPx = coords.positionInWindow().y + coords.size.height + }, ) } else { TxStrip( @@ -651,38 +662,41 @@ fun FT8AFApp(mainViewModel: MainViewModel) { // One-shot particle burst when a QSO completes. QsoCelebration(triggerAt = qsoCompletedAt) - // Expanded operate-controls sheet (design 3b) — sibling overlay so its scrim + sheet - // sit above the tab bar and the collapsed peek. Only meaningful when the drawer setting - // is on; guarded so turning the setting off can't leave a stale sheet open. - OperateControlsSheet( - visible = controlsDrawerEnabled && controlsExpanded, - isTransmitting = isTransmitting, - isActivated = isActivated, - bandModeLabel = bandModeLabel, - txSlot = txSlot, - huntEnabled = huntEnabled, - huntOptionLabel = huntOptionLabel, - isTuning = isTuning, - dxEnabled = dxEnabled, - txVolume = txVolume, - showVolumeSlider = showVolumeSlider, - cqModifier = cqModifier, - isFreeTextMode = isFreeTextMode, - fieldDayEnabled = fieldDayEnabled, - tuneRemainingSec = tuneRemainingSec, - onDismiss = { controlsExpanded = false }, - onCallCQ = onCallCQAction, - onStop = onStopAction, - onSelectTxPeriod = onSelectTxPeriodAction, - onToggleHunt = onToggleHuntAction, - onOpenHuntOptions = onOpenHuntOptionsAction, - onOpenBandMode = onOpenBandModeAction, - onToggleTune = onToggleTuneAction, - onToggleDx = onToggleDxAction, - onOpenCqOptions = onOpenCqOptionsAction, - onVolumeChange = onVolumeChangeAction, - onVolumeChangeFinished = onVolumeChangeFinishedAction, - ) + // Operate-controls drawer (design 3a/3b) — one bottom sheet that grows in place from the + // reserved peek slot. Sibling overlay so its scrim + sheet sit above the tab bar and + // content. Rendered only when the drawer setting is on. + if (controlsDrawerEnabled) { + OperateControlsDrawer( + expanded = controlsExpanded, + onExpandedChange = { controlsExpanded = it }, + anchorBottomPx = drawerAnchorBottomPx, + isTransmitting = isTransmitting, + isActivated = isActivated, + isTuning = isTuning, + bandModeLabel = bandModeLabel, + txSlot = txSlot, + huntEnabled = huntEnabled, + huntOptionLabel = huntOptionLabel, + dxEnabled = dxEnabled, + txVolume = txVolume, + showVolumeSlider = showVolumeSlider, + cqModifier = cqModifier, + isFreeTextMode = isFreeTextMode, + fieldDayEnabled = fieldDayEnabled, + tuneRemainingSec = tuneRemainingSec, + onCallCQ = onCallCQAction, + onStop = onStopAction, + onSelectTxPeriod = onSelectTxPeriodAction, + onToggleHunt = onToggleHuntAction, + onOpenHuntOptions = onOpenHuntOptionsAction, + onOpenBandMode = onOpenBandModeAction, + onToggleTune = onToggleTuneAction, + onToggleDx = onToggleDxAction, + onOpenCqOptions = onOpenCqOptionsAction, + onVolumeChange = onVolumeChangeAction, + onVolumeChangeFinished = onVolumeChangeFinishedAction, + ) + } // Frequency picker — sibling overlay so the scrim and sheet sit above the // tab bar and TxStrip. diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/OperateControlsDrawer.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/OperateControlsDrawer.kt index 3b45e0618..60024646c 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/OperateControlsDrawer.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/OperateControlsDrawer.kt @@ -1,5 +1,7 @@ package radio.ks3ckc.ft8af.ui.components +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectVerticalDragGestures @@ -8,33 +10,39 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.drawBehind -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.role import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import com.k1af.ft8af.R -import com.k1af.ft8af.rigs.CatConnectionState +import kotlin.math.roundToInt +import kotlinx.coroutines.launch import radio.ks3ckc.ft8af.theme.* /** @@ -54,131 +62,38 @@ internal fun drawerTargetExpanded( else -> currentlyExpanded } -/** The vertical drag distance on the peek handle that commits to expanding the drawer. */ +/** The vertical drag distance on the handle that commits to toggling the drawer. */ internal val DrawerDragThreshold = 48.dp /** - * The collapsed (peek) state of the operate-controls drawer — design option 3a. Renders inline - * above the tab bar: the rig/CAT status line docked on top, then a rounded-top drawer surface - * with a drag handle and only the primary action (Call CQ / Stop) alongside the TX-period toggle. - * Tapping the handle or swiping it up opens the full [OperateControlsSheet]; the rest of the - * controls live there. + * The collapsed height of the drawer — just tall enough for the handle (28) + the primary row + * (72), stopping before the next row so no secondary control peeks through when collapsed. */ -@Composable -fun OperateControlsPeek( - isTransmitting: Boolean, - isActivated: Boolean, - isTuning: Boolean, - slotMillis: Long, - txSlot: Int, - huntEnabled: Boolean, - cqModifier: String, - isFreeTextMode: Boolean, - fieldDayEnabled: Boolean, - showCatChip: Boolean, - catState: CatConnectionState, - onExpand: () -> Unit, - onCallCQ: () -> Unit, - onStop: () -> Unit, - onOpenCqOptions: () -> Unit, - onSelectTxPeriod: (Int) -> Unit, - onReconnectCat: () -> Unit, - modifier: Modifier = Modifier, -) { - val bgColor = if (isTransmitting) { - Brush.horizontalGradient( - listOf( - Color(0x1FFFAF5E), // rgba(255,175,94,0.12) - Color(0x0AFFAF5E), // rgba(255,175,94,0.04) - ) - ) - } else { - Brush.horizontalGradient(listOf(BgSurface, BgSurface)) - } - val actions = txStripActionState(isActivated, huntEnabled) - val cqIsStop = actions.cqIsStop - val variantSubtitle = cqStripSubtitle(cqIsStop, isFreeTextMode, fieldDayEnabled, cqModifier) - val cqSubtitle = variantSubtitle - ?: if (!cqIsStop) stringResource(R.string.tx_call_cq_subtitle) else null - - Column( - modifier = modifier - .fillMaxWidth() - .background(bgColor) - .drawBehind { - drawLine( - color = Border, - start = Offset(0f, 0f), - end = Offset(size.width, 0f), - strokeWidth = 1f, - ) - } - .padding(start = 16.dp, end = 16.dp, top = 10.dp, bottom = 12.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - // Rig/CAT status line docked above the drawer surface (mockup 3a). - OperateStatusRow( - isTransmitting = isTransmitting, - isTuning = isTuning, - slotMillis = slotMillis, - txSlot = txSlot, - showCatChip = showCatChip, - catState = catState, - onReconnectCat = onReconnectCat, - ) - - // The peeking drawer surface: rounded-top card with a drag handle + primary controls. - Column( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(topStart = 22.dp, topEnd = 22.dp)) - .background(BgSurface3), - ) { - DrawerHandle(currentlyExpanded = false, onSettle = { expand -> if (expand) onExpand() }) - Row( - modifier = Modifier - .fillMaxWidth() - .padding(start = 12.dp, end = 12.dp, bottom = 12.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - CallCqButton( - modifier = Modifier.weight(1.6f), - cqIsStop = cqIsStop, - cqDisabled = actions.cqDisabled, - subtitle = cqSubtitle, - onClick = { if (cqIsStop) onStop() else onCallCQ() }, - onOpenOptions = onOpenCqOptions, - optionsContentDescription = stringResource(R.string.tx_cq_options), - moreLabel = stringResource(R.string.tx_more), - ) - TxPeriodControl( - modifier = Modifier.weight(1f), - txSlot = txSlot, - onSelect = onSelectTxPeriod, - ) - } - } - } -} +internal val OperateDrawerPeekHeight = 110.dp /** - * The expanded (open) state of the operate-controls drawer — design option 3b. Reuses - * [FT8AFBottomSheet] (scrim + drag handle + swipe-down / scrim-tap / Back dismissal) and lays out - * the full control set in the mockup order: Band & Mode, Call CQ (+ MORE) / Hunt, TX period / - * Tune / DX, and the power slider. Every control is the same widget the collapsed peek and the - * legacy [TxStrip] use, so behavior is identical wherever the operator taps it. + * The operate-controls drawer (design 3a/3b) as a *single* bottom sheet that grows in place. + * + * The sheet is anchored so its bottom sits on [anchorBottomPx] (the window-space Y that the + * reserved peek slot in the main column occupies, i.e. just above the tab bar). Its content is a + * single top-aligned column — drag handle, the primary Call CQ / Stop + TX-period row, then the + * secondary controls (Band & Mode, Hunt, Tune / DX, power). Collapsed, the sheet is + * [OperateDrawerPeekHeight] tall and only the handle + primary row show (3a); dragging the handle + * up or tapping it grows the *same* surface upward to reveal the rest over a scrim (3b). Nothing + * slides over the primary controls — it is one sheet expanding, not a second sheet on top. */ @Composable -fun OperateControlsSheet( - visible: Boolean, +fun OperateControlsDrawer( + expanded: Boolean, + onExpandedChange: (Boolean) -> Unit, + anchorBottomPx: Float, isTransmitting: Boolean, isActivated: Boolean, + isTuning: Boolean, bandModeLabel: String, txSlot: Int, huntEnabled: Boolean, huntOptionLabel: String, - isTuning: Boolean, dxEnabled: Boolean, txVolume: Int, showVolumeSlider: Boolean, @@ -186,7 +101,6 @@ fun OperateControlsSheet( isFreeTextMode: Boolean, fieldDayEnabled: Boolean, tuneRemainingSec: Int, - onDismiss: () -> Unit, onCallCQ: () -> Unit, onStop: () -> Unit, onSelectTxPeriod: (Int) -> Unit, @@ -199,65 +113,180 @@ fun OperateControlsSheet( onVolumeChange: (Int) -> Unit, onVolumeChangeFinished: () -> Unit, ) { + // Not laid out yet — the reserved peek slot hasn't reported its position. Skip a frame. + if (anchorBottomPx <= 0f) return + + val density = LocalDensity.current + val peekPx = with(density) { OperateDrawerPeekHeight.toPx() } + val thresholdPx = with(density) { DrawerDragThreshold.toPx() } + val scope = rememberCoroutineScope() + + // Full natural height of the content, measured independently of the (clipped) sheet height. + var fullContentPx by remember { mutableFloatStateOf(peekPx) } + val rangePx = (fullContentPx - peekPx).coerceAtLeast(1f) + + // fraction 0 = collapsed peek, 1 = fully expanded. Animated on tap; follows the finger on drag. + val fraction = remember { Animatable(if (expanded) 1f else 0f) } + LaunchedEffect(expanded) { fraction.animateTo(if (expanded) 1f else 0f, tween(220)) } + + val sheetHeightPx = peekPx + rangePx * fraction.value + val scrimAlpha = 0.55f * fraction.value + val actions = txStripActionState(isActivated, huntEnabled) - FT8AFBottomSheet(visible = visible, onDismiss = onDismiss) { - Column( + val cqIsStop = actions.cqIsStop + val variantSubtitle = cqStripSubtitle(cqIsStop, isFreeTextMode, fieldDayEnabled, cqModifier) + val cqSubtitle = variantSubtitle + ?: if (!cqIsStop) stringResource(R.string.tx_call_cq_subtitle) else null + + Box(modifier = Modifier.fillMaxSize()) { + // Scrim over the content above the sheet only (stops at the tab bar so the tabs stay + // usable). Tappable to collapse once the sheet is mostly open. + if (scrimAlpha > 0.01f) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(with(density) { anchorBottomPx.toDp() }) + .background(Color.Black.copy(alpha = scrimAlpha)) + .then( + if (fraction.value > 0.5f) { + Modifier.clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) { onExpandedChange(false) } + } else { + Modifier + } + ) + ) + } + + // The sheet — positioned so its bottom edge lands on anchorBottomPx and it grows upward. + Box( modifier = Modifier .fillMaxWidth() - .padding(start = 16.dp, end = 16.dp, bottom = 20.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), + .offset { IntOffset(0, (anchorBottomPx - sheetHeightPx).roundToInt()) } + .height(with(density) { sheetHeightPx.toDp() }) + .clip(RoundedCornerShape(topStart = 22.dp, topEnd = 22.dp)) + .background(BgSurface3) + .clipToBounds(), ) { - BandModeRow(bandModeLabel = bandModeLabel, onOpenBandMode = onOpenBandMode) - CqHuntRow( - actions = actions, - huntOptionLabel = huntOptionLabel, - cqModifier = cqModifier, - isFreeTextMode = isFreeTextMode, - fieldDayEnabled = fieldDayEnabled, - onCallCQ = onCallCQ, - onStop = onStop, - onOpenCqOptions = onOpenCqOptions, - onToggleHunt = onToggleHunt, - onOpenHuntOptions = onOpenHuntOptions, - ) - PeriodTuneDxRow( - txSlot = txSlot, - isActivated = isActivated, - isTransmitting = isTransmitting, - isTuning = isTuning, - tuneRemainingSec = tuneRemainingSec, - dxEnabled = dxEnabled, - onSelectTxPeriod = onSelectTxPeriod, - onToggleTune = onToggleTune, - onToggleDx = onToggleDx, - ) - if (showVolumeSlider) { - TxVolumeRow( - txVolume = txVolume, - onVolumeChange = onVolumeChange, - onVolumeChangeFinished = onVolumeChangeFinished, + Column( + modifier = Modifier + .fillMaxWidth() + // Measure the natural (unbounded) content height so the expanded target is + // exact; the parent Box clips it to the animated height. + .wrapContentHeight(align = Alignment.Top, unbounded = true) + .onSizeChanged { fullContentPx = it.height.toFloat() }, + ) { + DrawerHandle( + expanded = expanded, + onToggle = { onExpandedChange(!expanded) }, + onDrag = { dy -> + val next = (fraction.value - dy / rangePx).coerceIn(0f, 1f) + scope.launch { fraction.snapTo(next) } + }, + onDragEnd = { totalDy -> + val target = drawerTargetExpanded(expanded, totalDy, thresholdPx) + scope.launch { fraction.animateTo(if (target) 1f else 0f, tween(220)) } + onExpandedChange(target) + }, ) + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, bottom = 16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + // ---- Primary row (visible in the peek): Call CQ / Stop + TX period ---- + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + CallCqButton( + modifier = Modifier.weight(1.6f), + cqIsStop = cqIsStop, + cqDisabled = actions.cqDisabled, + subtitle = cqSubtitle, + onClick = { if (cqIsStop) onStop() else onCallCQ() }, + onOpenOptions = onOpenCqOptions, + optionsContentDescription = stringResource(R.string.tx_cq_options), + moreLabel = stringResource(R.string.tx_more), + ) + TxPeriodControl( + modifier = Modifier.weight(1f), + txSlot = txSlot, + onSelect = onSelectTxPeriod, + compact = true, + ) + } + + // ---- Secondary controls, revealed as the sheet grows (design 3b) ---- + BandModeRow(bandModeLabel = bandModeLabel, onOpenBandMode = onOpenBandMode) + + HuntTile( + modifier = Modifier.fillMaxWidth(), + huntEnabled = actions.huntActive, + huntDisabled = actions.huntDisabled, + optionLabel = huntOptionLabel, + onToggle = onToggleHunt, + onOpenOptions = onOpenHuntOptions, + optionsContentDescription = stringResource(R.string.tx_hunt_options), + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + val tuneEnabled = isTuning || (!isActivated && !isTransmitting) + SecondaryButton( + modifier = Modifier.weight(1f), + label = tuneChipLabel(stringResource(R.string.tune_button), isTuning, tuneRemainingSec), + active = isTuning, + enabled = tuneEnabled, + onClick = onToggleTune, + ) + SecondaryButton( + modifier = Modifier.weight(1f), + label = stringResource(R.string.tx_dx), + active = dxEnabled, + enabled = true, + onClick = onToggleDx, + ) + } + + if (showVolumeSlider) { + TxVolumeRow( + txVolume = txVolume, + onVolumeChange = onVolumeChange, + onVolumeChangeFinished = onVolumeChangeFinished, + ) + } + } } } } } /** - * The drawer's grab handle: a centered 36×4 dp bar in a full-width, full-height touch target - * that both a tap and a vertical drag act on. On drag-end it settles via [drawerTargetExpanded]; - * a tap toggles. [onSettle] receives the desired expanded state. + * The drawer's grab handle: a centered 36×4 dp bar in a full-width touch target. A tap toggles the + * drawer; a vertical drag is fed live to [onDrag] (dy, negative = up) and settled in [onDragEnd] + * with the total drag distance. */ @Composable private fun DrawerHandle( - currentlyExpanded: Boolean, - onSettle: (Boolean) -> Unit, + expanded: Boolean, + onToggle: () -> Unit, + onDrag: (Float) -> Unit, + onDragEnd: (Float) -> Unit, modifier: Modifier = Modifier, ) { - val thresholdPx = with(LocalDensity.current) { DrawerDragThreshold.toPx() } - var dragAccum by remember { mutableFloatStateOf(0f) } + var totalDy by remember { mutableFloatStateOf(0f) } val expandLabel = stringResource(R.string.controls_drawer_expand) val collapseLabel = stringResource(R.string.controls_drawer_collapse) - val label = if (currentlyExpanded) collapseLabel else expandLabel + val label = if (expanded) collapseLabel else expandLabel Box( modifier = modifier @@ -266,17 +295,17 @@ private fun DrawerHandle( .clickable( interactionSource = remember { MutableInteractionSource() }, indication = null, - ) { onSettle(!currentlyExpanded) } + ) { onToggle() } .semantics { role = Role.Button; contentDescription = label } - .pointerInput(currentlyExpanded) { + .pointerInput(Unit) { detectVerticalDragGestures( - onDragStart = { dragAccum = 0f }, - onDragEnd = { - onSettle(drawerTargetExpanded(currentlyExpanded, dragAccum, thresholdPx)) - dragAccum = 0f + onDragStart = { totalDy = 0f }, + onDragEnd = { onDragEnd(totalDy); totalDy = 0f }, + onDragCancel = { totalDy = 0f }, + onVerticalDrag = { _, dy -> + totalDy += dy + onDrag(dy) }, - onDragCancel = { dragAccum = 0f }, - onVerticalDrag = { _, dy -> dragAccum += dy }, ) }, contentAlignment = Alignment.Center, diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/TxStrip.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/TxStrip.kt index 5d67e1b1a..3271503f0 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/TxStrip.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/TxStrip.kt @@ -718,27 +718,33 @@ internal fun TxPeriodControl( txSlot: Int, onSelect: (Int) -> Unit, modifier: Modifier = Modifier, + // Compact drops the "TX PERIOD" caption and uses short "1st"/"2nd" segment labels so the + // control reads cleanly in the narrow slot beside Call CQ in the drawer's primary row. + compact: Boolean = false, ) { Row( modifier = modifier - .height(52.dp) - .clip(RoundedCornerShape(12.dp)) + .height(if (compact) 72.dp else 52.dp) + .clip(RoundedCornerShape(if (compact) 14.dp else 12.dp)) .background(BgSurface3) - .border(1.dp, Border, RoundedCornerShape(12.dp)) - .padding(start = 12.dp, end = 6.dp), + .border(1.dp, Border, RoundedCornerShape(if (compact) 14.dp else 12.dp)) + .padding(horizontal = if (compact) 6.dp else 0.dp) + .padding(start = if (compact) 0.dp else 12.dp, end = if (compact) 0.dp else 6.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - Text( - text = stringResource(R.string.tx_period_label), - color = TextFaint, - fontSize = 10.sp, - fontWeight = FontWeight.SemiBold, - fontFamily = InterFamily, - letterSpacing = 0.6.sp, - maxLines = 1, - softWrap = false, - ) + if (!compact) { + Text( + text = stringResource(R.string.tx_period_label), + color = TextFaint, + fontSize = 10.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = InterFamily, + letterSpacing = 0.6.sp, + maxLines = 1, + softWrap = false, + ) + } Row( modifier = Modifier .weight(1f) @@ -748,13 +754,13 @@ internal fun TxPeriodControl( horizontalArrangement = Arrangement.spacedBy(6.dp), ) { PeriodSegment( - label = stringResource(R.string.tx_period_first), + label = stringResource(if (compact) R.string.tx_period_first_short else R.string.tx_period_first), selected = txSlot == 0, modifier = Modifier.weight(1f), onClick = { if (txSlot != 0) onSelect(0) }, ) PeriodSegment( - label = stringResource(R.string.tx_period_second), + label = stringResource(if (compact) R.string.tx_period_second_short else R.string.tx_period_second), selected = txSlot == 1, modifier = Modifier.weight(1f), onClick = { if (txSlot != 1) onSelect(1) }, diff --git a/ft8af/app/src/main/res/values/strings_compose.xml b/ft8af/app/src/main/res/values/strings_compose.xml index f05e1a292..2373f27e8 100644 --- a/ft8af/app/src/main/res/values/strings_compose.xml +++ b/ft8af/app/src/main/res/values/strings_compose.xml @@ -131,6 +131,8 @@ TX PERIOD 1st (even) 2nd (odd) + 1st + 2nd DX Off From 22a78305297791e03955142e37961d4db0cf55af Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Thu, 24 Sep 2026 15:44:47 -0500 Subject: [PATCH 8/8] feat: Hunt in the peek + a clear "pull up" affordance on the drawer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move Hunt up beside Call CQ in the always-visible peek (the mockup 3b primary pairing), reusing the shared CqHuntRow. TX period drops into the expanded section on its own row with Tune/DX (PeriodTuneDxRow) — there isn't room for a third control beside the big Call CQ button. Reverts the now-unused compact TX-period variant. - Make it obvious the sheet pulls up: the collapsed handle now shows a grabber bar plus an up-chevron and an amber "More controls" caption that gently bobs; expanded, it shows a down-chevron to collapse. Verified on emulator: peek shows Call CQ + Hunt + the hint; expand reveals Band & Mode, TX Period / Tune / DX, and power. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ui/components/OperateControlsDrawer.kt | 140 +++++++++--------- .../ks3ckc/ft8af/ui/components/TxStrip.kt | 38 ++--- .../src/main/res/values/strings_compose.xml | 3 +- 3 files changed, 91 insertions(+), 90 deletions(-) diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/OperateControlsDrawer.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/OperateControlsDrawer.kt index 60024646c..3117e27fd 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/OperateControlsDrawer.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/OperateControlsDrawer.kt @@ -1,6 +1,10 @@ package radio.ks3ckc.ft8af.ui.components import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -18,6 +22,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -29,6 +34,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onSizeChanged @@ -38,8 +44,10 @@ import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.role import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import com.k1af.ft8af.R import kotlin.math.roundToInt import kotlinx.coroutines.launch @@ -66,18 +74,18 @@ internal fun drawerTargetExpanded( internal val DrawerDragThreshold = 48.dp /** - * The collapsed height of the drawer — just tall enough for the handle (28) + the primary row - * (72), stopping before the next row so no secondary control peeks through when collapsed. + * The collapsed height of the drawer — the handle (with its "More controls" hint) plus the primary + * Call CQ + Hunt row, stopping before the next row so nothing peeks through when collapsed. */ -internal val OperateDrawerPeekHeight = 110.dp +internal val OperateDrawerPeekHeight = 120.dp /** * The operate-controls drawer (design 3a/3b) as a *single* bottom sheet that grows in place. * * The sheet is anchored so its bottom sits on [anchorBottomPx] (the window-space Y that the * reserved peek slot in the main column occupies, i.e. just above the tab bar). Its content is a - * single top-aligned column — drag handle, the primary Call CQ / Stop + TX-period row, then the - * secondary controls (Band & Mode, Hunt, Tune / DX, power). Collapsed, the sheet is + * single top-aligned column — a grab handle, the primary Call CQ / Stop + Hunt row, then the + * secondary controls (Band & Mode, TX period / Tune / DX, power). Collapsed, the sheet is * [OperateDrawerPeekHeight] tall and only the handle + primary row show (3a); dragging the handle * up or tapping it grows the *same* surface upward to reveal the rest over a scrim (3b). Nothing * slides over the primary controls — it is one sheet expanding, not a second sheet on top. @@ -133,10 +141,6 @@ fun OperateControlsDrawer( val scrimAlpha = 0.55f * fraction.value val actions = txStripActionState(isActivated, huntEnabled) - val cqIsStop = actions.cqIsStop - val variantSubtitle = cqStripSubtitle(cqIsStop, isFreeTextMode, fieldDayEnabled, cqModifier) - val cqSubtitle = variantSubtitle - ?: if (!cqIsStop) stringResource(R.string.tx_call_cq_subtitle) else null Box(modifier = Modifier.fillMaxSize()) { // Scrim over the content above the sheet only (stops at the tab bar so the tabs stay @@ -198,65 +202,35 @@ fun OperateControlsDrawer( .padding(start = 16.dp, end = 16.dp, bottom = 16.dp), verticalArrangement = Arrangement.spacedBy(10.dp), ) { - // ---- Primary row (visible in the peek): Call CQ / Stop + TX period ---- - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - CallCqButton( - modifier = Modifier.weight(1.6f), - cqIsStop = cqIsStop, - cqDisabled = actions.cqDisabled, - subtitle = cqSubtitle, - onClick = { if (cqIsStop) onStop() else onCallCQ() }, - onOpenOptions = onOpenCqOptions, - optionsContentDescription = stringResource(R.string.tx_cq_options), - moreLabel = stringResource(R.string.tx_more), - ) - TxPeriodControl( - modifier = Modifier.weight(1f), - txSlot = txSlot, - onSelect = onSelectTxPeriod, - compact = true, - ) - } + // ---- Primary row (visible in the peek): Call CQ / Stop + Hunt ---- + CqHuntRow( + actions = actions, + huntOptionLabel = huntOptionLabel, + cqModifier = cqModifier, + isFreeTextMode = isFreeTextMode, + fieldDayEnabled = fieldDayEnabled, + onCallCQ = onCallCQ, + onStop = onStop, + onOpenCqOptions = onOpenCqOptions, + onToggleHunt = onToggleHunt, + onOpenHuntOptions = onOpenHuntOptions, + ) // ---- Secondary controls, revealed as the sheet grows (design 3b) ---- BandModeRow(bandModeLabel = bandModeLabel, onOpenBandMode = onOpenBandMode) - HuntTile( - modifier = Modifier.fillMaxWidth(), - huntEnabled = actions.huntActive, - huntDisabled = actions.huntDisabled, - optionLabel = huntOptionLabel, - onToggle = onToggleHunt, - onOpenOptions = onOpenHuntOptions, - optionsContentDescription = stringResource(R.string.tx_hunt_options), + PeriodTuneDxRow( + txSlot = txSlot, + isActivated = isActivated, + isTransmitting = isTransmitting, + isTuning = isTuning, + tuneRemainingSec = tuneRemainingSec, + dxEnabled = dxEnabled, + onSelectTxPeriod = onSelectTxPeriod, + onToggleTune = onToggleTune, + onToggleDx = onToggleDx, ) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - val tuneEnabled = isTuning || (!isActivated && !isTransmitting) - SecondaryButton( - modifier = Modifier.weight(1f), - label = tuneChipLabel(stringResource(R.string.tune_button), isTuning, tuneRemainingSec), - active = isTuning, - enabled = tuneEnabled, - onClick = onToggleTune, - ) - SecondaryButton( - modifier = Modifier.weight(1f), - label = stringResource(R.string.tx_dx), - active = dxEnabled, - enabled = true, - onClick = onToggleDx, - ) - } - if (showVolumeSlider) { TxVolumeRow( txVolume = txVolume, @@ -271,7 +245,8 @@ fun OperateControlsDrawer( } /** - * The drawer's grab handle: a centered 36×4 dp bar in a full-width touch target. A tap toggles the + * The drawer's grab handle: a centered grabber bar plus — while collapsed — an up-chevron and + * "More controls" caption that gently bob to advertise that the sheet pulls up. A tap toggles the * drawer; a vertical drag is fed live to [onDrag] (dy, negative = up) and settled in [onDragEnd] * with the total drag distance. */ @@ -288,10 +263,19 @@ private fun DrawerHandle( val collapseLabel = stringResource(R.string.controls_drawer_collapse) val label = if (expanded) collapseLabel else expandLabel - Box( + // Gentle up-and-down bob on the hint, only while collapsed, to draw the eye to the affordance. + val bobTransition = rememberInfiniteTransition(label = "drawer-hint-bob") + val bob by bobTransition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable(tween(900), RepeatMode.Reverse), + label = "drawer-hint-bob-offset", + ) + + Column( modifier = modifier .fillMaxWidth() - .height(28.dp) + .height(42.dp) .clickable( interactionSource = remember { MutableInteractionSource() }, indication = null, @@ -308,7 +292,8 @@ private fun DrawerHandle( }, ) }, - contentAlignment = Alignment.Center, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(3.dp, Alignment.CenterVertically), ) { Box( modifier = Modifier @@ -317,5 +302,28 @@ private fun DrawerHandle( .clip(RoundedCornerShape(99.dp)) .background(Color(0x6694A3B8)), // rgba(148,163,184,0.40) ) + if (expanded) { + // Down-chevron when open — a plain "pull down to collapse" hint. + FT8AFIcons.ChevronDown(size = 14.dp, color = TextFaint, strokeWidth = 2.2f) + } else { + Row( + modifier = Modifier.offset(y = (bob * -3f).dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(5.dp), + ) { + // Up-chevron (the down-chevron glyph rotated) to say "pulls up". + Box(modifier = Modifier.rotate(180f)) { + FT8AFIcons.ChevronDown(size = 13.dp, color = Accent, strokeWidth = 2.4f) + } + Text( + text = stringResource(R.string.controls_drawer_more), + color = Accent, + fontSize = 11.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = InterFamily, + letterSpacing = 0.2.sp, + ) + } + } } } diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/TxStrip.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/TxStrip.kt index 3271503f0..5d67e1b1a 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/TxStrip.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/TxStrip.kt @@ -718,33 +718,27 @@ internal fun TxPeriodControl( txSlot: Int, onSelect: (Int) -> Unit, modifier: Modifier = Modifier, - // Compact drops the "TX PERIOD" caption and uses short "1st"/"2nd" segment labels so the - // control reads cleanly in the narrow slot beside Call CQ in the drawer's primary row. - compact: Boolean = false, ) { Row( modifier = modifier - .height(if (compact) 72.dp else 52.dp) - .clip(RoundedCornerShape(if (compact) 14.dp else 12.dp)) + .height(52.dp) + .clip(RoundedCornerShape(12.dp)) .background(BgSurface3) - .border(1.dp, Border, RoundedCornerShape(if (compact) 14.dp else 12.dp)) - .padding(horizontal = if (compact) 6.dp else 0.dp) - .padding(start = if (compact) 0.dp else 12.dp, end = if (compact) 0.dp else 6.dp), + .border(1.dp, Border, RoundedCornerShape(12.dp)) + .padding(start = 12.dp, end = 6.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - if (!compact) { - Text( - text = stringResource(R.string.tx_period_label), - color = TextFaint, - fontSize = 10.sp, - fontWeight = FontWeight.SemiBold, - fontFamily = InterFamily, - letterSpacing = 0.6.sp, - maxLines = 1, - softWrap = false, - ) - } + Text( + text = stringResource(R.string.tx_period_label), + color = TextFaint, + fontSize = 10.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = InterFamily, + letterSpacing = 0.6.sp, + maxLines = 1, + softWrap = false, + ) Row( modifier = Modifier .weight(1f) @@ -754,13 +748,13 @@ internal fun TxPeriodControl( horizontalArrangement = Arrangement.spacedBy(6.dp), ) { PeriodSegment( - label = stringResource(if (compact) R.string.tx_period_first_short else R.string.tx_period_first), + label = stringResource(R.string.tx_period_first), selected = txSlot == 0, modifier = Modifier.weight(1f), onClick = { if (txSlot != 0) onSelect(0) }, ) PeriodSegment( - label = stringResource(if (compact) R.string.tx_period_second_short else R.string.tx_period_second), + label = stringResource(R.string.tx_period_second), selected = txSlot == 1, modifier = Modifier.weight(1f), onClick = { if (txSlot != 1) onSelect(1) }, diff --git a/ft8af/app/src/main/res/values/strings_compose.xml b/ft8af/app/src/main/res/values/strings_compose.xml index 2373f27e8..70f377113 100644 --- a/ft8af/app/src/main/res/values/strings_compose.xml +++ b/ft8af/app/src/main/res/values/strings_compose.xml @@ -131,8 +131,6 @@ TX PERIOD 1st (even) 2nd (odd) - 1st - 2nd DX Off @@ -700,6 +698,7 @@ Collapse the operate controls into a bottom sheet you pull up. Off shows the full control strip at all times. Expand controls Collapse controls + More controls Save Output Level per Band Remember the TX volume separately for each band and restore it on band change TX volume %1$d%% restored for %2$s