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