Files
ulapp/FieldLogger/ViewModels/JobsViewModel.cs
2026-08-23 20:54:03 -05:00

212 lines
7.0 KiB
C#

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<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]
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<JobListItem>(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<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);
}
}
[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);
}
}