Utopia-Canteen-System/ViewModels/ScannerViewModel.cs

275 lines
8.3 KiB
C#

using DebounceTimer = System.Timers.Timer;
using System.Windows;
using System.Windows.Threading;
using System.Windows.Input;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using UtopiaCanteenSystem.Services;
namespace UtopiaCanteenSystem.ViewModels;
/// <summary>
/// ViewModel for ScannerView: RFID input, scan command, scanner status, current scan time.
/// Supports both Enter-to-submit and debounce-to-submit for keyboard-wedge scanners.
/// </summary>
public partial class ScannerViewModel : ObservableObject
{
private const int DebounceMs = 200;
private const int CooldownTickSeconds = 1;
private static readonly TimeSpan ScannerInactivityTimeout = TimeSpan.FromSeconds(10);
private readonly IRfidService _rfidService;
private readonly INavigationService _navigation;
private readonly AppSession _session;
private readonly Dispatcher _uiDispatcher;
private readonly DebounceTimer _debounceTimer;
private readonly DispatcherTimer _cooldownTimer;
private DateTime? _cooldownEndsUtc;
private int _lastDisplayedCooldownSeconds = -1;
private readonly DispatcherTimer _scannerStatusTimer;
private DateTime? _lastScanActivityUtc;
private readonly object _submitLock = new();
private bool _isSubmitting;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsWatermarkVisible))]
private string _cardIdInput = string.Empty;
/// <summary>True when CardIdInput is empty; used to show/hide the "Scan Card ID" watermark.</summary>
public bool IsWatermarkVisible => string.IsNullOrWhiteSpace(CardIdInput);
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ScannerStatusDisplay))]
private string _scannerStatus = "Disconnected";
public string ScannerStatusDisplay =>
string.Equals(ScannerStatus, "Connected", StringComparison.Ordinal) ? "Connected" : "waiting for scan...";
[ObservableProperty]
private string _currentScanTime = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss");
[ObservableProperty]
private string _message = string.Empty;
[ObservableProperty]
private bool _isSuccess;
public ScannerViewModel(IRfidService rfidService, INavigationService navigation, AppSession session)
{
_rfidService = rfidService;
_navigation = navigation;
_session = session;
_uiDispatcher = Application.Current?.Dispatcher ?? Dispatcher.CurrentDispatcher;
_debounceTimer = new DebounceTimer(DebounceMs)
{
AutoReset = false
};
_debounceTimer.Elapsed += (_, _) =>
{
// Timer runs on a background thread; marshal back to UI thread.
_uiDispatcher.BeginInvoke(() =>
{
lock (_submitLock)
{
if (_isSubmitting)
return;
}
// If something already cleared the input (e.g., Enter submit), do nothing.
if (string.IsNullOrWhiteSpace(CardIdInput))
return;
if (ScanCommand.CanExecute(null))
ScanCommand.Execute(null);
}, DispatcherPriority.Background);
};
_cooldownTimer = new DispatcherTimer
{
Interval = TimeSpan.FromSeconds(CooldownTickSeconds)
};
_cooldownTimer.Tick += (_, _) => UpdateCooldownMessage();
_scannerStatusTimer = new DispatcherTimer
{
Interval = TimeSpan.FromSeconds(1)
};
_scannerStatusTimer.Tick += (_, _) => UpdateScannerStatusFromActivity();
_scannerStatusTimer.Start();
RefreshScannerStatus();
RefreshTime();
}
partial void OnCardIdInputChanged(string value)
{
// Clear any previous message once a new scan starts.
if (!string.IsNullOrWhiteSpace(value) && !string.IsNullOrEmpty(Message))
Message = string.Empty;
// If the user starts scanning again, don't keep overwriting their feedback with an old countdown.
StopCooldownCountdown();
// Keyboard-wedge scanners "type" into the TextBox. Any incoming characters means activity/connected.
if (!string.IsNullOrWhiteSpace(value))
{
_lastScanActivityUtc = DateTime.UtcNow;
ScannerStatus = "Connected";
}
RestartDebounceTimer();
}
private void RestartDebounceTimer()
{
_debounceTimer.Stop();
// Only auto-submit when we have a non-empty value.
if (string.IsNullOrWhiteSpace(CardIdInput))
return;
_debounceTimer.Start();
}
public void RefreshScannerStatus()
{
// Default: waiting for activity.
ScannerStatus = "Disconnected";
}
public void RefreshTime()
{
CurrentScanTime = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss");
}
[RelayCommand]
private void Logout()
{
_session.Logout();
_navigation.NavigateToAdminLogin();
}
/// <summary>Processes the current CardIdInput as a scan and navigates to dashboard on success.</summary>
[RelayCommand]
private void Scan()
{
lock (_submitLock)
{
if (_isSubmitting)
return;
_isSubmitting = true;
}
try
{
// Ensure any pending debounce doesn't fire after an explicit submit.
_debounceTimer.Stop();
StopCooldownCountdown();
Message = string.Empty;
IsSuccess = false;
// Keep CardId as string (preserves leading zeros).
var cardId = CardIdInput?.Trim() ?? string.Empty;
// Avoid showing "empty" errors if something triggered submit after we already cleared input.
if (string.IsNullOrWhiteSpace(cardId))
return;
var result = _rfidService.ProcessScanDetailed(cardId);
IsSuccess = result.Success;
Message = result.Message;
// Always clear input after attempt so the next scan starts cleanly.
CardIdInput = string.Empty;
if (!result.Success && result.CooldownSecondsRemaining > 0)
{
StartCooldownCountdown(result.CooldownSecondsRemaining);
return;
}
if (result.Success)
{
_navigation.StartDashboardSession();
_navigation.NavigateToDashboard();
}
}
finally
{
lock (_submitLock)
{
_isSubmitting = false;
}
}
}
private void StartCooldownCountdown(int seconds)
{
if (seconds <= 0)
return;
_cooldownEndsUtc = DateTime.UtcNow.AddSeconds(seconds);
_lastDisplayedCooldownSeconds = -1;
UpdateCooldownMessage(); // immediate update
_cooldownTimer.Start();
}
private void StopCooldownCountdown()
{
_cooldownTimer.Stop();
_cooldownEndsUtc = null;
_lastDisplayedCooldownSeconds = -1;
}
private void UpdateCooldownMessage()
{
if (_cooldownEndsUtc is null)
return;
var remaining = (int)Math.Ceiling((_cooldownEndsUtc.Value - DateTime.UtcNow).TotalSeconds);
if (remaining <= 0)
{
StopCooldownCountdown();
Message = "You can scan now.";
IsSuccess = true;
return;
}
// Avoid redundant PropertyChanged churn.
if (remaining == _lastDisplayedCooldownSeconds)
return;
_lastDisplayedCooldownSeconds = remaining;
var unit = remaining == 1 ? "second" : "seconds";
Message = $"Please wait {remaining} {unit}…";
IsSuccess = false;
}
private void UpdateScannerStatusFromActivity()
{
if (_lastScanActivityUtc is null)
{
if (!string.Equals(ScannerStatus, "Disconnected", StringComparison.Ordinal))
ScannerStatus = "Disconnected";
return;
}
var inactiveFor = DateTime.UtcNow - _lastScanActivityUtc.Value;
if (inactiveFor > ScannerInactivityTimeout)
{
if (!string.Equals(ScannerStatus, "Disconnected", StringComparison.Ordinal))
ScannerStatus = "Disconnected";
}
}
~ScannerViewModel()
{
_scannerStatusTimer.Stop();
_cooldownTimer.Stop();
_debounceTimer.Stop();
}
}