diff --git a/App.xaml.cs b/App.xaml.cs
index 3f61a43..07dca65 100644
--- a/App.xaml.cs
+++ b/App.xaml.cs
@@ -1,21 +1,27 @@
-using Microsoft.EntityFrameworkCore;
using System.Net.Http;
using System.Threading;
using System.Windows;
+using Microsoft.EntityFrameworkCore;
+using UtopiaCanteenSystem.Api;
using UtopiaCanteenSystem.Data;
+using UtopiaCanteenSystem.Models;
using UtopiaCanteenSystem.Services;
using UtopiaCanteenSystem.ViewModels;
namespace UtopiaCanteenSystem;
///
-/// Application entry point. Initializes database, builds service graph, starts hourly sync timer.
+/// Application entry point. Initializes database, builds service graph, optional Kestrel API (server), HTTP client scans (client).
///
public partial class App : Application
{
- private static Mutex _mutex;
+ private static Mutex _mutex = null!;
private System.Timers.Timer? _syncTimer;
+ private System.Timers.Timer? _offlineCacheSyncTimer;
private int _isSyncRunning;
+ private int _isOfflineCacheSyncRunning;
+ private CancellationTokenSource? _apiHostCts;
+ private Task? _apiHostTask;
protected override void OnStartup(StartupEventArgs e)
{
@@ -28,33 +34,46 @@ public partial class App : Application
Current.Shutdown();
return;
}
+
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 employeeLookupService = new EmployeeLookupService(configService);
- //var employeePhotoService = new EmployeePhotoService(configService);
- var httpClient = new HttpClient();
+ var isServer = configService.GetAppMode() != AppMode.Client;
+
+ var employeeRfidTagSyncService = new EmployeeRfidTagSyncService(dbFactory, configService);
+ var mealMenuCacheSyncService = new MealMenuCacheSyncService(dbFactory, configService);
+ var offlineCacheSyncService = new OfflineCacheSyncService(employeeRfidTagSyncService, mealMenuCacheSyncService);
+ var employeeLookupService = new EmployeeLookupService(dbFactory, configService);
+
+ var httpClient = new HttpClient { Timeout = TimeSpan.FromMinutes(2) };
var employeePhotoService = new EmployeePhotoService(configService, httpClient);
- var menuLookupService = new MenuLookupService(configService);
+ var menuLookupService = new MenuLookupService(dbFactory);
var mealScheduleService = new ProductionMealScheduleService(configService);
- var mealSessionResolver = new DbMealSessionResolver(mealScheduleService);
+ var mealSessionResolver = new DbMealSessionResolver(dbFactory);
var syncService = new SyncService(dbFactory, configService);
- var rfidService = new RfidService(dbFactory, configService, employeeLookupService, mealSessionResolver, menuLookupService, syncService);
+
+ RfidService? serverRfid = null;
+ IRfidService rfidService;
+ if (isServer)
+ {
+ serverRfid = new RfidService(dbFactory, configService, employeeLookupService, mealSessionResolver, menuLookupService, syncService);
+ rfidService = serverRfid;
+ }
+ else
+ {
+ rfidService = new ClientRfidService(httpClient, configService);
+ }
+
var adminAuditService = new AdminAuditService(dbFactory);
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,
@@ -62,7 +81,7 @@ public partial class App : Application
() => new ScannerDashboardViewModel(rfidService, navigationService, session, configService, menuLookupService, employeePhotoService),
() => new MainDashboardViewModel(navigationService, rfidService, configService, session),
() => new AdminSettingsAuthViewModel(authService, session, navigationService, configService, employeeLookupService),
- () => new SettingsViewModel(configService, navigationService, adminAuditService, syncService),
+ () => new SettingsViewModel(configService, navigationService, adminAuditService, syncService, offlineCacheSyncService),
() => new MealSchedulesViewModel(mealScheduleService, navigationService, configService));
var mainViewModel = new MainViewModel(navigationService);
@@ -71,45 +90,120 @@ public partial class App : Application
{
DataContext = mainViewModel
};
- // Set the window to open maximized
mainWindow.WindowState = WindowState.Maximized;
mainWindow.Show();
- // Background sync: every 15 minutes, POST unsynced lunch_order_transactions to API
- if (configService.GetSyncServiceEnabled())
+ if (isServer && serverRfid != null)
{
- _syncTimer = new System.Timers.Timer(TimeSpan.FromMinutes(1).TotalMilliseconds)
+ _apiHostCts = new CancellationTokenSource();
+ var listenUrls = configService.GetLocalServerListenUrls();
+ var rfid = serverRfid;
+ var cts = _apiHostCts;
+ _apiHostTask = Task.Run(async () =>
{
- AutoReset = true
- };
- _syncTimer.Elapsed += async (_, _) =>
- {
- // Prevent overlapping sync runs; if one is still running, skip this tick.
- if (Interlocked.Exchange(ref _isSyncRunning, 1) == 1)
- return;
-
try
{
- await syncService.SyncNowAsync().ConfigureAwait(false);
+ await CanteenLocalApiHost.RunAsync(rfid, listenUrls, cts!.Token).ConfigureAwait(false);
}
- catch
+ catch (OperationCanceledException)
{
- // Ignore; will retry next tick
+ // Shutdown
}
- finally
+ catch (Exception ex)
{
- Interlocked.Exchange(ref _isSyncRunning, 0);
+ Logger.Log(ex, "App.CanteenLocalApiHost");
}
- };
- _syncTimer.Start();
+ }, CancellationToken.None);
+ }
+
+ if (isServer)
+ {
+ _ = RunOfflineCacheSyncAsync(offlineCacheSyncService);
+
+ if (!string.IsNullOrWhiteSpace(configService.GetHrmsLookupConnectionString()))
+ {
+ _offlineCacheSyncTimer = new System.Timers.Timer(TimeSpan.FromMinutes(15).TotalMilliseconds)
+ {
+ AutoReset = true
+ };
+ _offlineCacheSyncTimer.Elapsed += async (_, _) =>
+ {
+ if (Interlocked.Exchange(ref _isOfflineCacheSyncRunning, 1) == 1)
+ return;
+ try
+ {
+ await RunOfflineCacheSyncAsync(offlineCacheSyncService).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ Logger.Log(ex, "App.OfflineCacheSyncTimer");
+ }
+ finally
+ {
+ Interlocked.Exchange(ref _isOfflineCacheSyncRunning, 0);
+ }
+ };
+ _offlineCacheSyncTimer.Start();
+ }
+
+ if (configService.GetSyncServiceEnabled())
+ {
+ _syncTimer = new System.Timers.Timer(TimeSpan.FromMinutes(1).TotalMilliseconds)
+ {
+ AutoReset = true
+ };
+ _syncTimer.Elapsed += async (_, _) =>
+ {
+ if (Interlocked.Exchange(ref _isSyncRunning, 1) == 1)
+ return;
+ try
+ {
+ await syncService.SyncNowAsync().ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ Logger.Log(ex, "App.ProductionSyncTimer");
+ }
+ finally
+ {
+ Interlocked.Exchange(ref _isSyncRunning, 0);
+ }
+ };
+ _syncTimer.Start();
+ }
}
}
protected override void OnExit(ExitEventArgs e)
{
+ try
+ {
+ _apiHostCts?.Cancel();
+ _apiHostTask?.Wait(TimeSpan.FromSeconds(5));
+ }
+ catch
+ {
+ // Best-effort shutdown
+ }
+
_mutex.ReleaseMutex();
_syncTimer?.Stop();
_syncTimer?.Dispose();
+ _offlineCacheSyncTimer?.Stop();
+ _offlineCacheSyncTimer?.Dispose();
+ _apiHostCts?.Dispose();
base.OnExit(e);
}
+
+ private static async Task RunOfflineCacheSyncAsync(IOfflineCacheSyncService offlineCacheSyncService)
+ {
+ try
+ {
+ await offlineCacheSyncService.SyncEmployeeAndMenuCacheAsync().ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ Logger.Log(ex, "App.RunOfflineCacheSyncAsync");
+ }
+ }
}
diff --git a/Models/AppMode.cs b/Models/AppMode.cs
new file mode 100644
index 0000000..dd2d7c7
--- /dev/null
+++ b/Models/AppMode.cs
@@ -0,0 +1,10 @@
+namespace UtopiaCanteenSystem.Models;
+
+///
+/// Deployment role: central PC hosts SQLite and HTTP API; scanner PCs call the API only.
+///
+public enum AppMode
+{
+ Server = 0,
+ Client = 1
+}
diff --git a/Services/ConfigService.cs b/Services/ConfigService.cs
index 1083ba4..bef6fac 100644
--- a/Services/ConfigService.cs
+++ b/Services/ConfigService.cs
@@ -4,6 +4,7 @@ using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using UtopiaCanteenSystem.Data;
+using UtopiaCanteenSystem.Models;
namespace UtopiaCanteenSystem.Services;
@@ -235,6 +236,56 @@ public class ConfigService : IConfigService
SaveConfig();
}
+ public DateTime? GetLastEmployeeRfidCacheSyncUtc() => _config.LastEmployeeRfidCacheSyncUtc;
+
+ public void SetLastEmployeeRfidCacheSyncUtc(DateTime utc)
+ {
+ _config.LastEmployeeRfidCacheSyncUtc = utc;
+ SaveConfig();
+ }
+
+ public DateTime? GetLastMealMenuCacheSyncUtc() => _config.LastMealMenuCacheSyncUtc;
+
+ public void SetLastMealMenuCacheSyncUtc(DateTime utc)
+ {
+ _config.LastMealMenuCacheSyncUtc = utc;
+ SaveConfig();
+ }
+
+ public AppMode GetAppMode()
+ {
+ var raw = (_config.AppMode ?? string.Empty).Trim();
+ if (raw.Equals("Client", StringComparison.OrdinalIgnoreCase))
+ return AppMode.Client;
+ return AppMode.Server;
+ }
+
+ public void SetAppMode(AppMode mode)
+ {
+ _config.AppMode = mode == AppMode.Client ? "Client" : "Server";
+ SaveConfig();
+ }
+
+ public string GetCentralServerBaseUrl() => (_config.CentralServerBaseUrl ?? string.Empty).Trim();
+
+ public void SetCentralServerBaseUrl(string url)
+ {
+ _config.CentralServerBaseUrl = url?.Trim() ?? string.Empty;
+ SaveConfig();
+ }
+
+ public string GetLocalServerListenUrls()
+ {
+ var u = (_config.LocalServerListenUrls ?? string.Empty).Trim();
+ return string.IsNullOrEmpty(u) ? "http://0.0.0.0:5000" : u;
+ }
+
+ public void SetLocalServerListenUrls(string urls)
+ {
+ _config.LocalServerListenUrls = urls?.Trim() ?? string.Empty;
+ SaveConfig();
+ }
+
private AppConfig LoadConfig()
{
try
@@ -392,5 +443,17 @@ public class ConfigService : IConfigService
// Local HRMS MySQL for employee lookup by RFID. Separate from production sync.
public string HrmsLookupConnectionString { get; set; } = string.Empty;
+
+ public DateTime? LastEmployeeRfidCacheSyncUtc { get; set; }
+ public DateTime? LastMealMenuCacheSyncUtc { get; set; }
+
+ /// Server (default) or Client.
+ public string AppMode { get; set; } = "Server";
+
+ /// Scanner PCs: base URL of central app API (e.g. http://192.168.1.10:5000).
+ public string CentralServerBaseUrl { get; set; } = string.Empty;
+
+ /// Central PC: Kestrel listen URL(s), e.g. http://0.0.0.0:5000
+ public string LocalServerListenUrls { get; set; } = "http://0.0.0.0:5000";
}
}
diff --git a/Services/IConfigService.cs b/Services/IConfigService.cs
index a6ba34e..1460e04 100644
--- a/Services/IConfigService.cs
+++ b/Services/IConfigService.cs
@@ -1,3 +1,5 @@
+using UtopiaCanteenSystem.Models;
+
namespace UtopiaCanteenSystem.Services;
///
@@ -45,4 +47,20 @@ public interface IConfigService
/// Connection string for local HRMS MySQL (employee lookup by RFID). Separate from production sync.
string GetHrmsLookupConnectionString();
void SetHrmsLookupConnectionString(string connectionString);
+
+ DateTime? GetLastEmployeeRfidCacheSyncUtc();
+ void SetLastEmployeeRfidCacheSyncUtc(DateTime utc);
+
+ DateTime? GetLastMealMenuCacheSyncUtc();
+ void SetLastMealMenuCacheSyncUtc(DateTime utc);
+
+ AppMode GetAppMode();
+ void SetAppMode(AppMode mode);
+
+ string GetCentralServerBaseUrl();
+ void SetCentralServerBaseUrl(string url);
+
+ /// Kestrel listen URL(s) when (e.g. http://0.0.0.0:5000).
+ string GetLocalServerListenUrls();
+ void SetLocalServerListenUrls(string urls);
}
diff --git a/ViewModels/SettingsViewModel.cs b/ViewModels/SettingsViewModel.cs
index 7bc9795..9881761 100644
--- a/ViewModels/SettingsViewModel.cs
+++ b/ViewModels/SettingsViewModel.cs
@@ -1,11 +1,12 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
+using UtopiaCanteenSystem.Models;
using UtopiaCanteenSystem.Services;
namespace UtopiaCanteenSystem.ViewModels;
///
-/// ViewModel for SettingsView: editable API endpoint (UIND sync URL), save, load from config.
+/// ViewModel for SettingsView: scan settings, production post, and offline cache sync.
///
public partial class SettingsViewModel : ObservableObject
{
@@ -13,6 +14,7 @@ public partial class SettingsViewModel : ObservableObject
private readonly INavigationService _navigation;
private readonly IAdminAuditService _adminAudit;
private readonly ISyncService _syncService;
+ private readonly IOfflineCacheSyncService _offlineCacheSyncService;
[ObservableProperty]
private string _syncApiEndpoint = string.Empty;
@@ -44,30 +46,70 @@ public partial class SettingsViewModel : ObservableObject
[ObservableProperty]
private bool _isPosting;
+ [ObservableProperty]
+ private bool _isSyncingCache;
- public bool CanPostNow => !IsPosting;
+ [ObservableProperty]
+ private string _lastEmployeeRfidCacheSyncDisplay = "Never";
+
+ [ObservableProperty]
+ private string _lastMealMenuCacheSyncDisplay = "Never";
+
+ [ObservableProperty]
+ private string _selectedAppMode = "Server";
+
+ [ObservableProperty]
+ private string _centralServerBaseUrl = string.Empty;
+
+ [ObservableProperty]
+ private string _localServerListenUrls = "http://0.0.0.0:5000";
+
+ public IReadOnlyList AppModeOptions { get; } = new[] { "Server", "Client" };
+
+ /// Central PC: runs SQLite, sync jobs, and Kestrel API.
+ public bool IsCentralServerMode => string.Equals(SelectedAppMode, "Server", StringComparison.OrdinalIgnoreCase);
+
+ public bool CanPostNow => !IsPosting && IsCentralServerMode;
public string PostButtonText => IsPosting ? "Posting..." : "Post Data";
+ public bool CanSyncCacheNow => !IsSyncingCache && IsCentralServerMode;
+ public string SyncCacheButtonText => IsSyncingCache ? "Syncing cache..." : "Sync Now";
+
partial void OnIsPostingChanged(bool value)
{
OnPropertyChanged(nameof(PostButtonText));
OnPropertyChanged(nameof(CanPostNow));
}
+ partial void OnIsSyncingCacheChanged(bool value)
+ {
+ OnPropertyChanged(nameof(SyncCacheButtonText));
+ OnPropertyChanged(nameof(CanSyncCacheNow));
+ }
+
+ partial void OnSelectedAppModeChanged(string value)
+ {
+ OnPropertyChanged(nameof(IsCentralServerMode));
+ OnPropertyChanged(nameof(CanPostNow));
+ OnPropertyChanged(nameof(CanSyncCacheNow));
+ }
+
public SettingsViewModel(
IConfigService configService,
INavigationService navigation,
IAdminAuditService adminAudit,
- ISyncService syncService)
+ ISyncService syncService,
+ IOfflineCacheSyncService offlineCacheSyncService)
{
_configService = configService;
_navigation = navigation;
_adminAudit = adminAudit;
_syncService = syncService;
+ _offlineCacheSyncService = offlineCacheSyncService;
LoadFromConfig();
+ RefreshCacheSyncTimestamps();
}
- /// Loads config from service.
public void LoadFromConfig()
{
SyncApiEndpoint = _configService.GetSyncApiEndpoint();
@@ -76,12 +118,21 @@ public partial class SettingsViewModel : ObservableObject
ScanIntervalMinutes = _configService.GetScanIntervalMinutes().ToString();
ScanIntervalSeconds = _configService.GetScanIntervalSeconds().ToString();
- // Always prefer the latest admin login from the database.
var last = _adminAudit.GetLastLogin();
if (last != null && !string.IsNullOrWhiteSpace(last.EmployeeId))
AdminCardId = last.EmployeeId;
else
AdminCardId = "ADMIN";
+
+ SelectedAppMode = _configService.GetAppMode() == AppMode.Client ? "Client" : "Server";
+ CentralServerBaseUrl = _configService.GetCentralServerBaseUrl();
+ LocalServerListenUrls = _configService.GetLocalServerListenUrls();
+ }
+
+ public void RefreshCacheSyncTimestamps()
+ {
+ LastEmployeeRfidCacheSyncDisplay = FormatSyncTime(_configService.GetLastEmployeeRfidCacheSyncUtc());
+ LastMealMenuCacheSyncDisplay = FormatSyncTime(_configService.GetLastMealMenuCacheSyncUtc());
}
[RelayCommand]
@@ -124,13 +175,28 @@ public partial class SettingsViewModel : ObservableObject
return;
}
+ if (string.Equals(SelectedAppMode, "Client", StringComparison.OrdinalIgnoreCase) &&
+ string.IsNullOrWhiteSpace(CentralServerBaseUrl?.Trim()))
+ {
+ SaveMessage = "Client mode requires Central server base URL (e.g. http://192.168.1.10:5000).";
+ IsError = true;
+ IsSaving = false;
+ return;
+ }
+
+ _configService.SetAppMode(string.Equals(SelectedAppMode, "Client", StringComparison.OrdinalIgnoreCase)
+ ? AppMode.Client
+ : AppMode.Server);
+ _configService.SetCentralServerBaseUrl(CentralServerBaseUrl?.Trim() ?? string.Empty);
+ _configService.SetLocalServerListenUrls(LocalServerListenUrls?.Trim() ?? string.Empty);
+
_configService.SetSyncApiEndpoint(SyncApiEndpoint?.Trim() ?? string.Empty);
_configService.SetScanIntervalDays(days);
_configService.SetScanIntervalHours(hours);
_configService.SetScanIntervalMinutes(minutes);
_configService.SetScanIntervalSeconds(seconds);
_configService.SetAdminCardId(AdminCardId?.Trim() ?? string.Empty);
- SaveMessage = "Settings saved.";
+ SaveMessage = "Settings saved. Restart the app for App mode / server URL changes to take full effect.";
IsError = false;
IsSaving = false;
}
@@ -141,13 +207,64 @@ public partial class SettingsViewModel : ObservableObject
_navigation.NavigateBackFromSettings(GetDashboardTimeout());
}
- /// Opens Meal Schedules admin screen (Phase 2.5).
[RelayCommand]
private void OpenMealSchedules()
{
_navigation.NavigateToMealSchedules();
}
+ [RelayCommand]
+ private async Task SyncEmployeeAndMenuCacheNow()
+ {
+ if (IsSyncingCache)
+ return;
+
+ SaveMessage = "Syncing cache...";
+ IsError = false;
+ IsSyncingCache = true;
+
+ try
+ {
+ if (!IsCentralServerMode)
+ {
+ SaveMessage = "Cache sync runs only on the central server PC.";
+ IsError = true;
+ return;
+ }
+
+ if (string.IsNullOrWhiteSpace(_configService.GetHrmsLookupConnectionString()))
+ {
+ SaveMessage = "HRMS connection is not configured. Cannot sync offline cache.";
+ IsError = true;
+ return;
+ }
+
+ var result = await _offlineCacheSyncService.SyncEmployeeAndMenuCacheAsync().ConfigureAwait(true);
+ RefreshCacheSyncTimestamps();
+
+ if (result.Success)
+ {
+ SaveMessage = "Employee and menu cache synced successfully.";
+ IsError = false;
+ }
+ else
+ {
+ SaveMessage = result.ErrorMessage ?? "Cache sync failed.";
+ IsError = true;
+ }
+ }
+ catch (Exception ex)
+ {
+ Logger.Log(ex, "SettingsViewModel.SyncEmployeeAndMenuCacheNow");
+ SaveMessage = "Cache sync failed: " + ex.Message;
+ IsError = true;
+ }
+ finally
+ {
+ IsSyncingCache = false;
+ }
+ }
+
[RelayCommand]
private async Task PostDataNow()
{
@@ -160,7 +277,13 @@ public partial class SettingsViewModel : ObservableObject
try
{
- // If not configured, do not attempt sync.
+ if (!IsCentralServerMode)
+ {
+ SaveMessage = "Posting to production runs only on the central server PC.";
+ IsError = true;
+ return;
+ }
+
if (string.IsNullOrWhiteSpace(_configService.GetMySqlConnectionString()))
{
SaveMessage = "MySQL connection string not configured.";
@@ -172,8 +295,9 @@ public partial class SettingsViewModel : ObservableObject
SaveMessage = "Posted data to production.";
IsError = false;
}
- catch
+ catch (Exception ex)
{
+ Logger.Log(ex, "SettingsViewModel.PostDataNow");
SaveMessage = "Failed to post data to production.";
IsError = true;
}
@@ -183,5 +307,12 @@ public partial class SettingsViewModel : ObservableObject
}
}
+ private static string FormatSyncTime(DateTime? utc)
+ {
+ if (utc == null)
+ return "Never";
+ return utc.Value.ToLocalTime().ToString("MM/dd/yyyy, hh:mm:ss tt");
+ }
+
private TimeSpan GetDashboardTimeout() => _configService.GetScanInterval();
}
diff --git a/Views/SettingsView.xaml b/Views/SettingsView.xaml
index 4144d8a..41571b4 100644
--- a/Views/SettingsView.xaml
+++ b/Views/SettingsView.xaml
@@ -2,6 +2,7 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
+
@@ -273,6 +274,35 @@
Foreground="{StaticResource MutedText}"
Margin="0,0,0,20" />
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -396,7 +445,8 @@
Command="{Binding OpenMealSchedulesCommand}"
Style="{StaticResource OutlineButtonStyle}"
MinWidth="120"
- Margin="0,0,16,0" />
+ Margin="0,0,16,0"
+ Visibility="{Binding IsCentralServerMode, Converter={StaticResource BoolToVis}}" />