feat: complete UM Trace iOS and field UI integration
This commit is contained in:
105
FieldLogger/ViewModels/AppStatusViewModel.cs
Normal file
105
FieldLogger/ViewModels/AppStatusViewModel.cs
Normal file
@@ -0,0 +1,105 @@
|
||||
using System.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using FieldLogger.Models;
|
||||
using FieldLogger.Services;
|
||||
|
||||
namespace FieldLogger.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// Shared, glanceable hardware status shown above each primary workflow. The timer marks
|
||||
/// position data stale even when the receiver stops sending without disconnecting.
|
||||
/// </summary>
|
||||
public sealed partial class AppStatusViewModel : ObservableObject
|
||||
{
|
||||
private static readonly TimeSpan StaleAfter = TimeSpan.FromSeconds(2);
|
||||
|
||||
private readonly DeviceConnectionManager _manager;
|
||||
private readonly MaglinkService _gps;
|
||||
|
||||
[ObservableProperty]
|
||||
private ConnectionState _locatorState;
|
||||
|
||||
[ObservableProperty]
|
||||
private ConnectionState _gnssState;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _locatorStatus = "Locator disconnected";
|
||||
|
||||
[ObservableProperty]
|
||||
private string _gnssStatus = "GNSS disconnected";
|
||||
|
||||
[ObservableProperty]
|
||||
private string _positionSummary = "No GNSS fix · accuracy —";
|
||||
|
||||
public AppStatusViewModel(DeviceConnectionManager manager, MaglinkService gps)
|
||||
{
|
||||
_manager = manager;
|
||||
_gps = gps;
|
||||
|
||||
_manager.PropertyChanged += OnConnectionPropertyChanged;
|
||||
_gps.FixReceived += OnFixReceived;
|
||||
Refresh();
|
||||
}
|
||||
|
||||
private void OnConnectionPropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.PropertyName is nameof(DeviceConnectionManager.LocatorState)
|
||||
or nameof(DeviceConnectionManager.GpsState)
|
||||
or nameof(DeviceConnectionManager.LocatorName)
|
||||
or nameof(DeviceConnectionManager.GpsName))
|
||||
{
|
||||
Refresh();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnFixReceived(object? sender, GnssFix fix) => Refresh();
|
||||
|
||||
/// <summary>Refreshes status safely from device callbacks or a UI dispatcher timer.</summary>
|
||||
public void Refresh()
|
||||
{
|
||||
var dispatcher = Application.Current?.Dispatcher;
|
||||
if (dispatcher?.IsDispatchRequired == true)
|
||||
{
|
||||
dispatcher.Dispatch(RefreshCore);
|
||||
return;
|
||||
}
|
||||
|
||||
RefreshCore();
|
||||
}
|
||||
|
||||
private void RefreshCore()
|
||||
{
|
||||
LocatorState = _manager.LocatorState;
|
||||
GnssState = _manager.GpsState;
|
||||
LocatorStatus = DeviceLabel("Locator", _manager.LocatorName, LocatorState);
|
||||
GnssStatus = DeviceLabel("GNSS", _manager.GpsName, GnssState);
|
||||
|
||||
var fix = _gps.LatestFix;
|
||||
if (fix is null)
|
||||
{
|
||||
PositionSummary = GnssState == ConnectionState.Connected
|
||||
? "Waiting for fix · accuracy —"
|
||||
: "No GNSS fix · accuracy —";
|
||||
return;
|
||||
}
|
||||
|
||||
var stale = DateTime.UtcNow - fix.ReceivedUtc > StaleAfter;
|
||||
var staleLabel = stale ? " · STALE" : string.Empty;
|
||||
PositionSummary = $"{fix.StatusLabel}{staleLabel} · H ±{fix.Hrms:F3} m · " +
|
||||
$"V ±{fix.Vrms:F3} m · {fix.SatellitesUsed} sats · corr {fix.CorrectionAgeSeconds:F1} s";
|
||||
}
|
||||
|
||||
private static string DeviceLabel(string kind, string? name, ConnectionState state)
|
||||
{
|
||||
var stateText = state switch
|
||||
{
|
||||
ConnectionState.Connected => "connected",
|
||||
ConnectionState.Connecting => "connecting",
|
||||
_ => "disconnected",
|
||||
};
|
||||
|
||||
return state == ConnectionState.Disconnected || string.IsNullOrWhiteSpace(name)
|
||||
? $"{kind} {stateText}"
|
||||
: $"{kind} {stateText} · {name}";
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,13 @@ public sealed partial class HomeViewModel : ObservableObject
|
||||
|
||||
public bool HasNoActiveJob => !HasActiveJob;
|
||||
|
||||
public bool IsDeviceConsoleAvailable { get; } =
|
||||
#if DEBUG
|
||||
true;
|
||||
#else
|
||||
false;
|
||||
#endif
|
||||
|
||||
public string ActiveJobPointSummary => PointCount == 1
|
||||
? "1 point saved locally"
|
||||
: $"{PointCount} points saved locally";
|
||||
@@ -73,13 +80,6 @@ public sealed partial class HomeViewModel : ObservableObject
|
||||
/// <summary>Called from the page's OnAppearing.</summary>
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
// First launch: nothing saved yet, send the user to device selection.
|
||||
if (!_manager.HasSavedDevice(DeviceKind.Locator) && !_manager.HasSavedDevice(DeviceKind.RtkGps))
|
||||
{
|
||||
await Shell.Current.GoToAsync($"devicescan?kind={DeviceKind.Locator}");
|
||||
return;
|
||||
}
|
||||
|
||||
_manager.Start();
|
||||
await RefreshJobAsync();
|
||||
}
|
||||
@@ -139,6 +139,9 @@ public sealed partial class HomeViewModel : ObservableObject
|
||||
[RelayCommand]
|
||||
private Task ViewMapAsync() => Shell.Current.GoToAsync("//map");
|
||||
|
||||
[RelayCommand]
|
||||
private Task OpenDeviceConsoleAsync() => Shell.Current.GoToAsync("deviceconsole");
|
||||
|
||||
private void OnFixReceived(object? sender, GnssFix fix)
|
||||
{
|
||||
MainThread.BeginInvokeOnMainThread(() =>
|
||||
|
||||
@@ -40,41 +40,112 @@ public sealed partial class JobsViewModel : ObservableObject
|
||||
{
|
||||
private readonly AppDatabase _database;
|
||||
private readonly SettingsService _settings;
|
||||
private readonly SemaphoreSlim _refreshGate = new(1, 1);
|
||||
|
||||
public ObservableCollection<JobListItem> Jobs { get; } = new();
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _isBusy;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _isRefreshing;
|
||||
|
||||
public JobsViewModel(AppDatabase database, SettingsService settings)
|
||||
{
|
||||
_database = database;
|
||||
_settings = settings;
|
||||
}
|
||||
|
||||
public Task LoadAsync()
|
||||
=> ReloadAsync(showRefreshIndicator: false, skipIfBusy: true);
|
||||
|
||||
[RelayCommand]
|
||||
public async Task RefreshAsync()
|
||||
private Task RefreshAsync()
|
||||
=> ReloadAsync(showRefreshIndicator: true, skipIfBusy: false);
|
||||
|
||||
private async Task ReloadAsync(bool showRefreshIndicator, bool skipIfBusy)
|
||||
{
|
||||
IsBusy = true;
|
||||
var entered = skipIfBusy
|
||||
? await _refreshGate.WaitAsync(0)
|
||||
: await WaitForRefreshGateAsync();
|
||||
if (!entered)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
IsBusy = true;
|
||||
if (showRefreshIndicator)
|
||||
IsRefreshing = true;
|
||||
|
||||
var jobs = await _database.GetJobsAsync();
|
||||
var activeId = _settings.ActiveJobId;
|
||||
|
||||
Jobs.Clear();
|
||||
// Build a snapshot off-screen, then reconcile it with the existing observable
|
||||
// collection. Keeping the existing rows prevents CollectionView from repeatedly
|
||||
// discarding its measured cells and jumping back to the top.
|
||||
var items = new List<JobListItem>(jobs.Count);
|
||||
foreach (var job in jobs)
|
||||
{
|
||||
Jobs.Add(new JobListItem
|
||||
items.Add(new JobListItem
|
||||
{
|
||||
Job = job,
|
||||
PointCount = await _database.GetPointCountAsync(job.Id),
|
||||
IsActive = job.Id == activeId,
|
||||
});
|
||||
}
|
||||
|
||||
ApplySnapshot(items);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (showRefreshIndicator)
|
||||
IsRefreshing = false;
|
||||
IsBusy = false;
|
||||
_refreshGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> WaitForRefreshGateAsync()
|
||||
{
|
||||
await _refreshGate.WaitAsync();
|
||||
return true;
|
||||
}
|
||||
|
||||
private void ApplySnapshot(IReadOnlyList<JobListItem> incoming)
|
||||
{
|
||||
var incomingIds = incoming.Select(item => item.Job.Id).ToHashSet();
|
||||
|
||||
for (var index = Jobs.Count - 1; index >= 0; index--)
|
||||
{
|
||||
if (!incomingIds.Contains(Jobs[index].Job.Id))
|
||||
Jobs.RemoveAt(index);
|
||||
}
|
||||
|
||||
for (var targetIndex = 0; targetIndex < incoming.Count; targetIndex++)
|
||||
{
|
||||
var replacement = incoming[targetIndex];
|
||||
var existingIndex = -1;
|
||||
for (var index = targetIndex; index < Jobs.Count; index++)
|
||||
{
|
||||
if (Jobs[index].Job.Id == replacement.Job.Id)
|
||||
{
|
||||
existingIndex = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (existingIndex < 0)
|
||||
{
|
||||
Jobs.Insert(targetIndex, replacement);
|
||||
continue;
|
||||
}
|
||||
|
||||
var existing = Jobs[existingIndex];
|
||||
existing.PointCount = replacement.PointCount;
|
||||
existing.IsActive = replacement.IsActive;
|
||||
|
||||
if (existingIndex != targetIndex)
|
||||
Jobs.Move(existingIndex, targetIndex);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,7 +172,7 @@ public sealed partial class JobsViewModel : ObservableObject
|
||||
_settings.ActiveJobId = job.Id;
|
||||
Console.WriteLine($"NewJobAsync: Set active job to {job.Id}");
|
||||
|
||||
await RefreshAsync();
|
||||
await ReloadAsync(showRefreshIndicator: false, skipIfBusy: false);
|
||||
Console.WriteLine("NewJobAsync: Refresh complete");
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -135,6 +206,6 @@ public sealed partial class JobsViewModel : ObservableObject
|
||||
await _database.DeleteJobAsync(item.Job.Id);
|
||||
if (_settings.ActiveJobId == item.Job.Id)
|
||||
_settings.ActiveJobId = null;
|
||||
await RefreshAsync();
|
||||
await ReloadAsync(showRefreshIndicator: false, skipIfBusy: false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,118 +2,31 @@ using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using FieldLogger.Models;
|
||||
using FieldLogger.Services;
|
||||
using FieldLogger.Services.Sync;
|
||||
|
||||
namespace FieldLogger.ViewModels;
|
||||
|
||||
public sealed partial class SettingsViewModel : ObservableObject
|
||||
{
|
||||
private readonly DeviceConnectionManager _manager;
|
||||
private readonly SettingsService _settings;
|
||||
private readonly IMqttSyncService _sync;
|
||||
|
||||
public DeviceConnectionManager Manager => _manager;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _mapsApiKey = "";
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _mqttEnabled;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _mqttHost = "";
|
||||
|
||||
[ObservableProperty]
|
||||
private string _mqttPort = "443";
|
||||
|
||||
[ObservableProperty]
|
||||
private string _mqttOrgId = "";
|
||||
|
||||
[ObservableProperty]
|
||||
private string _mqttPassword = "";
|
||||
|
||||
[ObservableProperty]
|
||||
private string _syncStatus = "";
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _isSavingSync;
|
||||
|
||||
public string MqttClientId => _settings.MqttClientId;
|
||||
|
||||
public string AppVersion => AppInfo.Current.VersionString;
|
||||
|
||||
public SettingsViewModel(DeviceConnectionManager manager, SettingsService settings, IMqttSyncService sync)
|
||||
public SettingsViewModel(DeviceConnectionManager manager)
|
||||
{
|
||||
_manager = manager;
|
||||
_settings = settings;
|
||||
_sync = sync;
|
||||
MapsApiKey = settings.GoogleMapsApiKey;
|
||||
MqttEnabled = settings.MqttEnabled;
|
||||
MqttHost = settings.MqttHost;
|
||||
MqttPort = settings.MqttPort.ToString();
|
||||
MqttOrgId = settings.MqttOrgId;
|
||||
SyncStatus = sync.Status;
|
||||
_sync.StatusChanged += OnSyncStatusChanged;
|
||||
}
|
||||
|
||||
partial void OnMapsApiKeyChanged(string value) => _settings.GoogleMapsApiKey = value.Trim();
|
||||
|
||||
[RelayCommand]
|
||||
private async Task SaveSyncAsync()
|
||||
{
|
||||
if (!int.TryParse(MqttPort, out var port) || port is < 1 or > 65535)
|
||||
{
|
||||
await Shell.Current.DisplayAlert("Invalid MQTT Port", "Enter a port between 1 and 65535.", "OK");
|
||||
return;
|
||||
}
|
||||
if (MqttEnabled && (string.IsNullOrWhiteSpace(MqttHost) || string.IsNullOrWhiteSpace(MqttOrgId)))
|
||||
{
|
||||
await Shell.Current.DisplayAlert("Incomplete Sync Settings", "Host and organization id are required.", "OK");
|
||||
return;
|
||||
}
|
||||
|
||||
IsSavingSync = true;
|
||||
try
|
||||
{
|
||||
_settings.MqttHost = MqttHost;
|
||||
_settings.MqttPort = port;
|
||||
_settings.MqttOrgId = MqttOrgId;
|
||||
_settings.MqttEnabled = MqttEnabled;
|
||||
if (!string.IsNullOrWhiteSpace(MqttPassword))
|
||||
{
|
||||
await _settings.SetMqttPasswordAsync(MqttPassword);
|
||||
MqttPassword = "";
|
||||
}
|
||||
|
||||
if (MqttEnabled)
|
||||
{
|
||||
await _sync.ConnectAsync();
|
||||
await Shell.Current.DisplayAlert("Sync Connected", "The durable MQTT queue is connected.", "OK");
|
||||
}
|
||||
else
|
||||
{
|
||||
await _sync.DisconnectAsync();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await Shell.Current.DisplayAlert("Sync Connection Failed", ex.Message, "OK");
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsSavingSync = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnSyncStatusChanged(object? sender, string status) =>
|
||||
MainThread.BeginInvokeOnMainThread(() => SyncStatus = status);
|
||||
|
||||
[RelayCommand]
|
||||
private Task ChangeLocatorAsync() => Shell.Current.GoToAsync($"devicescan?kind={DeviceKind.Locator}");
|
||||
|
||||
[RelayCommand]
|
||||
private Task ChangeGpsAsync() => Shell.Current.GoToAsync($"devicescan?kind={DeviceKind.RtkGps}");
|
||||
|
||||
[RelayCommand]
|
||||
private Task OpenDebugAsync() => Shell.Current.GoToAsync("debug");
|
||||
|
||||
[RelayCommand]
|
||||
private async Task ForgetLocatorAsync()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user