Add the edit contact dialog

Ctrl+Y from the entry window, or Enter on a row in the log, opens the whole
contact in one dialog. It reaches the fields the log has no column for: QSX
frequency, name, QTH, comment, grid, power, radio number, run position, and the
country and WPX prefixes. Previous and Next walk the log without closing and
offer to save first.

The dialog checks values through the same QsoEditor the log columns use, so a
zone the contest will not take is refused the same way in both places.
QsoEdit now names the field that was refused, so the dialog can put the cursor
back in the right box, and QsoEditor.ApplyAll changes several fields at once.

Points and the multiplier flags are shown but not editable. They are worked out
from the rules every time the log changes, so a typed value would be lost on
the next edit. N1MM lets you type them; we do not, rather than offering a box
that does nothing.

A contact's own country prefix now wins over the country file when scoring.
Without this, correcting the country in the dialog would change what the log
shows and what Cabrillo says, but not the score.

LoggingSession.Update now broadcasts the change like Edit does, so a contact
saved from the dialog reaches the other stations.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-27 15:38:11 +00:00
co-authored by Claude Opus 5
parent eb31fa5ae1
commit b355b9b6bf
15 changed files with 474 additions and 21 deletions
+13 -10
View File
@@ -133,26 +133,29 @@ public sealed class LoggingSession
/// Changes one field of a logged contact. A refused edit leaves the log
/// alone and the caller gets the reason back.
public QsoEdit Edit(string id, QsoField field, string text)
public QsoEdit Edit(string id, QsoField field, string text) =>
EditWith(id, before => Editor.Apply(before, field, text));
/// Replaces a logged contact with one the caller has already built and
/// checked. The edit contact dialog changes many fields at once and comes
/// in this way.
public void Update(Qso qso) => EditWith(qso.Id, _ => QsoEdit.Accept(qso));
private QsoEdit EditWith(string id, Func<Qso, QsoEdit> change)
{
Qso before = Log.Qsos.FirstOrDefault(q => q.Id == id)
?? throw new InvalidOperationException($"no contact with id {id} in the log");
QsoEdit edit = Editor.Apply(before, field, text);
QsoEdit edit = change(before);
if (edit.Result is not null)
{
Update(edit.Result);
store.Update(edit.Result);
Log.Replace(edit.Result);
Edited?.Invoke(this, new QsoChange(edit.Result, before.Call.Text, before.TimestampUtc));
Changed?.Invoke(this, EventArgs.Empty);
}
return edit;
}
public void Update(Qso qso)
{
store.Update(qso);
Log.Replace(qso);
Changed?.Invoke(this, EventArgs.Empty);
}
private int NextSentNumber() =>
Log.Qsos.Count == 0 ? 1 : Log.Qsos.Max(q => q.SentNumber) + 1;
+3 -1
View File
@@ -3,7 +3,9 @@ using Nonemm.Core;
namespace Nonemm.Session;
/// The result of an edit: either the changed contact or an error message.
public sealed record QsoEdit(Qso? Result, string Error)
/// `Field` names the field that was refused, so a dialog can put the cursor
/// back in the right box.
public sealed record QsoEdit(Qso? Result, string Error, QsoField? Field = null)
{
public static QsoEdit Accept(Qso qso) => new(qso, "");
+44 -4
View File
@@ -28,7 +28,8 @@ public sealed class QsoEditor
public static string Read(Qso qso, QsoField field) => field switch
{
QsoField.Time => qso.TimestampUtc.ToString(TimeFormat, CultureInfo.InvariantCulture),
QsoField.Frequency => qso.Frequency.Kilohertz.ToString("0.0", CultureInfo.InvariantCulture),
QsoField.Frequency => Kilohertz(qso.Frequency),
QsoField.QsxFrequency => qso.QsxFrequency.Hertz == 0 ? "" : Kilohertz(qso.QsxFrequency),
QsoField.Mode => qso.Mode.Name,
QsoField.Call => qso.Call.Text,
QsoField.SentReport => qso.SentReport,
@@ -47,12 +48,39 @@ public sealed class QsoEditor
QsoField.Power => qso.Power,
QsoField.Comment => qso.Comment,
QsoField.Operator => qso.Operator,
QsoField.CountryPrefix => qso.CountryPrefix,
QsoField.WpxPrefix => qso.WpxPrefix,
QsoField.RadioNumber => Digits(qso.RadioNumber),
QsoField.RunPosition => Digits(qso.RunPosition),
_ => "",
};
/// Returns the contact with the field changed, or an error message. An empty
/// value clears the field, except for the call, time, mode and frequency.
public QsoEdit Apply(Qso qso, QsoField field, string text)
{
QsoEdit edit = Applied(qso, field, text);
return edit.IsAccepted ? edit : edit with { Field = field };
}
/// Applies several fields in one go, stopping at the first value the
/// contest will not take.
public QsoEdit ApplyAll(Qso qso, IEnumerable<KeyValuePair<QsoField, string>> values)
{
Qso changed = qso;
foreach ((QsoField field, string text) in values)
{
QsoEdit edit = Apply(changed, field, text);
if (edit.Result is null)
{
return edit;
}
changed = edit.Result;
}
return QsoEdit.Accept(changed);
}
private QsoEdit Applied(Qso qso, QsoField field, string text)
{
string value = Normalize(field, text);
if (KindError(field, value) is { } refused)
@@ -62,7 +90,8 @@ public sealed class QsoEditor
return field switch
{
QsoField.Time => WithTime(qso, value),
QsoField.Frequency => WithFrequency(qso, value),
QsoField.Frequency => WithFrequency(value, f => qso with { Frequency = f }, allowEmpty: false),
QsoField.QsxFrequency => WithFrequency(value, f => qso with { QsxFrequency = f }, allowEmpty: true),
QsoField.Mode => WithMode(qso, value),
QsoField.Call => WithCall(qso, value),
QsoField.SentReport => QsoEdit.Accept(qso with { SentReport = value }),
@@ -81,6 +110,10 @@ public sealed class QsoEditor
QsoField.Power => QsoEdit.Accept(qso with { Power = value }),
QsoField.Comment => QsoEdit.Accept(qso with { Comment = value }),
QsoField.Operator => QsoEdit.Accept(qso with { Operator = value }),
QsoField.CountryPrefix => QsoEdit.Accept(qso with { CountryPrefix = value }),
QsoField.WpxPrefix => QsoEdit.Accept(qso with { WpxPrefix = value }),
QsoField.RadioNumber => Count(value, n => qso with { RadioNumber = n }),
QsoField.RunPosition => Count(value, n => qso with { RunPosition = n }),
_ => QsoEdit.Refuse($"{field} cannot be edited"),
};
}
@@ -118,14 +151,18 @@ public sealed class QsoEditor
return QsoEdit.Accept(qso with { TimestampUtc = when });
}
private static QsoEdit WithFrequency(Qso qso, string value)
private static QsoEdit WithFrequency(string value, Func<Frequency, Qso> set, bool allowEmpty)
{
if (value.Length == 0 && allowEmpty)
{
return QsoEdit.Accept(set(Frequency.Zero));
}
if (!double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out double kilohertz)
|| kilohertz <= 0)
{
return QsoEdit.Refuse($"{value} is not a frequency in kilohertz");
}
return QsoEdit.Accept(qso with { Frequency = Frequency.FromKilohertz(kilohertz) });
return QsoEdit.Accept(set(Frequency.FromKilohertz(kilohertz)));
}
private static QsoEdit WithMode(Qso qso, string value)
@@ -178,6 +215,9 @@ public sealed class QsoEditor
&& number >= low
&& number <= high;
private static string Kilohertz(Frequency frequency) =>
frequency.Kilohertz.ToString("0.0", CultureInfo.InvariantCulture);
private static string Digits(int value) => value > 0 ? value.ToString(CultureInfo.InvariantCulture) : "";
private static IReadOnlyList<QsoColumn> BuildColumns(
+5
View File
@@ -6,6 +6,7 @@ public enum QsoField
{
Time,
Frequency,
QsxFrequency,
Mode,
Call,
SentReport,
@@ -24,4 +25,8 @@ public enum QsoField
Power,
Comment,
Operator,
CountryPrefix,
WpxPrefix,
RadioNumber,
RunPosition,
}