Ctrl+O asks who is at the radio, as it does in N1MM, and the callsign goes on
every contact logged from then on. A multi-operator station changes hands every
few hours, so each operator also keeps what he wants the program to look like:
where the windows sit, the theme, and the folder his recordings are in.
The windows on the screen are stored against the operator leaving, before the
dialog opens, and the operator taking over gets his own back. A window he opens
later is placed as it appears. An operator who is new to the list starts with
whatever is on the screen.
Nothing plays the recordings yet, so the folder is stored and waits on voice
keying. {OPERATOR} in a message sends the callsign.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RoGtneMQaz4M9w7Kk49AVD
687 lines
24 KiB
C#
687 lines
24 KiB
C#
using Avalonia.Controls;
|
|
using Avalonia.Interactivity;
|
|
using Avalonia.Platform.Storage;
|
|
using Nonemm.App.Configuration;
|
|
using Nonemm.App.Dialogs;
|
|
using Nonemm.App.Theming;
|
|
using Nonemm.Contests;
|
|
using Nonemm.Core;
|
|
using Nonemm.Core.Country;
|
|
using Nonemm.Formats.Adif;
|
|
using Nonemm.Formats.Cabrillo;
|
|
using Nonemm.Session;
|
|
using Nonemm.Spotting;
|
|
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, Logging.Instance.Entry);
|
|
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 async void OnEditLastContact(object? sender, RoutedEventArgs e)
|
|
{
|
|
if (Logging is null || Logging.Log.Qsos.Count == 0)
|
|
{
|
|
Status("there is nothing in the log yet");
|
|
return;
|
|
}
|
|
await new EditContactDialog(Logging.Session, Logging.Log.Qsos[^1].Id).ShowDialog(this);
|
|
}
|
|
|
|
/// N1MM's Ctrl+N. The note goes on the contact in the boxes when one is
|
|
/// being typed, and on the last logged contact otherwise.
|
|
private async void OnAddNote(object? sender, RoutedEventArgs e)
|
|
{
|
|
if (Logging is null)
|
|
{
|
|
return;
|
|
}
|
|
if (Logging.Entry.Call.Trim().Length > 0)
|
|
{
|
|
if (await new NoteDialog("Current Contact", Logging.Comment).ShowDialog<string?>(this) is { } typed)
|
|
{
|
|
Logging.Comment = typed;
|
|
SyncBoxes();
|
|
Status($"note on {Logging.Entry.Call.Trim()}: {typed}");
|
|
}
|
|
return;
|
|
}
|
|
if (Logging.Log.Qsos.Count == 0)
|
|
{
|
|
Status("there is nothing in the log yet");
|
|
return;
|
|
}
|
|
Qso last = Logging.Log.Qsos[^1];
|
|
string title = $"{last.Call.Text} @ {last.TimestampUtc:yyyy-MM-dd HH:mm:ss}";
|
|
if (await new NoteDialog(title, last.Comment).ShowDialog<string?>(this) is { } note)
|
|
{
|
|
Logging.Session.Update(last with { Comment = note });
|
|
Status($"note on {last.Call.Text}: {note}");
|
|
}
|
|
}
|
|
|
|
/// N1MM's Edit Current Contact: the same form the log uses, over what is
|
|
/// typed but not logged yet. What comes back goes into the boxes.
|
|
private async void OnEditCurrentContact(object? sender, RoutedEventArgs e)
|
|
{
|
|
if (Logging is null)
|
|
{
|
|
return;
|
|
}
|
|
if (Logging.Entry.Call.Trim().Length == 0)
|
|
{
|
|
Status("no callsign to edit");
|
|
return;
|
|
}
|
|
EditContactDialog dialog = new(Logging.Session, Logging.InProgress());
|
|
if (await dialog.ShowDialog<Qso?>(this) is { } edited)
|
|
{
|
|
Logging.LoadFrom(edited);
|
|
SyncBoxes();
|
|
Refresh();
|
|
}
|
|
}
|
|
|
|
private void OnQuickEditBack(object? sender, RoutedEventArgs e) => QuickEdit(forward: false);
|
|
|
|
private void OnQuickEditForward(object? sender, RoutedEventArgs e) => QuickEdit(forward: true);
|
|
|
|
/// Loads an earlier contact into the boxes. Enter writes the changes back,
|
|
/// Esc leaves the boxes as they were.
|
|
private void QuickEdit(bool forward)
|
|
{
|
|
if (Logging is null)
|
|
{
|
|
return;
|
|
}
|
|
if (!Logging.QuickEdit(forward))
|
|
{
|
|
Status(forward ? "not in quick edit" : "there is nothing earlier in the log");
|
|
return;
|
|
}
|
|
SyncBoxes();
|
|
Refresh();
|
|
boxes[0].Focus();
|
|
Status(Logging.Editing is null
|
|
? "back to the contact being typed"
|
|
: $"quick edit: {Logging.Editing.Call.Text} — Enter saves, Esc leaves");
|
|
}
|
|
|
|
/// N1MM's Ctrl+U: the received serial number goes up by one, for the
|
|
/// station that says it sent the next number. With no serial box, a
|
|
/// numeric exchange box is bumped instead, which is what N1MM does for the
|
|
/// contests that count in the exchange.
|
|
private void OnBumpNumber(object? sender, RoutedEventArgs e)
|
|
{
|
|
if (Logging is null)
|
|
{
|
|
return;
|
|
}
|
|
for (int at = 0; at < Logging.Entry.Exchange.Count; at++)
|
|
{
|
|
ExchangeField field = Logging.Entry.Exchange[at];
|
|
if ((field.Kind != ExchangeFieldKind.Number && field.Slot != ExchangeSlot.Exchange1) ||
|
|
!int.TryParse(Logging.Entry[at + 1].Trim(), out int number) || number <= 0)
|
|
{
|
|
continue;
|
|
}
|
|
Logging.Entry[at + 1] = (number + 1).ToString();
|
|
SyncBoxes();
|
|
Refresh();
|
|
return;
|
|
}
|
|
Status("no number to increase");
|
|
}
|
|
|
|
/// N1MM's Ctrl+F: shows the call being typed in the log window, and again
|
|
/// for the next contact with the same call.
|
|
private void OnFind(object? sender, RoutedEventArgs e)
|
|
{
|
|
if (Logging is null)
|
|
{
|
|
return;
|
|
}
|
|
string call = Logging.Entry.Call.Trim();
|
|
if (call.Length == 0)
|
|
{
|
|
Status("no callsign to find");
|
|
return;
|
|
}
|
|
Qso? found = Show(() => new LogWindow(session)).FindNextCall(call);
|
|
Status(found is null ? $"{call} not found" : $"{call} at {found.TimestampUtc:HH:mm:ss}");
|
|
}
|
|
|
|
/// Puts the call being typed on the cluster, or the last one logged when
|
|
/// nothing is typed. That is what N1MM's Spot It button does.
|
|
private async void OnSpotIt(object? sender, RoutedEventArgs e)
|
|
{
|
|
if (Logging is null)
|
|
{
|
|
Status("no contest is open");
|
|
return;
|
|
}
|
|
if (session.Cluster is not { IsConnected: true } cluster)
|
|
{
|
|
Status("not connected to a cluster node");
|
|
return;
|
|
}
|
|
string typed = Logging.Entry.Call.Trim();
|
|
Callsign call;
|
|
Frequency where;
|
|
if (typed.Length > 0)
|
|
{
|
|
call = Callsign.Parse(typed);
|
|
where = Logging.Frequency;
|
|
}
|
|
else if (Logging.Log.Qsos.Count > 0)
|
|
{
|
|
call = Logging.Log.Qsos[^1].Call;
|
|
where = Logging.Log.Qsos[^1].Frequency;
|
|
}
|
|
else
|
|
{
|
|
Status("nothing to spot");
|
|
return;
|
|
}
|
|
await cluster.SendSpotAsync(where, call, MessageExpander.Expand(session.Settings.SpotComment, Logging, session.Other(Logging)));
|
|
session.Bandmap.Add(new Spot(call, where, DateTime.UtcNow, SpotSource.Operator));
|
|
Status($"{call.Text} spotted on {where.Kilohertz:0.0}");
|
|
}
|
|
|
|
/// N1MM's Mark button and Alt+M: leaves a mark on our own bandmap saying
|
|
/// this frequency is busy, so the operator does not come back to it. It
|
|
/// goes nowhere near the cluster.
|
|
private void OnMark(object? sender, RoutedEventArgs e)
|
|
{
|
|
if (Logging is null)
|
|
{
|
|
Status("no contest is open");
|
|
return;
|
|
}
|
|
Spot mark = Spot.Mark(Logging.Frequency, DateTime.UtcNow, session.Settings.Station.Callsign);
|
|
session.Bandmap.Add(mark);
|
|
Status($"{mark.Call.Text} on {mark.Frequency.Kilohertz:0.0}");
|
|
boxes[0].Focus();
|
|
}
|
|
|
|
/// N1MM's Store button and Alt+O: puts the call being typed on our own
|
|
/// bandmap at this frequency so it can be worked later. Spot It sends the
|
|
/// same call to the cluster; this one stays here.
|
|
private void OnStore(object? sender, RoutedEventArgs e)
|
|
{
|
|
if (Logging is null)
|
|
{
|
|
Status("no contest is open");
|
|
return;
|
|
}
|
|
string typed = Logging.Entry.Call.Trim();
|
|
if (typed.Length == 0)
|
|
{
|
|
Status("nothing to store");
|
|
return;
|
|
}
|
|
Callsign call = Callsign.Parse(typed);
|
|
session.Bandmap.Add(new Spot(
|
|
call,
|
|
Logging.Frequency,
|
|
DateTime.UtcNow,
|
|
SpotSource.Operator,
|
|
session.Settings.Station.Callsign,
|
|
"Local spot"));
|
|
Status($"{call.Text} stored on {Logging.Frequency.Kilohertz:0.0}");
|
|
boxes[0].Focus();
|
|
}
|
|
|
|
private void OnShowLog(object? sender, RoutedEventArgs e) => Show(() => new LogWindow(session));
|
|
|
|
private void OnShowCallStack(object? sender, RoutedEventArgs e) =>
|
|
Show(() => new CallStackWindow(session, radioNumber));
|
|
|
|
/// N1MM opens the stack window when the first call goes on it and closes it
|
|
/// when the last one comes off.
|
|
private void ShowCallStack()
|
|
{
|
|
if (Logging is null)
|
|
{
|
|
return;
|
|
}
|
|
if (Logging.Stack.IsEmpty)
|
|
{
|
|
if (openWindows.TryGetValue(typeof(CallStackWindow), out Window? open))
|
|
{
|
|
open.Close();
|
|
}
|
|
return;
|
|
}
|
|
Show(() => new CallStackWindow(session, radioNumber), activate: false).Refresh();
|
|
}
|
|
|
|
private void OnShowCheck(object? sender, RoutedEventArgs e) => Show(() => new CheckWindow(session));
|
|
|
|
private void OnShowBandmap(object? sender, RoutedEventArgs e) => Show(() => new BandmapWindow(session, Tune));
|
|
|
|
private void OnShowAvailable(object? sender, RoutedEventArgs e) =>
|
|
Show(() => new AvailableWindow(session, Tune));
|
|
|
|
private void OnShowScore(object? sender, RoutedEventArgs e) => Show(() => new ScoreWindow(session));
|
|
|
|
/// N1MM's grey line window: where the daylight is now, and where it will be.
|
|
private void OnShowGrayline(object? sender, RoutedEventArgs e) =>
|
|
Show(() => new GraylineWindow(session));
|
|
|
|
private void OnShowTelnet(object? sender, RoutedEventArgs e) => Show(() => new TelnetWindow(session, Tune));
|
|
|
|
/// N1MM's digital interface window, which is where RTTY is worked from.
|
|
private void OnShowDigital(object? sender, RoutedEventArgs e) =>
|
|
Show(() => new DigitalWindow(session, this, radioNumber));
|
|
|
|
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 = FromCall(updated) });
|
|
if (Logging is not null)
|
|
{
|
|
session.OpenContest(Logging.Instance.ContestNumber);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// N1MM's station form has no continent or country box: it looks both up
|
|
/// from the callsign in the country file.
|
|
private StoredStation FromCall(StoredStation station)
|
|
{
|
|
CountryLookup? country = session.Countries?.Find(station.Callsign);
|
|
return country is null ? station : station with
|
|
{
|
|
Continent = country.Continent,
|
|
CountryPrefix = country.Entity.PrimaryPrefix,
|
|
};
|
|
}
|
|
|
|
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);
|
|
session.ConnectRadios();
|
|
try
|
|
{
|
|
session.ApplySo2rSettings();
|
|
}
|
|
catch (Exception error) when (error is IOException or UnauthorizedAccessException or ArgumentException)
|
|
{
|
|
Status($"could not open the SO2R box: {error.Message}");
|
|
return;
|
|
}
|
|
Status(session.Radios.Count switch
|
|
{
|
|
0 => "no radio",
|
|
1 => $"radio: rigctld at {updated.Radios[0].Host}:{updated.Radios[0].Port}",
|
|
_ => $"{session.Radios.Count} radios · Ctrl+Tab moves between them",
|
|
});
|
|
}
|
|
|
|
/// N1MM keeps the cluster settings on the telnet window rather than in a
|
|
/// dialog of their own, and so do we.
|
|
private void OnClusterSettings(object? sender, RoutedEventArgs e) =>
|
|
Show(() => new TelnetWindow(session, Tune)).ShowClusters();
|
|
|
|
/// N1MM puts ESM on the entry window's Config menu, and remembers it
|
|
/// between runs.
|
|
private void OnToggleEsm(object? sender, RoutedEventArgs e)
|
|
{
|
|
session.Save(session.Settings with { EsmEnabled = !session.Settings.EsmEnabled });
|
|
Status(session.Settings.EsmEnabled
|
|
? "ESM on — Enter sends the message the contact has got to"
|
|
: "ESM off");
|
|
Refresh();
|
|
}
|
|
|
|
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 OnKeyerSettings(object? sender, RoutedEventArgs e)
|
|
{
|
|
KeyerDialog dialog = new(session.Settings);
|
|
Settings? updated = await dialog.ShowDialog<Settings?>(this);
|
|
if (updated is null)
|
|
{
|
|
return;
|
|
}
|
|
session.Save(updated);
|
|
try
|
|
{
|
|
session.ApplyKeyerSettings();
|
|
Status(updated.KeyerKind == "none" ? "no keyer" : $"keyer: {updated.KeyerKind}");
|
|
}
|
|
catch (Exception error) when (error is InvalidOperationException or IOException or UnauthorizedAccessException)
|
|
{
|
|
Status($"could not open the keyer: {error.Message}");
|
|
}
|
|
BuildFunctionKeys();
|
|
}
|
|
|
|
private void OnEditCwMessages(object? sender, RoutedEventArgs e) => _ = EditMessages(ModeCategory.Cw);
|
|
|
|
private void OnEditPhoneMessages(object? sender, RoutedEventArgs e) => _ = EditMessages(ModeCategory.Phone);
|
|
|
|
private async void OnThemeSettings(object? sender, RoutedEventArgs e)
|
|
{
|
|
ThemeDialog dialog = new(session.Settings.Theme);
|
|
if (await dialog.ShowDialog<string?>(this) is not { } picked)
|
|
{
|
|
return;
|
|
}
|
|
session.Save(session.Settings with { Theme = picked });
|
|
Themes.Use(picked);
|
|
Status($"{picked} colours");
|
|
}
|
|
|
|
private async void OnQtcSetup(object? sender, RoutedEventArgs e)
|
|
{
|
|
QtcSetupDialog dialog = new(session.Settings);
|
|
if (await dialog.ShowDialog<Settings?>(this) is { } updated)
|
|
{
|
|
session.Save(updated);
|
|
}
|
|
}
|
|
|
|
/// Call history files are published per contest, so the operator points at
|
|
/// one rather than the program looking in a fixed place.
|
|
private async void OnCallHistoryFile(object? sender, RoutedEventArgs e)
|
|
{
|
|
IReadOnlyList<IStorageFile> files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
|
|
{
|
|
Title = "Call history file",
|
|
AllowMultiple = false,
|
|
SuggestedStartLocation = await Folder(session.Paths.SupportFiles),
|
|
});
|
|
if (files.FirstOrDefault()?.TryGetLocalPath() is not { } path)
|
|
{
|
|
return;
|
|
}
|
|
session.Save(session.Settings with { CallHistoryFile = path });
|
|
session.ReloadSupportFiles();
|
|
Status(session.History.Count > 0
|
|
? $"{session.History.Count} calls read from {Path.GetFileName(path)}"
|
|
: $"{Path.GetFileName(path)} holds no calls we could read");
|
|
}
|
|
|
|
/// The bandmap's CW, digital and phone boundaries.
|
|
private async void OnSubBandSettings(object? sender, RoutedEventArgs e)
|
|
{
|
|
SubBandsDialog dialog = new(session.Settings);
|
|
Settings? updated = await dialog.ShowDialog<Settings?>(this);
|
|
if (updated is null)
|
|
{
|
|
return;
|
|
}
|
|
session.Save(updated);
|
|
Status(updated.SubBands.Count switch
|
|
{
|
|
0 => "sub bands back to the defaults",
|
|
1 => "1 band changed from the defaults",
|
|
int changed => $"{changed} bands changed from the defaults",
|
|
});
|
|
}
|
|
|
|
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, {session.History.Count} in the call history");
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
/// The one window of that kind, opened if it was not open already.
|
|
/// `activate` is false for a window that opens on its own while the
|
|
/// operator is typing, so the keyboard stays in the entry boxes.
|
|
private T Show<T>(Func<T> create, bool activate = true) where T : Window
|
|
{
|
|
if (openWindows.TryGetValue(typeof(T), out Window? existing))
|
|
{
|
|
if (activate)
|
|
{
|
|
existing.Activate();
|
|
}
|
|
return (T)existing;
|
|
}
|
|
T window = create();
|
|
Place(window);
|
|
openWindows[typeof(T)] = window;
|
|
window.Closed += (_, _) => openWindows.Remove(typeof(T));
|
|
window.Show(this);
|
|
return window;
|
|
}
|
|
|
|
private async Task<IStorageFolder?> Folder(string path) =>
|
|
await StorageProvider.TryGetFolderFromPathAsync(path);
|
|
}
|