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] [NotifyPropertyChangedFor(nameof(JobSummary))] [NotifyPropertyChangedFor(nameof(DeleteDescription))] [NotifyPropertyChangedFor(nameof(AccessibilityDescription))] private int _pointCount; [ObservableProperty] [NotifyPropertyChangedFor(nameof(ActiveStatusLabel))] [NotifyPropertyChangedFor(nameof(ActiveActionLabel))] [NotifyPropertyChangedFor(nameof(ActiveActionDescription))] [NotifyPropertyChangedFor(nameof(AccessibilityDescription))] private bool _isActive; public string Name => Job.Name; public string CreatedLabel => Job.CreatedUtc.ToLocalTime().ToString("g"); public string ActiveStatusLabel => IsActive ? "ACTIVE JOB" : "NOT ACTIVE"; public string ActiveActionLabel => IsActive ? "Active" : "Set active"; public string JobSummary => $"Created {CreatedLabel} ยท {PointCount} {(PointCount == 1 ? "point" : "points")}"; public string ActiveActionDescription => IsActive ? $"{Name} is already the active job" : $"Make {Name} the active capture job"; public string DeleteDescription => $"Delete {Name} and its {PointCount} saved points"; public string AccessibilityDescription => $"{Name}. {ActiveStatusLabel}. {JobSummary}."; } 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] private Task RefreshAsync() => ReloadAsync(showRefreshIndicator: true, skipIfBusy: false); private async Task ReloadAsync(bool showRefreshIndicator, bool skipIfBusy) { 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; // 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) { 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); } } [RelayCommand] private async Task NewJobAsync() { try { Console.WriteLine("NewJobAsync: Starting..."); var name = await Shell.Current.DisplayPromptAsync("New Job", "Job name:", placeholder: $"Job {DateTime.Now:yyyy-MM-dd}"); Console.WriteLine($"NewJobAsync: User entered: '{name}'"); if (string.IsNullOrWhiteSpace(name)) { Console.WriteLine("NewJobAsync: Name was empty, cancelling"); return; } Console.WriteLine($"NewJobAsync: Creating job '{name.Trim()}'"); var job = await _database.CreateJobAsync(name.Trim()); Console.WriteLine($"NewJobAsync: Job created with ID {job.Id}"); _settings.ActiveJobId = job.Id; Console.WriteLine($"NewJobAsync: Set active job to {job.Id}"); await ReloadAsync(showRefreshIndicator: false, skipIfBusy: false); Console.WriteLine("NewJobAsync: Refresh complete"); } catch (Exception ex) { Console.WriteLine($"NewJobAsync: ERROR - {ex.Message}"); Console.WriteLine($"NewJobAsync: Stack trace - {ex.StackTrace}"); } } [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 ReloadAsync(showRefreshIndicator: false, skipIfBusy: false); } }