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)
This commit is contained in:
Claude
2025-12-18 10:59:15 +00:00
parent 20904e2a96
commit 1e153f2f85
30 changed files with 6665 additions and 0 deletions
@@ -0,0 +1,216 @@
//
// MainView.swift
// FT991A-Remote
//
// Main application window container
//
import SwiftUI
// MARK: - Main View
struct MainView: View {
@EnvironmentObject var radioViewModel: RadioViewModel
@EnvironmentObject var settingsController: SettingsController
@EnvironmentObject var logViewModel: LogViewModel
@State private var isDebugPanelDetached = false
@State private var isLogPanelDetached = false
var body: some View {
NavigationSplitView {
// Sidebar
SidebarView()
.frame(minWidth: 200)
} detail: {
// Main content
HSplitView {
// Radio control area
VStack(spacing: 0) {
// Connection bar
ConnectionBar()
.padding(.horizontal)
.padding(.top, 8)
Divider()
.padding(.top, 8)
// Radio view based on UI style
if settingsController.uiStyle == .modern {
ModernRadioView()
} else {
SkeuomorphRadioView()
}
}
.frame(minWidth: 600)
// Side panels
if settingsController.showLogPanel && !isLogPanelDetached {
Divider()
LogPanel()
.frame(minWidth: 300, maxWidth: 400)
}
if settingsController.showDebugPanel && !isDebugPanelDetached {
Divider()
DebugPanel()
.frame(minWidth: 300, maxWidth: 400)
}
}
}
.toolbar {
ToolbarItemGroup(placement: .primaryAction) {
// UI Style toggle
Picker("UI", selection: $settingsController.uiStyle) {
Image(systemName: "rectangle.3.group")
.tag(UIStyle.modern)
Image(systemName: "dial.medium")
.tag(UIStyle.skeuomorph)
}
.pickerStyle(.segmented)
.help("UI-Stil wechseln")
Divider()
// Panel toggles
Toggle(isOn: $settingsController.showLogPanel) {
Image(systemName: "list.bullet.rectangle")
}
.help("Log-Panel anzeigen")
Toggle(isOn: $settingsController.showDebugPanel) {
Image(systemName: "terminal")
}
.help("Debug-Panel anzeigen")
}
}
.navigationTitle("FT-991A Remote")
.onAppear {
setupKeyboardShortcuts()
}
}
private func setupKeyboardShortcuts() {
// Keyboard shortcuts are handled in the App commands
}
}
// MARK: - Sidebar View
struct SidebarView: View {
@EnvironmentObject var radioViewModel: RadioViewModel
var body: some View {
List {
Section("Verbindung") {
Label {
VStack(alignment: .leading) {
Text(radioViewModel.isConnected ? "Verbunden" : "Getrennt")
.font(.headline)
if radioViewModel.isConnected {
Text(radioViewModel.selectedPort)
.font(.caption)
.foregroundColor(.secondary)
}
}
} icon: {
Image(systemName: radioViewModel.isConnected ? "antenna.radiowaves.left.and.right" : "antenna.radiowaves.left.and.right.slash")
.foregroundColor(radioViewModel.isConnected ? .green : .red)
}
}
Section("Frequenz") {
Label {
Text(radioViewModel.frequencyDisplay + " Hz")
.font(.system(.body, design: .monospaced))
} icon: {
Image(systemName: "waveform")
}
if let band = radioViewModel.currentBand {
Label(band.rawValue, systemImage: "chart.bar")
}
Label(radioViewModel.mode.rawValue, systemImage: "waveform.path")
}
Section("Bänder") {
ForEach(Band.allCases, id: \.self) { band in
Button {
radioViewModel.selectBand(band)
} label: {
Label(band.rawValue, systemImage: "antenna.radiowaves.left.and.right")
}
.disabled(!radioViewModel.isConnected)
}
}
}
.listStyle(.sidebar)
}
}
// MARK: - Connection Bar
struct ConnectionBar: View {
@EnvironmentObject var radioViewModel: RadioViewModel
var body: some View {
HStack(spacing: 12) {
// Port selection
Picker("Port", selection: $radioViewModel.selectedPort) {
Text("Port wählen...").tag("")
ForEach(radioViewModel.availablePorts) { port in
Text(port.name).tag(port.path)
}
}
.frame(width: 200)
// Refresh button
Button {
radioViewModel.refreshPorts()
} label: {
Image(systemName: "arrow.clockwise")
}
.help("Ports aktualisieren")
// Baud rate
Picker("Baud", selection: $radioViewModel.baudRate) {
ForEach(SerialConfig.availableBaudRates, id: \.self) { rate in
Text("\(rate)").tag(rate)
}
}
.frame(width: 100)
Spacer()
// Connection status
HStack(spacing: 6) {
Circle()
.fill(radioViewModel.isConnected ? Color.green : Color.red)
.frame(width: 10, height: 10)
Text(radioViewModel.connectionState.displayString)
.foregroundColor(.secondary)
}
// Connect button
Button {
radioViewModel.toggleConnection()
} label: {
Text(radioViewModel.isConnected ? "Trennen" : "Verbinden")
}
.keyboardShortcut("k", modifiers: .command)
}
.padding(.vertical, 8)
}
}
// MARK: - Preview
#Preview {
MainView()
.environmentObject(RadioViewModel())
.environmentObject(SettingsController())
.environmentObject(LogViewModel())
.frame(width: 1200, height: 800)
}
@@ -0,0 +1,134 @@
//
// MenuBarView.swift
// FT991A-Remote
//
// Menu bar extra for background operation
//
import SwiftUI
// MARK: - Menu Bar View
struct MenuBarView: View {
@EnvironmentObject var radioViewModel: RadioViewModel
@EnvironmentObject var settingsController: SettingsController
var body: some View {
VStack(alignment: .leading, spacing: 8) {
// Connection status
HStack {
Circle()
.fill(radioViewModel.isConnected ? Color.green : Color.red)
.frame(width: 10, height: 10)
Text(radioViewModel.isConnected ? "Verbunden" : "Getrennt")
.font(.headline)
Spacer()
Button(radioViewModel.isConnected ? "Trennen" : "Verbinden") {
radioViewModel.toggleConnection()
}
.controlSize(.small)
}
if radioViewModel.isConnected {
Divider()
// Frequency display
VStack(alignment: .leading, spacing: 4) {
Text("Frequenz")
.font(.caption)
.foregroundColor(.secondary)
Text(radioViewModel.frequencyDisplay + " Hz")
.font(.system(.title3, design: .monospaced))
}
// Mode and Band
HStack {
Text(radioViewModel.mode.rawValue)
.padding(.horizontal, 8)
.padding(.vertical, 2)
.background(Color.accentColor.opacity(0.2))
.cornerRadius(4)
if let band = radioViewModel.currentBand {
Text(band.rawValue)
.foregroundColor(.secondary)
}
Spacer()
Text(radioViewModel.sMeterDisplay)
.font(.caption.monospacedDigit())
}
// TX Status
if radioViewModel.isTransmitting {
HStack {
Circle()
.fill(Color.red)
.frame(width: 10, height: 10)
Text("Senden")
.foregroundColor(.red)
}
}
Divider()
// Quick controls
HStack(spacing: 12) {
Button {
radioViewModel.selectVFO(radioViewModel.activeVFO == .a ? .b : .a)
} label: {
Text("VFO \(radioViewModel.activeVFO.rawValue)")
}
.controlSize(.small)
Button("A/B") {
radioViewModel.swapVFO()
}
.controlSize(.small)
Button("ATU") {
radioViewModel.startATUTune()
}
.controlSize(.small)
}
}
Divider()
// App controls
Button("Hauptfenster öffnen") {
NSApp.activate(ignoringOtherApps: true)
if let window = NSApp.windows.first {
window.makeKeyAndOrderFront(nil)
}
}
Button("Einstellungen...") {
NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil)
}
.keyboardShortcut(",", modifiers: .command)
Divider()
Button("Beenden") {
NSApp.terminate(nil)
}
.keyboardShortcut("q", modifiers: .command)
}
.padding()
.frame(width: 280)
}
}
// MARK: - Preview
#Preview {
MenuBarView()
.environmentObject(RadioViewModel())
.environmentObject(SettingsController())
}
@@ -0,0 +1,576 @@
//
// 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()
}
@@ -0,0 +1,148 @@
//
// AudioPanel.swift
// FT991A-Remote
//
// BlackHole audio routing panel
//
import SwiftUI
// MARK: - Audio Panel
struct AudioPanel: View {
@StateObject private var audioRouter = AudioRouter()
@EnvironmentObject var settingsController: SettingsController
var body: some View {
VStack(spacing: 0) {
// Header
HStack {
Text("Audio Routing")
.font(.headline)
Spacer()
Button {
audioRouter.refreshDevices()
} label: {
Image(systemName: "arrow.clockwise")
}
.help("Geräte aktualisieren")
}
.padding(.horizontal)
.padding(.vertical, 8)
.background(Color.secondary.opacity(0.1))
Divider()
ScrollView {
VStack(alignment: .leading, spacing: 16) {
// BlackHole Status
GroupBox("BlackHole Status") {
HStack {
Circle()
.fill(audioRouter.isBlackHoleInstalled ? Color.green : Color.red)
.frame(width: 12, height: 12)
Text(audioRouter.isBlackHoleInstalled ? "Installiert" : "Nicht gefunden")
Spacer()
if !audioRouter.isBlackHoleInstalled {
Link("Installieren", destination: URL(string: "https://existential.audio/blackhole/")!)
.font(.caption)
}
}
.padding(.vertical, 4)
if let device = audioRouter.blackHoleDevice {
Text("Gerät: \(device.name)")
.font(.caption)
.foregroundColor(.secondary)
}
}
// Input Device
GroupBox("Eingang (RX Audio)") {
Picker("Eingabegerät", selection: $audioRouter.selectedInputDevice) {
Text("Keines").tag(nil as AudioDeviceID?)
ForEach(audioRouter.inputDevices) { device in
Text(device.displayName).tag(device.id as AudioDeviceID?)
}
}
.pickerStyle(.menu)
if let ft991a = audioRouter.ft991aDevice {
HStack {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
Text("FT-991A erkannt: \(ft991a.name)")
.font(.caption)
}
}
}
// Output Device
GroupBox("Ausgang (TX Audio)") {
Picker("Ausgabegerät", selection: $audioRouter.selectedOutputDevice) {
Text("Keines").tag(nil as AudioDeviceID?)
ForEach(audioRouter.outputDevices) { device in
Text(device.displayName).tag(device.id as AudioDeviceID?)
}
}
.pickerStyle(.menu)
}
// Digital Mode Configuration
GroupBox("Digitale Betriebsarten") {
VStack(alignment: .leading, spacing: 8) {
Text("Für FT8, WSPR, RTTY und andere digitale Modi:")
.font(.caption)
.foregroundColor(.secondary)
Button("Für Digimodes konfigurieren") {
_ = audioRouter.configureForDigitalModes()
}
.disabled(!audioRouter.isBlackHoleInstalled)
Toggle("BlackHole verwenden", isOn: $settingsController.useBlackHole)
.disabled(!audioRouter.isBlackHoleInstalled)
}
.padding(.vertical, 4)
}
// Routing Diagram
GroupBox("Routing-Schema") {
VStack(alignment: .leading, spacing: 4) {
Text("FT-991A USB Audio → BlackHole → WSJT-X/fldigi")
.font(.caption.monospaced())
Text("WSJT-X/fldigi → BlackHole → FT-991A USB Audio")
.font(.caption.monospaced())
}
.foregroundColor(.secondary)
.padding(.vertical, 4)
}
// Error display
if let error = audioRouter.lastError {
HStack {
Image(systemName: "exclamationmark.triangle")
.foregroundColor(.orange)
Text(error)
.font(.caption)
}
}
}
.padding()
}
}
}
}
// MARK: - Preview
#Preview {
AudioPanel()
.environmentObject(SettingsController())
.frame(width: 350, height: 500)
}
@@ -0,0 +1,178 @@
//
// DebugPanel.swift
// FT991A-Remote
//
// CAT command console for debugging
//
import SwiftUI
// MARK: - Debug Panel
struct DebugPanel: View {
@EnvironmentObject var radioViewModel: RadioViewModel
@State private var commandInput = ""
@State private var autoScroll = true
@State private var showOnlySent = false
@State private var showOnlyReceived = false
var filteredHistory: [CommandLogEntry] {
radioViewModel.commandHistory.filter { entry in
if showOnlySent && entry.direction != .sent { return false }
if showOnlyReceived && entry.direction != .received { return false }
return true
}
}
var body: some View {
VStack(spacing: 0) {
// Header
HStack {
Text("CAT Konsole")
.font(.headline)
Spacer()
// Filter buttons
Toggle("TX", isOn: Binding(
get: { showOnlySent },
set: { showOnlySent = $0; if $0 { showOnlyReceived = false } }
))
.toggleStyle(.button)
.controlSize(.small)
Toggle("RX", isOn: Binding(
get: { showOnlyReceived },
set: { showOnlyReceived = $0; if $0 { showOnlySent = false } }
))
.toggleStyle(.button)
.controlSize(.small)
Toggle(isOn: $autoScroll) {
Image(systemName: "arrow.down.to.line")
}
.toggleStyle(.button)
.controlSize(.small)
.help("Auto-Scroll")
Button {
radioViewModel.clearCommandHistory()
} label: {
Image(systemName: "trash")
}
.controlSize(.small)
.help("Verlauf löschen")
}
.padding(.horizontal)
.padding(.vertical, 8)
.background(Color.secondary.opacity(0.1))
Divider()
// Command history
ScrollViewReader { proxy in
ScrollView {
LazyVStack(alignment: .leading, spacing: 2) {
ForEach(filteredHistory) { entry in
CommandLogRow(entry: entry)
.id(entry.id)
}
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
}
.font(.system(size: 11, design: .monospaced))
.onChange(of: radioViewModel.commandHistory.count) { _, _ in
if autoScroll, let last = filteredHistory.last {
withAnimation {
proxy.scrollTo(last.id, anchor: .bottom)
}
}
}
}
Divider()
// Command input
HStack {
TextField("CAT-Befehl eingeben (z.B. FA;)", text: $commandInput)
.textFieldStyle(.plain)
.font(.system(size: 12, design: .monospaced))
.onSubmit {
sendCommand()
}
Button("Senden") {
sendCommand()
}
.disabled(commandInput.isEmpty || !radioViewModel.isConnected)
.keyboardShortcut(.return, modifiers: [])
}
.padding(8)
.background(Color.secondary.opacity(0.1))
// Statistics
HStack {
Text("TX: \(radioViewModel.bytesSent) Bytes")
Spacer()
Text("RX: \(radioViewModel.bytesReceived) Bytes")
Spacer()
Text("\(radioViewModel.commandHistory.count) Befehle")
}
.font(.caption)
.foregroundColor(.secondary)
.padding(.horizontal, 8)
.padding(.vertical, 4)
}
}
private func sendCommand() {
guard !commandInput.isEmpty else { return }
var cmd = commandInput.trimmingCharacters(in: .whitespaces)
if !cmd.hasSuffix(";") {
cmd += ";"
}
radioViewModel.sendRawCommand(cmd)
commandInput = ""
}
}
// MARK: - Command Log Row
struct CommandLogRow: View {
let entry: CommandLogEntry
var body: some View {
HStack(alignment: .top, spacing: 8) {
Text(entry.timeString)
.foregroundColor(.secondary)
.frame(width: 80, alignment: .leading)
Text(entry.direction.symbol)
.foregroundColor(entry.direction == .sent ? .blue : .green)
.frame(width: 15)
Text(entry.command)
.foregroundColor(.primary)
if !entry.description.isEmpty {
Text("// \(entry.description)")
.foregroundColor(.secondary)
}
Spacer()
}
.padding(.vertical, 1)
}
}
// MARK: - Preview
#Preview {
DebugPanel()
.environmentObject(RadioViewModel())
.frame(width: 400, height: 500)
}
@@ -0,0 +1,318 @@
//
// LogPanel.swift
// FT991A-Remote
//
// QSO Log panel
//
import SwiftUI
// MARK: - Log Panel
struct LogPanel: View {
@EnvironmentObject var logViewModel: LogViewModel
@EnvironmentObject var radioViewModel: RadioViewModel
@State private var isAddingQSO = false
var body: some View {
VStack(spacing: 0) {
// Header
HStack {
Text("QSO Log")
.font(.headline)
Spacer()
Text("\(logViewModel.totalQSOs) QSOs")
.font(.caption)
.foregroundColor(.secondary)
Button {
isAddingQSO = true
} label: {
Image(systemName: "plus")
}
.help("Neues QSO hinzufügen")
}
.padding(.horizontal)
.padding(.vertical, 8)
.background(Color.secondary.opacity(0.1))
Divider()
// Quick entry form
if isAddingQSO {
QuickLogEntry(isPresented: $isAddingQSO)
Divider()
}
// Search and filter
HStack {
Image(systemName: "magnifyingglass")
.foregroundColor(.secondary)
TextField("Suchen...", text: $logViewModel.searchText)
.textFieldStyle(.plain)
if !logViewModel.searchText.isEmpty {
Button {
logViewModel.searchText = ""
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundColor(.secondary)
}
.buttonStyle(.plain)
}
Picker("Sortierung", selection: $logViewModel.sortOrder) {
ForEach(LogViewModel.SortOrder.allCases, id: \.self) { order in
Text(order.rawValue).tag(order)
}
}
.pickerStyle(.menu)
.frame(width: 150)
}
.padding(.horizontal)
.padding(.vertical, 6)
Divider()
// QSO List
List {
ForEach(logViewModel.filteredEntries) { entry in
QSORow(entry: entry)
.contextMenu {
Button("Bearbeiten") {
logViewModel.selectedEntry = entry
}
Button("Löschen", role: .destructive) {
logViewModel.deleteQSO(entry)
}
}
}
.onDelete(perform: logViewModel.deleteQSOs)
}
.listStyle(.plain)
Divider()
// Footer with statistics
HStack {
Text("\(logViewModel.uniqueCallsigns) Stationen")
Spacer()
if let file = logViewModel.currentLogFile {
Text(file.lastPathComponent)
.lineLimit(1)
.truncationMode(.middle)
}
}
.font(.caption)
.foregroundColor(.secondary)
.padding(.horizontal)
.padding(.vertical, 4)
}
.sheet(item: $logViewModel.selectedEntry) { entry in
QSOEditSheet(entry: entry)
}
}
}
// MARK: - QSO Row
struct QSORow: View {
let entry: QSOEntry
var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(entry.callsign)
.font(.headline)
Spacer()
Text(entry.dateDisplay)
.font(.caption)
.foregroundColor(.secondary)
Text(entry.timeDisplay)
.font(.caption)
.foregroundColor(.secondary)
}
HStack {
Text(entry.frequencyDisplay)
.font(.caption.monospacedDigit())
Text(entry.mode.rawValue)
.font(.caption)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.accentColor.opacity(0.2))
.cornerRadius(4)
Text(entry.bandDisplay)
.font(.caption)
.foregroundColor(.secondary)
Spacer()
Text("RST: \(entry.rstSent)/\(entry.rstReceived)")
.font(.caption)
.foregroundColor(.secondary)
}
if !entry.name.isEmpty || !entry.qth.isEmpty {
HStack {
if !entry.name.isEmpty {
Text(entry.name)
.font(.caption)
}
if !entry.qth.isEmpty {
Text("- \(entry.qth)")
.font(.caption)
.foregroundColor(.secondary)
}
}
}
}
.padding(.vertical, 4)
}
}
// MARK: - Quick Log Entry
struct QuickLogEntry: View {
@EnvironmentObject var logViewModel: LogViewModel
@EnvironmentObject var radioViewModel: RadioViewModel
@Binding var isPresented: Bool
var body: some View {
VStack(spacing: 8) {
HStack {
TextField("Rufzeichen", text: $logViewModel.currentQSO.callsign)
.textFieldStyle(.roundedBorder)
TextField("RST TX", text: $logViewModel.currentQSO.rstSent)
.textFieldStyle(.roundedBorder)
.frame(width: 50)
TextField("RST RX", text: $logViewModel.currentQSO.rstReceived)
.textFieldStyle(.roundedBorder)
.frame(width: 50)
}
HStack {
TextField("Name", text: $logViewModel.currentQSO.name)
.textFieldStyle(.roundedBorder)
TextField("QTH", text: $logViewModel.currentQSO.qth)
.textFieldStyle(.roundedBorder)
TextField("Locator", text: $logViewModel.currentQSO.locator)
.textFieldStyle(.roundedBorder)
.frame(width: 80)
}
HStack {
Button("Von Radio") {
logViewModel.updateFromRadio(
frequency: radioViewModel.activeFrequency,
mode: radioViewModel.mode,
power: radioViewModel.power
)
}
.disabled(!radioViewModel.isConnected)
Spacer()
Button("Abbrechen") {
logViewModel.resetCurrentQSO()
isPresented = false
}
Button("Speichern") {
logViewModel.addQSO()
isPresented = false
}
.disabled(logViewModel.currentQSO.callsign.isEmpty)
.keyboardShortcut(.return, modifiers: .command)
}
}
.padding()
.background(Color.secondary.opacity(0.05))
}
}
// MARK: - QSO Edit Sheet
struct QSOEditSheet: View {
@EnvironmentObject var logViewModel: LogViewModel
@Environment(\.dismiss) var dismiss
let entry: QSOEntry
@State private var editedEntry: QSOEntry
init(entry: QSOEntry) {
self.entry = entry
self._editedEntry = State(initialValue: entry)
}
var body: some View {
VStack(spacing: 16) {
Text("QSO bearbeiten")
.font(.headline)
Form {
TextField("Rufzeichen", text: $editedEntry.callsign)
TextField("Name", text: $editedEntry.name)
TextField("QTH", text: $editedEntry.qth)
TextField("Locator", text: $editedEntry.locator)
HStack {
TextField("RST TX", text: $editedEntry.rstSent)
TextField("RST RX", text: $editedEntry.rstReceived)
}
Picker("Mode", selection: $editedEntry.mode) {
ForEach(OperatingMode.allCases, id: \.self) { mode in
Text(mode.rawValue).tag(mode)
}
}
TextField("Notizen", text: $editedEntry.notes, axis: .vertical)
.lineLimit(3...6)
}
.formStyle(.grouped)
HStack {
Button("Abbrechen") {
dismiss()
}
.keyboardShortcut(.escape, modifiers: [])
Spacer()
Button("Speichern") {
logViewModel.updateQSO(editedEntry)
dismiss()
}
.keyboardShortcut(.return, modifiers: .command)
}
}
.padding()
.frame(width: 400, height: 400)
}
}
// MARK: - Preview
#Preview {
LogPanel()
.environmentObject(LogViewModel())
.environmentObject(RadioViewModel())
.frame(width: 350, height: 600)
}
@@ -0,0 +1,302 @@
//
// SettingsView.swift
// FT991A-Remote
//
// Application settings view
//
import SwiftUI
// MARK: - Settings View
struct SettingsView: View {
@EnvironmentObject var radioViewModel: RadioViewModel
@EnvironmentObject var settingsController: SettingsController
var body: some View {
TabView {
// Connection Settings
ConnectionSettingsView()
.tabItem {
Label("Verbindung", systemImage: "cable.connector")
}
// UI Settings
UISettingsView()
.tabItem {
Label("Oberfläche", systemImage: "paintbrush")
}
// Audio Settings
AudioSettingsView()
.tabItem {
Label("Audio", systemImage: "speaker.wave.2")
}
// Keyboard Settings
KeyboardSettingsView()
.tabItem {
Label("Tastatur", systemImage: "keyboard")
}
// Logging Settings
LoggingSettingsView()
.tabItem {
Label("Logging", systemImage: "doc.text")
}
}
.frame(width: 500, height: 400)
}
}
// MARK: - Connection Settings
struct ConnectionSettingsView: View {
@EnvironmentObject var settingsController: SettingsController
var body: some View {
Form {
Section("Serielle Verbindung") {
Picker("Standard-Baudrate", selection: $settingsController.defaultBaudRate) {
ForEach(SettingsController.availableBaudRates, id: \.self) { rate in
Text("\(rate) baud").tag(rate)
}
}
Toggle("Auto-Reconnect aktivieren", isOn: $settingsController.autoReconnect)
if settingsController.autoReconnect {
HStack {
Text("Intervall:")
Slider(value: $settingsController.reconnectInterval, in: 1...30, step: 1)
Text("\(Int(settingsController.reconnectInterval))s")
.frame(width: 30)
}
}
}
Section("FT-991A Einstellungen") {
Text("Stelle sicher, dass im Radio-Menü folgende Einstellungen aktiv sind:")
.font(.caption)
.foregroundColor(.secondary)
VStack(alignment: .leading, spacing: 4) {
Text("• CAT RATE: 38400 bps")
Text("• CAT TOT: 100 ms")
Text("• CAT RTS: OFF")
}
.font(.caption.monospaced())
}
}
.formStyle(.grouped)
.padding()
}
}
// MARK: - UI Settings
struct UISettingsView: View {
@EnvironmentObject var settingsController: SettingsController
var body: some View {
Form {
Section("Erscheinungsbild") {
Picker("UI-Stil", selection: $settingsController.uiStyle) {
Text("Modern").tag(UIStyle.modern)
Text("Frontpanel (Skeuomorph)").tag(UIStyle.skeuomorph)
}
Toggle("Kompakter Modus", isOn: $settingsController.compactMode)
}
Section("Sprache") {
Picker("Sprache", selection: $settingsController.language) {
ForEach(AppLanguage.allCases, id: \.self) { lang in
Text(lang.displayName).tag(lang)
}
}
Text("Änderungen werden nach Neustart wirksam.")
.font(.caption)
.foregroundColor(.secondary)
}
Section("Frequenz") {
Picker("Standard-Schrittweite", selection: $settingsController.frequencyStep) {
ForEach(FrequencyStep.allCases, id: \.self) { step in
Text(step.displayName).tag(step)
}
}
}
}
.formStyle(.grouped)
.padding()
}
}
// MARK: - Audio Settings
struct AudioSettingsView: View {
@EnvironmentObject var settingsController: SettingsController
@StateObject private var audioRouter = AudioRouter()
var body: some View {
Form {
Section("Audio-Geräte") {
Picker("Eingabegerät", selection: $settingsController.audioInputDevice) {
Text("Standard").tag("")
ForEach(audioRouter.inputDevices) { device in
Text(device.name).tag(device.uid)
}
}
Picker("Ausgabegerät", selection: $settingsController.audioOutputDevice) {
Text("Standard").tag("")
ForEach(audioRouter.outputDevices) { device in
Text(device.name).tag(device.uid)
}
}
}
Section("BlackHole Integration") {
HStack {
Circle()
.fill(audioRouter.isBlackHoleInstalled ? Color.green : Color.red)
.frame(width: 10, height: 10)
Text(audioRouter.isBlackHoleInstalled ? "BlackHole installiert" : "BlackHole nicht gefunden")
}
Toggle("BlackHole für Digimodes verwenden", isOn: $settingsController.useBlackHole)
.disabled(!audioRouter.isBlackHoleInstalled)
if !audioRouter.isBlackHoleInstalled {
Link("BlackHole herunterladen", destination: URL(string: "https://existential.audio/blackhole/")!)
}
}
}
.formStyle(.grouped)
.padding()
.onAppear {
audioRouter.refreshDevices()
}
}
}
// MARK: - Keyboard Settings
struct KeyboardSettingsView: View {
@EnvironmentObject var settingsController: SettingsController
var body: some View {
Form {
Section("Tastaturkürzel") {
Toggle("Shift = PTT (Push-to-Talk)", isOn: $settingsController.pttShortcutEnabled)
Toggle("Pfeiltasten = Frequenz ändern", isOn: $settingsController.arrowFrequencyEnabled)
Toggle("Pfeil hoch = ATU Tune", isOn: $settingsController.tunerShortcutEnabled)
}
Section("Übersicht") {
VStack(alignment: .leading, spacing: 8) {
KeyboardShortcutRow(key: "⌘K", action: "Verbinden/Trennen")
KeyboardShortcutRow(key: "⇧⌘S", action: "VFO A/B tauschen")
KeyboardShortcutRow(key: "⇧⌘E", action: "A=B")
KeyboardShortcutRow(key: "⇧⌘T", action: "ATU Tune")
KeyboardShortcutRow(key: "⌥⌘D", action: "Debug-Panel")
KeyboardShortcutRow(key: "⌥⌘L", action: "Log-Panel")
Divider()
KeyboardShortcutRow(key: "←/→", action: "Frequenz +/-")
KeyboardShortcutRow(key: "", action: "ATU Tune")
KeyboardShortcutRow(key: "Shift", action: "PTT (halten)")
}
}
}
.formStyle(.grouped)
.padding()
}
}
// MARK: - Keyboard Shortcut Row
struct KeyboardShortcutRow: View {
let key: String
let action: String
var body: some View {
HStack {
Text(key)
.font(.system(.caption, design: .monospaced))
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.secondary.opacity(0.2))
.cornerRadius(4)
.frame(width: 70, alignment: .leading)
Text(action)
.font(.caption)
}
}
}
// MARK: - Logging Settings
struct LoggingSettingsView: View {
@EnvironmentObject var settingsController: SettingsController
var body: some View {
Form {
Section("Log-Speicherort") {
HStack {
TextField("Verzeichnis", text: $settingsController.logDirectory)
.textFieldStyle(.roundedBorder)
Button("Wählen...") {
selectDirectory()
}
}
Text("Aktueller Pfad: \(settingsController.expandedLogDirectory)")
.font(.caption)
.foregroundColor(.secondary)
}
Section("Automatisches Speichern") {
Toggle("Log automatisch speichern", isOn: $settingsController.autoSaveLog)
Text("Speichert QSOs automatisch nach jeder Eingabe.")
.font(.caption)
.foregroundColor(.secondary)
}
Section("CSV-Format") {
Text("Felder: Call, Datum, Zeit, Frequenz, Mode, RST TX/RX, Name, QTH, Locator, Power, Notizen")
.font(.caption)
.foregroundColor(.secondary)
}
}
.formStyle(.grouped)
.padding()
}
private func selectDirectory() {
let panel = NSOpenPanel()
panel.canChooseFiles = false
panel.canChooseDirectories = true
panel.allowsMultipleSelection = false
panel.canCreateDirectories = true
panel.prompt = "Auswählen"
if panel.runModal() == .OK, let url = panel.url {
settingsController.logDirectory = url.path
}
}
}
// MARK: - Preview
#Preview {
SettingsView()
.environmentObject(RadioViewModel())
.environmentObject(SettingsController())
}
@@ -0,0 +1,484 @@
//
// SkeuomorphRadioView.swift
// FT991A-Remote
//
// Skeuomorphic FT-991A front panel replica
//
import SwiftUI
// MARK: - Skeuomorph Radio View
struct SkeuomorphRadioView: View {
@EnvironmentObject var radioViewModel: RadioViewModel
var body: some View {
ZStack {
// Background - dark metal texture
LinearGradient(
colors: [Color(white: 0.15), Color(white: 0.1)],
startPoint: .top,
endPoint: .bottom
)
.ignoresSafeArea()
VStack(spacing: 0) {
// Top section - Display
FrontPanelDisplay()
.padding()
Divider()
.background(Color.gray.opacity(0.3))
// Middle section - Main controls
HStack(spacing: 30) {
// Left side controls
VStack(spacing: 20) {
DialKnob(label: "AF GAIN", value: Binding(
get: { Double(radioViewModel.afGain) / 255.0 },
set: { radioViewModel.setAFGain(Int($0 * 255)) }
))
DialKnob(label: "RF GAIN", value: Binding(
get: { Double(radioViewModel.rfGain) / 255.0 },
set: { radioViewModel.setRFGain(Int($0 * 255)) }
))
}
.disabled(!radioViewModel.isConnected)
Spacer()
// Center - Main VFO dial
MainVFODial()
Spacer()
// Right side controls
VStack(spacing: 20) {
DialKnob(label: "SQL", value: Binding(
get: { Double(radioViewModel.squelch) / 255.0 },
set: { radioViewModel.setSquelch(Int($0 * 255)) }
))
DialKnob(label: "MIC", value: Binding(
get: { Double(radioViewModel.micGain) / 100.0 },
set: { radioViewModel.setMICGain(Int($0 * 100)) }
))
}
.disabled(!radioViewModel.isConnected)
}
.padding(.horizontal, 40)
.padding(.vertical, 20)
Divider()
.background(Color.gray.opacity(0.3))
// Bottom section - Buttons
FrontPanelButtons()
.padding()
}
}
}
}
// MARK: - Front Panel Display
struct FrontPanelDisplay: View {
@EnvironmentObject var radioViewModel: RadioViewModel
var body: some View {
ZStack {
// LCD background
RoundedRectangle(cornerRadius: 8)
.fill(
LinearGradient(
colors: [Color(red: 0.05, green: 0.15, blue: 0.1), Color(red: 0.02, green: 0.1, blue: 0.05)],
startPoint: .top,
endPoint: .bottom
)
)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(Color.gray.opacity(0.5), lineWidth: 2)
)
VStack(spacing: 8) {
// Top row - Status indicators
HStack {
LCDIndicator(label: "VFO-A", isActive: radioViewModel.activeVFO == .a)
LCDIndicator(label: "VFO-B", isActive: radioViewModel.activeVFO == .b)
Spacer()
LCDIndicator(label: radioViewModel.mode.rawValue, isActive: true, color: .cyan)
Spacer()
LCDIndicator(label: "TX", isActive: radioViewModel.isTransmitting, color: .red)
}
.padding(.horizontal)
// Main frequency display
HStack {
Spacer()
Text(radioViewModel.frequencyDisplay)
.font(.system(size: 56, weight: .bold, design: .monospaced))
.foregroundColor(Color(red: 0.3, green: 1.0, blue: 0.5))
.shadow(color: Color(red: 0.3, green: 1.0, blue: 0.5).opacity(0.5), radius: 10)
Text("Hz")
.font(.system(size: 20, weight: .medium, design: .monospaced))
.foregroundColor(Color(red: 0.3, green: 1.0, blue: 0.5).opacity(0.7))
Spacer()
}
// S-Meter
LCDSMeter(value: Double(radioViewModel.sMeter) / 255.0)
.padding(.horizontal)
// Bottom row - Additional info
HStack {
Text("\(radioViewModel.power)W")
.foregroundColor(Color(red: 0.3, green: 1.0, blue: 0.5).opacity(0.8))
Spacer()
if let band = radioViewModel.currentBand {
Text(band.rawValue)
.foregroundColor(Color(red: 0.3, green: 1.0, blue: 0.5).opacity(0.8))
}
Spacer()
Text(radioViewModel.sMeterDisplay)
.foregroundColor(Color(red: 0.3, green: 1.0, blue: 0.5).opacity(0.8))
}
.font(.system(size: 14, design: .monospaced))
.padding(.horizontal)
}
.padding()
}
.frame(height: 200)
}
}
// MARK: - LCD Indicator
struct LCDIndicator: View {
let label: String
let isActive: Bool
var color: Color = .green
var body: some View {
Text(label)
.font(.system(size: 12, weight: .bold, design: .monospaced))
.foregroundColor(isActive ? color : color.opacity(0.3))
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(
RoundedRectangle(cornerRadius: 3)
.fill(isActive ? color.opacity(0.2) : Color.clear)
)
}
}
// MARK: - LCD S-Meter
struct LCDSMeter: View {
let value: Double
var body: some View {
VStack(spacing: 2) {
// Scale labels
HStack {
ForEach([1, 3, 5, 7, 9], id: \.self) { s in
Text("S\(s)")
.font(.system(size: 8, design: .monospaced))
.foregroundColor(Color(red: 0.3, green: 1.0, blue: 0.5).opacity(0.5))
if s < 9 { Spacer() }
}
Text("+20")
.font(.system(size: 8, design: .monospaced))
.foregroundColor(Color.red.opacity(0.5))
Spacer()
Text("+60")
.font(.system(size: 8, design: .monospaced))
.foregroundColor(Color.red.opacity(0.5))
}
// Bar segments
HStack(spacing: 2) {
ForEach(0..<20, id: \.self) { i in
let threshold = Double(i) / 20.0
let isLit = value >= threshold
let isRed = i >= 12 // Above S9
RoundedRectangle(cornerRadius: 1)
.fill(isLit ? (isRed ? Color.red : Color(red: 0.3, green: 1.0, blue: 0.5)) : Color.gray.opacity(0.2))
.frame(height: 16)
}
}
}
}
}
// MARK: - Dial Knob
struct DialKnob: View {
let label: String
@Binding var value: Double
@State private var isDragging = false
@State private var lastAngle: Double = 0
var body: some View {
VStack(spacing: 8) {
Text(label)
.font(.system(size: 10, weight: .bold))
.foregroundColor(.gray)
ZStack {
// Knob base
Circle()
.fill(
LinearGradient(
colors: [Color(white: 0.3), Color(white: 0.15)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.overlay(
Circle()
.stroke(Color.gray.opacity(0.5), lineWidth: 2)
)
.shadow(color: .black.opacity(0.5), radius: 5, x: 2, y: 2)
// Knob texture (ridges)
ForEach(0..<12, id: \.self) { i in
Rectangle()
.fill(Color.white.opacity(0.1))
.frame(width: 1, height: 25)
.offset(y: -15)
.rotationEffect(.degrees(Double(i) * 30))
}
// Indicator line
Rectangle()
.fill(Color.white)
.frame(width: 3, height: 15)
.offset(y: -20)
.rotationEffect(.degrees(value * 270 - 135))
}
.frame(width: 60, height: 60)
.gesture(
DragGesture()
.onChanged { gesture in
let center = CGPoint(x: 30, y: 30)
let location = gesture.location
let angle = atan2(location.y - center.y, location.x - center.x)
let degrees = angle * 180 / .pi + 90
if isDragging {
let delta = (degrees - lastAngle) / 270
value = min(1, max(0, value + delta))
}
lastAngle = degrees
isDragging = true
}
.onEnded { _ in
isDragging = false
}
)
Text("\(Int(value * 100))%")
.font(.system(size: 10, design: .monospaced))
.foregroundColor(.gray)
}
}
}
// MARK: - Main VFO Dial
struct MainVFODial: View {
@EnvironmentObject var radioViewModel: RadioViewModel
@EnvironmentObject var settingsController: SettingsController
@State private var rotation: Double = 0
var body: some View {
VStack(spacing: 12) {
Text("MAIN DIAL")
.font(.system(size: 12, weight: .bold))
.foregroundColor(.gray)
ZStack {
// Large dial
Circle()
.fill(
LinearGradient(
colors: [Color(white: 0.25), Color(white: 0.1)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.overlay(
Circle()
.stroke(Color.gray.opacity(0.5), lineWidth: 3)
)
.shadow(color: .black.opacity(0.5), radius: 10, x: 4, y: 4)
// Dial markings
ForEach(0..<36, id: \.self) { i in
Rectangle()
.fill(Color.white.opacity(i % 3 == 0 ? 0.3 : 0.1))
.frame(width: i % 3 == 0 ? 2 : 1, height: i % 3 == 0 ? 20 : 10)
.offset(y: -65)
.rotationEffect(.degrees(Double(i) * 10 + rotation))
}
// Center cap
Circle()
.fill(Color(white: 0.2))
.frame(width: 40, height: 40)
.overlay(
Circle()
.stroke(Color.gray.opacity(0.3), lineWidth: 1)
)
}
.frame(width: 160, height: 160)
.gesture(
DragGesture()
.onChanged { gesture in
let delta = gesture.translation.width / 2
rotation += delta
// Convert rotation to frequency change
let steps = Int(delta / 10)
if steps != 0 {
for _ in 0..<abs(steps) {
if steps > 0 {
radioViewModel.incrementFrequency()
} else {
radioViewModel.decrementFrequency()
}
}
}
}
)
.disabled(!radioViewModel.isConnected)
// Step indicator
Picker("Step", selection: $settingsController.frequencyStep) {
ForEach(FrequencyStep.allCases, id: \.self) { step in
Text(step.displayName).tag(step)
}
}
.pickerStyle(.menu)
.frame(width: 100)
}
}
}
// MARK: - Front Panel Buttons
struct FrontPanelButtons: View {
@EnvironmentObject var radioViewModel: RadioViewModel
var body: some View {
HStack(spacing: 12) {
// Mode buttons
Group {
PanelButton(label: "LSB", isActive: radioViewModel.mode == .lsb) {
radioViewModel.setMode(.lsb)
}
PanelButton(label: "USB", isActive: radioViewModel.mode == .usb) {
radioViewModel.setMode(.usb)
}
PanelButton(label: "CW", isActive: radioViewModel.mode == .cw) {
radioViewModel.setMode(.cw)
}
PanelButton(label: "FM", isActive: radioViewModel.mode == .fm) {
radioViewModel.setMode(.fm)
}
PanelButton(label: "AM", isActive: radioViewModel.mode == .am) {
radioViewModel.setMode(.am)
}
}
.disabled(!radioViewModel.isConnected)
Spacer()
// Function buttons
Group {
PanelButton(label: "NB", isActive: radioViewModel.noiseBlanker) {
radioViewModel.toggleNB()
}
PanelButton(label: "NR", isActive: radioViewModel.noiseReduction) {
radioViewModel.toggleNR()
}
PanelButton(label: "ATU", isActive: false, color: .orange) {
radioViewModel.startATUTune()
}
}
.disabled(!radioViewModel.isConnected)
Spacer()
// VFO buttons
Group {
PanelButton(label: "A/B", isActive: false) {
radioViewModel.swapVFO()
}
PanelButton(label: "SPLIT", isActive: radioViewModel.split) {
radioViewModel.toggleSplit()
}
}
.disabled(!radioViewModel.isConnected)
Spacer()
// PTT
PanelButton(label: radioViewModel.isTransmitting ? "RX" : "TX",
isActive: radioViewModel.isTransmitting,
color: .red,
size: .large) {
radioViewModel.toggleTransmit()
}
.disabled(!radioViewModel.isConnected)
}
}
}
// MARK: - Panel Button
struct PanelButton: View {
let label: String
let isActive: Bool
var color: Color = .green
var size: Size = .normal
let action: () -> Void
enum Size {
case normal, large
}
var body: some View {
Button(action: action) {
Text(label)
.font(.system(size: size == .large ? 14 : 11, weight: .bold))
.foregroundColor(isActive ? .white : .gray)
.frame(width: size == .large ? 60 : 45, height: size == .large ? 40 : 30)
.background(
RoundedRectangle(cornerRadius: 4)
.fill(isActive ? color : Color(white: 0.2))
.overlay(
RoundedRectangle(cornerRadius: 4)
.stroke(Color.gray.opacity(0.3), lineWidth: 1)
)
.shadow(color: isActive ? color.opacity(0.5) : .clear, radius: 5)
)
}
.buttonStyle(.plain)
}
}
// MARK: - Preview
#Preview {
SkeuomorphRadioView()
.environmentObject(RadioViewModel())
.environmentObject(SettingsController())
.frame(width: 900, height: 700)
}