diff --git a/App.xaml b/App.xaml index 7ad0291..d338808 100644 --- a/App.xaml +++ b/App.xaml @@ -14,11 +14,8 @@ - - - - - + + diff --git a/App.xaml.cs b/App.xaml.cs index a875d9d..d60fcf3 100644 --- a/App.xaml.cs +++ b/App.xaml.cs @@ -1,4 +1,5 @@ using System.Windows; +using System.Threading; using Microsoft.EntityFrameworkCore; using UtopiaCanteenSystem.Data; using UtopiaCanteenSystem.Services; @@ -12,6 +13,7 @@ namespace UtopiaCanteenSystem; public partial class App : Application { private System.Timers.Timer? _syncTimer; + private int _isSyncRunning; protected override void OnStartup(StartupEventArgs e) { @@ -37,10 +39,10 @@ public partial class App : Application NavigationService navigationService = null!; navigationService = new NavigationService( session, - () => new AdminLoginViewModel(authService, session, navigationService), - () => new ScannerViewModel(rfidService, navigationService, session), + () => new AdminLoginViewModel(authService, session, navigationService, configService), + () => new ScannerDashboardViewModel(rfidService, navigationService, session), () => new MainDashboardViewModel(navigationService, rfidService, configService, session), - () => new AdminSettingsAuthViewModel(authService, session, navigationService), + () => new AdminSettingsAuthViewModel(authService, session, navigationService, configService), () => new SettingsViewModel(configService, navigationService)); var mainViewModel = new MainViewModel(navigationService); @@ -51,17 +53,28 @@ public partial class App : Application }; mainWindow.Show(); - // Hourly background sync: every 1 hour, POST unsynced ScanRecords to API - _syncTimer = new System.Timers.Timer(TimeSpan.FromHours(1).TotalMilliseconds); + // Background sync: every 3 hours, POST unsynced ScanRecords to API + _syncTimer = new System.Timers.Timer(TimeSpan.FromHours(3).TotalMilliseconds) + { + AutoReset = true + }; _syncTimer.Elapsed += async (_, _) => { + // Prevent overlapping sync runs; if one is still running, skip this tick. + if (Interlocked.Exchange(ref _isSyncRunning, 1) == 1) + return; + try { await syncService.SyncNowAsync().ConfigureAwait(false); } catch { - // Ignore; will retry next hour + // Ignore; will retry next tick + } + finally + { + Interlocked.Exchange(ref _isSyncRunning, 0); } }; _syncTimer.Start(); diff --git a/MainWindow.xaml b/MainWindow.xaml index 299123f..20dbf98 100644 --- a/MainWindow.xaml +++ b/MainWindow.xaml @@ -2,6 +2,7 @@ xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" Title="Utopia Canteen System" + Icon="pack://siteoforigin:,,,/assets/favicon.ico" MinHeight="600" MinWidth="800" WindowStartupLocation="CenterScreen" SizeToContent="WidthAndHeight"> diff --git a/Services/ConfigService.cs b/Services/ConfigService.cs index 7bd1fb7..1eee4ab 100644 --- a/Services/ConfigService.cs +++ b/Services/ConfigService.cs @@ -1,4 +1,6 @@ using System.IO; +using System.Security.Cryptography; +using System.Text; using System.Text.Json; namespace UtopiaCanteenSystem.Services; @@ -49,6 +51,66 @@ public class ConfigService : IConfigService SaveConfig(); } + public bool GetRememberAdminCredentials() => _config.RememberAdminCredentials; + + public void SetRememberAdminCredentials(bool remember) + { + _config.RememberAdminCredentials = remember; + SaveConfig(); + } + + public string GetSavedAdminUsername() => _config.SavedAdminUsername ?? string.Empty; + + public void SetSavedAdminUsername(string username) + { + _config.SavedAdminUsername = username ?? string.Empty; + SaveConfig(); + } + + public string GetSavedAdminPassword() + { + if (!_config.RememberAdminCredentials) + return string.Empty; + + var protectedValue = _config.SavedAdminPasswordProtected ?? string.Empty; + if (string.IsNullOrWhiteSpace(protectedValue)) + return string.Empty; + + try + { + var bytes = Convert.FromBase64String(protectedValue); + var clear = ProtectedData.Unprotect(bytes, null, DataProtectionScope.CurrentUser); + return Encoding.UTF8.GetString(clear); + } + catch + { + return string.Empty; + } + } + + public void SetSavedAdminPassword(string password) + { + if (!_config.RememberAdminCredentials) + { + _config.SavedAdminPasswordProtected = string.Empty; + SaveConfig(); + return; + } + + try + { + var clear = Encoding.UTF8.GetBytes(password ?? string.Empty); + var protectedBytes = ProtectedData.Protect(clear, null, DataProtectionScope.CurrentUser); + _config.SavedAdminPasswordProtected = Convert.ToBase64String(protectedBytes); + } + catch + { + _config.SavedAdminPasswordProtected = string.Empty; + } + + SaveConfig(); + } + private AppConfig LoadConfig() { try @@ -97,5 +159,10 @@ public class ConfigService : IConfigService public bool ScannerConnected { get; set; } = false; public int ScanTimeoutSeconds { get; set; } = 60; public string AdminCardId { get; set; } = "ADMIN"; + + // Admin credential persistence (optional). + public bool RememberAdminCredentials { get; set; } = false; + public string SavedAdminUsername { get; set; } = string.Empty; + public string SavedAdminPasswordProtected { get; set; } = string.Empty; } } diff --git a/Services/IConfigService.cs b/Services/IConfigService.cs index 23b5041..4ae7b39 100644 --- a/Services/IConfigService.cs +++ b/Services/IConfigService.cs @@ -13,4 +13,11 @@ public interface IConfigService void SetScanTimeoutSeconds(int seconds); string GetAdminCardId(); void SetAdminCardId(string cardId); + + bool GetRememberAdminCredentials(); + void SetRememberAdminCredentials(bool remember); + string GetSavedAdminUsername(); + void SetSavedAdminUsername(string username); + string GetSavedAdminPassword(); + void SetSavedAdminPassword(string password); } diff --git a/Services/NavigationService.cs b/Services/NavigationService.cs index 643f0d1..ebd43a6 100644 --- a/Services/NavigationService.cs +++ b/Services/NavigationService.cs @@ -25,7 +25,7 @@ public class NavigationService : INavigationService public event EventHandler? CurrentViewModelChanged; private readonly Func _adminLoginVm; - private readonly Func _scannerVm; + private readonly Func _scannerVm; private readonly Func _dashboardVm; private readonly Func _adminSettingsAuthVm; private readonly Func _settingsVm; @@ -33,7 +33,7 @@ public class NavigationService : INavigationService public NavigationService( AppSession session, Func adminLoginVm, - Func scannerVm, + Func scannerVm, Func dashboardVm, Func adminSettingsAuthVm, Func settingsVm) @@ -118,9 +118,7 @@ public class NavigationService : INavigationService public void NavigateBackFromSettings(TimeSpan timeout) { - if (IsDashboardSessionExpired(timeout)) - NavigateToScanner(); - else - NavigateToDashboard(); + // Single-screen mode: always return to the scanner/dashboard screen. + NavigateToScanner(); } } diff --git a/Services/SyncService.cs b/Services/SyncService.cs index 45b26f7..0abe6f6 100644 --- a/Services/SyncService.cs +++ b/Services/SyncService.cs @@ -8,8 +8,8 @@ using System.Net.Http; namespace UtopiaCanteenSystem.Services; /// -/// Hourly sync: fetches unsynced ScanRecords, POSTs them to the configured API, -/// and marks them as IsSynced on success. +/// Scheduled sync: fetches unsynced ScanRecords, POSTs them to the configured API, +/// and deletes uploaded records from local SQLite on success. /// public class SyncService : ISyncService { @@ -66,14 +66,14 @@ public class SyncService : ISyncService .Where(r => ids.Contains(r.Id)) .ToListAsync(cancellationToken) .ConfigureAwait(false); - foreach (var r in records) - r.IsSynced = true; + // On successful upload, delete uploaded scan records from local SQLite. + db.ScanRecords.RemoveRange(records); await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); } } catch { - // Leave records unsynced; will retry on next run + // Leave records intact; will retry on next run } } } diff --git a/UtopiaCanteenSystem.csproj b/UtopiaCanteenSystem.csproj index f9341a3..5bacad2 100644 --- a/UtopiaCanteenSystem.csproj +++ b/UtopiaCanteenSystem.csproj @@ -6,7 +6,7 @@ enable enable true - + assets\favicon.ico UtopiaCanteenSystem UtopiaCanteenSystem {A1B2C3D4-E5F6-7890-ABCD-EF1234567890} @@ -28,4 +28,17 @@ + + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + diff --git a/ViewModels/AdminLoginViewModel.cs b/ViewModels/AdminLoginViewModel.cs index 6df989b..be78ee7 100644 --- a/ViewModels/AdminLoginViewModel.cs +++ b/ViewModels/AdminLoginViewModel.cs @@ -9,6 +9,7 @@ public partial class AdminLoginViewModel : ObservableObject private readonly IAuthService _authService; private readonly AppSession _session; private readonly INavigationService _navigation; + private readonly IConfigService _config; [ObservableProperty] private string _username = string.Empty; @@ -23,11 +24,22 @@ public partial class AdminLoginViewModel : ObservableObject [ObservableProperty] private bool _isBusy; - public AdminLoginViewModel(IAuthService authService, AppSession session, INavigationService navigation) + [ObservableProperty] + private bool _rememberCredentials; + + public AdminLoginViewModel(IAuthService authService, AppSession session, INavigationService navigation, IConfigService config) { _authService = authService; _session = session; _navigation = navigation; + _config = config; + + RememberCredentials = _config.GetRememberAdminCredentials(); + if (RememberCredentials) + { + Username = _config.GetSavedAdminUsername(); + Password = _config.GetSavedAdminPassword(); + } } [RelayCommand] @@ -41,7 +53,8 @@ public partial class AdminLoginViewModel : ObservableObject var user = Username?.Trim() ?? string.Empty; var pass = Password ?? string.Empty; - // Requirement: after clicking login, clear fields. + // Requirement: always clear fields after clicking LOGIN (success or failure). + // Keep local copies for the ongoing request. Username = string.Empty; Password = string.Empty; @@ -62,6 +75,20 @@ public partial class AdminLoginViewModel : ObservableObject } _session.SetAdminAuthenticated(user, result.EmployeeId); + + // Persist credentials only if user opted in. + _config.SetRememberAdminCredentials(RememberCredentials); + if (RememberCredentials) + { + _config.SetSavedAdminUsername(user); + _config.SetSavedAdminPassword(pass); + } + else + { + _config.SetSavedAdminUsername(string.Empty); + _config.SetSavedAdminPassword(string.Empty); + } + _navigation.NavigateToScanner(); } finally diff --git a/ViewModels/AdminSettingsAuthViewModel.cs b/ViewModels/AdminSettingsAuthViewModel.cs index 367e8e6..5ee7f4c 100644 --- a/ViewModels/AdminSettingsAuthViewModel.cs +++ b/ViewModels/AdminSettingsAuthViewModel.cs @@ -9,6 +9,7 @@ public partial class AdminSettingsAuthViewModel : ObservableObject private readonly IAuthService _authService; private readonly AppSession _session; private readonly INavigationService _navigation; + private readonly IConfigService _config; [ObservableProperty] private string _username = string.Empty; @@ -23,17 +24,28 @@ public partial class AdminSettingsAuthViewModel : ObservableObject [ObservableProperty] private bool _isBusy; - public AdminSettingsAuthViewModel(IAuthService authService, AppSession session, INavigationService navigation) + [ObservableProperty] + private bool _rememberCredentials; + + public AdminSettingsAuthViewModel(IAuthService authService, AppSession session, INavigationService navigation, IConfigService config) { _authService = authService; _session = session; _navigation = navigation; + _config = config; + + RememberCredentials = _config.GetRememberAdminCredentials(); + if (RememberCredentials) + { + Username = _config.GetSavedAdminUsername(); + Password = _config.GetSavedAdminPassword(); + } } [RelayCommand] private void Cancel() { - _navigation.NavigateToDashboard(); + _navigation.NavigateToScanner(); } [RelayCommand] @@ -47,7 +59,8 @@ public partial class AdminSettingsAuthViewModel : ObservableObject var user = Username?.Trim() ?? string.Empty; var pass = Password ?? string.Empty; - // Clear fields after clicking confirm (per kiosk behavior). + // Requirement: always clear fields after clicking Confirm (success or failure). + // Keep local copies for the ongoing request. Username = string.Empty; Password = string.Empty; @@ -69,6 +82,20 @@ public partial class AdminSettingsAuthViewModel : ObservableObject // Refresh session details (who authenticated for Settings). _session.SetAdminAuthenticated(user, result.EmployeeId); + + // Persist credentials only if user opted in. + _config.SetRememberAdminCredentials(RememberCredentials); + if (RememberCredentials) + { + _config.SetSavedAdminUsername(user); + _config.SetSavedAdminPassword(pass); + } + else + { + _config.SetSavedAdminUsername(string.Empty); + _config.SetSavedAdminPassword(string.Empty); + } + _navigation.NavigateToSettings(); } finally diff --git a/ViewModels/MainDashoardViewModel.cs b/ViewModels/MainDashoardViewModel.cs index 0d28dda..282cdbb 100644 --- a/ViewModels/MainDashoardViewModel.cs +++ b/ViewModels/MainDashoardViewModel.cs @@ -115,15 +115,11 @@ public partial class MainDashboardViewModel : ObservableObject try { var todayCount = await _rfidService.GetTodayScanCountForCardAsync(cardId).ConfigureAwait(false); - var totalCount = await _rfidService.GetTotalScanCountForCardAsync(cardId).ConfigureAwait(false); - // Only show the "came again" message when we have evidence of a prior scan. - if (totalCount >= 2) + // Show "scanned again" ONLY when the same card has 2+ scans today. + if (todayCount >= 2) { - var msg = - todayCount >= 2 - ? $"Welcome back! You scanned again (#{todayCount} today)." - : "Welcome back! You scanned again."; + var msg = $"Welcome back! You scanned again (#{todayCount} today)."; System.Windows.Application.Current.Dispatcher.Invoke(() => { diff --git a/ViewModels/ScannerDashboardViewModel.cs b/ViewModels/ScannerDashboardViewModel.cs new file mode 100644 index 0000000..a7a14ea --- /dev/null +++ b/ViewModels/ScannerDashboardViewModel.cs @@ -0,0 +1,402 @@ +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 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"; + + public string ScannerStatusDisplay => + string.Equals(ScannerStatus, "Connected", StringComparison.Ordinal) ? "Connected" : "waiting for scan..."; + + [ObservableProperty] + private string _currentTime = DateTime.Now.ToString("MM/dd/yyyy, hh:mm:ss tt"); + + // --- Dashboard fields --- + [ObservableProperty] + private int _todaysScans; + + [ObservableProperty] + private string _lastCardId = "—"; + + [ObservableProperty] + private string _lastScanTimeDisplay = "—"; + + // Message area (green/red) + [ObservableProperty] + private string _message = string.Empty; + + [ObservableProperty] + private bool _isSuccess; + + public bool IsAdminAuthenticated => _session.IsAdminAuthenticated; + + public ScannerDashboardViewModel(IRfidService rfidService, INavigationService navigation, AppSession session) + { + _rfidService = rfidService; + _navigation = navigation; + _session = session; + _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(); + + RefreshScannerStatus(); + _ = RefreshDashboardAsync(); + } + + 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! You scanned again (#{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; + 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; + UpdateCooldownMessage(); + _cooldownTimer.Start(); + } + + private void StopCooldownCountdown() + { + _cooldownTimer.Stop(); + _cooldownEndsUtc = null; + _lastDisplayedCooldownSeconds = -1; + _isCooldownActive = false; + IsCooldownActiveUi = false; + IsProcessing = false; + } + + 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; + } + + 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; + } + + if (DateTime.UtcNow - _lastScanActivityUtc.Value > ScannerInactivityTimeout) + { + if (!string.Equals(ScannerStatus, "Disconnected", StringComparison.Ordinal)) + ScannerStatus = "Disconnected"; + } + } + + ~ScannerDashboardViewModel() + { + _scannerStatusTimer.Stop(); + _cooldownTimer.Stop(); + _clockTimer.Stop(); + _debounceTimer.Stop(); + } +} + diff --git a/Views/AdminLoginView.xaml b/Views/AdminLoginView.xaml index 481070b..be7d9e5 100644 --- a/Views/AdminLoginView.xaml +++ b/Views/AdminLoginView.xaml @@ -71,13 +71,30 @@ Color="#000000"/> - - + + + + + + + + + ImageFailed="LogoImage_OnImageFailed"/> + + + + + + + + + + + + diff --git a/Views/ScannerDashboardView.xaml.cs b/Views/ScannerDashboardView.xaml.cs new file mode 100644 index 0000000..0c9c6fa --- /dev/null +++ b/Views/ScannerDashboardView.xaml.cs @@ -0,0 +1,70 @@ +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Threading; +using UtopiaCanteenSystem.ViewModels; + +namespace UtopiaCanteenSystem.Views; + +/// +/// Minimal code-behind for focus management and Enter-key submission. +/// +public partial class ScannerDashboardView : UserControl +{ + private bool _isUnloaded; + + public ScannerDashboardView() + { + InitializeComponent(); + Loaded += OnLoaded; + Unloaded += OnUnloaded; + } + + private void OnLoaded(object sender, RoutedEventArgs e) + { + _isUnloaded = false; + FocusRfidInput(selectAll: true, DispatcherPriority.Input); + } + + private void OnUnloaded(object sender, RoutedEventArgs e) + { + _isUnloaded = true; + } + + private void FocusRfidInput(bool selectAll, DispatcherPriority priority) + { + if (_isUnloaded) + return; + + Dispatcher.BeginInvoke(() => + { + if (_isUnloaded || !IsVisible || !IsEnabled) + return; + + RfidInputTextBox.Focus(); + Keyboard.Focus(RfidInputTextBox); + + if (selectAll) + RfidInputTextBox.SelectAll(); + }, priority); + } + + private void RfidInputTextBox_OnLostKeyboardFocus(object sender, KeyboardFocusChangedEventArgs e) + { + // Keep view scan-ready while allowing clicks (Settings/Logout) to complete first. + FocusRfidInput(selectAll: false, DispatcherPriority.ApplicationIdle); + } + + private void RfidInputTextBox_OnKeyDown(object sender, KeyEventArgs e) + { + if (e.Key != Key.Enter) + return; + + if (DataContext is ScannerDashboardViewModel vm && vm.ScanCommand.CanExecute(null)) + { + vm.ScanCommand.Execute(null); + e.Handled = true; + } + } +} + diff --git a/Views/ScannerView.xaml b/Views/ScannerView.xaml index e046fd2..9789a5f 100644 --- a/Views/ScannerView.xaml +++ b/Views/ScannerView.xaml @@ -155,12 +155,12 @@ - +