LoggingSession mixed two things: the contest, which one station has one of, and what the operator is typing, which belongs to a radio. It is now ContestSession and RadioPosition. Two positions share one log, one score and one run of serial numbers, so a station worked on radio 1 is a dupe on radio 2. Frequency, mode, split, what is typed and whether you are running belong to each radio on its own. A second radio opens a second entry window. It has no menu of its own: it is another view of the same contest, not another program. Only the window for radio 1 opens the database and the connections. Ctrl+Tab moves the operator to the other radio and the keyboard follows. Ctrl+Shift+Tab puts both radios in the headphones. OtrspBox speaks OTRSP to an SO2R box over a serial port: TX1/TX2 for the key, RX1/RX2 and RX1S for the headphones, AUXnnn for an output line. It follows the active radio, and every message points the box at this radio before keying, so a message cannot go out of the radio the operator has just left. A command that would change nothing is not sent, because the box works relays; N1MM does the same. The box takes a Stream, so what it sends is tested without a serial port. Still missing: alternating CQ, and voice keying on the second radio. Looked at under Xvfb with two fake radios, one of them split: both windows up, radio 1 showing 14008.00 into 14020.0, radio 2 on 14030.00, Ctrl+Tab moving the keyboard between them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
256 lines
8.3 KiB
C#
256 lines
8.3 KiB
C#
using Avalonia.Controls;
|
|
using Avalonia.Interactivity;
|
|
using Avalonia.Layout;
|
|
using Nonemm.Core;
|
|
using Nonemm.Session;
|
|
|
|
namespace Nonemm.App.Dialogs;
|
|
|
|
/// One logged contact with every field on it, including the ones the log window
|
|
/// has no column for. Previous and Next walk the log without closing.
|
|
public sealed partial class EditContactDialog : Window
|
|
{
|
|
private static readonly QsoField[] ContactRow =
|
|
[
|
|
QsoField.Time, QsoField.Call,
|
|
QsoField.Frequency, QsoField.QsxFrequency,
|
|
QsoField.Mode, QsoField.Operator,
|
|
QsoField.SentReport, QsoField.ReceivedReport,
|
|
QsoField.Name, QsoField.Qth,
|
|
QsoField.RadioNumber, QsoField.RunPosition,
|
|
];
|
|
|
|
private static readonly QsoField[] ExchangeRow =
|
|
[
|
|
QsoField.SentNumber, QsoField.ReceivedNumber,
|
|
QsoField.Zone, QsoField.Section,
|
|
QsoField.Precedence, QsoField.Check,
|
|
QsoField.Exchange1, QsoField.MiscText,
|
|
QsoField.GridSquare, QsoField.Power,
|
|
QsoField.CountryPrefix, QsoField.WpxPrefix,
|
|
];
|
|
|
|
private readonly ContestSession logging;
|
|
private readonly Dictionary<QsoField, TextBox> boxes = [];
|
|
private int at;
|
|
|
|
public EditContactDialog(ContestSession logging, string contactId)
|
|
{
|
|
this.logging = logging;
|
|
InitializeComponent();
|
|
BuildFields(ContactFields, ContactRow);
|
|
BuildFields(ExchangeFields, ExchangeRow);
|
|
AddWideField(ExchangeFields, QsoField.Comment);
|
|
at = Math.Max(0, IndexOf(contactId));
|
|
Display(Current);
|
|
}
|
|
|
|
private Qso Current => logging.Log.Qsos[at];
|
|
|
|
private int IndexOf(string id)
|
|
{
|
|
for (int index = 0; index < logging.Log.Qsos.Count; index++)
|
|
{
|
|
if (logging.Log.Qsos[index].Id == id)
|
|
{
|
|
return index;
|
|
}
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
private void BuildFields(Grid grid, IReadOnlyList<QsoField> fields)
|
|
{
|
|
for (int index = 0; index < fields.Count; index++)
|
|
{
|
|
int row = index / 2;
|
|
int column = index % 2 == 0 ? 0 : 2;
|
|
if (column == 0)
|
|
{
|
|
grid.RowDefinitions.Add(new RowDefinition(GridLength.Auto));
|
|
}
|
|
grid.Children.Add(LabelFor(fields[index], row, column));
|
|
grid.Children.Add(BoxFor(fields[index], row, column + 1));
|
|
}
|
|
}
|
|
|
|
private void AddWideField(Grid grid, QsoField field)
|
|
{
|
|
int row = grid.RowDefinitions.Count;
|
|
grid.RowDefinitions.Add(new RowDefinition(GridLength.Auto));
|
|
grid.Children.Add(LabelFor(field, row, 0));
|
|
TextBox box = BoxFor(field, row, 1);
|
|
Grid.SetColumnSpan(box, 3);
|
|
}
|
|
|
|
private static TextBlock LabelFor(QsoField field, int row, int column)
|
|
{
|
|
TextBlock label = new()
|
|
{
|
|
Text = Label(field),
|
|
Margin = new Avalonia.Thickness(0, 0, 8, 4),
|
|
VerticalAlignment = VerticalAlignment.Center,
|
|
};
|
|
Grid.SetRow(label, row);
|
|
Grid.SetColumn(label, column);
|
|
return label;
|
|
}
|
|
|
|
private TextBox BoxFor(QsoField field, int row, int column)
|
|
{
|
|
TextBox box = new() { Margin = new Avalonia.Thickness(0, 0, 12, 4) };
|
|
Grid.SetRow(box, row);
|
|
Grid.SetColumn(box, column);
|
|
boxes[field] = box;
|
|
return box;
|
|
}
|
|
|
|
private void Display(Qso qso)
|
|
{
|
|
foreach ((QsoField field, TextBox box) in boxes)
|
|
{
|
|
box.Text = QsoEditor.Read(qso, field);
|
|
}
|
|
RunBox.IsChecked = qso.IsRunQso;
|
|
ClaimedBox.IsChecked = qso.IsClaimed;
|
|
PositionText.Text = $"contact {at + 1} of {logging.Log.Qsos.Count}";
|
|
DerivedText.Text =
|
|
$"band {qso.Band?.Name ?? "off band"} · continent {Or(qso.Continent)} · " +
|
|
$"station prefix {Or(qso.StationPrefix)} · {qso.Points} points · " +
|
|
$"multipliers {Or(Multipliers(qso))}. Points and multipliers are worked out " +
|
|
"from the rules every time the log changes, so they cannot be typed in here.";
|
|
MessageText.Text = "";
|
|
}
|
|
|
|
private static string Or(string text) => text.Length > 0 ? text : "none";
|
|
|
|
private static string Multipliers(Qso qso) => string.Concat(
|
|
qso.IsMultiplier1 ? "1" : "",
|
|
qso.IsMultiplier2 ? "2" : "",
|
|
qso.IsMultiplier3 ? "3" : "");
|
|
|
|
/// The fields whose box no longer matches the contact.
|
|
private List<KeyValuePair<QsoField, string>> Changes(Qso qso) =>
|
|
[.. boxes
|
|
.Where(pair => (pair.Value.Text ?? "") != QsoEditor.Read(qso, pair.Key))
|
|
.Select(pair => new KeyValuePair<QsoField, string>(pair.Key, pair.Value.Text ?? ""))];
|
|
|
|
private bool HasChanges() =>
|
|
Changes(Current).Count > 0
|
|
|| RunBox.IsChecked != Current.IsRunQso
|
|
|| ClaimedBox.IsChecked != Current.IsClaimed;
|
|
|
|
/// True when the contact was saved. A value the contest will not take stops
|
|
/// the save and puts the cursor back in the box that holds it.
|
|
private bool Save()
|
|
{
|
|
Qso before = Current;
|
|
QsoEdit edit = logging.Editor.ApplyAll(before, Changes(before));
|
|
if (edit.Result is null)
|
|
{
|
|
MessageText.Text = edit.Error;
|
|
if (edit.Field is { } field && boxes.TryGetValue(field, out TextBox? box))
|
|
{
|
|
box.Focus();
|
|
}
|
|
return false;
|
|
}
|
|
logging.Update(edit.Result with
|
|
{
|
|
IsRunQso = RunBox.IsChecked == true,
|
|
IsClaimed = ClaimedBox.IsChecked == true,
|
|
});
|
|
// editing the time re-sorts the log, so find the contact again
|
|
at = IndexOf(before.Id);
|
|
Display(Current);
|
|
MessageText.Text = $"{Current.Call.Text} updated";
|
|
return true;
|
|
}
|
|
|
|
private void OnUpdate(object? sender, RoutedEventArgs e) => Save();
|
|
|
|
private async void OnPrevious(object? sender, RoutedEventArgs e) => await Move(-1);
|
|
|
|
private async void OnNext(object? sender, RoutedEventArgs e) => await Move(1);
|
|
|
|
private async Task Move(int by)
|
|
{
|
|
int wanted = at + by;
|
|
if (wanted < 0 || wanted >= logging.Log.Qsos.Count)
|
|
{
|
|
MessageText.Text = by < 0 ? "this is the first contact" : "this is the last contact";
|
|
return;
|
|
}
|
|
if (!await KeepChanges())
|
|
{
|
|
return;
|
|
}
|
|
at = wanted;
|
|
Display(Current);
|
|
}
|
|
|
|
/// Offers to save before leaving the contact. False means stay where we are.
|
|
private async Task<bool> KeepChanges()
|
|
{
|
|
if (!HasChanges())
|
|
{
|
|
return true;
|
|
}
|
|
ConfirmDialog dialog = new(
|
|
"Save contact",
|
|
$"Save the changes to {Current.Call.Text}?",
|
|
"Save");
|
|
return !await dialog.ShowDialog<bool>(this) || Save();
|
|
}
|
|
|
|
private async void OnDelete(object? sender, RoutedEventArgs e)
|
|
{
|
|
Qso qso = Current;
|
|
ConfirmDialog dialog = new(
|
|
"Delete contact",
|
|
$"Delete {qso.Call.Text} at {qso.TimestampUtc:yyyy-MM-dd HH:mm:ss}? " +
|
|
"The other stations are told to delete it too.",
|
|
"Delete");
|
|
if (!await dialog.ShowDialog<bool>(this))
|
|
{
|
|
return;
|
|
}
|
|
logging.Delete(qso.Id);
|
|
if (logging.Log.Qsos.Count == 0)
|
|
{
|
|
Close();
|
|
return;
|
|
}
|
|
at = Math.Min(at, logging.Log.Qsos.Count - 1);
|
|
Display(Current);
|
|
MessageText.Text = $"{qso.Call.Text} deleted";
|
|
}
|
|
|
|
private async void OnClose(object? sender, RoutedEventArgs e)
|
|
{
|
|
if (await KeepChanges())
|
|
{
|
|
Close();
|
|
}
|
|
}
|
|
|
|
private static string Label(QsoField field) => field switch
|
|
{
|
|
QsoField.Time => "Time UTC",
|
|
QsoField.Frequency => "Frequency kHz",
|
|
QsoField.QsxFrequency => "QSX kHz",
|
|
QsoField.SentReport => "Report sent",
|
|
QsoField.ReceivedReport => "Report received",
|
|
QsoField.SentNumber => "Number sent",
|
|
QsoField.ReceivedNumber => "Number received",
|
|
QsoField.GridSquare => "Grid square",
|
|
QsoField.MiscText => "Misc",
|
|
QsoField.CountryPrefix => "Country prefix",
|
|
QsoField.WpxPrefix => "WPX prefix",
|
|
QsoField.RadioNumber => "Radio",
|
|
QsoField.RunPosition => "Run position",
|
|
QsoField.Qth => "QTH",
|
|
_ => field.ToString(),
|
|
};
|
|
}
|