Add the Avalonia application and the station network

The entry window types contacts and colours them as they are typed; the log,
check, bandmap, score and packet windows read the same session. Contacts are
shared with the other stations of a multi-operator entry in N1MM's contact
message, so an N1MM station on the same network sees them too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik
2026-08-27 10:56:35 +00:00
parent ffeeb2cdc1
commit 2834f63c8e
45 changed files with 2667 additions and 0 deletions

View File

@@ -0,0 +1,328 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using Nonemm.App.Configuration;
using Nonemm.App.Dialogs;
using Nonemm.Core;
using Nonemm.Formats.Adif;
using Nonemm.Formats.Cabrillo;
using Nonemm.Storage;
namespace Nonemm.App.Windows;
/// The entry window's menu. Each item does one thing and says what happened in
/// the status line.
public sealed partial class EntryWindow
{
private static readonly HttpClient Http = new() { Timeout = TimeSpan.FromSeconds(30) };
private async void OnNewDatabase(object? sender, RoutedEventArgs e)
{
IStorageFile? file = await StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
{
Title = "New log database",
SuggestedFileName = "ham.s3db",
DefaultExtension = "s3db",
SuggestedStartLocation = await Folder(session.Paths.Databases),
});
OpenDatabaseAt(file);
}
private async void OnOpenDatabase(object? sender, RoutedEventArgs e)
{
IReadOnlyList<IStorageFile> files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
{
Title = "Open log database",
AllowMultiple = false,
SuggestedStartLocation = await Folder(session.Paths.Databases),
});
OpenDatabaseAt(files.FirstOrDefault());
}
private void OpenDatabaseAt(IStorageFile? file)
{
if (file?.TryGetLocalPath() is not { } path)
{
return;
}
try
{
session.OpenDatabase(path);
Status($"log database {Path.GetFileName(path)}");
}
catch (Exception error) when (error is IOException or InvalidOperationException)
{
Status($"could not open {path}: {error.Message}");
}
}
private async void OnNewContest(object? sender, RoutedEventArgs e)
{
if (!HasDatabase())
{
return;
}
ContestSetupDialog dialog = new(session);
ContestInstance? chosen = await dialog.ShowDialog<ContestInstance?>(this);
if (chosen is null)
{
return;
}
session.StartContest(chosen);
Status($"{chosen.ContestName} started");
}
private async void OnOpenContest(object? sender, RoutedEventArgs e)
{
if (!HasDatabase())
{
return;
}
ContestPickerDialog dialog = new(session.Store.Contests());
int? chosen = await dialog.ShowDialog<int?>(this);
if (chosen is not null)
{
session.OpenContest(chosen.Value);
}
}
private async void OnExportCabrillo(object? sender, RoutedEventArgs e)
{
if (Logging is null)
{
Status("no contest is open");
return;
}
IStorageFile? file = await StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
{
Title = "Export Cabrillo",
SuggestedFileName = $"{session.Settings.Station.Callsign}.log",
});
if (file?.TryGetLocalPath() is not { } path)
{
return;
}
CabrilloWriter writer = new(Logging.Contest, Logging.Me);
CabrilloHeader header = new()
{
Contest = Logging.Contest.CabrilloName,
Callsign = Logging.Me.Callsign,
OperatorCategory = Logging.Instance.OperatorCategory,
AssistedCategory = Logging.Instance.AssistedCategory,
BandCategory = Logging.Instance.BandCategory,
ModeCategory = Logging.Instance.ModeCategory,
PowerCategory = Logging.Instance.PowerCategory,
StationCategory = Logging.Instance.StationCategory,
TransmitterCategory = Logging.Instance.TransmitterCategory,
OverlayCategory = Logging.Instance.OverlayCategory,
TimeCategory = Logging.Instance.TimeCategory,
ClaimedScore = Logging.Log.TotalScore,
Club = Logging.Me.Club,
Name = Logging.Me.Name,
Operators = Logging.Instance.Operators,
Soapbox = Logging.Instance.Soapbox,
};
await File.WriteAllTextAsync(path, writer.Write(header, Logging.Log.Qsos));
Status($"{Logging.Log.Qsos.Count} contacts written to {Path.GetFileName(path)}");
}
private async void OnExportAdif(object? sender, RoutedEventArgs e)
{
if (Logging is null)
{
Status("no contest is open");
return;
}
IStorageFile? file = await StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
{
Title = "Export ADIF",
SuggestedFileName = $"{session.Settings.Station.Callsign}.adi",
});
if (file?.TryGetLocalPath() is not { } path)
{
return;
}
await File.WriteAllTextAsync(path, new AdifWriter(Logging.Me).Write(Logging.Log.Qsos));
Status($"{Logging.Log.Qsos.Count} contacts written to {Path.GetFileName(path)}");
}
private async void OnImportAdif(object? sender, RoutedEventArgs e)
{
if (Logging is null)
{
Status("no contest is open");
return;
}
IReadOnlyList<IStorageFile> files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
{
Title = "Import ADIF",
AllowMultiple = false,
});
if (files.FirstOrDefault()?.TryGetLocalPath() is not { } path)
{
return;
}
IReadOnlyList<Qso> read = AdifReader.Read(
await File.ReadAllTextAsync(path),
Logging.Contest.Name,
Logging.Instance.ContestNumber);
foreach (Qso qso in read)
{
session.Store.Add(qso);
}
session.OpenContest(Logging.Instance.ContestNumber);
Status($"{read.Count} contacts read from {Path.GetFileName(path)}");
}
private void OnExit(object? sender, RoutedEventArgs e) => Close();
private void OnShowLog(object? sender, RoutedEventArgs e) => Show(() => new LogWindow(session));
private void OnShowCheck(object? sender, RoutedEventArgs e) => Show(() => new CheckWindow(session, () => Logging?.Entry.Call ?? ""));
private void OnShowBandmap(object? sender, RoutedEventArgs e) => Show(() => new BandmapWindow(session, Tune));
private void OnShowScore(object? sender, RoutedEventArgs e) => Show(() => new ScoreWindow(session));
private void OnShowPacket(object? sender, RoutedEventArgs e) => Show(() => new PacketWindow(session));
private async void OnStationSettings(object? sender, RoutedEventArgs e)
{
StationDialog dialog = new(session.Settings.Station);
StoredStation? updated = await dialog.ShowDialog<StoredStation?>(this);
if (updated is not null)
{
session.Save(session.Settings with { Station = updated });
if (Logging is not null)
{
session.OpenContest(Logging.Instance.ContestNumber);
}
}
}
private async void OnRadioSettings(object? sender, RoutedEventArgs e)
{
RadioDialog dialog = new(session.Settings);
Settings? updated = await dialog.ShowDialog<Settings?>(this);
if (updated is null)
{
return;
}
session.Save(updated);
if (updated.RadioEnabled)
{
session.ConnectRadio();
Status($"radio: rigctld at {updated.RigctldHost}:{updated.RigctldPort}");
}
else
{
session.DisconnectRadio();
Status("radio disconnected");
}
}
private async void OnClusterSettings(object? sender, RoutedEventArgs e)
{
ClusterDialog dialog = new(session.Settings);
Settings? updated = await dialog.ShowDialog<Settings?>(this);
if (updated is null)
{
return;
}
session.Save(updated);
if (updated.ClusterEnabled)
{
session.ConnectCluster();
Status($"cluster: {updated.ClusterHost}:{updated.ClusterPort}");
}
else
{
session.DisconnectCluster();
Status("cluster disconnected");
}
}
private async void OnNetworkSettings(object? sender, RoutedEventArgs e)
{
NetworkDialog dialog = new(session.Settings);
Settings? updated = await dialog.ShowDialog<Settings?>(this);
if (updated is not null)
{
session.Save(updated);
session.ApplyNetworkSettings();
Status(updated.NetworkEnabled ? "networked with the other stations" : "networking off");
}
}
private async void OnDownloadCountryFile(object? sender, RoutedEventArgs e) =>
await Download(
downloader => downloader.DownloadCountryFileAsync(session.Paths.CountryFile),
"country file");
private async void OnDownloadCallDatabase(object? sender, RoutedEventArgs e) =>
await Download(
downloader => downloader.DownloadCallDatabaseAsync(session.Paths.CallDatabaseFile),
"callsign database");
private void OnReloadSupportFiles(object? sender, RoutedEventArgs e)
{
session.ReloadSupportFiles();
Status($"country file {(session.Countries is null ? "missing" : "loaded")}, " +
$"{session.Calls.Count} callsigns");
}
private async Task Download(Func<SupportFileDownloader, Task<string>> fetch, string what)
{
Status($"fetching the {what}…");
try
{
string result = await fetch(new SupportFileDownloader(Http));
session.ReloadSupportFiles();
Status($"{what}: {result}");
}
catch (Exception error) when (error is InvalidOperationException or IOException)
{
Status(error.Message);
}
}
private void Tune(Frequency frequency, string call)
{
if (Logging is null)
{
return;
}
Logging.Tune(frequency);
_ = session.Radio?.TuneAsync(frequency);
Logging.Entry.Call = call;
SyncBoxes();
boxes[0].Focus();
Refresh();
}
private bool HasDatabase()
{
if (session.Settings.DatabasePath.Length > 0)
{
return true;
}
Status("open a log database first: File ▸ New Database");
return false;
}
private void Show<T>(Func<T> create) where T : Window
{
if (openWindows.TryGetValue(typeof(T), out Window? existing))
{
existing.Activate();
return;
}
T window = create();
openWindows[typeof(T)] = window;
window.Closed += (_, _) => openWindows.Remove(typeof(T));
window.Show(this);
}
private async Task<IStorageFolder?> Folder(string path) =>
await StorageProvider.TryGetFolderFromPathAsync(path);
}