commit 113b22f3449a85f94cec490f4054f9cdfa2a8143 Author: mustafa.ahmed Date: Sat Jan 31 16:53:32 2026 +0500 Initial commit: admin login + scanner flow + sqlite persistence + sync service diff --git a/App.xaml b/App.xaml new file mode 100644 index 0000000..7ad0291 --- /dev/null +++ b/App.xaml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/App.xaml.cs b/App.xaml.cs new file mode 100644 index 0000000..a875d9d --- /dev/null +++ b/App.xaml.cs @@ -0,0 +1,76 @@ +using System.Windows; +using Microsoft.EntityFrameworkCore; +using UtopiaCanteenSystem.Data; +using UtopiaCanteenSystem.Services; +using UtopiaCanteenSystem.ViewModels; + +namespace UtopiaCanteenSystem; + +/// +/// Application entry point. Initializes database, builds service graph, starts hourly sync timer. +/// +public partial class App : Application +{ + private System.Timers.Timer? _syncTimer; + + protected override void OnStartup(StartupEventArgs e) + { + base.OnStartup(e); + + // Build services (simple composition; no DI container) + var dbFactory = new DbContextFactory(); + + // Auto-create SQLite database on first run + using (var db = dbFactory.CreateDbContext()) + { + db.EnsureDatabaseCreated(); + } + + var configService = new ConfigService(); + var rfidService = new RfidService(dbFactory, configService); + var syncService = new SyncService(dbFactory, configService); + var session = new AppSession(); + var authenticationUrl = "https://portal.utopiaindustries.pk/uind/rest/auth/user/"; + var authService = new AuthService(authenticationUrl); + + // NavigationService: declare first so lambdas can capture it, then assign (avoids "used before declared") + NavigationService navigationService = null!; + navigationService = new NavigationService( + session, + () => new AdminLoginViewModel(authService, session, navigationService), + () => new ScannerViewModel(rfidService, navigationService, session), + () => new MainDashboardViewModel(navigationService, rfidService, configService, session), + () => new AdminSettingsAuthViewModel(authService, session, navigationService), + () => new SettingsViewModel(configService, navigationService)); + + var mainViewModel = new MainViewModel(navigationService); + + var mainWindow = new MainWindow + { + DataContext = mainViewModel + }; + mainWindow.Show(); + + // Hourly background sync: every 1 hour, POST unsynced ScanRecords to API + _syncTimer = new System.Timers.Timer(TimeSpan.FromHours(1).TotalMilliseconds); + _syncTimer.Elapsed += async (_, _) => + { + try + { + await syncService.SyncNowAsync().ConfigureAwait(false); + } + catch + { + // Ignore; will retry next hour + } + }; + _syncTimer.Start(); + } + + protected override void OnExit(ExitEventArgs e) + { + _syncTimer?.Stop(); + _syncTimer?.Dispose(); + base.OnExit(e); + } +} diff --git a/Converters/StringLengthToVisibilityConverter.cs b/Converters/StringLengthToVisibilityConverter.cs new file mode 100644 index 0000000..f8666db --- /dev/null +++ b/Converters/StringLengthToVisibilityConverter.cs @@ -0,0 +1,25 @@ +using System; +using System.Globalization; +using System.Windows; +using System.Windows.Data; + +namespace UtopiaCanteenSystem.Converters +{ + public class StringLengthToVisibilityConverter : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + // Binding is typically Text.Length (int): show watermark when empty (0), hide when has text + if (value is int length) + return length == 0 ? Visibility.Visible : Visibility.Collapsed; + if (value is string s) + return string.IsNullOrEmpty(s) ? Visibility.Visible : Visibility.Collapsed; + return Visibility.Visible; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + return Binding.DoNothing; // No reverse conversion + } + } +} diff --git a/Data/AppDbContext.cs b/Data/AppDbContext.cs new file mode 100644 index 0000000..6ee25ef --- /dev/null +++ b/Data/AppDbContext.cs @@ -0,0 +1,55 @@ +using Microsoft.EntityFrameworkCore; +using UtopiaCanteenSystem.Models; +using System.IO; + +namespace UtopiaCanteenSystem.Data; + +/// +/// SQLite DbContext for Labour and ScanRecord tables. +/// Database file is created in application directory on first run. +/// +public class AppDbContext : DbContext +{ + private static readonly string DbPath = Path.Combine( + AppDomain.CurrentDomain.BaseDirectory, + "utopia_canteen.db"); + + public DbSet Labour { get; set; } + public DbSet ScanRecords { get; set; } + + public AppDbContext() { } + + public AppDbContext(DbContextOptions options) : base(options) { } + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + if (!optionsBuilder.IsConfigured) + optionsBuilder.UseSqlite($"Data Source={DbPath}"); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + // Labour: CardId should be indexed for lookups. + modelBuilder.Entity(e => + { + e.HasKey(x => x.Id); + e.HasIndex(x => x.CardId); + }); + + // ScanRecord: Index for unsynced queries and by ScanTime. + modelBuilder.Entity(e => + { + e.HasKey(x => x.Id); + e.HasIndex(x => x.IsSynced); + e.HasIndex(x => x.ScanTime); + }); + } + + /// + /// Ensures database exists and is migrated. Call on app startup. + /// + public void EnsureDatabaseCreated() + { + Database.EnsureCreated(); + } +} diff --git a/Data/DbContextFactory.cs b/Data/DbContextFactory.cs new file mode 100644 index 0000000..2b7e811 --- /dev/null +++ b/Data/DbContextFactory.cs @@ -0,0 +1,22 @@ +using Microsoft.EntityFrameworkCore; +using System.IO; +namespace UtopiaCanteenSystem.Data; + +/// +/// Factory for creating AppDbContext instances. Used by RfidService and SyncService +/// to avoid sharing a single context across async operations. +/// +public class DbContextFactory : IDbContextFactory +{ + private static readonly DbContextOptions Options; + + static DbContextFactory() + { + var builder = new DbContextOptionsBuilder(); + var dbPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "utopia_canteen.db"); + builder.UseSqlite($"Data Source={dbPath}"); + Options = builder.Options; + } + + public AppDbContext CreateDbContext() => new AppDbContext(Options); +} diff --git a/Helpers/ScanSessionHelper.cs b/Helpers/ScanSessionHelper.cs new file mode 100644 index 0000000..052cc55 --- /dev/null +++ b/Helpers/ScanSessionHelper.cs @@ -0,0 +1,40 @@ +using System; + +namespace UtopiaCanteenSystem.Helpers; + +/// +/// Helper for scan session rules: limit one scan per minute (1-minute session). +/// +public static class ScanSessionHelper +{ + /// + /// Checks if a new scan is allowed: no other scan in the same minute (UTC). + /// + /// Last scan time in UTC; null if none. + /// Current time in UTC. + /// True if a new scan is allowed. + public static bool IsNewScanAllowed(DateTime? lastScanTimeUtc, DateTime nowUtc) + { + if (lastScanTimeUtc == null) + return true; + + // Same minute (year, month, day, hour, minute) = not allowed + var last = lastScanTimeUtc.Value; + if (last.Year == nowUtc.Year && + last.Month == nowUtc.Month && + last.Day == nowUtc.Day && + last.Hour == nowUtc.Hour && + last.Minute == nowUtc.Minute) + return false; + + return true; + } + + /// + /// Truncates the given UTC time to the start of its minute (session window). + /// + public static DateTime TruncateToMinute(DateTime utc) + { + return new DateTime(utc.Year, utc.Month, utc.Day, utc.Hour, utc.Minute, 0, DateTimeKind.Utc); + } +} diff --git a/MainWindow.xaml b/MainWindow.xaml new file mode 100644 index 0000000..299123f --- /dev/null +++ b/MainWindow.xaml @@ -0,0 +1,11 @@ + + + + + diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs new file mode 100644 index 0000000..6c5dd27 --- /dev/null +++ b/MainWindow.xaml.cs @@ -0,0 +1,15 @@ +using System.Windows; + +namespace UtopiaCanteenSystem; + +/// +/// Main window: hosts the current view via ContentControl and DataTemplates. +/// DataContext is set from App.xaml.cs to MainViewModel. No logic in code-behind. +/// +public partial class MainWindow : Window +{ + public MainWindow() + { + InitializeComponent(); + } +} diff --git a/Models/Labour.cs b/Models/Labour.cs new file mode 100644 index 0000000..ef5d6b0 --- /dev/null +++ b/Models/Labour.cs @@ -0,0 +1,13 @@ +namespace UtopiaCanteenSystem.Models; + +/// +/// Represents a labour/worker entity. CardId is the primary identifier from RFID. +/// +public class Labour +{ + public int Id { get; set; } + /// RFID card identifier; used for scan matching. + public string CardId { get; set; } = string.Empty; + /// Optional display name for the labour. + public string? Name { get; set; } +} diff --git a/Models/ScanRecord.cs b/Models/ScanRecord.cs new file mode 100644 index 0000000..13f1b1b --- /dev/null +++ b/Models/ScanRecord.cs @@ -0,0 +1,15 @@ +namespace UtopiaCanteenSystem.Models; + +/// +/// Represents a single RFID scan event. Used for local storage and UIND sync. +/// +public class ScanRecord +{ + public int Id { get; set; } + /// RFID card identifier at time of scan. + public string CardId { get; set; } = string.Empty; + /// UTC time when the scan occurred. + public DateTime ScanTime { get; set; } + /// True after record has been successfully sent to the sync API. + public bool IsSynced { get; set; } +} diff --git a/Services/AppSession.cs b/Services/AppSession.cs new file mode 100644 index 0000000..f49ea48 --- /dev/null +++ b/Services/AppSession.cs @@ -0,0 +1,29 @@ +namespace UtopiaCanteenSystem.Services; + +/// +/// Holds the current admin session state for the app. +/// +public class AppSession +{ + public bool IsAdminAuthenticated { get; private set; } + public string AdminUsername { get; private set; } = string.Empty; + public string AdminEmployeeId { get; private set; } = string.Empty; + public DateTime? LoginTimeUtc { get; private set; } + + public void SetAdminAuthenticated(string username, string employeeId) + { + IsAdminAuthenticated = true; + AdminUsername = username ?? string.Empty; + AdminEmployeeId = employeeId ?? string.Empty; + LoginTimeUtc = DateTime.UtcNow; + } + + public void Logout() + { + IsAdminAuthenticated = false; + AdminUsername = string.Empty; + AdminEmployeeId = string.Empty; + LoginTimeUtc = null; + } +} + diff --git a/Services/AuthResult.cs b/Services/AuthResult.cs new file mode 100644 index 0000000..f5459a7 --- /dev/null +++ b/Services/AuthResult.cs @@ -0,0 +1,9 @@ +namespace UtopiaCanteenSystem.Services; + +/// +/// Result of an admin authentication attempt. +/// +public readonly record struct AuthResult( + bool Success, + string EmployeeId); + diff --git a/Services/AuthService.cs b/Services/AuthService.cs new file mode 100644 index 0000000..45ee2b5 --- /dev/null +++ b/Services/AuthService.cs @@ -0,0 +1,56 @@ +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +namespace UtopiaCanteenSystem.Services; + +/// +/// Admin authentication service via UIND portal API (plain-text response). +/// +public class AuthService : IAuthService +{ + private static readonly HttpClient HttpClient = new(); + private readonly string _baseUrl; + + public AuthService(string authenticationUrl) + { + _baseUrl = (authenticationUrl ?? string.Empty).Trim(); + if (!string.IsNullOrEmpty(_baseUrl) && !_baseUrl.EndsWith("/")) + _baseUrl += "/"; + } + + public async Task LoginAsync(string username, string password, CancellationToken cancellationToken = default) + { + var user = username?.Trim() ?? string.Empty; + var pass = password ?? string.Empty; + if (string.IsNullOrWhiteSpace(_baseUrl)) + return new AuthResult(false, string.Empty); + + if (string.IsNullOrWhiteSpace(user) || string.IsNullOrEmpty(pass)) + return new AuthResult(false, string.Empty); + + // Build URL: + // {baseUrl}{username}/{password} + // Must URL-encode both values. + var url = $"{_baseUrl}{Uri.EscapeDataString(user)}/{Uri.EscapeDataString(pass)}"; + + try + { + using var resp = await HttpClient.GetAsync(url, cancellationToken).ConfigureAwait(false); + var body = (await resp.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false)).Trim(); + + // Portal returns plain text: + // "NOT FOUND" => failure + // otherwise => success and body contains EmployeeId (or user identifier) + if (body.Equals("NOT FOUND", StringComparison.OrdinalIgnoreCase)) + return new AuthResult(false, string.Empty); + + return new AuthResult(true, body); + } + catch + { + return new AuthResult(false, string.Empty); + } + } +} + diff --git a/Services/ConfigService.cs b/Services/ConfigService.cs new file mode 100644 index 0000000..7bd1fb7 --- /dev/null +++ b/Services/ConfigService.cs @@ -0,0 +1,101 @@ +using System.IO; +using System.Text.Json; + +namespace UtopiaCanteenSystem.Services; + +/// +/// Simple JSON-backed configuration stored alongside the app binaries. +/// +public class ConfigService : IConfigService +{ + private readonly string _configPath; + private AppConfig _config; + + public ConfigService() + { + _configPath = Path.Combine(AppContext.BaseDirectory, "appsettings.json"); + _config = LoadConfig(); + } + + public string GetSyncApiEndpoint() => _config.SyncApiEndpoint ?? string.Empty; + + public void SetSyncApiEndpoint(string endpoint) + { + _config.SyncApiEndpoint = endpoint ?? string.Empty; + SaveConfig(); + } + + public bool GetScannerConnected() => _config.ScannerConnected; + + public void SetScannerConnected(bool connected) + { + _config.ScannerConnected = connected; + SaveConfig(); + } + + public int GetScanTimeoutSeconds() => _config.ScanTimeoutSeconds; + + public void SetScanTimeoutSeconds(int seconds) + { + _config.ScanTimeoutSeconds = seconds; + SaveConfig(); + } + + public string GetAdminCardId() => _config.AdminCardId ?? string.Empty; + + public void SetAdminCardId(string cardId) + { + _config.AdminCardId = cardId ?? string.Empty; + SaveConfig(); + } + + private AppConfig LoadConfig() + { + try + { + if (!File.Exists(_configPath)) + { + var defaults = new AppConfig(); + SaveConfig(defaults); + return defaults; + } + + var json = File.ReadAllText(_configPath); + var config = JsonSerializer.Deserialize(json); + return config ?? new AppConfig(); + } + catch + { + return new AppConfig(); + } + } + + private void SaveConfig() + { + SaveConfig(_config); + } + + private void SaveConfig(AppConfig config) + { + try + { + var json = JsonSerializer.Serialize(config, new JsonSerializerOptions + { + WriteIndented = true + }); + File.WriteAllText(_configPath, json); + } + catch + { + // Ignore write failures (e.g., read-only location). + } + } + + private sealed class AppConfig + { + public string SyncApiEndpoint { get; set; } = "https://api.example.com/uind/sync"; + public bool ScannerConnected { get; set; } = false; + public int ScanTimeoutSeconds { get; set; } = 60; + public string AdminCardId { get; set; } = "ADMIN"; + } +} diff --git a/Services/IAuthService.cs b/Services/IAuthService.cs new file mode 100644 index 0000000..348eaf8 --- /dev/null +++ b/Services/IAuthService.cs @@ -0,0 +1,10 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace UtopiaCanteenSystem.Services; + +public interface IAuthService +{ + Task LoginAsync(string username, string password, CancellationToken cancellationToken = default); +} + diff --git a/Services/IConfigService.cs b/Services/IConfigService.cs new file mode 100644 index 0000000..23b5041 --- /dev/null +++ b/Services/IConfigService.cs @@ -0,0 +1,16 @@ +namespace UtopiaCanteenSystem.Services; + +/// +/// Provides persisted configuration values for the app. +/// +public interface IConfigService +{ + string GetSyncApiEndpoint(); + void SetSyncApiEndpoint(string endpoint); + bool GetScannerConnected(); + void SetScannerConnected(bool connected); + int GetScanTimeoutSeconds(); + void SetScanTimeoutSeconds(int seconds); + string GetAdminCardId(); + void SetAdminCardId(string cardId); +} diff --git a/Services/INavigationService.cs b/Services/INavigationService.cs new file mode 100644 index 0000000..3fe8a58 --- /dev/null +++ b/Services/INavigationService.cs @@ -0,0 +1,31 @@ +namespace UtopiaCanteenSystem.Services; + +/// +/// Navigates between main views (AdminLogin, Scanner, Dashboard, Settings). +/// +public interface INavigationService +{ + /// Current view model to display in the main content area. + object? CurrentViewModel { get; } + + /// Raised when CurrentViewModel changes. + event EventHandler? CurrentViewModelChanged; + + void NavigateToAdminLogin(); + void NavigateToScanner(); + void NavigateToDashboard(); + void NavigateToAdminSettingsAuth(); + void NavigateToSettings(); + + /// Starts a new dashboard session (used after scan). + void StartDashboardSession(); + + /// Returns true when the dashboard session is expired for the given timeout. + bool IsDashboardSessionExpired(TimeSpan timeout); + + /// Navigates back from Settings based on dashboard session expiry. + void NavigateBackFromSettings(TimeSpan timeout); + + /// Returns elapsed time since dashboard session start. + TimeSpan GetDashboardSessionElapsed(); +} diff --git a/Services/IRfidService.cs b/Services/IRfidService.cs new file mode 100644 index 0000000..c5ba6cc --- /dev/null +++ b/Services/IRfidService.cs @@ -0,0 +1,44 @@ +using System.Threading; +using System.Threading.Tasks; +using UtopiaCanteenSystem.Models; + +namespace UtopiaCanteenSystem.Services; + +/// +/// Handles RFID scan logic: validation, timeout rules, and persisting scan records. +/// +public interface IRfidService +{ + /// + /// Processes a scan for the given card ID. Applies configured timeout, + /// saves to ScanRecords if allowed, and returns result message. + /// + (bool Success, string Message) ProcessScan(string cardId); + + /// + /// Processes a scan and also returns cooldown seconds remaining when blocked by timeout rules. + /// + ScanResult ProcessScanDetailed(string cardId); + + /// Returns the last scan record for display (e.g. dashboard). + ScanRecord? GetLastScan(); + + /// Returns the count of scans recorded today (local date). + int GetTodayScanCount(); + + /// + /// Returns the count of scans recorded today (local date boundaries), asynchronously. + /// + Task GetTodayScanCountAsync(CancellationToken cancellationToken = default); + + /// + /// Returns total number of scans recorded for a given card ID (all time). + /// Used to detect repeat scans by the same user. + /// + Task GetTotalScanCountForCardAsync(string cardId, CancellationToken cancellationToken = default); + + /// + /// Returns number of scans recorded today (local day) for a given card ID. + /// + Task GetTodayScanCountForCardAsync(string cardId, CancellationToken cancellationToken = default); +} diff --git a/Services/ISyncService.cs b/Services/ISyncService.cs new file mode 100644 index 0000000..921839c --- /dev/null +++ b/Services/ISyncService.cs @@ -0,0 +1,10 @@ +namespace UtopiaCanteenSystem.Services; + +/// +/// Syncs unsynced ScanRecords to the configured UIND API endpoint. +/// +public interface ISyncService +{ + /// Runs one sync: POST unsynced records to API and mark as synced on success. + Task SyncNowAsync(CancellationToken cancellationToken = default); +} diff --git a/Services/NavigationService.cs b/Services/NavigationService.cs new file mode 100644 index 0000000..643f0d1 --- /dev/null +++ b/Services/NavigationService.cs @@ -0,0 +1,126 @@ +using UtopiaCanteenSystem.ViewModels; + +namespace UtopiaCanteenSystem.Services; + +/// +/// Holds the current view model and notifies when it changes for binding in MainWindow. +/// +public class NavigationService : INavigationService +{ + private object? _currentViewModel; + private DateTime? _dashboardSessionStartUtc; + private readonly AppSession _session; + + public object? CurrentViewModel + { + get => _currentViewModel; + private set + { + if (_currentViewModel == value) return; + _currentViewModel = value; + CurrentViewModelChanged?.Invoke(this, EventArgs.Empty); + } + } + + public event EventHandler? CurrentViewModelChanged; + + private readonly Func _adminLoginVm; + private readonly Func _scannerVm; + private readonly Func _dashboardVm; + private readonly Func _adminSettingsAuthVm; + private readonly Func _settingsVm; + + public NavigationService( + AppSession session, + Func adminLoginVm, + Func scannerVm, + Func dashboardVm, + Func adminSettingsAuthVm, + Func settingsVm) + { + _session = session; + _adminLoginVm = adminLoginVm; + _scannerVm = scannerVm; + _dashboardVm = dashboardVm; + _adminSettingsAuthVm = adminSettingsAuthVm; + _settingsVm = settingsVm; + } + + public void NavigateToAdminLogin() + { + CurrentViewModel = _adminLoginVm(); + } + + public void NavigateToScanner() + { + if (!_session.IsAdminAuthenticated) + { + NavigateToAdminLogin(); + return; + } + + CurrentViewModel = _scannerVm(); + } + + public void NavigateToDashboard() + { + if (!_session.IsAdminAuthenticated) + { + NavigateToAdminLogin(); + return; + } + + CurrentViewModel = _dashboardVm(); + } + + public void NavigateToSettings() + { + if (!_session.IsAdminAuthenticated) + { + NavigateToAdminLogin(); + return; + } + + CurrentViewModel = _settingsVm(); + } + + public void NavigateToAdminSettingsAuth() + { + if (!_session.IsAdminAuthenticated) + { + NavigateToAdminLogin(); + return; + } + + CurrentViewModel = _adminSettingsAuthVm(); + } + + public void StartDashboardSession() + { + _dashboardSessionStartUtc = DateTime.UtcNow; + } + + public bool IsDashboardSessionExpired(TimeSpan timeout) + { + if (!_dashboardSessionStartUtc.HasValue) + return true; + + return DateTime.UtcNow - _dashboardSessionStartUtc.Value >= timeout; + } + + public TimeSpan GetDashboardSessionElapsed() + { + if (!_dashboardSessionStartUtc.HasValue) + return TimeSpan.MaxValue; + + return DateTime.UtcNow - _dashboardSessionStartUtc.Value; + } + + public void NavigateBackFromSettings(TimeSpan timeout) + { + if (IsDashboardSessionExpired(timeout)) + NavigateToScanner(); + else + NavigateToDashboard(); + } +} diff --git a/Services/RfidService.cs b/Services/RfidService.cs new file mode 100644 index 0000000..f63a05f --- /dev/null +++ b/Services/RfidService.cs @@ -0,0 +1,186 @@ +using Microsoft.EntityFrameworkCore; +using System.Threading; +using System.Threading.Tasks; +using UtopiaCanteenSystem.Data; +using UtopiaCanteenSystem.Helpers; +using UtopiaCanteenSystem.Models; + +namespace UtopiaCanteenSystem.Services; + +/// +/// Handles RFID scan logic: validates input, enforces configurable timeout, +/// and saves ScanRecord to SQLite. +/// +public class RfidService : IRfidService +{ + private readonly IDbContextFactory _dbFactory; + private readonly IConfigService _configService; + + public RfidService(IDbContextFactory dbFactory, IConfigService configService) + { + _dbFactory = dbFactory; + _configService = configService; + } + + public (bool Success, string Message) ProcessScan(string cardId) + { + var result = ProcessScanDetailed(cardId); + return (result.Success, result.Message); + } + + public ScanResult ProcessScanDetailed(string cardId) + { + if (string.IsNullOrWhiteSpace(cardId)) + return new ScanResult(false, "Card ID cannot be empty.", 0); + + cardId = cardId.Trim(); + var nowUtc = DateTime.UtcNow; + + using var db = _dbFactory.CreateDbContext(); + + var timeoutSeconds = _configService.GetScanTimeoutSeconds(); + if (timeoutSeconds <= 0) + timeoutSeconds = 60; + + var windowStart = nowUtc.AddSeconds(-timeoutSeconds); + + // Enforce timeout: no duplicate scan within the timeout window + var lastInWindow = db.ScanRecords + .Where(r => r.CardId == cardId) + .Where(r => r.ScanTime >= windowStart) + .OrderByDescending(r => r.ScanTime) + .FirstOrDefault(); + + if (lastInWindow != null) + { + var remaining = GetCooldownRemainingSeconds(nowUtc, lastInWindow.ScanTime, timeoutSeconds); + return new ScanResult( + false, + $"Multiple scans within {FormatTimeout(timeoutSeconds)} are not allowed. Please wait.", + remaining); + } + + // Optional: prevent any scan within the timeout window (any card). + var lastAnyScanInWindow = db.ScanRecords + .Where(r => r.ScanTime >= windowStart) + .OrderByDescending(r => r.ScanTime) + .FirstOrDefault(); + + if (lastAnyScanInWindow != null) + { + var remaining = GetCooldownRemainingSeconds(nowUtc, lastAnyScanInWindow.ScanTime, timeoutSeconds); + return new ScanResult( + false, + $"Only one scan within {FormatTimeout(timeoutSeconds)} is allowed. Please wait.", + remaining); + } + + var record = new ScanRecord + { + CardId = cardId, + ScanTime = nowUtc, + IsSynced = false + }; + db.ScanRecords.Add(record); + db.SaveChanges(); + + return new ScanResult(true, "Scan recorded successfully.", 0); + } + + public ScanRecord? GetLastScan() + { + using var db = _dbFactory.CreateDbContext(); + return db.ScanRecords + .OrderByDescending(r => r.ScanTime) + .FirstOrDefault(); + } + + public int GetTodayScanCount() + { + // Define "today" by the local calendar day, but ScanTime is stored as UTC. + // Convert local day boundaries to UTC for correct comparisons. + var startOfTodayLocal = DateTime.Today; + var startOfTomorrowLocal = startOfTodayLocal.AddDays(1); + var startUtc = startOfTodayLocal.ToUniversalTime(); + var endUtc = startOfTomorrowLocal.ToUniversalTime(); + + using var db = _dbFactory.CreateDbContext(); + return db.ScanRecords + .Count(r => r.ScanTime >= startUtc && r.ScanTime < endUtc); + } + + public async Task GetTodayScanCountAsync(CancellationToken cancellationToken = default) + { + // Define "today" by the local calendar day, but ScanTime is stored as UTC. + // Convert local day boundaries to UTC for correct comparisons. + var startOfTodayLocal = DateTime.Today; + var startOfTomorrowLocal = startOfTodayLocal.AddDays(1); + var startUtc = startOfTodayLocal.ToUniversalTime(); + var endUtc = startOfTomorrowLocal.ToUniversalTime(); + + await using var db = await _dbFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + return await db.ScanRecords + .CountAsync(r => r.ScanTime >= startUtc && r.ScanTime < endUtc, cancellationToken) + .ConfigureAwait(false); + } + + public async Task GetTotalScanCountForCardAsync(string cardId, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(cardId)) + return 0; + + cardId = cardId.Trim(); + + await using var db = await _dbFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + return await db.ScanRecords + .CountAsync(r => r.CardId == cardId, cancellationToken) + .ConfigureAwait(false); + } + + public async Task GetTodayScanCountForCardAsync(string cardId, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(cardId)) + return 0; + + cardId = cardId.Trim(); + + // Define "today" by the local calendar day, but ScanTime is stored as UTC. + // Convert local day boundaries to UTC for correct comparisons. + var startOfTodayLocal = DateTime.Today; + var startOfTomorrowLocal = startOfTodayLocal.AddDays(1); + var startUtc = startOfTodayLocal.ToUniversalTime(); + var endUtc = startOfTomorrowLocal.ToUniversalTime(); + + await using var db = await _dbFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + return await db.ScanRecords + .CountAsync(r => r.CardId == cardId && r.ScanTime >= startUtc && r.ScanTime < endUtc, cancellationToken) + .ConfigureAwait(false); + } + + private static string FormatTimeout(int seconds) + { + if (seconds <= 0) + return "the timeout window"; + + var minutes = seconds / 60; + var secs = seconds % 60; + + if (minutes == 0) + return secs == 1 ? "1 second" : $"{secs} seconds"; + + if (secs == 0) + return minutes == 1 ? "1 minute" : $"{minutes} minutes"; + + var minutePart = minutes == 1 ? "1 minute" : $"{minutes} minutes"; + var secondPart = secs == 1 ? "1 second" : $"{secs} seconds"; + return $"{minutePart} {secondPart}"; + } + + private static int GetCooldownRemainingSeconds(DateTime nowUtc, DateTime lastScanUtc, int timeoutSeconds) + { + // Remaining = (lastScan + timeout) - now. Use ceiling so UI shows a whole-second countdown. + var endUtc = lastScanUtc.AddSeconds(timeoutSeconds); + var remaining = (int)Math.Ceiling((endUtc - nowUtc).TotalSeconds); + return Math.Max(0, remaining); + } +} diff --git a/Services/SampleSyncApiController.cs b/Services/SampleSyncApiController.cs new file mode 100644 index 0000000..fb9ccca --- /dev/null +++ b/Services/SampleSyncApiController.cs @@ -0,0 +1,53 @@ +// ============================================================================= +// SAMPLE API POST ENDPOINT (for reference / testing) +// ============================================================================= +// This file is NOT compiled; it shows how a server could accept the sync POST +// from UtopiaCanteenSystem. Add something like this to your UIND sync API. +// +// Expected request from UtopiaCanteenSystem: +// POST {SyncApiEndpoint} (e.g. https://api.example.com/uind/sync) +// Content-Type: application/json +// Body: array of scan records, e.g.: +// [ +// { "Id": 1, "CardId": "RFID123", "ScanTime": "2025-01-30T10:00:00Z", "IsSynced": false }, +// { "Id": 2, "CardId": "RFID456", "ScanTime": "2025-01-30T10:05:00Z", "IsSynced": false } +// ] +// +// Response: 2xx success → client will mark those records as IsSynced = true. +// ============================================================================= + +#if false // Sample ASP.NET Core controller (paste into your API project) + +using Microsoft.AspNetCore.Mvc; + +namespace YourApi.Controllers; + +[ApiController] +[Route("uind/sync")] +public class UindSyncController : ControllerBase +{ + [HttpPost] + public IActionResult Sync([FromBody] List records) + { + if (records == null || records.Count == 0) + return Ok(); + + // Persist or process records (e.g. save to your database) + foreach (var r in records) + { + // Save r.Id, r.CardId, r.ScanTime, etc. + } + + return Ok(); + } +} + +public class SyncRecordDto +{ + public int Id { get; set; } + public string CardId { get; set; } = string.Empty; + public DateTime ScanTime { get; set; } + public bool IsSynced { get; set; } +} + +#endif diff --git a/Services/ScanResult.cs b/Services/ScanResult.cs new file mode 100644 index 0000000..b38c219 --- /dev/null +++ b/Services/ScanResult.cs @@ -0,0 +1,10 @@ +namespace UtopiaCanteenSystem.Services; + +/// +/// Result of processing a scan, including an optional cooldown (in seconds) when blocked. +/// +public readonly record struct ScanResult( + bool Success, + string Message, + int CooldownSecondsRemaining); + diff --git a/Services/SyncService.cs b/Services/SyncService.cs new file mode 100644 index 0000000..45b26f7 --- /dev/null +++ b/Services/SyncService.cs @@ -0,0 +1,79 @@ +using System.Net.Http.Json; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using UtopiaCanteenSystem.Data; +using UtopiaCanteenSystem.Models; +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. +/// +public class SyncService : ISyncService +{ + private readonly IDbContextFactory _dbFactory; + private readonly IConfigService _configService; + private static readonly HttpClient HttpClient = new(); + + public SyncService(IDbContextFactory dbFactory, IConfigService configService) + { + _dbFactory = dbFactory; + _configService = configService; + } + + public async Task SyncNowAsync(CancellationToken cancellationToken = default) + { + var endpoint = _configService.GetSyncApiEndpoint(); + if (string.IsNullOrWhiteSpace(endpoint)) + return; + + List toSync; + using (var db = _dbFactory.CreateDbContext()) + { + toSync = await db.ScanRecords + .Where(r => !r.IsSynced) + .OrderBy(r => r.ScanTime) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + } + + if (toSync.Count == 0) + return; + + var payload = toSync.Select(r => new + { + r.Id, + r.CardId, + ScanTime = r.ScanTime, + r.IsSynced + }).ToList(); + + try + { + var response = await HttpClient + .PostAsJsonAsync(endpoint, payload, cancellationToken: cancellationToken) + .ConfigureAwait(false); + + if (!response.IsSuccessStatusCode) + return; + + var ids = toSync.Select(r => r.Id).ToList(); + using (var db = _dbFactory.CreateDbContext()) + { + var records = await db.ScanRecords + .Where(r => ids.Contains(r.Id)) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + foreach (var r in records) + r.IsSynced = true; + await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + } + catch + { + // Leave records unsynced; will retry on next run + } + } +} diff --git a/UtopiaCanteenSystem.csproj b/UtopiaCanteenSystem.csproj new file mode 100644 index 0000000..f9341a3 --- /dev/null +++ b/UtopiaCanteenSystem.csproj @@ -0,0 +1,31 @@ + + + + WinExe + net8.0-windows + enable + enable + true + + UtopiaCanteenSystem + UtopiaCanteenSystem + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890} + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + PreserveNewest + + + + diff --git a/ViewModels/AdminLoginViewModel.cs b/ViewModels/AdminLoginViewModel.cs new file mode 100644 index 0000000..6df989b --- /dev/null +++ b/ViewModels/AdminLoginViewModel.cs @@ -0,0 +1,73 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using UtopiaCanteenSystem.Services; + +namespace UtopiaCanteenSystem.ViewModels; + +public partial class AdminLoginViewModel : ObservableObject +{ + private readonly IAuthService _authService; + private readonly AppSession _session; + private readonly INavigationService _navigation; + + [ObservableProperty] + private string _username = string.Empty; + + // Updated from code-behind (PasswordBox doesn't support binding cleanly). + [ObservableProperty] + private string _password = string.Empty; + + [ObservableProperty] + private string _errorMessage = string.Empty; + + [ObservableProperty] + private bool _isBusy; + + public AdminLoginViewModel(IAuthService authService, AppSession session, INavigationService navigation) + { + _authService = authService; + _session = session; + _navigation = navigation; + } + + [RelayCommand] + private async Task AdminLoginAsync() + { + if (IsBusy) + return; + + ErrorMessage = string.Empty; + + var user = Username?.Trim() ?? string.Empty; + var pass = Password ?? string.Empty; + + // Requirement: after clicking login, clear fields. + Username = string.Empty; + Password = string.Empty; + + if (string.IsNullOrWhiteSpace(user) || string.IsNullOrEmpty(pass)) + { + ErrorMessage = "Username and password are required."; + return; + } + + IsBusy = true; + try + { + var result = await _authService.LoginAsync(user, pass).ConfigureAwait(true); + if (!result.Success) + { + ErrorMessage = "Invalid username or password."; + return; + } + + _session.SetAdminAuthenticated(user, result.EmployeeId); + _navigation.NavigateToScanner(); + } + finally + { + IsBusy = false; + } + } +} + diff --git a/ViewModels/AdminSettingsAuthViewModel.cs b/ViewModels/AdminSettingsAuthViewModel.cs new file mode 100644 index 0000000..367e8e6 --- /dev/null +++ b/ViewModels/AdminSettingsAuthViewModel.cs @@ -0,0 +1,80 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using UtopiaCanteenSystem.Services; + +namespace UtopiaCanteenSystem.ViewModels; + +public partial class AdminSettingsAuthViewModel : ObservableObject +{ + private readonly IAuthService _authService; + private readonly AppSession _session; + private readonly INavigationService _navigation; + + [ObservableProperty] + private string _username = string.Empty; + + // Updated from code-behind (PasswordBox doesn't support binding cleanly). + [ObservableProperty] + private string _password = string.Empty; + + [ObservableProperty] + private string _errorMessage = string.Empty; + + [ObservableProperty] + private bool _isBusy; + + public AdminSettingsAuthViewModel(IAuthService authService, AppSession session, INavigationService navigation) + { + _authService = authService; + _session = session; + _navigation = navigation; + } + + [RelayCommand] + private void Cancel() + { + _navigation.NavigateToDashboard(); + } + + [RelayCommand] + private async Task ConfirmAsync() + { + if (IsBusy) + return; + + ErrorMessage = string.Empty; + + var user = Username?.Trim() ?? string.Empty; + var pass = Password ?? string.Empty; + + // Clear fields after clicking confirm (per kiosk behavior). + Username = string.Empty; + Password = string.Empty; + + if (string.IsNullOrWhiteSpace(user) || string.IsNullOrEmpty(pass)) + { + ErrorMessage = "Admin username and password are required."; + return; + } + + IsBusy = true; + try + { + var result = await _authService.LoginAsync(user, pass).ConfigureAwait(true); + if (!result.Success) + { + ErrorMessage = "Admin rights required. Invalid username/password."; + return; + } + + // Refresh session details (who authenticated for Settings). + _session.SetAdminAuthenticated(user, result.EmployeeId); + _navigation.NavigateToSettings(); + } + finally + { + IsBusy = false; + } + } +} + diff --git a/ViewModels/MainDashoardViewModel.cs b/ViewModels/MainDashoardViewModel.cs new file mode 100644 index 0000000..0d28dda --- /dev/null +++ b/ViewModels/MainDashoardViewModel.cs @@ -0,0 +1,260 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using System.Windows.Threading; +using UtopiaCanteenSystem.Services; + +namespace UtopiaCanteenSystem.ViewModels; + +/// +/// ViewModel for MainDashboardView: shows last scanned card ID, date/time, scan count, and success/error message. +/// Manages a configurable session timer to navigate back to login. +/// +public partial class MainDashboardViewModel : ObservableObject +{ + private readonly INavigationService _navigation; + private readonly IRfidService _rfidService; + private readonly IConfigService _configService; + private readonly AppSession _session; + private CancellationTokenSource? _timerCancellation; + private readonly DispatcherTimer _clockTimer; + private readonly DispatcherTimer _todayCountTimer; + private DateTime _lastCountDate = DateTime.Today; + + [ObservableProperty] + private string _lastCardId = "—"; + + [ObservableProperty] + private string _lastScanTime = "—"; + + [ObservableProperty] + private int _scanCount; + + [ObservableProperty] + private string _statusMessage = "Scan recorded successfully."; + + [ObservableProperty] + private string _currentTime = string.Empty; + + [ObservableProperty] + private bool _isSuccess = true; + + [ObservableProperty] + private bool _canScan = true; + + [ObservableProperty] + private bool _isAdmin; + + public MainDashboardViewModel( + INavigationService navigation, + IRfidService rfidService, + IConfigService configService, + AppSession session) + { + _navigation = navigation; + _rfidService = rfidService; + _configService = configService; + _session = session; + IsAdmin = _session.IsAdminAuthenticated; + UpdateCurrentTime(); + _clockTimer = new DispatcherTimer + { + Interval = TimeSpan.FromSeconds(1) + }; + _clockTimer.Tick += (_, _) => UpdateCurrentTime(); + _clockTimer.Start(); + UpdateScanDetails(); + + // Refresh "today's scans" periodically so it auto-resets after midnight even without scans. + _todayCountTimer = new DispatcherTimer + { + Interval = TimeSpan.FromSeconds(30) + }; + _todayCountTimer.Tick += (_, _) => + { + if (DateTime.Today == _lastCountDate) + return; + + _lastCountDate = DateTime.Today; + _ = LoadTodayScanCountAsync(); + }; + _todayCountTimer.Start(); + + // Initial load of today's count (async). + _ = LoadTodayScanCountAsync(); + + StartDashboardTimer(); + } + + /// + /// Updates scan details: last scan info, scan count for today, and last scan time. + /// Call this after a new scan occurs. + /// + public void UpdateScanDetails() + { + var last = _rfidService.GetLastScan(); + if (last != null) + { + LastCardId = last.CardId; + LastScanTime = last.ScanTime.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss"); + // Default success message; may be replaced with "welcome back" if repeat detected. + IsSuccess = true; + StatusMessage = "Scan recorded successfully."; + _ = UpdateRepeatMessageAsync(last.CardId); + } + else + { + LastCardId = "—"; + LastScanTime = "—"; + IsSuccess = true; + StatusMessage = string.Empty; + } + } + + private async Task UpdateRepeatMessageAsync(string cardId) + { + 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) + { + var msg = + todayCount >= 2 + ? $"Welcome back! You scanned again (#{todayCount} today)." + : "Welcome back! You scanned again."; + + System.Windows.Application.Current.Dispatcher.Invoke(() => + { + IsSuccess = true; + StatusMessage = msg; + }); + } + } + catch + { + // If DB query fails, keep the default status message. + } + } + + public async Task LoadTodayScanCountAsync() + { + try + { + // Counts only scans for the current LOCAL day (converted to UTC boundaries internally). + var count = await _rfidService.GetTodayScanCountAsync().ConfigureAwait(false); + + // Ensure property changes happen on the UI thread. + System.Windows.Application.Current.Dispatcher.Invoke(() => ScanCount = count); + } + catch + { + // If DB is unavailable for any reason, fail gracefully. + System.Windows.Application.Current.Dispatcher.Invoke(() => ScanCount = 0); + } + } + + /// + /// Starts the dashboard session timer. When the session expires, navigates back to login. + /// + private void StartDashboardTimer() + { + // Cancel any existing timer + _timerCancellation?.Cancel(); + _timerCancellation = new CancellationTokenSource(); + + // For the session, restrict scans to current employee + CanScan = true; // Current employee can scan + + var timeout = GetDashboardTimeout(); + if (_navigation.IsDashboardSessionExpired(timeout)) + { + _navigation.NavigateToScanner(); + return; + } + + // Use remaining time since the dashboard session started. + var remaining = timeout - _navigation.GetDashboardSessionElapsed(); + + if (remaining <= TimeSpan.Zero) + { + _navigation.NavigateToScanner(); + return; + } + + // Start async timer + _ = Task.Run(async () => + { + try + { + await Task.Delay(remaining, _timerCancellation.Token); + + // Timer completed: navigate back to login + // Use Dispatcher to ensure UI thread + System.Windows.Application.Current.Dispatcher.Invoke(() => + { + if (ReferenceEquals(_navigation.CurrentViewModel, this)) + _navigation.NavigateToScanner(); + }); + } + catch (OperationCanceledException) + { + // Timer was cancelled (new scan occurred, timer reset) + } + }); + } + + /// + /// Called when a new scan occurs. + /// + public void OnScanOccurred() + { + UpdateScanDetails(); + _lastCountDate = DateTime.Today; + _ = LoadTodayScanCountAsync(); + } + + [RelayCommand] + private void BackToLogin() + { + _timerCancellation?.Cancel(); + _navigation.NavigateToScanner(); + } + + [RelayCommand] + private void GoToSettings() + { + // Require admin credentials at time of access (kiosk mode). + _navigation.NavigateToAdminSettingsAuth(); + } + + /// + /// Cleanup: cancel timer when ViewModel is disposed or view is closed. + /// + ~MainDashboardViewModel() + { + _clockTimer.Stop(); + _todayCountTimer.Stop(); + _timerCancellation?.Cancel(); + _timerCancellation?.Dispose(); + } + + private TimeSpan GetDashboardTimeout() + { + var seconds = _configService.GetScanTimeoutSeconds(); + if (seconds <= 0) + seconds = 60; + return TimeSpan.FromSeconds(seconds); + } + + private void UpdateCurrentTime() + { + CurrentTime = DateTime.Now.ToString("MM/dd/yyyy, hh:mm:ss tt"); + } + +} + + + + diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs new file mode 100644 index 0000000..c1a530b --- /dev/null +++ b/ViewModels/MainViewModel.cs @@ -0,0 +1,22 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using UtopiaCanteenSystem.Services; + +namespace UtopiaCanteenSystem.ViewModels; + +/// +/// Main window ViewModel: exposes current view model from navigation for ContentControl binding. +/// +public partial class MainViewModel : ObservableObject +{ + [ObservableProperty] + private object? _currentViewModel; + + public INavigationService Navigation { get; } + + public MainViewModel(INavigationService navigation) + { + Navigation = navigation; + Navigation.CurrentViewModelChanged += (_, _) => CurrentViewModel = Navigation.CurrentViewModel; + Navigation.NavigateToAdminLogin(); + } +} diff --git a/ViewModels/ScannerViewModel.cs b/ViewModels/ScannerViewModel.cs new file mode 100644 index 0000000..0e0df9c --- /dev/null +++ b/ViewModels/ScannerViewModel.cs @@ -0,0 +1,274 @@ +using DebounceTimer = System.Timers.Timer; +using System.Windows; +using System.Windows.Threading; +using System.Windows.Input; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using UtopiaCanteenSystem.Services; + +namespace UtopiaCanteenSystem.ViewModels; + +/// +/// ViewModel for ScannerView: RFID input, scan command, scanner status, current scan time. +/// Supports both Enter-to-submit and debounce-to-submit for keyboard-wedge scanners. +/// +public partial class ScannerViewModel : 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 readonly DispatcherTimer _scannerStatusTimer; + private DateTime? _lastScanActivityUtc; + private readonly object _submitLock = new(); + private bool _isSubmitting; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsWatermarkVisible))] + private string _cardIdInput = string.Empty; + + /// True when CardIdInput is empty; used to show/hide the "Scan Card ID" watermark. + 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 _currentScanTime = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"); + + [ObservableProperty] + private string _message = string.Empty; + + [ObservableProperty] + private bool _isSuccess; + + public ScannerViewModel(IRfidService rfidService, INavigationService navigation, AppSession session) + { + _rfidService = rfidService; + _navigation = navigation; + _session = session; + + _uiDispatcher = Application.Current?.Dispatcher ?? Dispatcher.CurrentDispatcher; + _debounceTimer = new DebounceTimer(DebounceMs) + { + AutoReset = false + }; + _debounceTimer.Elapsed += (_, _) => + { + // Timer runs on a background thread; marshal back to UI thread. + _uiDispatcher.BeginInvoke(() => + { + lock (_submitLock) + { + if (_isSubmitting) + return; + } + + // If something already cleared the input (e.g., Enter submit), do nothing. + if (string.IsNullOrWhiteSpace(CardIdInput)) + return; + + if (ScanCommand.CanExecute(null)) + ScanCommand.Execute(null); + }, DispatcherPriority.Background); + }; + + _cooldownTimer = new DispatcherTimer + { + Interval = TimeSpan.FromSeconds(CooldownTickSeconds) + }; + _cooldownTimer.Tick += (_, _) => UpdateCooldownMessage(); + + _scannerStatusTimer = new DispatcherTimer + { + Interval = TimeSpan.FromSeconds(1) + }; + _scannerStatusTimer.Tick += (_, _) => UpdateScannerStatusFromActivity(); + _scannerStatusTimer.Start(); + + RefreshScannerStatus(); + RefreshTime(); + } + + partial void OnCardIdInputChanged(string value) + { + // Clear any previous message once a new scan starts. + if (!string.IsNullOrWhiteSpace(value) && !string.IsNullOrEmpty(Message)) + Message = string.Empty; + + // If the user starts scanning again, don't keep overwriting their feedback with an old countdown. + StopCooldownCountdown(); + + // Keyboard-wedge scanners "type" into the TextBox. Any incoming characters means activity/connected. + if (!string.IsNullOrWhiteSpace(value)) + { + _lastScanActivityUtc = DateTime.UtcNow; + ScannerStatus = "Connected"; + } + + RestartDebounceTimer(); + } + + private void RestartDebounceTimer() + { + _debounceTimer.Stop(); + + // Only auto-submit when we have a non-empty value. + if (string.IsNullOrWhiteSpace(CardIdInput)) + return; + + _debounceTimer.Start(); + } + + public void RefreshScannerStatus() + { + // Default: waiting for activity. + ScannerStatus = "Disconnected"; + } + + public void RefreshTime() + { + CurrentScanTime = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"); + } + + [RelayCommand] + private void Logout() + { + _session.Logout(); + _navigation.NavigateToAdminLogin(); + } + + /// Processes the current CardIdInput as a scan and navigates to dashboard on success. + [RelayCommand] + private void Scan() + { + lock (_submitLock) + { + if (_isSubmitting) + return; + _isSubmitting = true; + } + + try + { + // Ensure any pending debounce doesn't fire after an explicit submit. + _debounceTimer.Stop(); + StopCooldownCountdown(); + + Message = string.Empty; + IsSuccess = false; + + // Keep CardId as string (preserves leading zeros). + var cardId = CardIdInput?.Trim() ?? string.Empty; + + // Avoid showing "empty" errors if something triggered submit after we already cleared input. + if (string.IsNullOrWhiteSpace(cardId)) + return; + + var result = _rfidService.ProcessScanDetailed(cardId); + IsSuccess = result.Success; + Message = result.Message; + + // Always clear input after attempt so the next scan starts cleanly. + CardIdInput = string.Empty; + + if (!result.Success && result.CooldownSecondsRemaining > 0) + { + StartCooldownCountdown(result.CooldownSecondsRemaining); + return; + } + + if (result.Success) + { + _navigation.StartDashboardSession(); + _navigation.NavigateToDashboard(); + } + } + finally + { + lock (_submitLock) + { + _isSubmitting = false; + } + } + } + + private void StartCooldownCountdown(int seconds) + { + if (seconds <= 0) + return; + + _cooldownEndsUtc = DateTime.UtcNow.AddSeconds(seconds); + _lastDisplayedCooldownSeconds = -1; + UpdateCooldownMessage(); // immediate update + _cooldownTimer.Start(); + } + + private void StopCooldownCountdown() + { + _cooldownTimer.Stop(); + _cooldownEndsUtc = null; + _lastDisplayedCooldownSeconds = -1; + } + + 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; + } + + // Avoid redundant PropertyChanged churn. + 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; + } + + var inactiveFor = DateTime.UtcNow - _lastScanActivityUtc.Value; + if (inactiveFor > ScannerInactivityTimeout) + { + if (!string.Equals(ScannerStatus, "Disconnected", StringComparison.Ordinal)) + ScannerStatus = "Disconnected"; + } + } + + ~ScannerViewModel() + { + _scannerStatusTimer.Stop(); + _cooldownTimer.Stop(); + _debounceTimer.Stop(); + } +} + diff --git a/ViewModels/SettingsViewModel.cs b/ViewModels/SettingsViewModel.cs new file mode 100644 index 0000000..6b39771 --- /dev/null +++ b/ViewModels/SettingsViewModel.cs @@ -0,0 +1,81 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using UtopiaCanteenSystem.Services; + +namespace UtopiaCanteenSystem.ViewModels; + +/// +/// ViewModel for SettingsView: editable API endpoint (UIND sync URL), save, load from config. +/// +public partial class SettingsViewModel : ObservableObject +{ + private readonly IConfigService _configService; + private readonly INavigationService _navigation; + + [ObservableProperty] + private string _syncApiEndpoint = string.Empty; + + [ObservableProperty] + private string _scanTimeoutSeconds = "30"; + + [ObservableProperty] + private string _adminCardId = "ADMIN"; + + [ObservableProperty] + private string _saveMessage = string.Empty; + + [ObservableProperty] + private bool _isError; + + [ObservableProperty] + private bool _isSaving; + + public SettingsViewModel(IConfigService configService, INavigationService navigation) + { + _configService = configService; + _navigation = navigation; + LoadFromConfig(); + } + + /// Loads SyncApiEndpoint from config service. + public void LoadFromConfig() + { + SyncApiEndpoint = _configService.GetSyncApiEndpoint(); + ScanTimeoutSeconds = _configService.GetScanTimeoutSeconds().ToString(); + AdminCardId = _configService.GetAdminCardId(); + } + + [RelayCommand] + private void Save() + { + IsSaving = true; + if (!int.TryParse(ScanTimeoutSeconds, out var seconds) || seconds <= 0) + { + SaveMessage = "Scan timeout must be a positive number of seconds."; + IsError = true; + IsSaving = false; + return; + } + + _configService.SetSyncApiEndpoint(SyncApiEndpoint?.Trim() ?? string.Empty); + _configService.SetScanTimeoutSeconds(seconds); + _configService.SetAdminCardId(AdminCardId?.Trim() ?? string.Empty); + SaveMessage = "Settings saved."; + IsError = false; + IsSaving = false; + } + + [RelayCommand] + private void Back() + { + _navigation.NavigateBackFromSettings(GetDashboardTimeout()); + } + + private TimeSpan GetDashboardTimeout() + { + var seconds = _configService.GetScanTimeoutSeconds(); + if (seconds <= 0) + seconds = 60; + return TimeSpan.FromSeconds(seconds); + } +} diff --git a/Views/AdminLoginView.xaml b/Views/AdminLoginView.xaml new file mode 100644 index 0000000..481070b --- /dev/null +++ b/Views/AdminLoginView.xaml @@ -0,0 +1,125 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Views/MainDashboardView.xaml.cs b/Views/MainDashboardView.xaml.cs new file mode 100644 index 0000000..cd65843 --- /dev/null +++ b/Views/MainDashboardView.xaml.cs @@ -0,0 +1,21 @@ +using System.Windows.Controls; + +namespace UtopiaCanteenSystem.Views; + +/// +/// Main dashboard view: last scanned card ID, date/time, success/error message. +/// No logic in code-behind; all binding and commands in ViewModel. +/// +public partial class MainDashboardView : UserControl +{ + public MainDashboardView() + { + InitializeComponent(); + } + + public MainDashboardView(ViewModels.MainDashboardViewModel viewModel) + { + InitializeComponent(); + DataContext = viewModel; + } +} diff --git a/Views/ScannerView.xaml b/Views/ScannerView.xaml new file mode 100644 index 0000000..e046fd2 --- /dev/null +++ b/Views/ScannerView.xaml @@ -0,0 +1,260 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Views/ScannerView.xaml.cs b/Views/ScannerView.xaml.cs new file mode 100644 index 0000000..0755f92 --- /dev/null +++ b/Views/ScannerView.xaml.cs @@ -0,0 +1,71 @@ +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Threading; +using UtopiaCanteenSystem.ViewModels; + +namespace UtopiaCanteenSystem.Views; + +/// +/// Scanner view: minimal code-behind for focus management and Enter-key submission. +/// +public partial class ScannerView : UserControl +{ + private bool _isUnloaded; + + public ScannerView() + { + 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 the view "scan ready" by restoring focus to the RFID TextBox. + // Use a low priority so clicks (e.g., Logout) complete first. + FocusRfidInput(selectAll: false, DispatcherPriority.ApplicationIdle); + } + + private void RfidInputTextBox_OnKeyDown(object sender, KeyEventArgs e) + { + if (e.Key != Key.Enter) + return; + + if (DataContext is ScannerViewModel vm && vm.ScanCommand.CanExecute(null)) + { + vm.ScanCommand.Execute(null); + e.Handled = true; + } + } +} + diff --git a/Views/SettingsView.xaml b/Views/SettingsView.xaml new file mode 100644 index 0000000..ad62db9 --- /dev/null +++ b/Views/SettingsView.xaml @@ -0,0 +1,315 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +