From 2abd38cfccfec6ccfc5d4e0176245316496d824f Mon Sep 17 00:00:00 2001 From: Syed Mustafa Ahmed Naqvi Date: Thu, 21 May 2026 09:33:17 +0500 Subject: [PATCH] Wire server/client mode and cache sync settings Adds Server and Client application modes, central server URL settings, and local API listen URL settings. Wires startup so server mode hosts the RFID API and cache sync timers, while client mode sends scans to the central server. Updates settings UI to save mode and manually sync cache data. --- App.xaml.cs | 162 +++++++++++++++++++++++++------- Models/AppMode.cs | 10 ++ Services/ConfigService.cs | 63 +++++++++++++ Services/IConfigService.cs | 18 ++++ ViewModels/SettingsViewModel.cs | 149 +++++++++++++++++++++++++++-- Views/SettingsView.xaml | 54 ++++++++++- Views/SettingsView.xaml.cs | 1 + 7 files changed, 412 insertions(+), 45 deletions(-) create mode 100644 Models/AppMode.cs 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}}" />