using System.Collections.ObjectModel; using System.Globalization; using System.IO; using System.Text; using DebounceTimer = System.Timers.Timer; using Microsoft.Win32; 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 DispatcherTimer _scannerStatusTimer; private DateTime? _lastScanActivityUtc; 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 _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()}"; // --- Left panel: Employee information (sample data for display; replace with real lookup when available) --- [ObservableProperty] private string _employeeName = "Syed Mustufa Ahmed Naqvi"; [ObservableProperty] private string _employeeDepartment = "Technology"; [ObservableProperty] private string _employeeId = "15399"; /// Item selected by the employee for the order (Sehri/Iftari). [ObservableProperty] private string _employeeOrderItem = "Sehri/Iftari"; /// Optional profile image path; null = show placeholder. [ObservableProperty] private string? _employeeProfileImagePath = "pack://siteoforigin:,,,/assets/emp-pic/emppic.jpeg"; /// Last 4 orders for the Order History card (newest first). public ObservableCollection OrderHistory { get; } = new(); /// All orders for today, for the \"View All\" modal (newest first). public ObservableCollection TodayOrderHistory { get; } = new(); [ObservableProperty] [NotifyPropertyChangedFor(nameof(HasOrderHistory))] private bool _orderHistoryEmpty; public bool HasOrderHistory => !OrderHistoryEmpty; [ObservableProperty] private bool _isOrderHistoryModalOpen; 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 (keyboard-wedge style scanners). _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(); } private static string GetRelativeDateLabel(DateTime utcTime) { var localDate = utcTime.ToLocalTime().Date; var today = DateTime.Today; if (localDate == today) return "Today"; if (localDate == today.AddDays(-1)) return "Yesterday"; return localDate.ToString("MMM d"); } 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; } } // Any incoming characters means activity from the scanner keyboard wedge. if (!string.IsNullOrWhiteSpace(value)) { _lastScanActivityUtc = DateTime.UtcNow; ScannerStatus = "Connected"; } // Clear any previous message once a new scan starts (outside cooldown). if (!string.IsNullOrWhiteSpace(value) && !string.IsNullOrEmpty(Message)) Message = string.Empty; // 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"; } 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 && !string.Equals(ScannerStatus, "Disconnected", StringComparison.Ordinal)) { 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); LoadOrderHistory(); }); } 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 LoadOrderHistory() { var scans = _rfidService.GetLastScans(4); OrderHistory.Clear(); foreach (var r in scans) { var local = r.ScanTime.ToLocalTime(); // EmployeeId: placeholder format (will be replaced with real employee lookup later) var employeeId = string.IsNullOrEmpty(r.CardId) ? "—" : $"EMP-{(r.CardId.Length >= 4 ? r.CardId[^4..] : r.CardId)}"; var employeeName = string.IsNullOrEmpty(r.CardId) ? "—" : $"Employee {(r.CardId.Length >= 4 ? r.CardId[^4..] : r.CardId)}"; OrderHistory.Add(new OrderHistoryItem { EmployeeId = employeeId, EmployeeName = employeeName, Department = "—", ScanId = r.CardId ?? string.Empty, // ScanId remains as CardId OrderTimeUtc = r.ScanTime, TimeDisplay = local.ToString("hh:mm tt"), RelativeDateLabel = GetRelativeDateLabel(r.ScanTime) }); } OrderHistoryEmpty = OrderHistory.Count == 0; } private void LoadTodayOrderHistory() { var scans = _rfidService.GetScansForToday(); TodayOrderHistory.Clear(); foreach (var r in scans) { var local = r.ScanTime.ToLocalTime(); // EmployeeId: placeholder format (will be replaced with real employee lookup later) var employeeId = string.IsNullOrEmpty(r.CardId) ? "—" : $"EMP-{(r.CardId.Length >= 4 ? r.CardId[^4..] : r.CardId)}"; var employeeName = string.IsNullOrEmpty(r.CardId) ? "—" : $"Employee {(r.CardId.Length >= 4 ? r.CardId[^4..] : r.CardId)}"; TodayOrderHistory.Add(new OrderHistoryItem { EmployeeId = employeeId, EmployeeName = employeeName, Department = "—", ScanId = r.CardId ?? string.Empty, // ScanId remains as CardId OrderTimeUtc = r.ScanTime, OrderItem = "Sehri/Iftari", TimeDisplay = local.ToString("hh:mm tt"), RelativeDateLabel = GetRelativeDateLabel(r.ScanTime) }); } } [RelayCommand] private void OpenOrderHistoryModal() { // Delay opening by one UI tick to prevent the same mouse click from immediately closing the modal _uiDispatcher.BeginInvoke(() => { LoadTodayOrderHistory(); IsOrderHistoryModalOpen = true; }, DispatcherPriority.Loaded); } [RelayCommand] private void CloseOrderHistoryModal() { IsOrderHistoryModalOpen = false; } /// /// Exports today's scan records to a CSV file (opens in Excel). User chooses path via Save File dialog. /// [RelayCommand] private void DownloadTodayRecords() { var records = _rfidService.GetScansForToday(); if (records.Count == 0) { Message = "No records for today to download."; IsSuccess = false; return; } var dateStr = DateTime.Today.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); var defaultFileName = $"CanteenRecords_{dateStr}.csv"; var dialog = new SaveFileDialog { Filter = "CSV (Excel)|*.csv|All files|*.*", DefaultExt = ".csv", FileName = defaultFileName }; if (dialog.ShowDialog() != true) return; try { var csv = BuildCsvFromScanRecords(records); var utf8WithBom = new UTF8Encoding(true); File.WriteAllText(dialog.FileName, csv, utf8WithBom); Message = $"Downloaded {records.Count} record(s) to {Path.GetFileName(dialog.FileName)}"; IsSuccess = true; } catch (Exception ex) { Message = "Download failed: " + ex.Message; IsSuccess = false; } } private static string BuildCsvFromScanRecords(IReadOnlyList records) { var sb = new StringBuilder(); sb.AppendLine("Id,CardId,ScanTime,IsSynced,SiteId,DeviceId,IpAddress"); foreach (var r in records) { sb.Append(r.Id); sb.Append(','); sb.Append(EscapeCsv(r.CardId)); sb.Append(','); sb.Append(EscapeCsv(r.ScanTime.ToString("O", CultureInfo.InvariantCulture))); sb.Append(','); sb.Append(r.IsSynced ? "Yes" : "No"); sb.Append(','); sb.Append(EscapeCsv(r.SiteId)); sb.Append(','); sb.Append(EscapeCsv(r.DeviceId)); sb.Append(','); sb.Append(EscapeCsv(r.IpAddress)); sb.AppendLine(); } return sb.ToString(); } private static string EscapeCsv(string value) { if (string.IsNullOrEmpty(value)) return value; if (value.IndexOfAny(new[] { ',', '"', '\r', '\n' }) < 0) return value; return "\"" + value.Replace("\"", "\"\"") + "\""; } 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; Message = string.Empty; // single alert only: cooldown box CooldownAlertMessage = FormatCooldownMessage(seconds); UpdateCooldownMessage(); _cooldownTimer.Start(); } private void StopCooldownCountdown() { _cooldownTimer.Stop(); _cooldownEndsUtc = null; _lastDisplayedCooldownSeconds = -1; _isCooldownActive = false; IsCooldownActiveUi = false; IsProcessing = false; CooldownAlertMessage = string.Empty; } /// Formats remaining seconds as one concise phrase in the best unit (e.g. "2 days", "1 minute", "30 seconds"). private static string FormatRemainingInBestUnit(int totalSeconds) { if (totalSeconds <= 0) return "0 seconds"; if (totalSeconds >= 86400) { var d = totalSeconds / 86400; return d == 1 ? "1 day" : $"{d} days"; } if (totalSeconds >= 3600) { var h = totalSeconds / 3600; return h == 1 ? "1 hour" : $"{h} hours"; } if (totalSeconds >= 60) { var m = totalSeconds / 60; return m == 1 ? "1 minute" : $"{m} minutes"; } return totalSeconds == 1 ? "1 second" : $"{totalSeconds} seconds"; } /// Single concise cooldown line for the one visible alert. private static string FormatCooldownMessage(int remainingSeconds) { var phrase = FormatRemainingInBestUnit(remainingSeconds); return $"Rescan in {phrase}."; } 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; Message = string.Empty; // single alert only CooldownAlertMessage = FormatCooldownMessage(remaining); IsSuccess = false; } ~ScannerDashboardViewModel() { _scannerStatusTimer.Stop(); _cooldownTimer.Stop(); _clockTimer.Stop(); _debounceTimer.Stop(); } }