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:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user