diff --git a/App.xaml.cs b/App.xaml.cs
index d60fcf3..180c5b1 100644
--- a/App.xaml.cs
+++ b/App.xaml.cs
@@ -40,7 +40,7 @@ public partial class App : Application
navigationService = new NavigationService(
session,
() => new AdminLoginViewModel(authService, session, navigationService, configService),
- () => new ScannerDashboardViewModel(rfidService, navigationService, session),
+ () => new ScannerDashboardViewModel(rfidService, navigationService, session, configService),
() => new MainDashboardViewModel(navigationService, rfidService, configService, session),
() => new AdminSettingsAuthViewModel(authService, session, navigationService, configService),
() => new SettingsViewModel(configService, navigationService));
@@ -53,8 +53,8 @@ public partial class App : Application
};
mainWindow.Show();
- // Background sync: every 3 hours, POST unsynced ScanRecords to API
- _syncTimer = new System.Timers.Timer(TimeSpan.FromHours(3).TotalMilliseconds)
+ // Background sync: every 15 minutes, POST unsynced ScanRecords to API
+ _syncTimer = new System.Timers.Timer(TimeSpan.FromMinutes(15).TotalMilliseconds)
{
AutoReset = true
};
diff --git a/Converters/StringToVisibilityConverter.cs b/Converters/StringToVisibilityConverter.cs
new file mode 100644
index 0000000..79751c2
--- /dev/null
+++ b/Converters/StringToVisibilityConverter.cs
@@ -0,0 +1,22 @@
+using System;
+using System.Globalization;
+using System.Windows;
+using System.Windows.Data;
+
+namespace UtopiaCanteenSystem.Converters
+{
+ public class StringToVisibilityConverter : IValueConverter
+ {
+ public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
+ {
+ return string.IsNullOrWhiteSpace(value?.ToString())
+ ? Visibility.Collapsed
+ : Visibility.Visible;
+ }
+
+ public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
+ {
+ throw new NotImplementedException();
+ }
+ }
+}
diff --git a/Data/AppDbContext.cs b/Data/AppDbContext.cs
index 6ee25ef..bbfb880 100644
--- a/Data/AppDbContext.cs
+++ b/Data/AppDbContext.cs
@@ -1,6 +1,7 @@
+using System.Data;
+using System.IO;
using Microsoft.EntityFrameworkCore;
using UtopiaCanteenSystem.Models;
-using System.IO;
namespace UtopiaCanteenSystem.Data;
@@ -51,5 +52,47 @@ public class AppDbContext : DbContext
public void EnsureDatabaseCreated()
{
Database.EnsureCreated();
+ UpgradeScanRecordsSchemaIfNeeded();
+ }
+
+ ///
+ /// Lightweight schema upgrade: add SiteId and DeviceId to ScanRecords if missing (no EF migrations).
+ /// Does not delete any data.
+ ///
+ private void UpgradeScanRecordsSchemaIfNeeded()
+ {
+ try
+ {
+ var conn = Database.GetDbConnection();
+ if (conn.State != ConnectionState.Open)
+ conn.Open();
+
+ var columns = new List();
+ using (var cmd = conn.CreateCommand())
+ {
+ cmd.CommandText = "SELECT name FROM pragma_table_info('ScanRecords')";
+ using var r = cmd.ExecuteReader();
+ while (r.Read())
+ columns.Add(r.GetString(0));
+ }
+
+ if (!columns.Contains("SiteId", StringComparer.OrdinalIgnoreCase))
+ {
+ using var cmd = conn.CreateCommand();
+ cmd.CommandText = "ALTER TABLE ScanRecords ADD COLUMN SiteId TEXT DEFAULT ''";
+ cmd.ExecuteNonQuery();
+ }
+
+ if (!columns.Contains("DeviceId", StringComparer.OrdinalIgnoreCase))
+ {
+ using var cmd = conn.CreateCommand();
+ cmd.CommandText = "ALTER TABLE ScanRecords ADD COLUMN DeviceId TEXT DEFAULT ''";
+ cmd.ExecuteNonQuery();
+ }
+ }
+ catch
+ {
+ // Ignore; existing DB may already have columns or be incompatible
+ }
}
}
diff --git a/Models/ScanRecord.cs b/Models/ScanRecord.cs
index 13f1b1b..decc340 100644
--- a/Models/ScanRecord.cs
+++ b/Models/ScanRecord.cs
@@ -12,4 +12,8 @@ public class ScanRecord
public DateTime ScanTime { get; set; }
/// True after record has been successfully sent to the sync API.
public bool IsSynced { get; set; }
+ /// Site identifier where the scan occurred (from config).
+ public string SiteId { get; set; } = string.Empty;
+ /// Stable device identifier that generated the scan (from config).
+ public string DeviceId { get; set; } = string.Empty;
}
diff --git a/Services/ConfigService.cs b/Services/ConfigService.cs
index 1eee4ab..fce01dd 100644
--- a/Services/ConfigService.cs
+++ b/Services/ConfigService.cs
@@ -51,6 +51,32 @@ public class ConfigService : IConfigService
SaveConfig();
}
+ public string GetSiteId() => _config.SiteId ?? "SITE : 1";
+
+ public void SetSiteId(string siteId)
+ {
+ _config.SiteId = string.IsNullOrWhiteSpace(siteId) ? "SITE : 1" : (siteId ?? string.Empty);
+ SaveConfig();
+ }
+
+ public string GetDeviceId()
+ {
+ var id = _config.DeviceId ?? string.Empty;
+ if (string.IsNullOrWhiteSpace(id))
+ {
+ id = Guid.NewGuid().ToString("N");
+ _config.DeviceId = id;
+ SaveConfig();
+ }
+ return id;
+ }
+
+ public void SetDeviceId(string deviceId)
+ {
+ _config.DeviceId = deviceId ?? string.Empty;
+ SaveConfig();
+ }
+
public bool GetRememberAdminCredentials() => _config.RememberAdminCredentials;
public void SetRememberAdminCredentials(bool remember)
@@ -159,6 +185,8 @@ public class ConfigService : IConfigService
public bool ScannerConnected { get; set; } = false;
public int ScanTimeoutSeconds { get; set; } = 60;
public string AdminCardId { get; set; } = "ADMIN";
+ public string SiteId { get; set; } = "SITE : 1";
+ public string DeviceId { get; set; } = string.Empty;
// Admin credential persistence (optional).
public bool RememberAdminCredentials { get; set; } = false;
diff --git a/Services/IConfigService.cs b/Services/IConfigService.cs
index 4ae7b39..c7854c3 100644
--- a/Services/IConfigService.cs
+++ b/Services/IConfigService.cs
@@ -14,6 +14,11 @@ public interface IConfigService
string GetAdminCardId();
void SetAdminCardId(string cardId);
+ string GetSiteId();
+ void SetSiteId(string siteId);
+ string GetDeviceId();
+ void SetDeviceId(string deviceId);
+
bool GetRememberAdminCredentials();
void SetRememberAdminCredentials(bool remember);
string GetSavedAdminUsername();
diff --git a/Services/IRfidService.cs b/Services/IRfidService.cs
index c5ba6cc..3560cca 100644
--- a/Services/IRfidService.cs
+++ b/Services/IRfidService.cs
@@ -31,6 +31,9 @@ public interface IRfidService
///
Task GetTodayScanCountAsync(CancellationToken cancellationToken = default);
+ /// Returns total number of scans/orders recorded (all time).
+ Task GetTotalScanCountAsync(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.
diff --git a/Services/RfidService.cs b/Services/RfidService.cs
index f63a05f..b23518a 100644
--- a/Services/RfidService.cs
+++ b/Services/RfidService.cs
@@ -56,7 +56,7 @@ public class RfidService : IRfidService
var remaining = GetCooldownRemainingSeconds(nowUtc, lastInWindow.ScanTime, timeoutSeconds);
return new ScanResult(
false,
- $"Multiple scans within {FormatTimeout(timeoutSeconds)} are not allowed. Please wait.",
+ $"One order per customer within {FormatTimeout(timeoutSeconds)}. Ask this customer to rescan after countdown.",
remaining);
}
@@ -71,7 +71,7 @@ public class RfidService : IRfidService
var remaining = GetCooldownRemainingSeconds(nowUtc, lastAnyScanInWindow.ScanTime, timeoutSeconds);
return new ScanResult(
false,
- $"Only one scan within {FormatTimeout(timeoutSeconds)} is allowed. Please wait.",
+ $"Only one order at a time within {FormatTimeout(timeoutSeconds)}. Ask this customer to rescan after countdown.",
remaining);
}
@@ -79,12 +79,14 @@ public class RfidService : IRfidService
{
CardId = cardId,
ScanTime = nowUtc,
- IsSynced = false
+ IsSynced = false,
+ SiteId = _configService.GetSiteId(),
+ DeviceId = _configService.GetDeviceId()
};
db.ScanRecords.Add(record);
db.SaveChanges();
- return new ScanResult(true, "Scan recorded successfully.", 0);
+ return new ScanResult(true, "Order recorded successfully.", 0);
}
public ScanRecord? GetLastScan()
@@ -124,6 +126,12 @@ public class RfidService : IRfidService
.ConfigureAwait(false);
}
+ public async Task GetTotalScanCountAsync(CancellationToken cancellationToken = default)
+ {
+ await using var db = await _dbFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
+ return await db.ScanRecords.CountAsync(cancellationToken).ConfigureAwait(false);
+ }
+
public async Task GetTotalScanCountForCardAsync(string cardId, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(cardId))
diff --git a/Services/SyncService.cs b/Services/SyncService.cs
index 0abe6f6..f2151b3 100644
--- a/Services/SyncService.cs
+++ b/Services/SyncService.cs
@@ -44,10 +44,11 @@ public class SyncService : ISyncService
var payload = toSync.Select(r => new
{
- r.Id,
- r.CardId,
- ScanTime = r.ScanTime,
- r.IsSynced
+ DeviceLocalRowId = r.Id,
+ ScanTimeUtc = r.ScanTime,
+ SiteId = r.SiteId ?? string.Empty,
+ DeviceId = r.DeviceId ?? string.Empty,
+ r.CardId
}).ToList();
try
diff --git a/ViewModels/ScannerDashboardViewModel.cs b/ViewModels/ScannerDashboardViewModel.cs
index a7a14ea..8a561fc 100644
--- a/ViewModels/ScannerDashboardViewModel.cs
+++ b/ViewModels/ScannerDashboardViewModel.cs
@@ -20,6 +20,7 @@ public partial class ScannerDashboardViewModel : ObservableObject
private readonly IRfidService _rfidService;
private readonly INavigationService _navigation;
private readonly AppSession _session;
+ private readonly IConfigService _configService;
private readonly Dispatcher _uiDispatcher;
private readonly DebounceTimer _debounceTimer;
@@ -55,8 +56,9 @@ public partial class ScannerDashboardViewModel : ObservableObject
[NotifyPropertyChangedFor(nameof(ScannerStatusDisplay))]
private string _scannerStatus = "Disconnected";
+ /// Shows scanner device connectivity: connected = ready; disconnected = check device.
public string ScannerStatusDisplay =>
- string.Equals(ScannerStatus, "Connected", StringComparison.Ordinal) ? "Connected" : "waiting for scan...";
+ string.Equals(ScannerStatus, "Connected", StringComparison.Ordinal) ? "Scanner connected" : "Scanner not connected";
[ObservableProperty]
private string _currentTime = DateTime.Now.ToString("MM/dd/yyyy, hh:mm:ss tt");
@@ -65,12 +67,22 @@ public partial class ScannerDashboardViewModel : ObservableObject
[ObservableProperty]
private int _todaysScans;
+ [ObservableProperty]
+ private int _totalOrders;
+
[ObservableProperty]
private string _lastCardId = "—";
[ObservableProperty]
private string _lastScanTimeDisplay = "—";
+ /// Prominent alert when customer scanned during cooldown: ask them to rescan after countdown.
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(ShowCooldownAlert))]
+ private string _cooldownAlertMessage = string.Empty;
+
+ public bool ShowCooldownAlert => !string.IsNullOrWhiteSpace(CooldownAlertMessage);
+
// Message area (green/red)
[ObservableProperty]
private string _message = string.Empty;
@@ -80,11 +92,23 @@ public partial class ScannerDashboardViewModel : ObservableObject
public bool IsAdminAuthenticated => _session.IsAdminAuthenticated;
- public ScannerDashboardViewModel(IRfidService rfidService, INavigationService navigation, AppSession session)
+ // --- Menu + site selection ---
+ [ObservableProperty]
+ private bool _isMenuOpen;
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(CurrentSiteDisplay))]
+ private string _siteNumber = "1";
+
+ /// Display string for current site (e.g. "SITE : 1").
+ public string CurrentSiteDisplay => string.IsNullOrWhiteSpace(SiteNumber) ? "SITE : 1" : $"SITE : {SiteNumber.Trim()}";
+
+ public ScannerDashboardViewModel(IRfidService rfidService, INavigationService navigation, AppSession session, IConfigService configService)
{
_rfidService = rfidService;
_navigation = navigation;
_session = session;
+ _configService = configService;
_uiDispatcher = Application.Current?.Dispatcher ?? Dispatcher.CurrentDispatcher;
// Debounce auto-submit (keyboard wedge scanners).
@@ -121,10 +145,33 @@ public partial class ScannerDashboardViewModel : ObservableObject
_clockTimer.Tick += (_, _) => CurrentTime = DateTime.Now.ToString("MM/dd/yyyy, hh:mm:ss tt");
_clockTimer.Start();
+ // Load initial site from config (e.g. "SITE : 1" -> "1").
+ var siteId = _configService.GetSiteId();
+ if (!string.IsNullOrWhiteSpace(siteId) && siteId.StartsWith("SITE : ", StringComparison.OrdinalIgnoreCase))
+ {
+ var num = siteId.Substring("SITE : ".Length).Trim();
+ if (num.Length > 0 && num.All(char.IsDigit))
+ SiteNumber = num;
+ }
+
RefreshScannerStatus();
_ = RefreshDashboardAsync();
}
+ partial void OnSiteNumberChanged(string value)
+ {
+ // Numeric only: filter to digits so display stays valid.
+ var digits = value == null ? string.Empty : new string(value.Where(char.IsDigit).ToArray());
+ if (digits != value)
+ {
+ SiteNumber = digits;
+ return;
+ }
+ // Persist to config when valid (non-empty numeric).
+ if (!string.IsNullOrWhiteSpace(digits))
+ _configService.SetSiteId("SITE : " + digits);
+ }
+
partial void OnCardIdInputChanged(string value)
{
// If the field is cleared, also clear any remembered blocked-id prefix.
@@ -285,7 +332,7 @@ public partial class ScannerDashboardViewModel : ObservableObject
_uiDispatcher.Invoke(() =>
{
IsSuccess = true;
- Message = $"Welcome back! You scanned again (#{todayCountForCard} today).";
+ Message = $"Welcome back! Another order (#{todayCountForCard} today).";
});
}
}
@@ -305,6 +352,7 @@ public partial class ScannerDashboardViewModel : ObservableObject
_uiDispatcher.Invoke(() =>
{
TodaysScans = todayCount;
+ TotalOrders = todayCount; // Total orders = today's total only (not previous days)
ApplyLastScan(last);
});
}
@@ -338,6 +386,7 @@ public partial class ScannerDashboardViewModel : ObservableObject
_cooldownBlockedCardId = CardIdInput?.Trim() ?? string.Empty;
_cooldownEndsUtc = DateTime.UtcNow.AddSeconds(seconds);
_lastDisplayedCooldownSeconds = -1;
+ CooldownAlertMessage = $"Ask this customer to rescan their card after the countdown ({seconds} seconds remaining)";
UpdateCooldownMessage();
_cooldownTimer.Start();
}
@@ -350,18 +399,23 @@ public partial class ScannerDashboardViewModel : ObservableObject
_isCooldownActive = false;
IsCooldownActiveUi = false;
IsProcessing = false;
+ CooldownAlertMessage = string.Empty;
}
private void UpdateCooldownMessage()
{
if (_cooldownEndsUtc is null)
+ {
+ CooldownAlertMessage = string.Empty;
return;
+ }
var remaining = (int)Math.Ceiling((_cooldownEndsUtc.Value - DateTime.UtcNow).TotalSeconds);
if (remaining <= 0)
{
StopCooldownCountdown();
- Message = "You can scan now.";
+ CooldownAlertMessage = string.Empty;
+ Message = "Ready for next order.";
IsSuccess = true;
return;
}
@@ -372,6 +426,7 @@ public partial class ScannerDashboardViewModel : ObservableObject
var unit = remaining == 1 ? "second" : "seconds";
Message = $"Please wait {remaining} {unit}…";
+ CooldownAlertMessage = $"Ask this customer to rescan their card after the countdown ({remaining} {unit} remaining)";
IsSuccess = false;
}
diff --git a/Views/AdminLoginView.xaml b/Views/AdminLoginView.xaml
index be7d9e5..d2d86a6 100644
--- a/Views/AdminLoginView.xaml
+++ b/Views/AdminLoginView.xaml
@@ -80,7 +80,7 @@
+ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+ xmlns:local="clr-namespace:UtopiaCanteenSystem.Converters">
+
+
@@ -10,7 +13,7 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
-
-
+
-
-
+
+
-
-
-
+
\ No newline at end of file
diff --git a/assets/logo-final.png b/assets/logo-final.png
new file mode 100644
index 0000000..c619c5d
Binary files /dev/null and b/assets/logo-final.png differ
diff --git a/assets/logo1.png b/assets/logo1.png
new file mode 100644
index 0000000..65a07f2
Binary files /dev/null and b/assets/logo1.png differ
diff --git a/assets/logo2.png b/assets/logo2.png
new file mode 100644
index 0000000..4b12dfa
Binary files /dev/null and b/assets/logo2.png differ
diff --git a/assets/logo3.png b/assets/logo3.png
new file mode 100644
index 0000000..de40e96
Binary files /dev/null and b/assets/logo3.png differ
diff --git a/assets/logo4.png b/assets/logo4.png
new file mode 100644
index 0000000..4323341
Binary files /dev/null and b/assets/logo4.png differ