Initial commit: FieldLogger MAUI app with Maglink BLE support
Added Maglink RTK GNSS receiver integration with correct BLE UUIDs and device name filtering (ML-* prefix). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
107
FieldLogger/ViewModels/DeviceScanViewModel.cs
Normal file
107
FieldLogger/ViewModels/DeviceScanViewModel.cs
Normal file
@@ -0,0 +1,107 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using FieldLogger.Models;
|
||||
using FieldLogger.Services;
|
||||
using FieldLogger.Services.Ble;
|
||||
|
||||
namespace FieldLogger.ViewModels;
|
||||
|
||||
[QueryProperty(nameof(KindName), "kind")]
|
||||
public sealed partial class DeviceScanViewModel : ObservableObject
|
||||
{
|
||||
private readonly BleScanner _scanner;
|
||||
private readonly DeviceConnectionManager _manager;
|
||||
private CancellationTokenSource? _scanCts;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _kindName = nameof(DeviceKind.Locator);
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(ScanButtonText))]
|
||||
private bool _isScanning;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _title = "Select Device";
|
||||
|
||||
[ObservableProperty]
|
||||
private string _hint = "";
|
||||
|
||||
public ObservableCollection<DiscoveredDevice> Devices { get; } = new();
|
||||
|
||||
public DeviceKind Kind => Enum.TryParse<DeviceKind>(KindName, out var k) ? k : DeviceKind.Locator;
|
||||
|
||||
public string ScanButtonText => IsScanning ? "Scanning…" : "Scan Again";
|
||||
|
||||
public DeviceScanViewModel(BleScanner scanner, DeviceConnectionManager manager)
|
||||
{
|
||||
_scanner = scanner;
|
||||
_manager = manager;
|
||||
_scanner.DeviceDiscovered += OnDeviceDiscovered;
|
||||
}
|
||||
|
||||
partial void OnKindNameChanged(string value)
|
||||
{
|
||||
Title = Kind == DeviceKind.Locator ? "Select Locating Receiver" : "Select RTK GPS Receiver";
|
||||
Hint = Kind == DeviceKind.Locator
|
||||
? "Looking for UM receivers (UMRX / DT100). Make sure Bluetooth is enabled on the receiver."
|
||||
: "Looking for Maglink RTK receivers. Make sure the receiver is powered on.";
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
public async Task ScanAsync()
|
||||
{
|
||||
if (IsScanning)
|
||||
return;
|
||||
|
||||
Devices.Clear();
|
||||
IsScanning = true;
|
||||
_scanCts = new CancellationTokenSource();
|
||||
try
|
||||
{
|
||||
await _scanner.ScanAsync(TimeSpan.FromSeconds(12), _scanCts.Token);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await Shell.Current.DisplayAlert("Scan Failed", ex.Message, "OK");
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsScanning = false;
|
||||
_scanCts?.Dispose();
|
||||
_scanCts = null;
|
||||
}
|
||||
}
|
||||
|
||||
public void StopScan() => _scanCts?.Cancel();
|
||||
|
||||
[RelayCommand]
|
||||
private async Task SelectAsync(DiscoveredDevice device)
|
||||
{
|
||||
StopScan();
|
||||
await _manager.UseDeviceAsync(Kind, device.Id, device.Name);
|
||||
await Shell.Current.GoToAsync("..");
|
||||
}
|
||||
|
||||
private void OnDeviceDiscovered(object? sender, DiscoveredDevice device)
|
||||
{
|
||||
// Filter by device name prefix based on device type.
|
||||
if (Kind == DeviceKind.Locator && !UmReceiverService.IsUmReceiverName(device.Name))
|
||||
return;
|
||||
if (Kind == DeviceKind.Maglink && !MaglinkService.IsMaglinkName(device.Name))
|
||||
return;
|
||||
|
||||
MainThread.BeginInvokeOnMainThread(() =>
|
||||
{
|
||||
var existing = Devices.FirstOrDefault(d => d.Id == device.Id);
|
||||
if (existing is not null)
|
||||
Devices.Remove(existing);
|
||||
|
||||
// Keep the list sorted by signal strength.
|
||||
var index = 0;
|
||||
while (index < Devices.Count && Devices[index].Rssi >= device.Rssi)
|
||||
index++;
|
||||
Devices.Insert(index, device);
|
||||
});
|
||||
}
|
||||
}
|
||||
149
FieldLogger/ViewModels/HomeViewModel.cs
Normal file
149
FieldLogger/ViewModels/HomeViewModel.cs
Normal file
@@ -0,0 +1,149 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using FieldLogger.Models;
|
||||
using FieldLogger.Services;
|
||||
using FieldLogger.Services.Data;
|
||||
|
||||
namespace FieldLogger.ViewModels;
|
||||
|
||||
public sealed partial class HomeViewModel : ObservableObject
|
||||
{
|
||||
private readonly DeviceConnectionManager _manager;
|
||||
private readonly MaglinkService _gps;
|
||||
private readonly PointLogger _pointLogger;
|
||||
private readonly AppDatabase _database;
|
||||
private readonly SettingsService _settings;
|
||||
|
||||
public DeviceConnectionManager Manager => _manager;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _activeJobName = "No active job";
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _hasActiveJob;
|
||||
|
||||
[ObservableProperty]
|
||||
private int _pointCount;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _fixSummary = "Waiting for GPS…";
|
||||
|
||||
[ObservableProperty]
|
||||
private string _fixStatusLabel = "—";
|
||||
|
||||
[ObservableProperty]
|
||||
private string _fixAccuracy = "";
|
||||
|
||||
public ObservableCollection<LoggedPoint> RecentPoints { get; } = new();
|
||||
|
||||
public HomeViewModel(DeviceConnectionManager manager, MaglinkService gps,
|
||||
PointLogger pointLogger, AppDatabase database, SettingsService settings)
|
||||
{
|
||||
_manager = manager;
|
||||
_gps = gps;
|
||||
_pointLogger = pointLogger;
|
||||
_database = database;
|
||||
_settings = settings;
|
||||
|
||||
_gps.FixReceived += OnFixReceived;
|
||||
_pointLogger.PointSaved += OnPointSaved;
|
||||
_pointLogger.PacketIgnoredNoJob += OnPacketIgnoredNoJob;
|
||||
}
|
||||
|
||||
/// <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();
|
||||
}
|
||||
|
||||
public async Task RefreshJobAsync()
|
||||
{
|
||||
var jobId = _settings.ActiveJobId;
|
||||
if (jobId is null)
|
||||
{
|
||||
HasActiveJob = false;
|
||||
ActiveJobName = "No active job";
|
||||
PointCount = 0;
|
||||
RecentPoints.Clear();
|
||||
return;
|
||||
}
|
||||
|
||||
var job = await _database.GetJobAsync(jobId.Value);
|
||||
if (job is null)
|
||||
{
|
||||
_settings.ActiveJobId = null;
|
||||
await RefreshJobAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
HasActiveJob = true;
|
||||
ActiveJobName = job.Name;
|
||||
PointCount = await _database.GetPointCountAsync(job.Id);
|
||||
|
||||
var points = await _database.GetPointsAsync(job.Id);
|
||||
RecentPoints.Clear();
|
||||
foreach (var p in points.OrderByDescending(p => p.TimestampUtc).Take(10))
|
||||
RecentPoints.Add(p);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task NewJobAsync()
|
||||
{
|
||||
var name = await Shell.Current.DisplayPromptAsync("New Job", "Job name:",
|
||||
placeholder: $"Job {DateTime.Now:yyyy-MM-dd}");
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
return;
|
||||
|
||||
var job = await _database.CreateJobAsync(name.Trim());
|
||||
_settings.ActiveJobId = job.Id;
|
||||
await RefreshJobAsync();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private Task SelectJobAsync() => Shell.Current.GoToAsync("//jobs");
|
||||
|
||||
[RelayCommand]
|
||||
private Task SelectLocatorAsync() => Shell.Current.GoToAsync($"devicescan?kind={DeviceKind.Locator}");
|
||||
|
||||
[RelayCommand]
|
||||
private Task SelectGpsAsync() => Shell.Current.GoToAsync($"devicescan?kind={DeviceKind.RtkGps}");
|
||||
|
||||
[RelayCommand]
|
||||
private Task ViewMapAsync() => Shell.Current.GoToAsync("//map");
|
||||
|
||||
private void OnFixReceived(object? sender, GnssFix fix)
|
||||
{
|
||||
MainThread.BeginInvokeOnMainThread(() =>
|
||||
{
|
||||
FixStatusLabel = fix.StatusLabel;
|
||||
FixSummary = fix.HasPosition
|
||||
? $"{fix.Latitude:F8}, {fix.Longitude:F8} · {fix.AltitudeCorrected:F2} m"
|
||||
: "No position";
|
||||
FixAccuracy = $"H ±{fix.Hrms:F3} m · V ±{fix.Vrms:F3} m · {fix.SatellitesUsed} sats · batt {fix.BatteryPercent}%";
|
||||
});
|
||||
}
|
||||
|
||||
private void OnPointSaved(object? sender, LoggedPoint point)
|
||||
{
|
||||
RecentPoints.Insert(0, point);
|
||||
while (RecentPoints.Count > 10)
|
||||
RecentPoints.RemoveAt(RecentPoints.Count - 1);
|
||||
PointCount++;
|
||||
}
|
||||
|
||||
private async void OnPacketIgnoredNoJob(object? sender, EventArgs e)
|
||||
{
|
||||
await Shell.Current.DisplayAlert("No Active Job",
|
||||
"A point was logged on the receiver, but no job is active so it was not saved. Create or select a job first.",
|
||||
"OK");
|
||||
}
|
||||
}
|
||||
59
FieldLogger/ViewModels/JobDetailViewModel.cs
Normal file
59
FieldLogger/ViewModels/JobDetailViewModel.cs
Normal file
@@ -0,0 +1,59 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using FieldLogger.Models;
|
||||
using FieldLogger.Services.Data;
|
||||
|
||||
namespace FieldLogger.ViewModels;
|
||||
|
||||
[QueryProperty(nameof(JobId), "jobId")]
|
||||
public sealed partial class JobDetailViewModel : ObservableObject
|
||||
{
|
||||
private readonly AppDatabase _database;
|
||||
|
||||
[ObservableProperty]
|
||||
private int _jobId;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _jobName = "";
|
||||
|
||||
[ObservableProperty]
|
||||
private int _pointCount;
|
||||
|
||||
public ObservableCollection<LoggedPoint> Points { get; } = new();
|
||||
|
||||
public JobDetailViewModel(AppDatabase database)
|
||||
{
|
||||
_database = database;
|
||||
}
|
||||
|
||||
public async Task RefreshAsync()
|
||||
{
|
||||
var job = await _database.GetJobAsync(JobId);
|
||||
if (job is null)
|
||||
return;
|
||||
|
||||
JobName = job.Name;
|
||||
var points = await _database.GetPointsAsync(JobId);
|
||||
PointCount = points.Count;
|
||||
Points.Clear();
|
||||
foreach (var p in points)
|
||||
Points.Add(p);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private Task ViewMapAsync() => Shell.Current.GoToAsync($"//map?jobId={JobId}");
|
||||
|
||||
[RelayCommand]
|
||||
private async Task DeletePointAsync(LoggedPoint point)
|
||||
{
|
||||
var confirmed = await Shell.Current.DisplayAlert("Delete Point",
|
||||
$"Delete point #{point.Id} logged {point.TimestampUtc.ToLocalTime():g}?", "Delete", "Cancel");
|
||||
if (!confirmed)
|
||||
return;
|
||||
|
||||
await _database.DeletePointAsync(point.Id);
|
||||
Points.Remove(point);
|
||||
PointCount = Points.Count;
|
||||
}
|
||||
}
|
||||
104
FieldLogger/ViewModels/JobsViewModel.cs
Normal file
104
FieldLogger/ViewModels/JobsViewModel.cs
Normal file
@@ -0,0 +1,104 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using FieldLogger.Models;
|
||||
using FieldLogger.Services;
|
||||
using FieldLogger.Services.Data;
|
||||
|
||||
namespace FieldLogger.ViewModels;
|
||||
|
||||
public sealed partial class JobListItem : ObservableObject
|
||||
{
|
||||
public required Job Job { get; init; }
|
||||
|
||||
[ObservableProperty]
|
||||
private int _pointCount;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _isActive;
|
||||
|
||||
public string Name => Job.Name;
|
||||
public string CreatedLabel => Job.CreatedUtc.ToLocalTime().ToString("g");
|
||||
}
|
||||
|
||||
public sealed partial class JobsViewModel : ObservableObject
|
||||
{
|
||||
private readonly AppDatabase _database;
|
||||
private readonly SettingsService _settings;
|
||||
|
||||
public ObservableCollection<JobListItem> Jobs { get; } = new();
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _isBusy;
|
||||
|
||||
public JobsViewModel(AppDatabase database, SettingsService settings)
|
||||
{
|
||||
_database = database;
|
||||
_settings = settings;
|
||||
}
|
||||
|
||||
public async Task RefreshAsync()
|
||||
{
|
||||
IsBusy = true;
|
||||
try
|
||||
{
|
||||
var jobs = await _database.GetJobsAsync();
|
||||
var activeId = _settings.ActiveJobId;
|
||||
|
||||
Jobs.Clear();
|
||||
foreach (var job in jobs)
|
||||
{
|
||||
Jobs.Add(new JobListItem
|
||||
{
|
||||
Job = job,
|
||||
PointCount = await _database.GetPointCountAsync(job.Id),
|
||||
IsActive = job.Id == activeId,
|
||||
});
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task NewJobAsync()
|
||||
{
|
||||
var name = await Shell.Current.DisplayPromptAsync("New Job", "Job name:",
|
||||
placeholder: $"Job {DateTime.Now:yyyy-MM-dd}");
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
return;
|
||||
|
||||
var job = await _database.CreateJobAsync(name.Trim());
|
||||
_settings.ActiveJobId = job.Id;
|
||||
await RefreshAsync();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task SetActiveAsync(JobListItem item)
|
||||
{
|
||||
_settings.ActiveJobId = item.Job.Id;
|
||||
foreach (var j in Jobs)
|
||||
j.IsActive = j.Job.Id == item.Job.Id;
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private Task OpenAsync(JobListItem item)
|
||||
=> Shell.Current.GoToAsync($"jobdetail?jobId={item.Job.Id}");
|
||||
|
||||
[RelayCommand]
|
||||
private async Task DeleteAsync(JobListItem item)
|
||||
{
|
||||
var confirmed = await Shell.Current.DisplayAlert("Delete Job",
|
||||
$"Delete \"{item.Job.Name}\" and its {item.PointCount} logged points?", "Delete", "Cancel");
|
||||
if (!confirmed)
|
||||
return;
|
||||
|
||||
await _database.DeleteJobAsync(item.Job.Id);
|
||||
if (_settings.ActiveJobId == item.Job.Id)
|
||||
_settings.ActiveJobId = null;
|
||||
await RefreshAsync();
|
||||
}
|
||||
}
|
||||
53
FieldLogger/ViewModels/MapViewModel.cs
Normal file
53
FieldLogger/ViewModels/MapViewModel.cs
Normal file
@@ -0,0 +1,53 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using FieldLogger.Models;
|
||||
using FieldLogger.Services;
|
||||
using FieldLogger.Services.Data;
|
||||
|
||||
namespace FieldLogger.ViewModels;
|
||||
|
||||
[QueryProperty(nameof(JobId), "jobId")]
|
||||
public sealed partial class MapViewModel : ObservableObject
|
||||
{
|
||||
private readonly AppDatabase _database;
|
||||
private readonly SettingsService _settings;
|
||||
|
||||
/// <summary>Job to display; 0 means "use the active job".</summary>
|
||||
[ObservableProperty]
|
||||
private int _jobId;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _jobName = "";
|
||||
|
||||
[ObservableProperty]
|
||||
private string _statusText = "";
|
||||
|
||||
public SettingsService Settings => _settings;
|
||||
|
||||
public MapViewModel(AppDatabase database, SettingsService settings)
|
||||
{
|
||||
_database = database;
|
||||
_settings = settings;
|
||||
}
|
||||
|
||||
/// <summary>Loads the points to plot (only points with a valid GNSS position).</summary>
|
||||
public async Task<List<LoggedPoint>> LoadPointsAsync()
|
||||
{
|
||||
var jobId = JobId > 0 ? JobId : _settings.ActiveJobId;
|
||||
if (jobId is null)
|
||||
{
|
||||
JobName = "";
|
||||
StatusText = "No job selected. Create or activate a job to see its points.";
|
||||
return new List<LoggedPoint>();
|
||||
}
|
||||
|
||||
var job = await _database.GetJobAsync(jobId.Value);
|
||||
JobName = job?.Name ?? "";
|
||||
|
||||
var all = await _database.GetPointsAsync(jobId.Value);
|
||||
var plottable = all.Where(p => p.GpsValid).ToList();
|
||||
StatusText = plottable.Count == all.Count
|
||||
? $"{JobName}: {all.Count} points"
|
||||
: $"{JobName}: {plottable.Count} of {all.Count} points have GPS positions";
|
||||
return plottable;
|
||||
}
|
||||
}
|
||||
52
FieldLogger/ViewModels/SettingsViewModel.cs
Normal file
52
FieldLogger/ViewModels/SettingsViewModel.cs
Normal file
@@ -0,0 +1,52 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using FieldLogger.Models;
|
||||
using FieldLogger.Services;
|
||||
|
||||
namespace FieldLogger.ViewModels;
|
||||
|
||||
public sealed partial class SettingsViewModel : ObservableObject
|
||||
{
|
||||
private readonly DeviceConnectionManager _manager;
|
||||
private readonly SettingsService _settings;
|
||||
|
||||
public DeviceConnectionManager Manager => _manager;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _mapsApiKey = "";
|
||||
|
||||
public string AppVersion => AppInfo.Current.VersionString;
|
||||
|
||||
public SettingsViewModel(DeviceConnectionManager manager, SettingsService settings)
|
||||
{
|
||||
_manager = manager;
|
||||
_settings = settings;
|
||||
MapsApiKey = settings.GoogleMapsApiKey;
|
||||
}
|
||||
|
||||
partial void OnMapsApiKeyChanged(string value) => _settings.GoogleMapsApiKey = value.Trim();
|
||||
|
||||
[RelayCommand]
|
||||
private Task ChangeLocatorAsync() => Shell.Current.GoToAsync($"devicescan?kind={DeviceKind.Locator}");
|
||||
|
||||
[RelayCommand]
|
||||
private Task ChangeGpsAsync() => Shell.Current.GoToAsync($"devicescan?kind={DeviceKind.RtkGps}");
|
||||
|
||||
[RelayCommand]
|
||||
private async Task ForgetLocatorAsync()
|
||||
{
|
||||
if (await ConfirmForgetAsync(_manager.LocatorName ?? "locating receiver"))
|
||||
await _manager.ForgetDeviceAsync(DeviceKind.Locator);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task ForgetGpsAsync()
|
||||
{
|
||||
if (await ConfirmForgetAsync(_manager.GpsName ?? "RTK GPS receiver"))
|
||||
await _manager.ForgetDeviceAsync(DeviceKind.RtkGps);
|
||||
}
|
||||
|
||||
private static Task<bool> ConfirmForgetAsync(string name) =>
|
||||
Shell.Current.DisplayAlert("Forget Device",
|
||||
$"Forget \"{name}\"? The app will no longer auto-connect to it.", "Forget", "Cancel");
|
||||
}
|
||||
Reference in New Issue
Block a user