Wire WPF app for server and client modes

Updates application startup to compose server-mode backend services or client-mode API services, and adds empty/no-op services for frontend-only client behavior.
feature/centralized-offline-canteen
SYED MUSTUFA AHMED NAQVI 2026-05-25 16:21:39 +05:00
parent bf1f8accd1
commit 8123d67121
5 changed files with 120 additions and 59 deletions

View File

@ -11,7 +11,7 @@ using UtopiaCanteenSystem.ViewModels;
namespace UtopiaCanteenSystem;
/// <summary>
/// Application entry point. Initializes database, builds service graph, optional Kestrel API (server), HTTP client scans (client).
/// WPF shell: Server = backend + SQLite + API host; Client = scanner frontend calling backend HTTP API only.
/// </summary>
public partial class App : Application
{
@ -37,73 +37,80 @@ public partial class App : Application
base.OnStartup(e);
var configService = new ConfigService();
var isServer = configService.GetAppMode() == AppMode.Server;
var httpClient = new HttpClient { Timeout = TimeSpan.FromMinutes(5) };
var backendApiClient = new CanteenBackendApiClient(httpClient, configService);
var dbFactory = new DbContextFactory();
CanteenBackendServices? backendServices = null;
RfidService? serverRfid = null;
IEmployeeLookupService employeeLookupService;
IMenuLookupService menuLookupService;
IRfidService scannerRfidService;
ISyncService? syncService = null;
ProductionMealScheduleService? mealScheduleService = null;
using (var db = dbFactory.CreateDbContext())
db.EnsureDatabaseCreated();
var configService = new ConfigService();
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(dbFactory);
var mealScheduleService = new ProductionMealScheduleService(configService);
var mealSessionResolver = new DbMealSessionResolver(dbFactory);
var syncService = new SyncService(dbFactory, configService);
RfidService? serverRfid = null;
IRfidService rfidService;
if (isServer)
{
var employeeRfidTagSync = new EmployeeRfidTagSyncService(dbFactory, configService);
var mealMenuCacheSync = new MealMenuCacheSyncService(dbFactory, configService);
var offlineCacheSync = new OfflineCacheSyncService(employeeRfidTagSync, mealMenuCacheSync);
employeeLookupService = new EmployeeLookupService(dbFactory, configService);
menuLookupService = new MenuLookupService(dbFactory);
mealScheduleService = new ProductionMealScheduleService(configService);
var mealSessionResolver = new DbMealSessionResolver(dbFactory);
syncService = new SyncService(dbFactory, configService);
serverRfid = new RfidService(dbFactory, configService, employeeLookupService, mealSessionResolver, menuLookupService, syncService);
rfidService = serverRfid;
backendServices = new CanteenBackendServices(serverRfid, offlineCacheSync, syncService, configService);
scannerRfidService = serverRfid;
}
else
{
rfidService = new ClientRfidService(httpClient, configService);
employeeLookupService = new EmployeeLookupService(dbFactory, configService);
menuLookupService = new EmptyMenuLookupService();
scannerRfidService = backendApiClient;
}
var employeePhotoService = new EmployeePhotoService(configService, httpClient);
var adminAuditService = new AdminAuditService(dbFactory);
var session = new AppSession();
var authenticationUrl = "https://portal.utopiaindustries.pk/uind/rest/auth/user/";
var authService = new AuthService(authenticationUrl);
var authService = new AuthService("https://portal.utopiaindustries.pk/uind/rest/auth/user/");
NavigationService navigationService = null!;
navigationService = new NavigationService(
session,
() => new AdminLoginViewModel(authService, session, navigationService, configService, adminAuditService, employeeLookupService),
() => 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, offlineCacheSyncService),
() => new MealSchedulesViewModel(mealScheduleService, navigationService, configService));
() => new AdminLoginViewModel(authService, session, navigationService, configService, adminAuditService, employeeLookupService, backendApiClient),
() => new ScannerDashboardViewModel(scannerRfidService, navigationService, session, configService, menuLookupService, employeePhotoService),
() => new MainDashboardViewModel(navigationService, scannerRfidService, configService, session),
() => new AdminSettingsAuthViewModel(authService, session, navigationService, configService, employeeLookupService, backendApiClient),
() => new SettingsViewModel(configService, navigationService, adminAuditService, backendApiClient),
() => new MealSchedulesViewModel(
mealScheduleService ?? new ProductionMealScheduleService(configService),
navigationService,
configService));
var mainViewModel = new MainViewModel(navigationService);
var mainWindow = new MainWindow
{
DataContext = mainViewModel
};
var mainWindow = new MainWindow { DataContext = new MainViewModel(navigationService) };
mainWindow.WindowState = WindowState.Maximized;
mainWindow.Show();
if (isServer && serverRfid != null)
if (isServer && backendServices != null)
{
_apiHostCts = new CancellationTokenSource();
var listenUrls = configService.GetLocalServerListenUrls();
var rfid = serverRfid;
var backend = backendServices;
var cts = _apiHostCts;
_apiHostTask = Task.Run(async () =>
{
try
{
await CanteenLocalApiHost.RunAsync(rfid, listenUrls, cts!.Token).ConfigureAwait(false);
await Task.Delay(500, cts!.Token).ConfigureAwait(false);
await CanteenBackendHost.RunAsync(backend, listenUrls, cts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
@ -111,28 +118,22 @@ public partial class App : Application
}
catch (Exception ex)
{
Logger.Log(ex, "App.CanteenLocalApiHost");
Logger.Log(ex, "App.CanteenBackendHost");
}
}, CancellationToken.None);
}
if (isServer)
{
_ = RunOfflineCacheSyncAsync(offlineCacheSyncService);
_ = RunOfflineCacheSyncViaApiAsync(backendApiClient);
if (!string.IsNullOrWhiteSpace(configService.GetHrmsLookupConnectionString()))
{
_offlineCacheSyncTimer = new System.Timers.Timer(TimeSpan.FromMinutes(15).TotalMilliseconds)
{
AutoReset = true
};
_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);
await RunOfflineCacheSyncViaApiAsync(backendApiClient).ConfigureAwait(false);
}
catch (Exception ex)
{
@ -146,19 +147,17 @@ public partial class App : Application
_offlineCacheSyncTimer.Start();
}
if (configService.GetSyncServiceEnabled())
if (configService.GetSyncServiceEnabled() && syncService != null)
{
_syncTimer = new System.Timers.Timer(TimeSpan.FromMinutes(1).TotalMilliseconds)
{
AutoReset = true
};
_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);
if (await backendApiClient.HealthCheckAsync().ConfigureAwait(false))
await backendApiClient.SyncOrdersNowAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
@ -183,7 +182,7 @@ public partial class App : Application
}
catch
{
// Best-effort shutdown
// Best-effort
}
_mutex.ReleaseMutex();
@ -195,15 +194,23 @@ public partial class App : Application
base.OnExit(e);
}
private static async Task RunOfflineCacheSyncAsync(IOfflineCacheSyncService offlineCacheSyncService)
private static async Task RunOfflineCacheSyncViaApiAsync(ICanteenBackendApiClient api)
{
try
{
await offlineCacheSyncService.SyncEmployeeAndMenuCacheAsync().ConfigureAwait(false);
for (var i = 0; i < 30; i++)
{
if (await api.HealthCheckAsync().ConfigureAwait(false))
{
await api.SyncCacheNowAsync().ConfigureAwait(false);
return;
}
await Task.Delay(1000).ConfigureAwait(false);
}
}
catch (Exception ex)
{
Logger.Log(ex, "App.RunOfflineCacheSyncAsync");
Logger.Log(ex, "App.RunOfflineCacheSyncViaApiAsync");
}
}
}

View File

@ -0,0 +1,20 @@
using UtopiaCanteenSystem.Models;
namespace UtopiaCanteenSystem.Services;
/// <summary>
/// Client scanner UI: menu data comes from scan API response, not local SQLite/HRMS.
/// </summary>
public sealed class EmptyMenuLookupService : IMenuLookupService
{
public Task<IReadOnlyList<HrmsMenuItem>> GetMenuItemsForSiteAsync(int siteIdNumeric, CancellationToken cancellationToken = default) =>
Task.FromResult<IReadOnlyList<HrmsMenuItem>>(Array.Empty<HrmsMenuItem>());
public Task<IReadOnlyList<HrmsMenuItem>> GetMenuItemsForSiteAndDateAsync(
int siteIdNumeric,
DateTime menuDateLocal,
string gradeType,
string mealName,
CancellationToken cancellationToken = default) =>
Task.FromResult<IReadOnlyList<HrmsMenuItem>>(Array.Empty<HrmsMenuItem>());
}

View File

@ -28,7 +28,7 @@ public class NavigationService : INavigationService
private readonly Func<ScannerDashboardViewModel> _scannerVm;
private readonly Func<MainDashboardViewModel> _dashboardVm;
private readonly Func<AdminSettingsAuthViewModel> _adminSettingsAuthVm;
private readonly Func<SettingsViewModel> _settingsVm;
private readonly Func<object> _settingsVm;
private readonly Func<MealSchedulesViewModel> _mealSchedulesVm;
public NavigationService(
@ -37,7 +37,7 @@ public class NavigationService : INavigationService
Func<ScannerDashboardViewModel> scannerVm,
Func<MainDashboardViewModel> dashboardVm,
Func<AdminSettingsAuthViewModel> adminSettingsAuthVm,
Func<SettingsViewModel> settingsVm,
Func<object> settingsVm,
Func<MealSchedulesViewModel> mealSchedulesVm)
{
_session = session;

View File

@ -0,0 +1,10 @@
namespace UtopiaCanteenSystem.Services;
/// <summary>
/// Client scanners do not call HRMS for employee photos.
/// </summary>
public sealed class NoOpEmployeePhotoService : IEmployeePhotoService
{
public Task<byte[]?> GetPhotoBytesAsync(string parentDocumentId, CancellationToken cancellationToken = default) =>
Task.FromResult<byte[]?>(null);
}

View File

@ -0,0 +1,24 @@
using UtopiaCanteenSystem.Models;
namespace UtopiaCanteenSystem.Services;
/// <summary>
/// Meal schedules are owned by the backend; clients do not query HRMS directly.
/// </summary>
public sealed class NoOpMealScheduleService : IMealScheduleService
{
public IReadOnlyList<MealSchedule> GetActiveSchedulesForSite(string siteId) =>
Array.Empty<MealSchedule>();
public Task<IReadOnlyList<MealSchedule>> GetAllSchedulesAsync(CancellationToken cancellationToken = default) =>
Task.FromResult<IReadOnlyList<MealSchedule>>(Array.Empty<MealSchedule>());
public Task<long> CreateAsync(MealSchedule schedule, CancellationToken cancellationToken = default) =>
Task.FromResult(0L);
public Task UpdateAsync(MealSchedule schedule, CancellationToken cancellationToken = default) =>
Task.CompletedTask;
public Task DeleteAsync(long id, CancellationToken cancellationToken = default) =>
Task.CompletedTask;
}