Files
Ai/FT991A-Remote/FT991A-Remote/Views/ModernView/ModernRadioView.swift
T
Claude 1e153f2f85 Add FT-991A Remote Control App for macOS
Complete Phase 1 implementation of the Yaesu FT-991A remote control
application with CAT protocol support over USB serial (CP210x).

Features implemented:
- SerialPortManager with auto-detection of CP210x ports
- Full CAT protocol parser and command builder
- RadioState model with all transceiver parameters
- Modern SwiftUI interface with frequency/mode/level controls
- Skeuomorphic front panel view (switchable)
- Debug panel with CAT command console
- QSO log panel with CSV export/import
- Audio routing panel with BlackHole integration
- Settings with connection, UI, keyboard configuration
- Menu bar extra for background operation
- German/English localization
- Logging system for debugging

Supports: Frequency control, VFO A/B, all modes (LSB/USB/CW/FM/AM/
DATA/RTTY/C4FM), level controls, NB/NR/DNF/ATU/Split functions,
S-meter/Power/SWR metering, PTT control via Shift key.

Target: macOS 15.0+ (Sequoia/Tahoe)
2025-12-18 10:59:15 +00:00

577 lines
20 KiB
Swift

//
// ModernRadioView.swift
// FT991A-Remote
//
// Modern UI style for radio control
//
import SwiftUI
// MARK: - Modern Radio View
struct ModernRadioView: View {
@EnvironmentObject var radioViewModel: RadioViewModel
@EnvironmentObject var settingsController: SettingsController
var body: some View {
ScrollView {
VStack(spacing: 20) {
// Frequency Section
FrequencyView()
// Mode & Filter Section
HStack(spacing: 20) {
ModeView()
Spacer()
LevelView()
}
// Functions Section
FunctionsView()
// Metering Section
MeteringView()
// PTT Section
PTTButton()
Spacer()
}
.padding()
}
}
}
// MARK: - Frequency View
struct FrequencyView: View {
@EnvironmentObject var radioViewModel: RadioViewModel
@EnvironmentObject var settingsController: SettingsController
@State private var frequencyInput = ""
@State private var isEditing = false
var body: some View {
GroupBox("Frequenz") {
VStack(spacing: 16) {
// VFO Selection
HStack {
// VFO A
Button {
radioViewModel.selectVFO(.a)
} label: {
HStack {
Circle()
.fill(radioViewModel.activeVFO == .a ? Color.green : Color.gray.opacity(0.3))
.frame(width: 12, height: 12)
Text("VFO-A")
.font(.headline)
Text(radioViewModel.formatFrequency(radioViewModel.vfoAFrequency))
.font(.system(.body, design: .monospaced))
.foregroundColor(.secondary)
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(radioViewModel.activeVFO == .a ? Color.accentColor.opacity(0.1) : Color.clear)
.cornerRadius(8)
}
.buttonStyle(.plain)
.disabled(!radioViewModel.isConnected)
Spacer()
// VFO controls
HStack(spacing: 8) {
Button("A/B") {
radioViewModel.swapVFO()
}
.help("VFO A und B tauschen")
Button("A=B") {
radioViewModel.equalizeVFO()
}
.help("VFO B auf A-Frequenz setzen")
}
.disabled(!radioViewModel.isConnected)
Spacer()
// VFO B
Button {
radioViewModel.selectVFO(.b)
} label: {
HStack {
Text(radioViewModel.formatFrequency(radioViewModel.vfoBFrequency))
.font(.system(.body, design: .monospaced))
.foregroundColor(.secondary)
Text("VFO-B")
.font(.headline)
Circle()
.fill(radioViewModel.activeVFO == .b ? Color.green : Color.gray.opacity(0.3))
.frame(width: 12, height: 12)
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(radioViewModel.activeVFO == .b ? Color.accentColor.opacity(0.1) : Color.clear)
.cornerRadius(8)
}
.buttonStyle(.plain)
.disabled(!radioViewModel.isConnected)
}
// Main frequency display
HStack {
Button {
radioViewModel.decrementFrequency()
} label: {
Image(systemName: "minus.circle.fill")
.font(.title)
}
.buttonStyle(.plain)
.keyboardShortcut(.leftArrow, modifiers: [])
.disabled(!radioViewModel.isConnected)
Spacer()
// Frequency display
VStack(spacing: 4) {
Text(radioViewModel.frequencyDisplay)
.font(.system(size: 48, weight: .bold, design: .monospaced))
.foregroundColor(radioViewModel.isTransmitting ? .red : .primary)
Text("Hz")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
Button {
radioViewModel.incrementFrequency()
} label: {
Image(systemName: "plus.circle.fill")
.font(.title)
}
.buttonStyle(.plain)
.keyboardShortcut(.rightArrow, modifiers: [])
.disabled(!radioViewModel.isConnected)
}
// Frequency step selector
HStack {
Text("Schritt:")
.foregroundColor(.secondary)
Picker("Schritt", selection: $settingsController.frequencyStep) {
ForEach(FrequencyStep.allCases, id: \.self) { step in
Text(step.displayName).tag(step)
}
}
.pickerStyle(.segmented)
.frame(maxWidth: 500)
}
// Band buttons
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(Band.allCases, id: \.self) { band in
Button {
radioViewModel.selectBand(band)
} label: {
Text(band.rawValue)
.font(.caption)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(radioViewModel.currentBand == band ? Color.accentColor : Color.secondary.opacity(0.2))
.foregroundColor(radioViewModel.currentBand == band ? .white : .primary)
.cornerRadius(6)
}
.buttonStyle(.plain)
.disabled(!radioViewModel.isConnected)
}
}
}
}
.padding()
}
}
}
// MARK: - Mode View
struct ModeView: View {
@EnvironmentObject var radioViewModel: RadioViewModel
let commonModes: [OperatingMode] = [.lsb, .usb, .cw, .fm, .am]
let digitalModes: [OperatingMode] = [.dataLSB, .dataUSB, .rttyLSB, .rttyUSB, .c4fm]
var body: some View {
GroupBox("Betriebsart") {
VStack(alignment: .leading, spacing: 12) {
// Common modes
HStack(spacing: 8) {
ForEach(commonModes, id: \.self) { mode in
Button {
radioViewModel.setMode(mode)
} label: {
Text(mode.rawValue)
.font(.caption.bold())
.frame(width: 50)
.padding(.vertical, 6)
.background(radioViewModel.mode == mode ? Color.accentColor : Color.secondary.opacity(0.2))
.foregroundColor(radioViewModel.mode == mode ? .white : .primary)
.cornerRadius(6)
}
.buttonStyle(.plain)
.disabled(!radioViewModel.isConnected)
}
}
// Digital modes
HStack(spacing: 8) {
ForEach(digitalModes, id: \.self) { mode in
Button {
radioViewModel.setMode(mode)
} label: {
Text(mode.rawValue)
.font(.caption.bold())
.frame(minWidth: 50)
.padding(.horizontal, 8)
.padding(.vertical, 6)
.background(radioViewModel.mode == mode ? Color.orange : Color.secondary.opacity(0.2))
.foregroundColor(radioViewModel.mode == mode ? .white : .primary)
.cornerRadius(6)
}
.buttonStyle(.plain)
.disabled(!radioViewModel.isConnected)
}
}
}
.padding()
}
}
}
// MARK: - Level View
struct LevelView: View {
@EnvironmentObject var radioViewModel: RadioViewModel
var body: some View {
GroupBox("Pegel") {
VStack(spacing: 12) {
LevelSlider(label: "AF", value: Binding(
get: { Double(radioViewModel.afGain) },
set: { radioViewModel.setAFGain(Int($0)) }
), range: 0...255, disabled: !radioViewModel.isConnected)
LevelSlider(label: "RF", value: Binding(
get: { Double(radioViewModel.rfGain) },
set: { radioViewModel.setRFGain(Int($0)) }
), range: 0...255, disabled: !radioViewModel.isConnected)
LevelSlider(label: "SQL", value: Binding(
get: { Double(radioViewModel.squelch) },
set: { radioViewModel.setSquelch(Int($0)) }
), range: 0...255, disabled: !radioViewModel.isConnected)
LevelSlider(label: "MIC", value: Binding(
get: { Double(radioViewModel.micGain) },
set: { radioViewModel.setMICGain(Int($0)) }
), range: 0...100, disabled: !radioViewModel.isConnected)
LevelSlider(label: "PWR", value: Binding(
get: { Double(radioViewModel.power) },
set: { radioViewModel.setPower(Int($0)) }
), range: 5...100, unit: "W", disabled: !radioViewModel.isConnected)
}
.padding()
}
.frame(width: 300)
}
}
// MARK: - Level Slider
struct LevelSlider: View {
let label: String
@Binding var value: Double
let range: ClosedRange<Double>
var unit: String = "%"
var disabled: Bool = false
var displayValue: String {
if unit == "W" {
return "\(Int(value))W"
} else {
let percent = (value - range.lowerBound) / (range.upperBound - range.lowerBound) * 100
return "\(Int(percent))%"
}
}
var body: some View {
HStack {
Text(label)
.font(.caption.bold())
.frame(width: 35, alignment: .leading)
Slider(value: $value, in: range)
.disabled(disabled)
Text(displayValue)
.font(.caption.monospacedDigit())
.frame(width: 45, alignment: .trailing)
}
}
}
// MARK: - Functions View
struct FunctionsView: View {
@EnvironmentObject var radioViewModel: RadioViewModel
var body: some View {
GroupBox("Funktionen") {
HStack(spacing: 12) {
FunctionButton(label: "NB", isActive: radioViewModel.noiseBlanker) {
radioViewModel.toggleNB()
}
.disabled(!radioViewModel.isConnected)
FunctionButton(label: "NR", isActive: radioViewModel.noiseReduction) {
radioViewModel.toggleNR()
}
.disabled(!radioViewModel.isConnected)
FunctionButton(label: "DNF", isActive: radioViewModel.dnf) {
radioViewModel.toggleDNF()
}
.disabled(!radioViewModel.isConnected)
FunctionButton(label: "CONT", isActive: radioViewModel.contour) {
// Toggle contour
}
.disabled(!radioViewModel.isConnected)
Divider()
.frame(height: 30)
FunctionButton(label: "ATU", isActive: radioViewModel.atu, color: .orange) {
radioViewModel.startATUTune()
}
.disabled(!radioViewModel.isConnected)
.keyboardShortcut(.upArrow, modifiers: [])
FunctionButton(label: "SPLIT", isActive: radioViewModel.split) {
radioViewModel.toggleSplit()
}
.disabled(!radioViewModel.isConnected)
FunctionButton(label: "IPO", isActive: radioViewModel.ipo) {
// Toggle IPO
}
.disabled(!radioViewModel.isConnected)
Spacer()
}
.padding()
}
}
}
// MARK: - Function Button
struct FunctionButton: View {
let label: String
let isActive: Bool
var color: Color = .accentColor
let action: () -> Void
var body: some View {
Button(action: action) {
Text(label)
.font(.caption.bold())
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(isActive ? color : Color.secondary.opacity(0.2))
.foregroundColor(isActive ? .white : .primary)
.cornerRadius(6)
}
.buttonStyle(.plain)
}
}
// MARK: - Metering View
struct MeteringView: View {
@EnvironmentObject var radioViewModel: RadioViewModel
var body: some View {
GroupBox("Messwerte") {
VStack(spacing: 16) {
// S-Meter
VStack(alignment: .leading, spacing: 4) {
HStack {
Text("S-Meter")
.font(.caption)
.foregroundColor(.secondary)
Spacer()
Text(radioViewModel.sMeterDisplay)
.font(.caption.bold().monospacedDigit())
}
SMeterBar(value: Double(radioViewModel.sMeter) / 255.0)
}
// Power meter (only shown when transmitting)
if radioViewModel.isTransmitting {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text("Leistung")
.font(.caption)
.foregroundColor(.secondary)
Spacer()
Text("\(radioViewModel.powerMeter)W")
.font(.caption.bold().monospacedDigit())
}
MeterBar(value: Double(radioViewModel.powerMeter) / 100.0, color: .orange)
}
VStack(alignment: .leading, spacing: 4) {
HStack {
Text("SWR")
.font(.caption)
.foregroundColor(.secondary)
Spacer()
Text(String(format: "%.1f:1", 1.0 + Double(radioViewModel.swrMeter) / 50.0))
.font(.caption.bold().monospacedDigit())
}
MeterBar(value: Double(radioViewModel.swrMeter) / 255.0, color: radioViewModel.swrMeter > 100 ? .red : .green)
}
}
}
.padding()
}
}
}
// MARK: - S-Meter Bar
struct SMeterBar: View {
let value: Double
var body: some View {
GeometryReader { geometry in
ZStack(alignment: .leading) {
// Background
RoundedRectangle(cornerRadius: 4)
.fill(Color.secondary.opacity(0.2))
// S-Unit markers
HStack(spacing: 0) {
ForEach(0..<10) { i in
Rectangle()
.fill(Color.secondary.opacity(0.3))
.frame(width: 1)
if i < 9 {
Spacer()
}
}
}
.padding(.horizontal, 2)
// Value bar
RoundedRectangle(cornerRadius: 4)
.fill(
LinearGradient(
colors: [.green, .yellow, .orange, .red],
startPoint: .leading,
endPoint: .trailing
)
)
.frame(width: max(0, geometry.size.width * value))
}
}
.frame(height: 20)
}
}
// MARK: - Meter Bar
struct MeterBar: View {
let value: Double
var color: Color = .green
var body: some View {
GeometryReader { geometry in
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 4)
.fill(Color.secondary.opacity(0.2))
RoundedRectangle(cornerRadius: 4)
.fill(color)
.frame(width: max(0, geometry.size.width * min(1, value)))
}
}
.frame(height: 16)
}
}
// MARK: - PTT Button
struct PTTButton: View {
@EnvironmentObject var radioViewModel: RadioViewModel
@State private var isPressed = false
var body: some View {
GroupBox("PTT") {
VStack(spacing: 12) {
Button {
radioViewModel.toggleTransmit()
} label: {
HStack {
Image(systemName: radioViewModel.isTransmitting ? "mic.fill" : "mic")
Text(radioViewModel.isTransmitting ? "EMPFANG" : "SENDEN")
.font(.headline)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
.background(radioViewModel.isTransmitting ? Color.red : Color.accentColor)
.foregroundColor(.white)
.cornerRadius(8)
}
.buttonStyle(.plain)
.disabled(!radioViewModel.isConnected)
Text("Shift-Taste gedrückt halten = PTT")
.font(.caption)
.foregroundColor(.secondary)
// TX indicator
HStack {
Circle()
.fill(radioViewModel.isTransmitting ? Color.red : Color.gray.opacity(0.3))
.frame(width: 16, height: 16)
Text(radioViewModel.isTransmitting ? "TX" : "RX")
.font(.caption.bold())
.foregroundColor(radioViewModel.isTransmitting ? .red : .green)
}
}
.padding()
}
}
}
// MARK: - Preview
#Preview {
ModernRadioView()
.environmentObject(RadioViewModel())
.environmentObject(SettingsController())
.frame(width: 800, height: 900)
.padding()
}