using DebounceTimer = System.Timers.Timer;
using System.Windows;
using System.Windows.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using UtopiaCanteenSystem.Models;
using UtopiaCanteenSystem.Services;
namespace UtopiaCanteenSystem.ViewModels;
///
/// Single-screen ViewModel that combines continuous scanning + dashboard info.
///
public partial class ScannerDashboardViewModel : 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 IConfigService _configService;
private readonly Dispatcher _uiDispatcher;
private readonly DebounceTimer _debounceTimer;
private readonly DispatcherTimer _cooldownTimer;
private DateTime? _cooldownEndsUtc;
private int _lastDisplayedCooldownSeconds = -1;
private bool _isCooldownActive;
private string _cooldownBlockedCardId = string.Empty;
private bool _suppressCooldownRewrite;
private readonly DispatcherTimer _scannerStatusTimer;
private DateTime? _lastScanActivityUtc;
private readonly DispatcherTimer _clockTimer;
private readonly object _submitLock = new();
private bool _isSubmitting;
// --- Scanner input ---
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsWatermarkVisible))]
private string _cardIdInput = string.Empty;
[ObservableProperty]
private bool _isProcessing;
[ObservableProperty]
private bool _isCooldownActiveUi;
public bool IsWatermarkVisible => string.IsNullOrWhiteSpace(CardIdInput);
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ScannerStatusDisplay))]
private string _scannerStatus = "Disconnected";
/// Shows scanner device connectivity: connected = ready; disconnected = check device.
public string ScannerStatusDisplay =>
string.Equals(ScannerStatus, "Connected", StringComparison.Ordinal) ? "Scanner connected" : "Scanner not connected";
[ObservableProperty]
private string _currentTime = DateTime.Now.ToString("MM/dd/yyyy, hh:mm:ss tt");
// --- Dashboard fields ---
[ObservableProperty]
private int _todaysScans;
[ObservableProperty]
private int _totalOrders;
[ObservableProperty]
private string _lastCardId = "—";
[ObservableProperty]
private string _lastScanTimeDisplay = "—";
/// Prominent alert when customer scanned during cooldown: ask them to rescan after countdown.
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShowCooldownAlert))]
private string _cooldownAlertMessage = string.Empty;
public bool ShowCooldownAlert => !string.IsNullOrWhiteSpace(CooldownAlertMessage);
// Message area (green/red)
[ObservableProperty]
private string _message = string.Empty;
[ObservableProperty]
private bool _isSuccess;
public bool IsAdminAuthenticated => _session.IsAdminAuthenticated;
// --- Menu + site selection ---
[ObservableProperty]
private bool _isMenuOpen;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(CurrentSiteDisplay))]
private string _siteNumber = "1";
/// Display string for current site (e.g. "SITE : 1").
public string CurrentSiteDisplay => string.IsNullOrWhiteSpace(SiteNumber) ? "SITE : 1" : $"SITE : {SiteNumber.Trim()}";
public ScannerDashboardViewModel(IRfidService rfidService, INavigationService navigation, AppSession session, IConfigService configService)
{
_rfidService = rfidService;
_navigation = navigation;
_session = session;
_configService = configService;
_uiDispatcher = Application.Current?.Dispatcher ?? Dispatcher.CurrentDispatcher;
// Debounce auto-submit (keyboard wedge scanners).
_debounceTimer = new DebounceTimer(DebounceMs) { AutoReset = false };
_debounceTimer.Elapsed += (_, _) =>
{
_uiDispatcher.BeginInvoke(() =>
{
lock (_submitLock)
{
if (_isSubmitting || IsProcessing || _isCooldownActive)
return;
}
if (string.IsNullOrWhiteSpace(CardIdInput))
return;
if (ScanCommand.CanExecute(null))
ScanCommand.Execute(null);
}, DispatcherPriority.Background);
};
// Cooldown countdown updates.
_cooldownTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(CooldownTickSeconds) };
_cooldownTimer.Tick += (_, _) => UpdateCooldownMessage();
// Activity-based scanner status.
_scannerStatusTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
_scannerStatusTimer.Tick += (_, _) => UpdateScannerStatusFromActivity();
_scannerStatusTimer.Start();
// Current time display.
_clockTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
_clockTimer.Tick += (_, _) => CurrentTime = DateTime.Now.ToString("MM/dd/yyyy, hh:mm:ss tt");
_clockTimer.Start();
// Load initial site from config (e.g. "SITE : 1" -> "1").
var siteId = _configService.GetSiteId();
if (!string.IsNullOrWhiteSpace(siteId) && siteId.StartsWith("SITE : ", StringComparison.OrdinalIgnoreCase))
{
var num = siteId.Substring("SITE : ".Length).Trim();
if (num.Length > 0 && num.All(char.IsDigit))
SiteNumber = num;
}
RefreshScannerStatus();
_ = RefreshDashboardAsync();
}
partial void OnSiteNumberChanged(string value)
{
// Numeric only: filter to digits so display stays valid.
var digits = value == null ? string.Empty : new string(value.Where(char.IsDigit).ToArray());
if (digits != value)
{
SiteNumber = digits;
return;
}
// Persist to config when valid (non-empty numeric).
if (!string.IsNullOrWhiteSpace(digits))
_configService.SetSiteId("SITE : " + digits);
}
partial void OnCardIdInputChanged(string value)
{
// If the field is cleared, also clear any remembered blocked-id prefix.
if (string.IsNullOrEmpty(value))
_cooldownBlockedCardId = string.Empty;
// While a cooldown is active, keep showing the blocked card id and ignore any typed input
// (keyboard wedge scanners can still send keystrokes).
if (_isCooldownActive &&
!_suppressCooldownRewrite &&
!string.IsNullOrEmpty(_cooldownBlockedCardId) &&
!string.Equals(value, _cooldownBlockedCardId, StringComparison.Ordinal))
{
try
{
_suppressCooldownRewrite = true;
CardIdInput = _cooldownBlockedCardId;
return;
}
finally
{
_suppressCooldownRewrite = false;
}
}
// If a cooldown is active and the next scan starts typing into the existing text,
// strip the previous blocked card id so the new scan doesn't append to it.
if (!_suppressCooldownRewrite &&
!string.IsNullOrEmpty(_cooldownBlockedCardId) &&
value.Length > _cooldownBlockedCardId.Length &&
value.StartsWith(_cooldownBlockedCardId, StringComparison.Ordinal))
{
try
{
_suppressCooldownRewrite = true;
CardIdInput = value.Substring(_cooldownBlockedCardId.Length);
_cooldownBlockedCardId = string.Empty;
return;
}
finally
{
_suppressCooldownRewrite = false;
}
}
// Clear any previous message once a new scan starts (outside cooldown).
if (!string.IsNullOrWhiteSpace(value) && !string.IsNullOrEmpty(Message))
Message = string.Empty;
// Any incoming characters means activity.
if (!string.IsNullOrWhiteSpace(value))
{
_lastScanActivityUtc = DateTime.UtcNow;
ScannerStatus = "Connected";
}
// Don't auto-submit while cooldown is active or while we're processing.
if (!_isCooldownActive && !IsProcessing)
RestartDebounceTimer();
}
private void RestartDebounceTimer()
{
_debounceTimer.Stop();
if (string.IsNullOrWhiteSpace(CardIdInput))
return;
_debounceTimer.Start();
}
private void RefreshScannerStatus()
{
ScannerStatus = "Disconnected";
}
[RelayCommand]
private void Logout()
{
_session.Logout();
_navigation.NavigateToAdminLogin();
}
[RelayCommand]
private void OpenSettings()
{
if (!_session.IsAdminAuthenticated)
return;
// Kiosk mode: require re-auth for settings.
_navigation.NavigateToAdminSettingsAuth();
}
[RelayCommand]
private void Scan()
{
lock (_submitLock)
{
if (_isSubmitting || IsProcessing || _isCooldownActive)
return;
_isSubmitting = true;
IsProcessing = true;
}
try
{
_debounceTimer.Stop();
Message = string.Empty;
IsSuccess = false;
var cardId = CardIdInput?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(cardId))
return;
var result = _rfidService.ProcessScanDetailed(cardId);
IsSuccess = result.Success;
Message = result.Message;
if (!result.Success && result.CooldownSecondsRemaining > 0)
{
// Requirement: when blocked due to cooldown, keep the scanned ID visible
// so users understand which card was blocked.
CardIdInput = cardId;
StartCooldownCountdown(result.CooldownSecondsRemaining);
return;
}
// Clear input for normal success/failure so the next scan starts cleanly.
CardIdInput = string.Empty;
if (result.Success)
{
// Refresh dashboard info on success (record is saved).
_ = RefreshDashboardAfterSuccessfulScanAsync(cardId);
}
}
finally
{
lock (_submitLock)
{
_isSubmitting = false;
// Cooldown keeps the UI locked; otherwise unlock after scan completes.
if (!_isCooldownActive)
IsProcessing = false;
}
}
}
private async Task RefreshDashboardAfterSuccessfulScanAsync(string cardId)
{
await RefreshDashboardAsync().ConfigureAwait(false);
// If the same card scanned 2+ times today, show a "scanned again" message.
try
{
var todayCountForCard = await _rfidService.GetTodayScanCountForCardAsync(cardId).ConfigureAwait(false);
if (todayCountForCard >= 2)
{
_uiDispatcher.Invoke(() =>
{
IsSuccess = true;
Message = $"Welcome back! Another order (#{todayCountForCard} today).";
});
}
}
catch
{
// Ignore; keep original message.
}
}
private async Task RefreshDashboardAsync()
{
try
{
var todayCount = await _rfidService.GetTodayScanCountAsync().ConfigureAwait(false);
var last = _rfidService.GetLastScan();
_uiDispatcher.Invoke(() =>
{
TodaysScans = todayCount;
TotalOrders = todayCount; // Total orders = today's total only (not previous days)
ApplyLastScan(last);
});
}
catch
{
// If DB is unavailable, fail gracefully.
}
}
private void ApplyLastScan(ScanRecord? last)
{
if (last == null)
{
LastCardId = "—";
LastScanTimeDisplay = "—";
return;
}
LastCardId = last.CardId;
LastScanTimeDisplay = last.ScanTime.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss");
}
private void StartCooldownCountdown(int seconds)
{
if (seconds <= 0)
return;
_isCooldownActive = true;
IsCooldownActiveUi = true;
IsProcessing = true; // keep textbox disabled during cooldown
_cooldownBlockedCardId = CardIdInput?.Trim() ?? string.Empty;
_cooldownEndsUtc = DateTime.UtcNow.AddSeconds(seconds);
_lastDisplayedCooldownSeconds = -1;
CooldownAlertMessage = $"Ask this customer to rescan their card after the countdown ({seconds} seconds remaining)";
UpdateCooldownMessage();
_cooldownTimer.Start();
}
private void StopCooldownCountdown()
{
_cooldownTimer.Stop();
_cooldownEndsUtc = null;
_lastDisplayedCooldownSeconds = -1;
_isCooldownActive = false;
IsCooldownActiveUi = false;
IsProcessing = false;
CooldownAlertMessage = string.Empty;
}
private void UpdateCooldownMessage()
{
if (_cooldownEndsUtc is null)
{
CooldownAlertMessage = string.Empty;
return;
}
var remaining = (int)Math.Ceiling((_cooldownEndsUtc.Value - DateTime.UtcNow).TotalSeconds);
if (remaining <= 0)
{
StopCooldownCountdown();
CooldownAlertMessage = string.Empty;
Message = "Ready for next order.";
IsSuccess = true;
return;
}
if (remaining == _lastDisplayedCooldownSeconds)
return;
_lastDisplayedCooldownSeconds = remaining;
var unit = remaining == 1 ? "second" : "seconds";
Message = $"Please wait {remaining} {unit}…";
CooldownAlertMessage = $"Ask this customer to rescan their card after the countdown ({remaining} {unit} remaining)";
IsSuccess = false;
}
private void UpdateScannerStatusFromActivity()
{
if (_lastScanActivityUtc is null)
{
if (!string.Equals(ScannerStatus, "Disconnected", StringComparison.Ordinal))
ScannerStatus = "Disconnected";
return;
}
if (DateTime.UtcNow - _lastScanActivityUtc.Value > ScannerInactivityTimeout)
{
if (!string.Equals(ScannerStatus, "Disconnected", StringComparison.Ordinal))
ScannerStatus = "Disconnected";
}
}
~ScannerDashboardViewModel()
{
_scannerStatusTimer.Stop();
_cooldownTimer.Stop();
_clockTimer.Stop();
_debounceTimer.Stop();
}
}