feat: complete UM Trace iOS and field UI integration

This commit is contained in:
Brent Perteet
2026-08-23 20:54:03 -05:00
parent 16c2ada09b
commit 36f583169c
38 changed files with 548 additions and 212 deletions

View 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}";
}
}

View File

@@ -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(() =>

View File

@@ -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);
}
}

View File

@@ -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()
{