212 lines
7.0 KiB
C#
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);
|
|
}
|
|
}
|