Files
Nonemm/src/Nonemm.App/Dialogs/EditContactDialog.axaml.cs
ericek111 1185faed78 Two entry windows and an SO2R box
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>
2026-08-27 16:49:24 +00:00

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(),
};
}