feat: complete UM Trace iOS and field UI integration
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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}">
|
||||
|
||||
<TabBar>
|
||||
<ShellContent Title="Home" Route="home" ContentTemplate="{DataTemplate views:HomePage}" />
|
||||
<ShellContent Title="Jobs" Route="jobs" ContentTemplate="{DataTemplate views:JobsPage}" />
|
||||
<ShellContent Title="Map" Route="map" ContentTemplate="{DataTemplate views:MapPage}" />
|
||||
<ShellContent Title="Settings" Route="settings" ContentTemplate="{DataTemplate views:SettingsPage}" />
|
||||
</TabBar>
|
||||
<Shell.FlyoutHeader>
|
||||
<Grid BackgroundColor="{DynamicResource UlColorSurfaceStrong}" Padding="20,28,20,20">
|
||||
<VerticalStackLayout Spacing="2">
|
||||
<Label Text="UM TRACE" FontSize="20" FontAttributes="Bold"
|
||||
CharacterSpacing="1.5" TextColor="{DynamicResource UlColorOnStrong}" />
|
||||
<Label Text="Utility locating" FontSize="14"
|
||||
TextColor="{DynamicResource UlColorBorder}" />
|
||||
</VerticalStackLayout>
|
||||
</Grid>
|
||||
</Shell.FlyoutHeader>
|
||||
|
||||
<!-- Jobs is intentionally first: Shell selects the first flyout item at startup. -->
|
||||
<FlyoutItem Title="Jobs" Route="jobs">
|
||||
<ShellContent ContentTemplate="{DataTemplate views:JobsPage}" />
|
||||
</FlyoutItem>
|
||||
<FlyoutItem Title="Map" Route="map">
|
||||
<ShellContent ContentTemplate="{DataTemplate views:MapPage}" />
|
||||
</FlyoutItem>
|
||||
<FlyoutItem Title="Settings" Route="settings">
|
||||
<ShellContent ContentTemplate="{DataTemplate views:SettingsPage}" />
|
||||
</FlyoutItem>
|
||||
|
||||
</Shell>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,10 +64,10 @@
|
||||
|
||||
<ItemGroup>
|
||||
<!-- App Icon -->
|
||||
<MauiIcon Include="Resources\AppIcon\appicon.svg" ForegroundFile="Resources\AppIcon\appiconfg.svg" Color="#512BD4" />
|
||||
<MauiIcon Include="Resources\AppIcon\appicon.svg" ForegroundFile="Resources\AppIcon\appiconfg.png" Color="#FFFFFF" />
|
||||
|
||||
<!-- Splash Screen -->
|
||||
<MauiSplashScreen Include="Resources\Splash\splash.svg" Color="#512BD4" BaseSize="128,128" />
|
||||
<MauiSplashScreen Include="Resources\Splash\um_dual_receiver.png" Color="#EEF3F2" BaseSize="480,480" />
|
||||
|
||||
<!-- Images -->
|
||||
<MauiImage Include="Resources\Images\*" />
|
||||
|
||||
@@ -44,6 +44,7 @@ public static class MauiProgram
|
||||
builder.Services.AddSingleton<MapViewModel>();
|
||||
builder.Services.AddSingleton<DebugViewModel>();
|
||||
builder.Services.AddSingleton<SettingsViewModel>();
|
||||
builder.Services.AddSingleton<AppStatusViewModel>();
|
||||
builder.Services.AddTransient<DeviceScanViewModel>();
|
||||
builder.Services.AddTransient<JobDetailViewModel>();
|
||||
|
||||
|
||||
@@ -35,8 +35,8 @@
|
||||
<key>XSAppIconAssets</key>
|
||||
<string>Assets.xcassets/appicon.appiconset</string>
|
||||
<key>NSBluetoothAlwaysUsageDescription</key>
|
||||
<string>Field Logger uses Bluetooth to connect to your Underground Magnetics locating receiver and Maglink RTK GPS receiver.</string>
|
||||
<string>UM Trace uses Bluetooth to connect to your Underground Magnetics locating receiver and Maglink RTK GPS receiver.</string>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string>Field Logger uses your location to display logged points on the map.</string>
|
||||
<string>UM Trace uses your location to display logged points on the map.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg width="456" height="456" viewBox="0 0 456 456" version="1.1" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="0" y="0" width="456" height="456" fill="#512BD4" />
|
||||
</svg>
|
||||
<rect x="0" y="0" width="456" height="456" fill="#FFFFFF" />
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 228 B After Width: | Height: | Size: 229 B |
BIN
FieldLogger/Resources/AppIcon/appiconfg.png
Normal file
BIN
FieldLogger/Resources/AppIcon/appiconfg.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.9 KiB |
@@ -1,8 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg width="456" height="456" viewBox="0 0 456 456" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||
<path d="m 105.50037,281.60863 c -2.70293,0 -5.00091,-0.90042 -6.893127,-2.70209 -1.892214,-1.84778 -2.837901,-4.04181 -2.837901,-6.58209 0,-2.58722 0.945687,-4.80389 2.837901,-6.65167 1.892217,-1.84778 4.190197,-2.77167 6.893127,-2.77167 2.74819,0 5.06798,0.92389 6.96019,2.77167 1.93749,1.84778 2.90581,4.06445 2.90581,6.65167 0,2.54028 -0.96832,4.73431 -2.90581,6.58209 -1.89221,1.80167 -4.212,2.70209 -6.96019,2.70209 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
|
||||
<path d="M 213.56111,280.08446 H 195.99044 L 149.69953,207.0544 c -1.17121,-1.84778 -2.14037,-3.76515 -2.90581,-5.75126 h -0.40578 c 0.36051,2.12528 0.54076,6.67515 0.54076,13.6496 v 65.13172 h -15.54349 v -99.36009 h 18.71925 l 44.7374,71.29798 c 1.89222,2.95695 3.1087,4.98917 3.64945,6.09751 h 0.26996 c -0.45021,-2.6325 -0.67573,-7.09015 -0.67573,-13.37293 v -64.02256 h 15.47557 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
|
||||
<path d="m 289.25134,280.08446 h -54.40052 v -99.36009 h 52.23835 v 13.99669 h -36.15411 v 28.13085 h 33.31621 v 13.9271 h -33.31621 v 29.37835 h 38.31628 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
|
||||
<path d="M 366.56466,194.72106 H 338.7222 v 85.3634 h -16.08423 v -85.3634 h -27.77455 v -13.99669 h 71.70124 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.8 KiB |
@@ -1,8 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg width="456" height="456" viewBox="0 0 456 456" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||
<path d="m 105.50037,281.60863 c -2.70293,0 -5.00091,-0.90042 -6.893127,-2.70209 -1.892214,-1.84778 -2.837901,-4.04181 -2.837901,-6.58209 0,-2.58722 0.945687,-4.80389 2.837901,-6.65167 1.892217,-1.84778 4.190197,-2.77167 6.893127,-2.77167 2.74819,0 5.06798,0.92389 6.96019,2.77167 1.93749,1.84778 2.90581,4.06445 2.90581,6.65167 0,2.54028 -0.96832,4.73431 -2.90581,6.58209 -1.89221,1.80167 -4.212,2.70209 -6.96019,2.70209 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
|
||||
<path d="M 213.56111,280.08446 H 195.99044 L 149.69953,207.0544 c -1.17121,-1.84778 -2.14037,-3.76515 -2.90581,-5.75126 h -0.40578 c 0.36051,2.12528 0.54076,6.67515 0.54076,13.6496 v 65.13172 h -15.54349 v -99.36009 h 18.71925 l 44.7374,71.29798 c 1.89222,2.95695 3.1087,4.98917 3.64945,6.09751 h 0.26996 c -0.45021,-2.6325 -0.67573,-7.09015 -0.67573,-13.37293 v -64.02256 h 15.47557 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
|
||||
<path d="m 289.25134,280.08446 h -54.40052 v -99.36009 h 52.23835 v 13.99669 h -36.15411 v 28.13085 h 33.31621 v 13.9271 h -33.31621 v 29.37835 h 38.31628 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
|
||||
<path d="M 366.56466,194.72106 H 338.7222 v 85.3634 h -16.08423 v -85.3634 h -27.77455 v -13.99669 h 71.70124 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.8 KiB |
BIN
FieldLogger/Resources/Splash/um_dual_receiver.png
Normal file
BIN
FieldLogger/Resources/Splash/um_dual_receiver.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 265 KiB |
@@ -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}).")
|
||||
|
||||
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()
|
||||
{
|
||||
|
||||
35
FieldLogger/Views/Controls/AppStatusBar.xaml
Normal file
35
FieldLogger/Views/Controls/AppStatusBar.xaml
Normal file
@@ -0,0 +1,35 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<ContentView xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||
xmlns:vm="clr-namespace:FieldLogger.ViewModels"
|
||||
x:Class="FieldLogger.Views.Controls.AppStatusBar"
|
||||
x:DataType="vm:AppStatusViewModel">
|
||||
<Border BackgroundColor="{DynamicResource UlColorSurfaceStrong}"
|
||||
StrokeThickness="0" Padding="12,8">
|
||||
<Grid RowDefinitions="Auto,Auto" RowSpacing="4">
|
||||
<Grid ColumnDefinitions="*,*" ColumnSpacing="12">
|
||||
<HorizontalStackLayout Spacing="6">
|
||||
<Label Text="●" FontSize="13"
|
||||
TextColor="{Binding LocatorState, Converter={StaticResource StateColor}}"
|
||||
VerticalTextAlignment="Center" />
|
||||
<Label Text="{Binding LocatorStatus}" FontSize="12"
|
||||
TextColor="{DynamicResource UlColorOnStrong}"
|
||||
LineBreakMode="TailTruncation" />
|
||||
</HorizontalStackLayout>
|
||||
<HorizontalStackLayout Grid.Column="1" Spacing="6">
|
||||
<Label Text="●" FontSize="13"
|
||||
TextColor="{Binding GnssState, Converter={StaticResource StateColor}}"
|
||||
VerticalTextAlignment="Center" />
|
||||
<Label Text="{Binding GnssStatus}" FontSize="12"
|
||||
TextColor="{DynamicResource UlColorOnStrong}"
|
||||
LineBreakMode="TailTruncation" />
|
||||
</HorizontalStackLayout>
|
||||
</Grid>
|
||||
<Label Grid.Row="1" Text="{Binding PositionSummary}" FontSize="12"
|
||||
FontFamily="{StaticResource UlTypeFamilyMono}"
|
||||
TextColor="{DynamicResource UlColorOnStrong}"
|
||||
LineBreakMode="TailTruncation"
|
||||
SemanticProperties.Description="Current GNSS fix type, estimated accuracy, satellites, and correction age" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</ContentView>
|
||||
40
FieldLogger/Views/Controls/AppStatusBar.xaml.cs
Normal file
40
FieldLogger/Views/Controls/AppStatusBar.xaml.cs
Normal file
@@ -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();
|
||||
}
|
||||
}
|
||||
15
FieldLogger/Views/Controls/FloatingMenuButton.xaml
Normal file
15
FieldLogger/Views/Controls/FloatingMenuButton.xaml
Normal file
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<ContentView xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||
x:Class="FieldLogger.Views.Controls.FloatingMenuButton">
|
||||
<Button Text="☰" FontSize="24" FontAttributes="Bold"
|
||||
WidthRequest="56" HeightRequest="56" CornerRadius="28"
|
||||
Padding="0" Margin="16"
|
||||
BackgroundColor="{DynamicResource UlColorPrimary}"
|
||||
TextColor="{DynamicResource UlColorOnPrimary}"
|
||||
BorderColor="{DynamicResource UlColorSurfaceRaised}" BorderWidth="2"
|
||||
Clicked="OnMenuClicked"
|
||||
SemanticProperties.Description="Open UM Trace menu"
|
||||
MinimumHeightRequest="{StaticResource UlTouchComfortable}"
|
||||
MinimumWidthRequest="{StaticResource UlTouchComfortable}" />
|
||||
</ContentView>
|
||||
15
FieldLogger/Views/Controls/FloatingMenuButton.xaml.cs
Normal file
15
FieldLogger/Views/Controls/FloatingMenuButton.xaml.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
namespace FieldLogger.Views.Controls;
|
||||
|
||||
public partial class FloatingMenuButton : ContentView
|
||||
{
|
||||
public FloatingMenuButton()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
private void OnMenuClicked(object? sender, EventArgs e)
|
||||
{
|
||||
if (Shell.Current is not null)
|
||||
Shell.Current.FlyoutIsPresented = true;
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,13 @@
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||
xmlns:vm="clr-namespace:FieldLogger.ViewModels"
|
||||
xmlns:models="clr-namespace:FieldLogger.Models"
|
||||
xmlns:controls="clr-namespace:FieldLogger.Views.Controls"
|
||||
x:Class="FieldLogger.Views.DebugPage"
|
||||
x:DataType="vm:DebugViewModel"
|
||||
Title="Debug">
|
||||
<ScrollView>
|
||||
Title="Device Console">
|
||||
<Grid RowDefinitions="Auto,*">
|
||||
<controls:AppStatusBar x:Name="AppStatus" />
|
||||
<ScrollView Grid.Row="1">
|
||||
<VerticalStackLayout Padding="16" Spacing="12">
|
||||
|
||||
<Label Text="Connection Status" FontAttributes="Bold" FontSize="16" />
|
||||
@@ -88,4 +91,5 @@
|
||||
|
||||
</VerticalStackLayout>
|
||||
</ScrollView>
|
||||
</Grid>
|
||||
</ContentPage>
|
||||
|
||||
@@ -6,11 +6,12 @@ public partial class DebugPage : ContentPage
|
||||
{
|
||||
private readonly DebugViewModel _viewModel;
|
||||
|
||||
public DebugPage(DebugViewModel viewModel)
|
||||
public DebugPage(DebugViewModel viewModel, AppStatusViewModel appStatusViewModel)
|
||||
{
|
||||
InitializeComponent();
|
||||
_viewModel = viewModel;
|
||||
BindingContext = viewModel;
|
||||
AppStatus.BindingContext = appStatusViewModel;
|
||||
}
|
||||
|
||||
protected override void OnAppearing()
|
||||
|
||||
@@ -3,16 +3,19 @@
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||
xmlns:vm="clr-namespace:FieldLogger.ViewModels"
|
||||
xmlns:models="clr-namespace:FieldLogger.Models"
|
||||
xmlns:controls="clr-namespace:FieldLogger.Views.Controls"
|
||||
x:Class="FieldLogger.Views.HomePage"
|
||||
x:DataType="vm:HomeViewModel"
|
||||
Title="Field Logger"
|
||||
Title="Debug"
|
||||
BackgroundColor="{DynamicResource UlColorCanvas}">
|
||||
<ScrollView>
|
||||
<Grid RowDefinitions="Auto,*">
|
||||
<controls:AppStatusBar x:Name="AppStatus" />
|
||||
<ScrollView Grid.Row="1">
|
||||
<VerticalStackLayout Padding="16" Spacing="16">
|
||||
<VerticalStackLayout Spacing="4">
|
||||
<Label Text="FIELD LOGGER" FontSize="14" FontAttributes="Bold" CharacterSpacing="1.5"
|
||||
<Label Text="UM TRACE DEBUG" FontSize="14" FontAttributes="Bold" CharacterSpacing="1.5"
|
||||
TextColor="{DynamicResource UlColorPrimary}" />
|
||||
<Label Text="Live field capture" Style="{StaticResource UlPageTitle}"
|
||||
<Label Text="Capture diagnostics" Style="{StaticResource UlPageTitle}"
|
||||
TextColor="{DynamicResource UlColorText}"
|
||||
SemanticProperties.HeadingLevel="Level1" />
|
||||
</VerticalStackLayout>
|
||||
@@ -187,6 +190,13 @@
|
||||
</DataTemplate>
|
||||
</CollectionView.ItemTemplate>
|
||||
</CollectionView>
|
||||
|
||||
<Button Style="{StaticResource UlButtonSecondary}"
|
||||
Text="Open device console" Command="{Binding OpenDeviceConsoleCommand}"
|
||||
IsVisible="{Binding IsDeviceConsoleAvailable}"
|
||||
MinimumHeightRequest="{StaticResource UlTouchMinimum}"
|
||||
SemanticProperties.Description="Open raw locator and GNSS device messages" />
|
||||
</VerticalStackLayout>
|
||||
</ScrollView>
|
||||
</Grid>
|
||||
</ContentPage>
|
||||
|
||||
@@ -7,10 +7,11 @@ public partial class HomePage : ContentPage
|
||||
private readonly HomeViewModel _viewModel;
|
||||
private bool _initialized;
|
||||
|
||||
public HomePage(HomeViewModel viewModel)
|
||||
public HomePage(HomeViewModel viewModel, AppStatusViewModel appStatusViewModel)
|
||||
{
|
||||
InitializeComponent();
|
||||
BindingContext = _viewModel = viewModel;
|
||||
AppStatus.BindingContext = appStatusViewModel;
|
||||
}
|
||||
|
||||
protected override async void OnAppearing()
|
||||
|
||||
@@ -3,11 +3,14 @@
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||
xmlns:vm="clr-namespace:FieldLogger.ViewModels"
|
||||
xmlns:models="clr-namespace:FieldLogger.Models"
|
||||
xmlns:controls="clr-namespace:FieldLogger.Views.Controls"
|
||||
x:Class="FieldLogger.Views.JobDetailPage"
|
||||
x:DataType="vm:JobDetailViewModel"
|
||||
Title="{Binding JobName}"
|
||||
BackgroundColor="{DynamicResource UlColorCanvas}">
|
||||
<Grid RowDefinitions="Auto,*" Padding="16" RowSpacing="16">
|
||||
<Grid RowDefinitions="Auto,*">
|
||||
<controls:AppStatusBar x:Name="AppStatus" />
|
||||
<Grid Grid.Row="1" RowDefinitions="Auto,*" Padding="16" RowSpacing="16">
|
||||
<Border Style="{StaticResource UlCard}" BackgroundColor="{DynamicResource UlColorSurfaceRaised}"
|
||||
Stroke="{DynamicResource UlColorBorder}" StrokeShape="RoundRectangle 12" Padding="16">
|
||||
<Grid RowDefinitions="Auto,Auto" ColumnDefinitions="*,Auto" RowSpacing="6" ColumnSpacing="12">
|
||||
@@ -37,7 +40,7 @@
|
||||
<VerticalStackLayout Spacing="8">
|
||||
<Label Text="No captures for this job" Style="{StaticResource UlEmptyStateTitle}"
|
||||
HorizontalTextAlignment="Center" TextColor="{DynamicResource UlColorText}" />
|
||||
<Label Text="Return Home and press LOG on the receiver when the job is active."
|
||||
<Label Text="Open Debug from Settings and press LOG on the receiver when the job is active."
|
||||
Style="{StaticResource UlEmptyStateBody}"
|
||||
FontSize="16" HorizontalTextAlignment="Center"
|
||||
TextColor="{DynamicResource UlColorTextMuted}" />
|
||||
@@ -103,4 +106,5 @@
|
||||
</CollectionView.ItemTemplate>
|
||||
</CollectionView>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</ContentPage>
|
||||
|
||||
@@ -6,10 +6,11 @@ public partial class JobDetailPage : ContentPage
|
||||
{
|
||||
private readonly JobDetailViewModel _viewModel;
|
||||
|
||||
public JobDetailPage(JobDetailViewModel viewModel)
|
||||
public JobDetailPage(JobDetailViewModel viewModel, AppStatusViewModel appStatusViewModel)
|
||||
{
|
||||
InitializeComponent();
|
||||
BindingContext = _viewModel = viewModel;
|
||||
AppStatus.BindingContext = appStatusViewModel;
|
||||
}
|
||||
|
||||
protected override async void OnAppearing()
|
||||
|
||||
@@ -2,11 +2,18 @@
|
||||
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||
xmlns:vm="clr-namespace:FieldLogger.ViewModels"
|
||||
xmlns:controls="clr-namespace:FieldLogger.Views.Controls"
|
||||
x:Class="FieldLogger.Views.JobsPage"
|
||||
x:DataType="vm:JobsViewModel"
|
||||
Title="Jobs"
|
||||
Shell.NavBarIsVisible="False"
|
||||
BackgroundColor="{DynamicResource UlColorCanvas}">
|
||||
<Grid RowDefinitions="Auto,Auto,*" Padding="16" RowSpacing="16">
|
||||
<Grid RowDefinitions="Auto,*"
|
||||
HorizontalOptions="Fill" VerticalOptions="Fill">
|
||||
<controls:AppStatusBar x:Name="AppStatus" />
|
||||
|
||||
<Grid Grid.Row="1" RowDefinitions="Auto,Auto,*" Padding="16" RowSpacing="16"
|
||||
HorizontalOptions="Fill" VerticalOptions="Fill">
|
||||
<VerticalStackLayout Spacing="4">
|
||||
<Label Text="FIELD WORK" FontSize="14" FontAttributes="Bold" CharacterSpacing="1.5"
|
||||
TextColor="{DynamicResource UlColorPrimary}" />
|
||||
@@ -22,9 +29,15 @@
|
||||
MinimumHeightRequest="{StaticResource UlTouchComfortable}" FontSize="16" FontAttributes="Bold"
|
||||
SemanticProperties.Description="Create a new job and make it active" />
|
||||
|
||||
<RefreshView Grid.Row="2" Command="{Binding RefreshCommand}" IsRefreshing="{Binding IsBusy}">
|
||||
<RefreshView Grid.Row="2"
|
||||
Command="{Binding RefreshCommand}"
|
||||
IsRefreshing="{Binding IsRefreshing, Mode=OneWay}"
|
||||
HorizontalOptions="Fill" VerticalOptions="Fill">
|
||||
<CollectionView ItemsSource="{Binding Jobs}"
|
||||
SelectionMode="None"
|
||||
ItemSizingStrategy="MeasureFirstItem"
|
||||
ItemsUpdatingScrollMode="KeepItemsInView"
|
||||
HorizontalOptions="Fill" VerticalOptions="Fill"
|
||||
SemanticProperties.Description="Jobs available on this device">
|
||||
<CollectionView.EmptyView>
|
||||
<Border Style="{StaticResource UlCard}" BackgroundColor="{DynamicResource UlColorSurfaceRaised}"
|
||||
@@ -88,7 +101,15 @@
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</CollectionView.ItemTemplate>
|
||||
<CollectionView.Footer>
|
||||
<!-- Keeps the final row scrollable above the overlaid menu without
|
||||
reserving a permanent bottom band in the page layout. -->
|
||||
<ContentView HeightRequest="72" />
|
||||
</CollectionView.Footer>
|
||||
</CollectionView>
|
||||
</RefreshView>
|
||||
</Grid>
|
||||
|
||||
<controls:FloatingMenuButton Grid.RowSpan="2" HorizontalOptions="End" VerticalOptions="End" ZIndex="20" />
|
||||
</Grid>
|
||||
</ContentPage>
|
||||
|
||||
@@ -6,15 +6,16 @@ public partial class JobsPage : ContentPage
|
||||
{
|
||||
private readonly JobsViewModel _viewModel;
|
||||
|
||||
public JobsPage(JobsViewModel viewModel)
|
||||
public JobsPage(JobsViewModel viewModel, AppStatusViewModel appStatusViewModel)
|
||||
{
|
||||
InitializeComponent();
|
||||
BindingContext = _viewModel = viewModel;
|
||||
AppStatus.BindingContext = appStatusViewModel;
|
||||
}
|
||||
|
||||
protected override async void OnAppearing()
|
||||
{
|
||||
base.OnAppearing();
|
||||
await _viewModel.RefreshAsync();
|
||||
await _viewModel.LoadAsync();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,34 +2,31 @@
|
||||
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||
xmlns:vm="clr-namespace:FieldLogger.ViewModels"
|
||||
xmlns:controls="clr-namespace:FieldLogger.Views.Controls"
|
||||
x:Class="FieldLogger.Views.MapPage"
|
||||
x:DataType="vm:MapViewModel"
|
||||
Title="Map"
|
||||
Shell.NavBarIsVisible="False"
|
||||
BackgroundColor="{DynamicResource UlColorMap}">
|
||||
<Grid RowDefinitions="Auto,*">
|
||||
<Border Style="{StaticResource UlCard}" Margin="12" Padding="16" BackgroundColor="{DynamicResource UlColorSurfaceRaised}"
|
||||
Stroke="{DynamicResource UlColorBorder}" StrokeShape="RoundRectangle 12"
|
||||
ZIndex="1">
|
||||
<Grid RowDefinitions="Auto,Auto" ColumnDefinitions="*,Auto" RowSpacing="4" ColumnSpacing="12">
|
||||
<VerticalStackLayout>
|
||||
<Label Text="LIVE MAP" FontSize="14" FontAttributes="Bold" CharacterSpacing="1.5"
|
||||
TextColor="{DynamicResource UlColorPrimary}" />
|
||||
<Label Text="{Binding JobName, TargetNullValue='No active job'}"
|
||||
Style="{StaticResource UlSectionTitle}" TextColor="{DynamicResource UlColorText}"
|
||||
SemanticProperties.HeadingLevel="Level1" />
|
||||
</VerticalStackLayout>
|
||||
<Button Grid.Column="1" Style="{StaticResource UlButtonSecondary}"
|
||||
Text="Refresh" Clicked="OnRefreshClicked"
|
||||
MinimumHeightRequest="{StaticResource UlTouchMinimum}" MinimumWidthRequest="88"
|
||||
SemanticProperties.Description="Refresh captured points on the map" />
|
||||
<Label Grid.Row="1" Grid.ColumnSpan="2" Text="{Binding StatusText}" FontSize="14"
|
||||
TextColor="{DynamicResource UlColorTextMuted}"
|
||||
SemanticProperties.Description="Current map data status" />
|
||||
</Grid>
|
||||
</Border>
|
||||
<Grid>
|
||||
<!-- Populated in code-behind: native Map control on iOS/Android/macOS, Google Maps WebView on Windows. -->
|
||||
<ContentView Grid.RowSpan="2" x:Name="MapHost"
|
||||
<ContentView x:Name="MapHost"
|
||||
BackgroundColor="{DynamicResource UlColorMap}"
|
||||
SemanticProperties.Description="Map of locally captured points for the selected job" />
|
||||
|
||||
<controls:AppStatusBar x:Name="AppStatus" VerticalOptions="Start" ZIndex="20" />
|
||||
|
||||
<Button Text="↻" FontSize="22" FontAttributes="Bold"
|
||||
WidthRequest="48" HeightRequest="48" CornerRadius="24" Padding="0"
|
||||
Margin="16,82,16,16" HorizontalOptions="End" VerticalOptions="Start"
|
||||
BackgroundColor="{DynamicResource UlColorSurfaceRaised}"
|
||||
TextColor="{DynamicResource UlColorPrimary}"
|
||||
BorderColor="{DynamicResource UlColorBorder}" BorderWidth="1"
|
||||
Clicked="OnRefreshClicked" ZIndex="20"
|
||||
MinimumHeightRequest="{StaticResource UlTouchMinimum}"
|
||||
MinimumWidthRequest="{StaticResource UlTouchMinimum}"
|
||||
SemanticProperties.Description="Refresh captured points on the map" />
|
||||
|
||||
<controls:FloatingMenuButton HorizontalOptions="End" VerticalOptions="End" ZIndex="20" />
|
||||
</Grid>
|
||||
</ContentPage>
|
||||
|
||||
@@ -23,10 +23,11 @@ public partial class MapPage : ContentPage
|
||||
private Map? _map;
|
||||
#endif
|
||||
|
||||
public MapPage(MapViewModel viewModel)
|
||||
public MapPage(MapViewModel viewModel, AppStatusViewModel appStatusViewModel)
|
||||
{
|
||||
InitializeComponent();
|
||||
BindingContext = _viewModel = viewModel;
|
||||
AppStatus.BindingContext = appStatusViewModel;
|
||||
}
|
||||
|
||||
protected override async void OnAppearing()
|
||||
|
||||
@@ -2,11 +2,20 @@
|
||||
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||
xmlns:vm="clr-namespace:FieldLogger.ViewModels"
|
||||
xmlns:controls="clr-namespace:FieldLogger.Views.Controls"
|
||||
x:Class="FieldLogger.Views.SettingsPage"
|
||||
x:DataType="vm:SettingsViewModel"
|
||||
Title="Settings">
|
||||
<ScrollView>
|
||||
<VerticalStackLayout Padding="16" Spacing="12">
|
||||
Title="Settings"
|
||||
Shell.NavBarIsVisible="False"
|
||||
BackgroundColor="{DynamicResource UlColorCanvas}">
|
||||
<Grid RowDefinitions="Auto,*">
|
||||
<controls:AppStatusBar x:Name="AppStatus" />
|
||||
<ScrollView Grid.Row="1">
|
||||
<VerticalStackLayout Padding="16,16,16,88" Spacing="12">
|
||||
|
||||
<Label Text="Settings" Style="{StaticResource UlPageTitle}"
|
||||
TextColor="{DynamicResource UlColorText}"
|
||||
SemanticProperties.HeadingLevel="Level1" />
|
||||
|
||||
<Label Text="Devices" FontAttributes="Bold" FontSize="16" />
|
||||
|
||||
@@ -40,36 +49,33 @@
|
||||
</VerticalStackLayout>
|
||||
</Border>
|
||||
|
||||
<Label Text="Map" FontAttributes="Bold" FontSize="16" Margin="0,12,0,0" />
|
||||
<Border StrokeThickness="0" Background="{AppThemeBinding Light=#F2F2F7, Dark=#1C1C1E}" StrokeShape="RoundRectangle 12" Padding="12">
|
||||
<VerticalStackLayout Spacing="6">
|
||||
<Label Text="Google Maps API Key (Windows map view)" FontSize="12" TextColor="Gray" />
|
||||
<Entry Text="{Binding MapsApiKey}" Placeholder="AIza…" IsPassword="False" />
|
||||
<Label FontSize="11" TextColor="Gray"
|
||||
Text="On Android the key is configured in AndroidManifest.xml; iOS and macOS use Apple Maps and need no key." />
|
||||
</VerticalStackLayout>
|
||||
</Border>
|
||||
|
||||
<Label Text="Sync" FontAttributes="Bold" FontSize="16" Margin="0,12,0,0" />
|
||||
<Border StrokeThickness="0" Background="{AppThemeBinding Light=#F2F2F7, Dark=#1C1C1E}" StrokeShape="RoundRectangle 12" Padding="12">
|
||||
<VerticalStackLayout Spacing="8">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<Label Text="Durable cloud sync" VerticalOptions="Center" />
|
||||
<Switch Grid.Column="1" IsToggled="{Binding MqttEnabled}" />
|
||||
<VerticalStackLayout Spacing="8" Margin="0,12,0,0">
|
||||
<Label Text="Diagnostics" FontAttributes="Bold" FontSize="16" />
|
||||
<Border Style="{StaticResource UlCard}"
|
||||
BackgroundColor="{DynamicResource UlColorSurfaceRaised}"
|
||||
Stroke="{DynamicResource UlColorBorder}" StrokeShape="RoundRectangle 12" Padding="12">
|
||||
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="12">
|
||||
<VerticalStackLayout Spacing="2" VerticalOptions="Center">
|
||||
<Label Text="Debug" FontAttributes="Bold" FontSize="16"
|
||||
TextColor="{DynamicResource UlColorText}" />
|
||||
<Label Text="Capture readiness, device state, sync, recent points, and console tools"
|
||||
FontSize="12" TextColor="{DynamicResource UlColorTextMuted}" />
|
||||
</VerticalStackLayout>
|
||||
<Button Grid.Column="1" Text="Open" Command="{Binding OpenDebugCommand}"
|
||||
Style="{StaticResource UlButtonSecondary}"
|
||||
MinimumHeightRequest="{StaticResource UlTouchMinimum}"
|
||||
SemanticProperties.Description="Open UM Trace diagnostics" />
|
||||
</Grid>
|
||||
<Entry Text="{Binding MqttHost}" Placeholder="dev.hub.umagul.net" />
|
||||
<Entry Text="{Binding MqttPort}" Placeholder="443 (WSS) or 8884 (MQTTS)" Keyboard="Numeric" />
|
||||
<Entry Text="{Binding MqttOrgId}" Placeholder="Organization ID / MQTT username" />
|
||||
<Entry Text="{Binding MqttPassword}" Placeholder="New MQTT password (stored securely)" IsPassword="True" />
|
||||
<Label Text="{Binding MqttClientId, StringFormat='Client: {0}'}" FontSize="10" TextColor="Gray" />
|
||||
<Label Text="{Binding SyncStatus}" FontSize="12" TextColor="DodgerBlue" />
|
||||
<Button Text="Save & Connect" Command="{Binding SaveSyncCommand}" />
|
||||
</VerticalStackLayout>
|
||||
</Border>
|
||||
</Border>
|
||||
</VerticalStackLayout>
|
||||
|
||||
<Label Text="{Binding AppVersion, StringFormat='Field Logger v{0}'}"
|
||||
FontSize="11" TextColor="Gray" HorizontalOptions="Center" Margin="0,20,0,0" />
|
||||
<Label Text="{Binding AppVersion, StringFormat='UM Trace v{0}'}"
|
||||
FontSize="11" TextColor="{DynamicResource UlColorTextMuted}"
|
||||
HorizontalOptions="Center" Margin="0,20,0,0" />
|
||||
|
||||
</VerticalStackLayout>
|
||||
</ScrollView>
|
||||
</ScrollView>
|
||||
|
||||
<controls:FloatingMenuButton Grid.RowSpan="2" HorizontalOptions="End" VerticalOptions="End" ZIndex="20" />
|
||||
</Grid>
|
||||
</ContentPage>
|
||||
|
||||
@@ -4,9 +4,10 @@ namespace FieldLogger.Views;
|
||||
|
||||
public partial class SettingsPage : ContentPage
|
||||
{
|
||||
public SettingsPage(SettingsViewModel viewModel)
|
||||
public SettingsPage(SettingsViewModel viewModel, AppStatusViewModel appStatusViewModel)
|
||||
{
|
||||
InitializeComponent();
|
||||
BindingContext = viewModel;
|
||||
AppStatus.BindingContext = appStatusViewModel;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Field Logger
|
||||
# UM Trace
|
||||
|
||||
Cross-platform .NET MAUI app (Windows, macOS, iOS, Android) for logging utility locate
|
||||
points from an **Underground Magnetics locating receiver** paired with RTK GPS positions
|
||||
|
||||
@@ -38,6 +38,16 @@ export DEVELOPER_DIR="${developer_dir}"
|
||||
# referenced headless library's assets file. Restore that library explicitly before building.
|
||||
"${dotnet_bin}" restore "${repo_dir}/src/FieldLogger.Sync/FieldLogger.Sync.csproj"
|
||||
|
||||
# A partial MSBuild target (for example, a compile-only IDE validation) can leave a
|
||||
# FieldLogger.dll whose generated XAML code is newer than its embedded MauiXaml resources.
|
||||
# Always clear the narrowed iOS output before the real device build so App.xaml, page XAML,
|
||||
# app icons, and splash assets are regenerated as one consistent build graph.
|
||||
"${dotnet_bin}" clean "${repo_dir}/FieldLogger/FieldLogger.csproj" \
|
||||
-f net9.0-ios \
|
||||
-c Debug \
|
||||
-p:TargetFrameworks=net9.0-ios \
|
||||
-p:RuntimeIdentifier=ios-arm64
|
||||
|
||||
"${dotnet_bin}" build "${repo_dir}/FieldLogger/FieldLogger.csproj" \
|
||||
-t:Run \
|
||||
-p:TargetFrameworks=net9.0-ios \
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<!--
|
||||
Cloud-sync core for Field Logger: the durable outbound queue, the Point wire mapping,
|
||||
Cloud-sync core for UM Trace: the durable outbound queue, the Point wire mapping,
|
||||
and the MQTTS publish/ack engine (SRS-SYN-2/5/7, §3.4.2, telemetry-schema.md).
|
||||
Deliberately a plain net9.0 library (NO MAUI head) so the queue-durability, ack-release,
|
||||
and exactly-once behaviour run headless in the QA gate without the Android/iOS workloads.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<!--
|
||||
QA smoke/regression harness for the Field Logger app (S1-f, trace: SRS §6 gate / NFR-8).
|
||||
QA smoke/regression harness for the UM Trace app (S1-f, trace: SRS §6 gate / NFR-8).
|
||||
|
||||
Targets plain net9.0 (NOT the MAUI platform TFMs) so `dotnet test` runs on any dev/CI
|
||||
machine without the MAUI workload, keeping the QA gate fast. It deliberately does NOT
|
||||
|
||||
@@ -101,6 +101,11 @@ public sealed class Sprint3UiContractTests
|
||||
var shell = File.ReadAllText(FromRoot("FieldLogger/AppShell.xaml"));
|
||||
Assert.DoesNotContain("Title=\"Debug\"", shell, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("views:DebugPage", shell, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("<TabBar", shell, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("Title=\"Home\"", shell, StringComparison.Ordinal);
|
||||
Assert.True(shell.IndexOf("Title=\"Jobs\"", StringComparison.Ordinal) <
|
||||
shell.IndexOf("Title=\"Map\"", StringComparison.Ordinal),
|
||||
"Jobs must be the first primary route so it is selected at startup.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -113,6 +118,77 @@ public sealed class Sprint3UiContractTests
|
||||
Assert.DoesNotContain("CaptureCommand", home, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Primary_pages_use_status_chrome_and_floating_menu()
|
||||
{
|
||||
foreach (var path in new[]
|
||||
{
|
||||
"FieldLogger/Views/JobsPage.xaml",
|
||||
"FieldLogger/Views/MapPage.xaml",
|
||||
"FieldLogger/Views/SettingsPage.xaml",
|
||||
})
|
||||
{
|
||||
var page = File.ReadAllText(FromRoot(path));
|
||||
Assert.Contains("controls:AppStatusBar", page, StringComparison.Ordinal);
|
||||
Assert.Contains("controls:FloatingMenuButton", page, StringComparison.Ordinal);
|
||||
Assert.Contains("Shell.NavBarIsVisible=\"False\"", page, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Settings_hides_manual_map_and_broker_configuration()
|
||||
{
|
||||
var settings = File.ReadAllText(FromRoot("FieldLogger/Views/SettingsPage.xaml"));
|
||||
Assert.DoesNotContain("Google Maps API Key", settings, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("Mqtt", settings, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("Durable cloud sync", settings, StringComparison.Ordinal);
|
||||
Assert.Contains("OpenDebugCommand", settings, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Map_has_no_heading_panel_and_fills_its_root_grid()
|
||||
{
|
||||
var map = File.ReadAllText(FromRoot("FieldLogger/Views/MapPage.xaml"));
|
||||
Assert.DoesNotContain("LIVE MAP", map, StringComparison.Ordinal);
|
||||
Assert.Contains("<ContentView x:Name=\"MapHost\"", map, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void User_facing_app_name_is_UM_Trace()
|
||||
{
|
||||
var shell = File.ReadAllText(FromRoot("FieldLogger/AppShell.xaml"));
|
||||
var settings = File.ReadAllText(FromRoot("FieldLogger/Views/SettingsPage.xaml"));
|
||||
Assert.Contains("Title=\"UM Trace\"", shell, StringComparison.Ordinal);
|
||||
Assert.Contains("UM Trace v", settings, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Jobs_uses_a_stable_full_height_refresh_layout()
|
||||
{
|
||||
var page = File.ReadAllText(FromRoot("FieldLogger/Views/JobsPage.xaml"));
|
||||
var viewModel = File.ReadAllText(FromRoot("FieldLogger/ViewModels/JobsViewModel.cs"));
|
||||
|
||||
Assert.Contains("IsRefreshing=\"{Binding IsRefreshing, Mode=OneWay}\"", page, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("IsRefreshing=\"{Binding IsBusy}\"", page, StringComparison.Ordinal);
|
||||
Assert.Contains("ItemsUpdatingScrollMode=\"KeepItemsInView\"", page, StringComparison.Ordinal);
|
||||
Assert.Contains("HorizontalOptions=\"Fill\" VerticalOptions=\"Fill\"", page, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("Padding=\"16,16,16,88\"", page, StringComparison.Ordinal);
|
||||
Assert.Contains("ApplySnapshot(items)", viewModel, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("Jobs.Clear()", viewModel, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Device_deploy_rebuilds_Xaml_and_brand_assets_from_a_clean_iOS_output()
|
||||
{
|
||||
var script = File.ReadAllText(FromRoot("scripts/deploy-ios-device.sh"));
|
||||
var cleanIndex = script.IndexOf("\"${dotnet_bin}\" clean", StringComparison.Ordinal);
|
||||
var runIndex = script.IndexOf("\"${dotnet_bin}\" build", StringComparison.Ordinal);
|
||||
|
||||
Assert.True(cleanIndex >= 0, "Device deployment must clean stale partial MAUI outputs.");
|
||||
Assert.True(runIndex > cleanIndex, "The clean must run before the device build.");
|
||||
Assert.Contains("-p:RuntimeIdentifier=ios-arm64", script, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static string FromRoot(string relativePath) => Path.Combine(FindRepositoryRoot(), relativePath);
|
||||
|
||||
private static string FindRepositoryRoot()
|
||||
|
||||
@@ -167,7 +167,7 @@ public class RoundTripTests
|
||||
int triggersSeen = 0;
|
||||
|
||||
// Stand-in App: consume telemetry, and on each trigger apply a simple accuracy gate
|
||||
// and write back a capture-result. This is what Field Logger's Locator Driver does.
|
||||
// and write back a capture-result. This is what UM Trace's Locator Driver does.
|
||||
link.ToApp += frame =>
|
||||
{
|
||||
switch (Frames.PeekType(frame))
|
||||
|
||||
Reference in New Issue
Block a user