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}}" />