1e153f2f85
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)
319 lines
9.2 KiB
Swift
319 lines
9.2 KiB
Swift
//
|
|
// 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)
|
|
}
|