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"/>
+
+
+
+
+
@@ -117,7 +143,34 @@
HorizontalAlignment="Center"
TextAlignment="Center"
TextWrapping="Wrap"/>
-
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Views/AdminLoginView.xaml.cs b/Views/AdminLoginView.xaml.cs
index 7c2fa75..479de47 100644
--- a/Views/AdminLoginView.xaml.cs
+++ b/Views/AdminLoginView.xaml.cs
@@ -1,7 +1,9 @@
using System.Windows;
using System.Windows.Controls;
using System.ComponentModel;
+using System.Windows.Input;
using System.Windows.Threading;
+using System.Windows.Media.Imaging;
using UtopiaCanteenSystem.ViewModels;
namespace UtopiaCanteenSystem.Views;
@@ -24,10 +26,11 @@ public partial class AdminLoginView : UserControl
{
Dispatcher.BeginInvoke(() =>
{
- // Ensure stale PasswordBox contents never carry across sessions (PasswordBox isn't bindable).
- PasswordBox.Password = string.Empty;
- if (DataContext is AdminLoginViewModel vm)
- vm.Password = string.Empty;
+ // If VM has a remembered password, prefill the PasswordBox (it's not bindable).
+ if (DataContext is AdminLoginViewModel vm && !string.IsNullOrEmpty(vm.Password))
+ PasswordBox.Password = vm.Password;
+
+ TryLoadLogo();
UsernameTextBox.Focus();
UsernameTextBox.SelectAll();
@@ -43,10 +46,9 @@ public partial class AdminLoginView : UserControl
if (_vm != null)
_vm.PropertyChanged += VmOnPropertyChanged;
- // When we navigate back here after logout, the view might be reused; always clear password UI.
- PasswordBox.Password = string.Empty;
- if (DataContext is AdminLoginViewModel vm)
- vm.Password = string.Empty;
+ // Sync PasswordBox from VM when navigating here (supports remembered creds).
+ if (_vm != null)
+ PasswordBox.Password = _vm.Password ?? string.Empty;
}
private void VmOnPropertyChanged(object? sender, PropertyChangedEventArgs e)
@@ -64,5 +66,46 @@ public partial class AdminLoginView : UserControl
if (DataContext is AdminLoginViewModel vm)
vm.Password = PasswordBox.Password;
}
+
+ private void LogoImage_OnImageFailed(object sender, ExceptionRoutedEventArgs e)
+ {
+ // If assets/logo.png isn't present in output, fall back to text title.
+ LogoImage.Visibility = Visibility.Collapsed;
+ LogoFallbackText.Visibility = Visibility.Visible;
+ }
+
+ private void TryLoadLogo()
+ {
+ try
+ {
+ var uri = new Uri("pack://siteoforigin:,,,/assets/logo.png", UriKind.Absolute);
+ var bmp = new BitmapImage();
+ bmp.BeginInit();
+ bmp.UriSource = uri;
+ bmp.CacheOption = BitmapCacheOption.OnLoad;
+ bmp.EndInit();
+
+ LogoImage.Source = bmp;
+ LogoFallbackText.Visibility = Visibility.Collapsed;
+ LogoImage.Visibility = Visibility.Visible;
+ }
+ catch
+ {
+ LogoImage.Visibility = Visibility.Collapsed;
+ LogoFallbackText.Visibility = Visibility.Visible;
+ }
+ }
+
+ private void AdminLogin_OnKeyDown(object sender, KeyEventArgs e)
+ {
+ if (e.Key != Key.Enter)
+ return;
+
+ if (DataContext is AdminLoginViewModel vm && vm.AdminLoginCommand.CanExecute(null))
+ {
+ vm.AdminLoginCommand.Execute(null);
+ e.Handled = true;
+ }
+ }
}
diff --git a/Views/AdminSettingsAuthView.xaml b/Views/AdminSettingsAuthView.xaml
index 81834b1..623fec9 100644
--- a/Views/AdminSettingsAuthView.xaml
+++ b/Views/AdminSettingsAuthView.xaml
@@ -96,7 +96,13 @@
Color="#000000"/>
-
+
+
+
+
+
+
+
+
+
@@ -156,7 +171,34 @@
HorizontalAlignment="Center"
TextAlignment="Center"
TextWrapping="Wrap"/>
-
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Views/AdminSettingsAuthView.xaml.cs b/Views/AdminSettingsAuthView.xaml.cs
index e04d2fc..dffae39 100644
--- a/Views/AdminSettingsAuthView.xaml.cs
+++ b/Views/AdminSettingsAuthView.xaml.cs
@@ -1,6 +1,7 @@
using System.ComponentModel;
using System.Windows;
using System.Windows.Controls;
+using System.Windows.Input;
using System.Windows.Threading;
using UtopiaCanteenSystem.ViewModels;
@@ -24,9 +25,9 @@ public partial class AdminSettingsAuthView : UserControl
{
Dispatcher.BeginInvoke(() =>
{
- PasswordBox.Password = string.Empty;
- if (DataContext is AdminSettingsAuthViewModel vm)
- vm.Password = string.Empty;
+ // If VM has a remembered password, prefill the PasswordBox (it's not bindable).
+ if (DataContext is AdminSettingsAuthViewModel vm && !string.IsNullOrEmpty(vm.Password))
+ PasswordBox.Password = vm.Password;
UsernameTextBox.Focus();
UsernameTextBox.SelectAll();
@@ -42,9 +43,9 @@ public partial class AdminSettingsAuthView : UserControl
if (_vm != null)
_vm.PropertyChanged += VmOnPropertyChanged;
- PasswordBox.Password = string.Empty;
- if (DataContext is AdminSettingsAuthViewModel vm)
- vm.Password = string.Empty;
+ // Sync PasswordBox from VM when navigating here (supports remembered creds).
+ if (_vm != null)
+ PasswordBox.Password = _vm.Password ?? string.Empty;
}
private void VmOnPropertyChanged(object? sender, PropertyChangedEventArgs e)
@@ -61,5 +62,17 @@ public partial class AdminSettingsAuthView : UserControl
if (DataContext is AdminSettingsAuthViewModel vm)
vm.Password = PasswordBox.Password;
}
+
+ private void Confirm_OnKeyDown(object sender, KeyEventArgs e)
+ {
+ if (e.Key != Key.Enter)
+ return;
+
+ if (DataContext is AdminSettingsAuthViewModel vm && vm.ConfirmCommand.CanExecute(null))
+ {
+ vm.ConfirmCommand.Execute(null);
+ e.Handled = true;
+ }
+ }
}
diff --git a/Views/ScannerDashboardView.xaml b/Views/ScannerDashboardView.xaml
new file mode 100644
index 0000000..fa2e482
--- /dev/null
+++ b/Views/ScannerDashboardView.xaml
@@ -0,0 +1,334 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
-
+