diff --git a/FieldLogger/App.xaml.cs b/FieldLogger/App.xaml.cs index d8a56b4..e239445 100644 --- a/FieldLogger/App.xaml.cs +++ b/FieldLogger/App.xaml.cs @@ -14,8 +14,12 @@ public partial class App : Application _connectionManager = connectionManager; _syncService = syncService; - Console.WriteLine("===== FIELD LOGGER APP STARTING ====="); + Console.WriteLine("===== UM TRACE APP STARTING ====="); Console.WriteLine($"Console output is working! Time: {DateTime.Now:HH:mm:ss}"); + + // Root navigation now opens on Jobs, so connection recovery must not depend on + // the former Home page appearing first. + _connectionManager.Start(); } protected override Window CreateWindow(IActivationState? activationState) diff --git a/FieldLogger/AppShell.xaml b/FieldLogger/AppShell.xaml index 1ff3dfd..acc6f56 100644 --- a/FieldLogger/AppShell.xaml +++ b/FieldLogger/AppShell.xaml @@ -4,7 +4,8 @@ xmlns="http://schemas.microsoft.com/dotnet/2021/maui" xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" xmlns:views="clr-namespace:FieldLogger.Views" - Title="Field Logger" + Title="UM Trace" + FlyoutBehavior="Flyout" BackgroundColor="{DynamicResource UlColorCanvas}" ForegroundColor="{DynamicResource UlColorText}" TitleColor="{DynamicResource UlColorText}" @@ -14,11 +15,26 @@ TabBarTitleColor="{DynamicResource UlColorPrimary}" TabBarUnselectedColor="{DynamicResource UlColorTextMuted}"> - - - - - - + + + + + + + + + + + + + + + + + diff --git a/FieldLogger/AppShell.xaml.cs b/FieldLogger/AppShell.xaml.cs index 75033ff..3002987 100644 --- a/FieldLogger/AppShell.xaml.cs +++ b/FieldLogger/AppShell.xaml.cs @@ -10,10 +10,10 @@ public partial class AppShell : Shell Routing.RegisterRoute("devicescan", typeof(DeviceScanPage)); Routing.RegisterRoute("jobdetail", typeof(JobDetailPage)); + Routing.RegisterRoute("debug", typeof(HomePage)); #if DEBUG - // Keep diagnostics available to development builds without presenting Debug as - // a production primary tab. - Routing.RegisterRoute("debug", typeof(DebugPage)); + // The raw device console is a development-only maintenance interface. + Routing.RegisterRoute("deviceconsole", typeof(DebugPage)); #endif } } diff --git a/FieldLogger/FieldLogger.csproj b/FieldLogger/FieldLogger.csproj index b11545b..1f608fd 100644 --- a/FieldLogger/FieldLogger.csproj +++ b/FieldLogger/FieldLogger.csproj @@ -64,10 +64,10 @@ - + - + diff --git a/FieldLogger/MauiProgram.cs b/FieldLogger/MauiProgram.cs index f65cf29..aa00ca2 100644 --- a/FieldLogger/MauiProgram.cs +++ b/FieldLogger/MauiProgram.cs @@ -44,6 +44,7 @@ public static class MauiProgram builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddTransient(); builder.Services.AddTransient(); diff --git a/FieldLogger/Platforms/MacCatalyst/Info.plist b/FieldLogger/Platforms/MacCatalyst/Info.plist index 54a53df..ec048e8 100644 --- a/FieldLogger/Platforms/MacCatalyst/Info.plist +++ b/FieldLogger/Platforms/MacCatalyst/Info.plist @@ -35,8 +35,8 @@ XSAppIconAssets Assets.xcassets/appicon.appiconset NSBluetoothAlwaysUsageDescription - Field Logger uses Bluetooth to connect to your Underground Magnetics locating receiver and Maglink RTK GPS receiver. + UM Trace uses Bluetooth to connect to your Underground Magnetics locating receiver and Maglink RTK GPS receiver. NSLocationWhenInUseUsageDescription - Field Logger uses your location to display logged points on the map. + UM Trace uses your location to display logged points on the map. diff --git a/FieldLogger/Resources/AppIcon/appicon.svg b/FieldLogger/Resources/AppIcon/appicon.svg index 9d63b65..e24dfc9 100644 --- a/FieldLogger/Resources/AppIcon/appicon.svg +++ b/FieldLogger/Resources/AppIcon/appicon.svg @@ -1,4 +1,4 @@ - - \ No newline at end of file + + diff --git a/FieldLogger/Resources/AppIcon/appiconfg.png b/FieldLogger/Resources/AppIcon/appiconfg.png new file mode 100644 index 0000000..0d4b161 Binary files /dev/null and b/FieldLogger/Resources/AppIcon/appiconfg.png differ diff --git a/FieldLogger/Resources/AppIcon/appiconfg.svg b/FieldLogger/Resources/AppIcon/appiconfg.svg deleted file mode 100644 index 21dfb25..0000000 --- a/FieldLogger/Resources/AppIcon/appiconfg.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/FieldLogger/Resources/Splash/splash.svg b/FieldLogger/Resources/Splash/splash.svg deleted file mode 100644 index 21dfb25..0000000 --- a/FieldLogger/Resources/Splash/splash.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/FieldLogger/Resources/Splash/um_dual_receiver.png b/FieldLogger/Resources/Splash/um_dual_receiver.png new file mode 100644 index 0000000..c5a6433 Binary files /dev/null and b/FieldLogger/Resources/Splash/um_dual_receiver.png differ diff --git a/FieldLogger/Services/Ble/BleScanner.cs b/FieldLogger/Services/Ble/BleScanner.cs index b81e895..a576766 100644 --- a/FieldLogger/Services/Ble/BleScanner.cs +++ b/FieldLogger/Services/Ble/BleScanner.cs @@ -109,7 +109,7 @@ public sealed class BleScanner new InvalidOperationException("Bluetooth is turned off."), BluetoothState.Unauthorized => new PermissionException( - "Bluetooth access is denied. Enable Field Logger in System Settings > Privacy & Security > Bluetooth."), + "Bluetooth access is denied. Enable UM Trace in System Settings > Privacy & Security > Bluetooth."), BluetoothState.Unavailable => new InvalidOperationException("Bluetooth is not available on this Mac."), _ => new InvalidOperationException($"Bluetooth is not ready (state: {_bluetooth.State}).") diff --git a/FieldLogger/ViewModels/AppStatusViewModel.cs b/FieldLogger/ViewModels/AppStatusViewModel.cs new file mode 100644 index 0000000..3bce95b --- /dev/null +++ b/FieldLogger/ViewModels/AppStatusViewModel.cs @@ -0,0 +1,105 @@ +using System.ComponentModel; +using CommunityToolkit.Mvvm.ComponentModel; +using FieldLogger.Models; +using FieldLogger.Services; + +namespace FieldLogger.ViewModels; + +/// +/// Shared, glanceable hardware status shown above each primary workflow. The timer marks +/// position data stale even when the receiver stops sending without disconnecting. +/// +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(); + + /// Refreshes status safely from device callbacks or a UI dispatcher timer. + 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}"; + } +} diff --git a/FieldLogger/ViewModels/HomeViewModel.cs b/FieldLogger/ViewModels/HomeViewModel.cs index 8c0cf36..c0939e4 100644 --- a/FieldLogger/ViewModels/HomeViewModel.cs +++ b/FieldLogger/ViewModels/HomeViewModel.cs @@ -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 /// Called from the page's OnAppearing. 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(() => diff --git a/FieldLogger/ViewModels/JobsViewModel.cs b/FieldLogger/ViewModels/JobsViewModel.cs index 03c3a46..f53704f 100644 --- a/FieldLogger/ViewModels/JobsViewModel.cs +++ b/FieldLogger/ViewModels/JobsViewModel.cs @@ -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 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(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 WaitForRefreshGateAsync() + { + await _refreshGate.WaitAsync(); + return true; + } + + private void ApplySnapshot(IReadOnlyList 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); } } diff --git a/FieldLogger/ViewModels/SettingsViewModel.cs b/FieldLogger/ViewModels/SettingsViewModel.cs index 73586ec..431a57d 100644 --- a/FieldLogger/ViewModels/SettingsViewModel.cs +++ b/FieldLogger/ViewModels/SettingsViewModel.cs @@ -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() { diff --git a/FieldLogger/Views/Controls/AppStatusBar.xaml b/FieldLogger/Views/Controls/AppStatusBar.xaml new file mode 100644 index 0000000..c85453c --- /dev/null +++ b/FieldLogger/Views/Controls/AppStatusBar.xaml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + diff --git a/FieldLogger/Views/Controls/AppStatusBar.xaml.cs b/FieldLogger/Views/Controls/AppStatusBar.xaml.cs new file mode 100644 index 0000000..209546c --- /dev/null +++ b/FieldLogger/Views/Controls/AppStatusBar.xaml.cs @@ -0,0 +1,40 @@ +using FieldLogger.ViewModels; + +namespace FieldLogger.Views.Controls; + +public partial class AppStatusBar : ContentView +{ + private IDispatcherTimer? _staleTimer; + + public AppStatusBar() + { + InitializeComponent(); + } + + protected override void OnHandlerChanged() + { + base.OnHandlerChanged(); + + if (Handler is null) + { + _staleTimer?.Stop(); + _staleTimer = null; + return; + } + + if (_staleTimer is not null) + return; + + _staleTimer = Dispatcher.CreateTimer(); + _staleTimer.Interval = TimeSpan.FromSeconds(1); + _staleTimer.Tick += (_, _) => (BindingContext as AppStatusViewModel)?.Refresh(); + _staleTimer.Start(); + (BindingContext as AppStatusViewModel)?.Refresh(); + } + + protected override void OnBindingContextChanged() + { + base.OnBindingContextChanged(); + (BindingContext as AppStatusViewModel)?.Refresh(); + } +} diff --git a/FieldLogger/Views/Controls/FloatingMenuButton.xaml b/FieldLogger/Views/Controls/FloatingMenuButton.xaml new file mode 100644 index 0000000..a1beb59 --- /dev/null +++ b/FieldLogger/Views/Controls/FloatingMenuButton.xaml @@ -0,0 +1,15 @@ + + +