using System.Collections.ObjectModel; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using FieldLogger.Models; using FieldLogger.Services; using FieldLogger.Services.Data; using FieldLogger.Services.Sync; 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; private readonly IMqttSyncService _sync; 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 = ""; [ObservableProperty] private string _syncStatus = ""; 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"; public string CaptureReadinessText => HasActiveJob ? "READY · Press LOG on the locating receiver" : "BLOCKED · Choose or create an active job first"; public ObservableCollection RecentPoints { get; } = new(); public HomeViewModel(DeviceConnectionManager manager, MaglinkService gps, PointLogger pointLogger, AppDatabase database, SettingsService settings, IMqttSyncService sync) { _manager = manager; _gps = gps; _pointLogger = pointLogger; _database = database; _settings = settings; _sync = sync; SyncStatus = sync.Status; _gps.FixReceived += OnFixReceived; _pointLogger.PointSaved += OnPointSaved; _pointLogger.PacketIgnoredNoJob += OnPacketIgnoredNoJob; _sync.StatusChanged += OnSyncStatusChanged; _sync.PointRejected += OnPointRejected; } /// Called from the page's OnAppearing. public async Task InitializeAsync() { _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"); [RelayCommand] private Task OpenDeviceConsoleAsync() => Shell.Current.GoToAsync("deviceconsole"); 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++; } partial void OnHasActiveJobChanged(bool value) { OnPropertyChanged(nameof(HasNoActiveJob)); OnPropertyChanged(nameof(CaptureReadinessText)); } partial void OnPointCountChanged(int value) => OnPropertyChanged(nameof(ActiveJobPointSummary)); 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"); } private void OnSyncStatusChanged(object? sender, string status) => MainThread.BeginInvokeOnMainThread(() => SyncStatus = status); private void OnPointRejected(object? sender, PointSyncFailureEventArgs e) => MainThread.BeginInvokeOnMainThread(async () => await Shell.Current.DisplayAlert( "Point Saved Locally — Sync Needs Attention", $"Point #{e.Point.Id} remains on this device and was not uploaded. Reason: {e.ReasonCode}.", "OK")); }