feat: scanner+dashboard unified UI with cooldown and local sqlite sync

pull/1/head
SYED MUSTUFA AHMED NAQVI 2026-02-02 16:45:55 +05:00
parent c835ec3e3d
commit 3f08666c15
20 changed files with 1167 additions and 64 deletions

View File

@ -14,11 +14,8 @@
<DataTemplate DataType="{x:Type vm:AdminSettingsAuthViewModel}"> <DataTemplate DataType="{x:Type vm:AdminSettingsAuthViewModel}">
<views:AdminSettingsAuthView /> <views:AdminSettingsAuthView />
</DataTemplate> </DataTemplate>
<DataTemplate DataType="{x:Type vm:ScannerViewModel}"> <DataTemplate DataType="{x:Type vm:ScannerDashboardViewModel}">
<views:ScannerView /> <views:ScannerDashboardView />
</DataTemplate>
<DataTemplate DataType="{x:Type vm:MainDashboardViewModel}">
<views:MainDashboardView />
</DataTemplate> </DataTemplate>
<DataTemplate DataType="{x:Type vm:SettingsViewModel}"> <DataTemplate DataType="{x:Type vm:SettingsViewModel}">
<views:SettingsView /> <views:SettingsView />

View File

@ -1,4 +1,5 @@
using System.Windows; using System.Windows;
using System.Threading;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using UtopiaCanteenSystem.Data; using UtopiaCanteenSystem.Data;
using UtopiaCanteenSystem.Services; using UtopiaCanteenSystem.Services;
@ -12,6 +13,7 @@ namespace UtopiaCanteenSystem;
public partial class App : Application public partial class App : Application
{ {
private System.Timers.Timer? _syncTimer; private System.Timers.Timer? _syncTimer;
private int _isSyncRunning;
protected override void OnStartup(StartupEventArgs e) protected override void OnStartup(StartupEventArgs e)
{ {
@ -37,10 +39,10 @@ public partial class App : Application
NavigationService navigationService = null!; NavigationService navigationService = null!;
navigationService = new NavigationService( navigationService = new NavigationService(
session, session,
() => new AdminLoginViewModel(authService, session, navigationService), () => new AdminLoginViewModel(authService, session, navigationService, configService),
() => new ScannerViewModel(rfidService, navigationService, session), () => new ScannerDashboardViewModel(rfidService, navigationService, session),
() => new MainDashboardViewModel(navigationService, rfidService, configService, session), () => new MainDashboardViewModel(navigationService, rfidService, configService, session),
() => new AdminSettingsAuthViewModel(authService, session, navigationService), () => new AdminSettingsAuthViewModel(authService, session, navigationService, configService),
() => new SettingsViewModel(configService, navigationService)); () => new SettingsViewModel(configService, navigationService));
var mainViewModel = new MainViewModel(navigationService); var mainViewModel = new MainViewModel(navigationService);
@ -51,17 +53,28 @@ public partial class App : Application
}; };
mainWindow.Show(); mainWindow.Show();
// Hourly background sync: every 1 hour, POST unsynced ScanRecords to API // Background sync: every 3 hours, POST unsynced ScanRecords to API
_syncTimer = new System.Timers.Timer(TimeSpan.FromHours(1).TotalMilliseconds); _syncTimer = new System.Timers.Timer(TimeSpan.FromHours(3).TotalMilliseconds)
{
AutoReset = true
};
_syncTimer.Elapsed += async (_, _) => _syncTimer.Elapsed += async (_, _) =>
{ {
// Prevent overlapping sync runs; if one is still running, skip this tick.
if (Interlocked.Exchange(ref _isSyncRunning, 1) == 1)
return;
try try
{ {
await syncService.SyncNowAsync().ConfigureAwait(false); await syncService.SyncNowAsync().ConfigureAwait(false);
} }
catch catch
{ {
// Ignore; will retry next hour // Ignore; will retry next tick
}
finally
{
Interlocked.Exchange(ref _isSyncRunning, 0);
} }
}; };
_syncTimer.Start(); _syncTimer.Start();

View File

@ -2,6 +2,7 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Utopia Canteen System" Title="Utopia Canteen System"
Icon="pack://siteoforigin:,,,/assets/favicon.ico"
MinHeight="600" MinWidth="800" MinHeight="600" MinWidth="800"
WindowStartupLocation="CenterScreen" WindowStartupLocation="CenterScreen"
SizeToContent="WidthAndHeight"> SizeToContent="WidthAndHeight">

View File

@ -1,4 +1,6 @@
using System.IO; using System.IO;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json; using System.Text.Json;
namespace UtopiaCanteenSystem.Services; namespace UtopiaCanteenSystem.Services;
@ -49,6 +51,66 @@ public class ConfigService : IConfigService
SaveConfig(); 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() private AppConfig LoadConfig()
{ {
try try
@ -97,5 +159,10 @@ public class ConfigService : IConfigService
public bool ScannerConnected { get; set; } = false; public bool ScannerConnected { get; set; } = false;
public int ScanTimeoutSeconds { get; set; } = 60; public int ScanTimeoutSeconds { get; set; } = 60;
public string AdminCardId { get; set; } = "ADMIN"; 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;
} }
} }

View File

@ -13,4 +13,11 @@ public interface IConfigService
void SetScanTimeoutSeconds(int seconds); void SetScanTimeoutSeconds(int seconds);
string GetAdminCardId(); string GetAdminCardId();
void SetAdminCardId(string cardId); void SetAdminCardId(string cardId);
bool GetRememberAdminCredentials();
void SetRememberAdminCredentials(bool remember);
string GetSavedAdminUsername();
void SetSavedAdminUsername(string username);
string GetSavedAdminPassword();
void SetSavedAdminPassword(string password);
} }

View File

@ -25,7 +25,7 @@ public class NavigationService : INavigationService
public event EventHandler? CurrentViewModelChanged; public event EventHandler? CurrentViewModelChanged;
private readonly Func<AdminLoginViewModel> _adminLoginVm; private readonly Func<AdminLoginViewModel> _adminLoginVm;
private readonly Func<ScannerViewModel> _scannerVm; private readonly Func<ScannerDashboardViewModel> _scannerVm;
private readonly Func<MainDashboardViewModel> _dashboardVm; private readonly Func<MainDashboardViewModel> _dashboardVm;
private readonly Func<AdminSettingsAuthViewModel> _adminSettingsAuthVm; private readonly Func<AdminSettingsAuthViewModel> _adminSettingsAuthVm;
private readonly Func<SettingsViewModel> _settingsVm; private readonly Func<SettingsViewModel> _settingsVm;
@ -33,7 +33,7 @@ public class NavigationService : INavigationService
public NavigationService( public NavigationService(
AppSession session, AppSession session,
Func<AdminLoginViewModel> adminLoginVm, Func<AdminLoginViewModel> adminLoginVm,
Func<ScannerViewModel> scannerVm, Func<ScannerDashboardViewModel> scannerVm,
Func<MainDashboardViewModel> dashboardVm, Func<MainDashboardViewModel> dashboardVm,
Func<AdminSettingsAuthViewModel> adminSettingsAuthVm, Func<AdminSettingsAuthViewModel> adminSettingsAuthVm,
Func<SettingsViewModel> settingsVm) Func<SettingsViewModel> settingsVm)
@ -118,9 +118,7 @@ public class NavigationService : INavigationService
public void NavigateBackFromSettings(TimeSpan timeout) public void NavigateBackFromSettings(TimeSpan timeout)
{ {
if (IsDashboardSessionExpired(timeout)) // Single-screen mode: always return to the scanner/dashboard screen.
NavigateToScanner(); NavigateToScanner();
else
NavigateToDashboard();
} }
} }

View File

@ -8,8 +8,8 @@ using System.Net.Http;
namespace UtopiaCanteenSystem.Services; namespace UtopiaCanteenSystem.Services;
/// <summary> /// <summary>
/// Hourly sync: fetches unsynced ScanRecords, POSTs them to the configured API, /// Scheduled sync: fetches unsynced ScanRecords, POSTs them to the configured API,
/// and marks them as IsSynced on success. /// and deletes uploaded records from local SQLite on success.
/// </summary> /// </summary>
public class SyncService : ISyncService public class SyncService : ISyncService
{ {
@ -66,14 +66,14 @@ public class SyncService : ISyncService
.Where(r => ids.Contains(r.Id)) .Where(r => ids.Contains(r.Id))
.ToListAsync(cancellationToken) .ToListAsync(cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
foreach (var r in records) // On successful upload, delete uploaded scan records from local SQLite.
r.IsSynced = true; db.ScanRecords.RemoveRange(records);
await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
} }
} }
catch catch
{ {
// Leave records unsynced; will retry on next run // Leave records intact; will retry on next run
} }
} }
} }

View File

@ -6,7 +6,7 @@
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<UseWPF>true</UseWPF> <UseWPF>true</UseWPF>
<ApplicationIcon></ApplicationIcon> <ApplicationIcon Condition="Exists('assets\\favicon.ico')">assets\favicon.ico</ApplicationIcon>
<RootNamespace>UtopiaCanteenSystem</RootNamespace> <RootNamespace>UtopiaCanteenSystem</RootNamespace>
<AssemblyName>UtopiaCanteenSystem</AssemblyName> <AssemblyName>UtopiaCanteenSystem</AssemblyName>
<ProjectGuid>{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}</ProjectGuid> <ProjectGuid>{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}</ProjectGuid>
@ -28,4 +28,17 @@
</None> </None>
</ItemGroup> </ItemGroup>
<ItemGroup>
<!-- Optional: login logo. Place at solution root: assets/logo.png -->
<Content Include="..\assets\logo.png" Link="assets\logo.png" Condition="Exists('..\assets\logo.png')">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="..\assets\favicon.ico" Link="assets\favicon.ico" Condition="Exists('..\assets\favicon.ico')">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="..\assets\scanner.png" Link="assets\scanner.png" Condition="Exists('..\assets\scanner.png')">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
</Project> </Project>

View File

@ -9,6 +9,7 @@ public partial class AdminLoginViewModel : ObservableObject
private readonly IAuthService _authService; private readonly IAuthService _authService;
private readonly AppSession _session; private readonly AppSession _session;
private readonly INavigationService _navigation; private readonly INavigationService _navigation;
private readonly IConfigService _config;
[ObservableProperty] [ObservableProperty]
private string _username = string.Empty; private string _username = string.Empty;
@ -23,11 +24,22 @@ public partial class AdminLoginViewModel : ObservableObject
[ObservableProperty] [ObservableProperty]
private bool _isBusy; 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; _authService = authService;
_session = session; _session = session;
_navigation = navigation; _navigation = navigation;
_config = config;
RememberCredentials = _config.GetRememberAdminCredentials();
if (RememberCredentials)
{
Username = _config.GetSavedAdminUsername();
Password = _config.GetSavedAdminPassword();
}
} }
[RelayCommand] [RelayCommand]
@ -41,7 +53,8 @@ public partial class AdminLoginViewModel : ObservableObject
var user = Username?.Trim() ?? string.Empty; var user = Username?.Trim() ?? string.Empty;
var pass = Password ?? 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; Username = string.Empty;
Password = string.Empty; Password = string.Empty;
@ -62,6 +75,20 @@ public partial class AdminLoginViewModel : ObservableObject
} }
_session.SetAdminAuthenticated(user, result.EmployeeId); _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(); _navigation.NavigateToScanner();
} }
finally finally

View File

@ -9,6 +9,7 @@ public partial class AdminSettingsAuthViewModel : ObservableObject
private readonly IAuthService _authService; private readonly IAuthService _authService;
private readonly AppSession _session; private readonly AppSession _session;
private readonly INavigationService _navigation; private readonly INavigationService _navigation;
private readonly IConfigService _config;
[ObservableProperty] [ObservableProperty]
private string _username = string.Empty; private string _username = string.Empty;
@ -23,17 +24,28 @@ public partial class AdminSettingsAuthViewModel : ObservableObject
[ObservableProperty] [ObservableProperty]
private bool _isBusy; 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; _authService = authService;
_session = session; _session = session;
_navigation = navigation; _navigation = navigation;
_config = config;
RememberCredentials = _config.GetRememberAdminCredentials();
if (RememberCredentials)
{
Username = _config.GetSavedAdminUsername();
Password = _config.GetSavedAdminPassword();
}
} }
[RelayCommand] [RelayCommand]
private void Cancel() private void Cancel()
{ {
_navigation.NavigateToDashboard(); _navigation.NavigateToScanner();
} }
[RelayCommand] [RelayCommand]
@ -47,7 +59,8 @@ public partial class AdminSettingsAuthViewModel : ObservableObject
var user = Username?.Trim() ?? string.Empty; var user = Username?.Trim() ?? string.Empty;
var pass = Password ?? 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; Username = string.Empty;
Password = string.Empty; Password = string.Empty;
@ -69,6 +82,20 @@ public partial class AdminSettingsAuthViewModel : ObservableObject
// Refresh session details (who authenticated for Settings). // Refresh session details (who authenticated for Settings).
_session.SetAdminAuthenticated(user, result.EmployeeId); _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(); _navigation.NavigateToSettings();
} }
finally finally

View File

@ -115,15 +115,11 @@ public partial class MainDashboardViewModel : ObservableObject
try try
{ {
var todayCount = await _rfidService.GetTodayScanCountForCardAsync(cardId).ConfigureAwait(false); 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. // Show "scanned again" ONLY when the same card has 2+ scans today.
if (totalCount >= 2) if (todayCount >= 2)
{ {
var msg = var msg = $"Welcome back! You scanned again (#{todayCount} today).";
todayCount >= 2
? $"Welcome back! You scanned again (#{todayCount} today)."
: "Welcome back! You scanned again.";
System.Windows.Application.Current.Dispatcher.Invoke(() => System.Windows.Application.Current.Dispatcher.Invoke(() =>
{ {

View File

@ -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;
/// <summary>
/// Single-screen ViewModel that combines continuous scanning + dashboard info.
/// </summary>
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();
}
}

View File

@ -71,13 +71,30 @@
Color="#000000"/> Color="#000000"/>
</Border.Effect> </Border.Effect>
<StackPanel> <Grid>
<TextBlock Text="Utopia Canteen System" <Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<StackPanel Grid.Row="0">
<Grid HorizontalAlignment="Center" Margin="0,0,0,12">
<Image x:Name="LogoImage"
Source="pack://siteoforigin:,,,/assets/logo.png"
Height="190"
MaxWidth="520"
Stretch="Uniform"
HorizontalAlignment="Center"
ImageFailed="LogoImage_OnImageFailed"/>
<!-- Fallback if logo.png is missing -->
<TextBlock x:Name="LogoFallbackText"
Text="Utopia Canteen System"
FontSize="32" FontSize="32"
FontWeight="Bold" FontWeight="Bold"
Foreground="{StaticResource TitleTextBrush}" Foreground="{StaticResource TitleTextBrush}"
HorizontalAlignment="Center" HorizontalAlignment="Center"
Margin="0,0,0,12"/> Visibility="Collapsed"/>
</Grid>
<TextBlock Text="Admin Login" <TextBlock Text="Admin Login"
FontSize="16" FontSize="16"
@ -93,6 +110,7 @@
<TextBox x:Name="UsernameTextBox" <TextBox x:Name="UsernameTextBox"
Text="{Binding Username, UpdateSourceTrigger=PropertyChanged}" Text="{Binding Username, UpdateSourceTrigger=PropertyChanged}"
Style="{StaticResource TextInputStyle}" Style="{StaticResource TextInputStyle}"
KeyDown="AdminLogin_OnKeyDown"
Margin="0,0,0,16"/> Margin="0,0,0,16"/>
<TextBlock Text="Password" <TextBlock Text="Password"
@ -103,8 +121,16 @@
<PasswordBox x:Name="PasswordBox" <PasswordBox x:Name="PasswordBox"
Style="{StaticResource PasswordInputStyle}" Style="{StaticResource PasswordInputStyle}"
PasswordChanged="PasswordBox_OnPasswordChanged" PasswordChanged="PasswordBox_OnPasswordChanged"
KeyDown="AdminLogin_OnKeyDown"
Margin="0,0,0,24"/> Margin="0,0,0,24"/>
<CheckBox Content="Save credentials"
IsChecked="{Binding RememberCredentials}"
Foreground="{StaticResource MutedTextBrush}"
FontSize="14"
FontWeight="SemiBold"
Margin="0,0,0,16"/>
<Button Content="LOGIN" <Button Content="LOGIN"
Command="{Binding AdminLoginCommand}" Command="{Binding AdminLoginCommand}"
Style="{StaticResource RoundedButtonStyle}"/> Style="{StaticResource RoundedButtonStyle}"/>
@ -118,6 +144,33 @@
TextAlignment="Center" TextAlignment="Center"
TextWrapping="Wrap"/> TextWrapping="Wrap"/>
</StackPanel> </StackPanel>
<!-- Loader overlay -->
<Grid Grid.RowSpan="2"
Background="#80FFFFFF"
Visibility="{Binding IsBusy, Converter={StaticResource BoolToVisibility}}"
IsHitTestVisible="{Binding IsBusy}">
<Border Background="White"
BorderBrush="#e2e8f0"
BorderThickness="1"
CornerRadius="12"
Padding="18"
HorizontalAlignment="Center"
VerticalAlignment="Center">
<StackPanel>
<TextBlock Text="Signing in…"
FontSize="16"
FontWeight="SemiBold"
Foreground="{StaticResource TitleTextBrush}"
HorizontalAlignment="Center"
Margin="0,0,0,10"/>
<ProgressBar Width="220"
Height="6"
IsIndeterminate="True"/>
</StackPanel>
</Border>
</Grid>
</Grid>
</Border> </Border>
</Viewbox> </Viewbox>
</Grid> </Grid>

View File

@ -1,7 +1,9 @@
using System.Windows; using System.Windows;
using System.Windows.Controls; using System.Windows.Controls;
using System.ComponentModel; using System.ComponentModel;
using System.Windows.Input;
using System.Windows.Threading; using System.Windows.Threading;
using System.Windows.Media.Imaging;
using UtopiaCanteenSystem.ViewModels; using UtopiaCanteenSystem.ViewModels;
namespace UtopiaCanteenSystem.Views; namespace UtopiaCanteenSystem.Views;
@ -24,10 +26,11 @@ public partial class AdminLoginView : UserControl
{ {
Dispatcher.BeginInvoke(() => Dispatcher.BeginInvoke(() =>
{ {
// Ensure stale PasswordBox contents never carry across sessions (PasswordBox isn't bindable). // If VM has a remembered password, prefill the PasswordBox (it's not bindable).
PasswordBox.Password = string.Empty; if (DataContext is AdminLoginViewModel vm && !string.IsNullOrEmpty(vm.Password))
if (DataContext is AdminLoginViewModel vm) PasswordBox.Password = vm.Password;
vm.Password = string.Empty;
TryLoadLogo();
UsernameTextBox.Focus(); UsernameTextBox.Focus();
UsernameTextBox.SelectAll(); UsernameTextBox.SelectAll();
@ -43,10 +46,9 @@ public partial class AdminLoginView : UserControl
if (_vm != null) if (_vm != null)
_vm.PropertyChanged += VmOnPropertyChanged; _vm.PropertyChanged += VmOnPropertyChanged;
// When we navigate back here after logout, the view might be reused; always clear password UI. // Sync PasswordBox from VM when navigating here (supports remembered creds).
PasswordBox.Password = string.Empty; if (_vm != null)
if (DataContext is AdminLoginViewModel vm) PasswordBox.Password = _vm.Password ?? string.Empty;
vm.Password = string.Empty;
} }
private void VmOnPropertyChanged(object? sender, PropertyChangedEventArgs e) private void VmOnPropertyChanged(object? sender, PropertyChangedEventArgs e)
@ -64,5 +66,46 @@ public partial class AdminLoginView : UserControl
if (DataContext is AdminLoginViewModel vm) if (DataContext is AdminLoginViewModel vm)
vm.Password = PasswordBox.Password; 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;
}
}
} }

View File

@ -96,7 +96,13 @@
Color="#000000"/> Color="#000000"/>
</Border.Effect> </Border.Effect>
<StackPanel> <Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<StackPanel Grid.Row="0">
<TextBlock Text="Admin Authentication Required" <TextBlock Text="Admin Authentication Required"
FontSize="28" FontSize="28"
FontWeight="Bold" FontWeight="Bold"
@ -118,6 +124,7 @@
<TextBox x:Name="UsernameTextBox" <TextBox x:Name="UsernameTextBox"
Text="{Binding Username, UpdateSourceTrigger=PropertyChanged}" Text="{Binding Username, UpdateSourceTrigger=PropertyChanged}"
Style="{StaticResource TextInputStyle}" Style="{StaticResource TextInputStyle}"
KeyDown="Confirm_OnKeyDown"
Margin="0,0,0,16"/> Margin="0,0,0,16"/>
<TextBlock Text="Password" <TextBlock Text="Password"
@ -128,8 +135,16 @@
<PasswordBox x:Name="PasswordBox" <PasswordBox x:Name="PasswordBox"
Style="{StaticResource PasswordInputStyle}" Style="{StaticResource PasswordInputStyle}"
PasswordChanged="PasswordBox_OnPasswordChanged" PasswordChanged="PasswordBox_OnPasswordChanged"
KeyDown="Confirm_OnKeyDown"
Margin="0,0,0,24"/> Margin="0,0,0,24"/>
<CheckBox Content="Save credentials"
IsChecked="{Binding RememberCredentials}"
Foreground="{StaticResource MutedTextBrush}"
FontSize="14"
FontWeight="SemiBold"
Margin="0,0,0,16"/>
<Grid> <Grid>
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/> <ColumnDefinition Width="*"/>
@ -157,6 +172,33 @@
TextAlignment="Center" TextAlignment="Center"
TextWrapping="Wrap"/> TextWrapping="Wrap"/>
</StackPanel> </StackPanel>
<!-- Loader overlay -->
<Grid Grid.RowSpan="2"
Background="#80FFFFFF"
Visibility="{Binding IsBusy, Converter={StaticResource BoolToVisibility}}"
IsHitTestVisible="{Binding IsBusy}">
<Border Background="White"
BorderBrush="#e2e8f0"
BorderThickness="1"
CornerRadius="12"
Padding="18"
HorizontalAlignment="Center"
VerticalAlignment="Center">
<StackPanel>
<TextBlock Text="Authenticating…"
FontSize="16"
FontWeight="SemiBold"
Foreground="{StaticResource TitleTextBrush}"
HorizontalAlignment="Center"
Margin="0,0,0,10"/>
<ProgressBar Width="220"
Height="6"
IsIndeterminate="True"/>
</StackPanel>
</Border>
</Grid>
</Grid>
</Border> </Border>
</Viewbox> </Viewbox>
</Grid> </Grid>

View File

@ -1,6 +1,7 @@
using System.ComponentModel; using System.ComponentModel;
using System.Windows; using System.Windows;
using System.Windows.Controls; using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Threading; using System.Windows.Threading;
using UtopiaCanteenSystem.ViewModels; using UtopiaCanteenSystem.ViewModels;
@ -24,9 +25,9 @@ public partial class AdminSettingsAuthView : UserControl
{ {
Dispatcher.BeginInvoke(() => Dispatcher.BeginInvoke(() =>
{ {
PasswordBox.Password = string.Empty; // If VM has a remembered password, prefill the PasswordBox (it's not bindable).
if (DataContext is AdminSettingsAuthViewModel vm) if (DataContext is AdminSettingsAuthViewModel vm && !string.IsNullOrEmpty(vm.Password))
vm.Password = string.Empty; PasswordBox.Password = vm.Password;
UsernameTextBox.Focus(); UsernameTextBox.Focus();
UsernameTextBox.SelectAll(); UsernameTextBox.SelectAll();
@ -42,9 +43,9 @@ public partial class AdminSettingsAuthView : UserControl
if (_vm != null) if (_vm != null)
_vm.PropertyChanged += VmOnPropertyChanged; _vm.PropertyChanged += VmOnPropertyChanged;
PasswordBox.Password = string.Empty; // Sync PasswordBox from VM when navigating here (supports remembered creds).
if (DataContext is AdminSettingsAuthViewModel vm) if (_vm != null)
vm.Password = string.Empty; PasswordBox.Password = _vm.Password ?? string.Empty;
} }
private void VmOnPropertyChanged(object? sender, PropertyChangedEventArgs e) private void VmOnPropertyChanged(object? sender, PropertyChangedEventArgs e)
@ -61,5 +62,17 @@ public partial class AdminSettingsAuthView : UserControl
if (DataContext is AdminSettingsAuthViewModel vm) if (DataContext is AdminSettingsAuthViewModel vm)
vm.Password = PasswordBox.Password; 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;
}
}
} }

View File

@ -0,0 +1,334 @@
<UserControl x:Class="UtopiaCanteenSystem.Views.ScannerDashboardView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<UserControl.Resources>
<SolidColorBrush x:Key="AccentBrush" Color="#5BA3A0"/>
<SolidColorBrush x:Key="AccentBorderBrush" Color="#995BA3A0"/>
<SolidColorBrush x:Key="MutedTextBrush" Color="#718096"/>
<SolidColorBrush x:Key="TitleTextBrush" Color="#2D3748"/>
<SolidColorBrush x:Key="ErrorTextBrush" Color="#E53E3E"/>
<SolidColorBrush x:Key="ConnectedBrush" Color="#38A169"/>
<SolidColorBrush x:Key="SuccessTextBrush" Color="#38A169"/>
<Style x:Key="RoundedButtonStyle" TargetType="Button">
<Setter Property="Foreground" Value="White"/>
<Setter Property="Background" Value="{StaticResource AccentBrush}"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="FontSize" Value="18"/>
<Setter Property="FontWeight" Value="Bold"/>
<Setter Property="Padding" Value="24,12"/>
<Setter Property="MinHeight" Value="50"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border Background="{TemplateBinding Background}"
CornerRadius="8"
SnapsToDevicePixels="True">
<ContentPresenter HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="SecondaryButtonStyle" TargetType="Button">
<Setter Property="Foreground" Value="{StaticResource TitleTextBrush}"/>
<Setter Property="Background" Value="#f4f7f7"/>
<Setter Property="BorderBrush" Value="#e2e8f0"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="FontSize" Value="16"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Padding" Value="20,12"/>
<Setter Property="MinHeight" Value="50"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="ButtonBorder"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="8">
<ContentPresenter HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="ButtonBorder" Property="Background" Value="#eef2f2"/>
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="ButtonBorder" Property="Background" Value="#e6eded"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="LogoutLinkButtonStyle" TargetType="Button">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Foreground" Value="{StaticResource MutedTextBrush}"/>
<Setter Property="FontSize" Value="14"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Padding" Value="0"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border Background="{TemplateBinding Background}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Foreground" Value="{StaticResource TitleTextBrush}"/>
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter Property="Opacity" Value="0.75"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="StatusTextStyle" TargetType="TextBlock">
<Setter Property="Foreground" Value="{StaticResource MutedTextBrush}"/>
<Setter Property="FontSize" Value="14"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Style.Triggers>
<DataTrigger Binding="{Binding ScannerStatus}" Value="Connected">
<Setter Property="Foreground" Value="{StaticResource ConnectedBrush}"/>
</DataTrigger>
</Style.Triggers>
</Style>
<Style x:Key="StatusIconStyle" TargetType="TextBlock">
<Setter Property="FontFamily" Value="Segoe MDL2 Assets"/>
<Setter Property="FontSize" Value="16"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="VerticalAlignment" Value="Center"/>
<Setter Property="Text" Value=""/>
<Setter Property="Foreground" Value="{StaticResource ErrorTextBrush}"/>
<Style.Triggers>
<DataTrigger Binding="{Binding ScannerStatus}" Value="Connected">
<Setter Property="Foreground" Value="{StaticResource ConnectedBrush}"/>
</DataTrigger>
</Style.Triggers>
</Style>
<Style x:Key="StatusBorderStyle" TargetType="Border">
<Setter Property="Background" Value="#dcfce7"/>
<Setter Property="BorderBrush" Value="#86efac"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="CornerRadius" Value="8"/>
<Setter Property="Padding" Value="14"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsSuccess}" Value="False">
<Setter Property="Background" Value="#fee2e2"/>
<Setter Property="BorderBrush" Value="#fca5a5"/>
</DataTrigger>
</Style.Triggers>
</Style>
</UserControl.Resources>
<Grid Background="#F0F2F5">
<ScrollViewer VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled"
Padding="40">
<Border HorizontalAlignment="Center"
VerticalAlignment="Top"
Width="{Binding RelativeSource={RelativeSource AncestorType=ScrollViewer}, Path=ViewportWidth}"
MaxWidth="900"
Padding="40"
Background="White"
CornerRadius="12">
<Border.Effect>
<DropShadowEffect BlurRadius="24"
ShadowDepth="0"
Opacity="0.08"
Color="#000000"/>
</Border.Effect>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- Top section -->
<StackPanel Grid.Row="0" HorizontalAlignment="Center">
<Image Source="pack://siteoforigin:,,,/assets/scanner.png"
Height="120"
MaxWidth="320"
Stretch="Uniform"
Margin="0,0,0,10"/>
<TextBlock Text="Utopia Canteen System"
FontSize="32"
FontWeight="Bold"
Foreground="{StaticResource TitleTextBrush}"
HorizontalAlignment="Center"/>
<TextBlock Text="Scan your card to record"
FontSize="16"
Foreground="{StaticResource MutedTextBrush}"
HorizontalAlignment="Center"
Margin="0,8,0,0"/>
</StackPanel>
<!-- Scanner input -->
<StackPanel Grid.Row="1" Margin="0,24,0,0">
<Border MinHeight="50"
CornerRadius="8"
BorderBrush="{StaticResource AccentBorderBrush}"
BorderThickness="2">
<Grid>
<TextBox x:Name="RfidInputTextBox"
Text="{Binding CardIdInput, UpdateSourceTrigger=PropertyChanged}"
Background="Transparent"
BorderThickness="0"
FontSize="18"
MinHeight="50"
VerticalContentAlignment="Center"
HorizontalContentAlignment="Center"
Focusable="True"
KeyDown="RfidInputTextBox_OnKeyDown"
LostKeyboardFocus="RfidInputTextBox_OnLostKeyboardFocus"/>
<TextBlock Text="Scan Card ID"
Foreground="Gray"
FontSize="18"
VerticalAlignment="Center"
HorizontalAlignment="Center"
TextAlignment="Center"
IsHitTestVisible="False"
Visibility="{Binding IsWatermarkVisible, Converter={StaticResource BoolToVisibility}}"/>
</Grid>
</Border>
<Button Content="SCAN"
Command="{Binding ScanCommand}"
Margin="0,16,0,0"
HorizontalAlignment="Stretch"
Style="{StaticResource RoundedButtonStyle}"/>
</StackPanel>
<!-- Status row + timestamp -->
<StackPanel Grid.Row="2" Margin="0,18,0,0" HorizontalAlignment="Center">
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
<TextBlock Style="{StaticResource StatusIconStyle}" Margin="0,0,8,0"/>
<TextBlock Text="{Binding ScannerStatusDisplay}" Style="{StaticResource StatusTextStyle}"/>
</StackPanel>
<TextBlock Text="{Binding CurrentTime}"
FontSize="14"
Foreground="{StaticResource MutedTextBrush}"
Margin="0,10,0,0"
HorizontalAlignment="Center"/>
</StackPanel>
<!-- Divider -->
<Border Grid.Row="3"
Height="1"
Background="#e2e8f0"
Margin="0,24,0,24"/>
<!-- Dashboard section -->
<Grid Grid.Row="4">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Border Grid.Row="0" Background="#f4f7f7" CornerRadius="8" Padding="24" Margin="0,0,0,16">
<StackPanel HorizontalAlignment="Center">
<TextBlock Text="{Binding TodaysScans}"
FontSize="56"
FontWeight="Bold"
Foreground="{StaticResource TitleTextBrush}"
HorizontalAlignment="Center"/>
<TextBlock Text="Today's Scans"
FontSize="16"
Foreground="{StaticResource MutedTextBrush}"
HorizontalAlignment="Center"
Margin="0,6,0,0"/>
</StackPanel>
</Border>
<Border Grid.Row="1" BorderBrush="#e2e8f0" BorderThickness="1" CornerRadius="8" Padding="16" Margin="0,0,0,16">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Row="0" Grid.Column="0" Margin="0,0,12,12">
<TextBlock Text="LAST CARD ID" FontSize="12" Foreground="{StaticResource MutedTextBrush}" FontWeight="SemiBold"/>
<TextBlock Text="{Binding LastCardId}" FontSize="20" FontWeight="SemiBold" Foreground="{StaticResource TitleTextBrush}" />
</StackPanel>
<StackPanel Grid.Row="0" Grid.Column="1" Margin="12,0,0,12">
<TextBlock Text="LAST SCAN TIME" FontSize="12" Foreground="{StaticResource MutedTextBrush}" FontWeight="SemiBold"/>
<TextBlock Text="{Binding LastScanTimeDisplay}" FontSize="20" FontWeight="SemiBold" Foreground="{StaticResource TitleTextBrush}" />
</StackPanel>
</Grid>
</Border>
<Border Grid.Row="2" Style="{StaticResource StatusBorderStyle}">
<TextBlock Text="{Binding Message}"
FontSize="16"
FontWeight="SemiBold"
Foreground="{StaticResource SuccessTextBrush}">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Foreground" Value="{StaticResource SuccessTextBrush}"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsSuccess}" Value="False">
<Setter Property="Foreground" Value="{StaticResource ErrorTextBrush}"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</Border>
</Grid>
<!-- Bottom row -->
<Grid Grid.Row="5" Margin="0,24,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<Button Grid.Column="0"
Content="Settings"
Command="{Binding OpenSettingsCommand}"
Style="{StaticResource SecondaryButtonStyle}"
Visibility="{Binding IsAdminAuthenticated, Converter={StaticResource BoolToVisibility}}"
Focusable="False"
IsTabStop="False"/>
<Button Grid.Column="2"
Command="{Binding LogoutCommand}"
Style="{StaticResource LogoutLinkButtonStyle}"
Focusable="False"
IsTabStop="False">
<StackPanel Orientation="Horizontal">
<TextBlock Text="⎋" FontSize="14" Margin="0,0,8,0" VerticalAlignment="Center"/>
<TextBlock Text="Logout" FontSize="14" VerticalAlignment="Center"/>
</StackPanel>
</Button>
</Grid>
</Grid>
</Border>
</ScrollViewer>
</Grid>
</UserControl>

View File

@ -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;
/// <summary>
/// Minimal code-behind for focus management and Enter-key submission.
/// </summary>
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;
}
}
}

View File

@ -155,10 +155,10 @@
<!-- Row 0: main scanner content --> <!-- Row 0: main scanner content -->
<StackPanel Grid.Row="0" HorizontalAlignment="Stretch" VerticalAlignment="Center"> <StackPanel Grid.Row="0" HorizontalAlignment="Stretch" VerticalAlignment="Center">
<TextBlock Text="Utopia Canteen System" <Image Source="pack://siteoforigin:,,,/assets/scanner.png"
FontSize="32" Height="120"
FontWeight="Bold" MaxWidth="320"
Foreground="{StaticResource TitleTextBrush}" Stretch="Uniform"
HorizontalAlignment="Center" HorizontalAlignment="Center"
Margin="0,0,0,12"/> Margin="0,0,0,12"/>

BIN
assets/logo.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB