diff --git a/UtopiaCanteen.Client/App.xaml b/UtopiaCanteen.Client/App.xaml new file mode 100644 index 0000000..461d862 --- /dev/null +++ b/UtopiaCanteen.Client/App.xaml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/UtopiaCanteen.Client/App.xaml.cs b/UtopiaCanteen.Client/App.xaml.cs new file mode 100644 index 0000000..a6e0826 --- /dev/null +++ b/UtopiaCanteen.Client/App.xaml.cs @@ -0,0 +1,65 @@ +using System.Net.Http; +using System.Windows; +using Microsoft.EntityFrameworkCore; +using UtopiaCanteenSystem; +using UtopiaCanteenSystem.Data; +using UtopiaCanteenSystem.Services; +using UtopiaCanteen.Client.ViewModels; +using UtopiaCanteenSystem.ViewModels; + +namespace UtopiaCanteen.Client; + +public partial class App : Application +{ + private static Mutex _mutex = null!; + + protected override void OnStartup(StartupEventArgs e) + { + base.OnStartup(e); + bool isNewInstance; + _mutex = new Mutex(true, "UtopiaCanteenClientMutex", out isNewInstance); + + if (!isNewInstance) + { + MessageBox.Show("Another instance of the scanner client is already running.", "Warning", + MessageBoxButton.OK, MessageBoxImage.Warning); + Current.Shutdown(); + return; + } + + var configService = new ClientConfigService(); + var httpClient = new HttpClient { Timeout = TimeSpan.FromMinutes(5) }; + var backendApiClient = new CanteenBackendApiClient(httpClient, configService); + + var dbFactory = new DbContextFactory(); + using (var db = dbFactory.CreateDbContext()) + db.EnsureDatabaseCreated(); + + var employeeLookupService = new EmployeeLookupService(dbFactory, configService); + var menuLookupService = new EmptyMenuLookupService(); + var employeePhotoService = new NoOpEmployeePhotoService(); + var adminAuditService = new AdminAuditService(dbFactory); + var session = new AppSession(); + 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, backendApiClient), + () => new ScannerDashboardViewModel(backendApiClient, navigationService, session, configService, menuLookupService, employeePhotoService), + () => new MainDashboardViewModel(navigationService, backendApiClient, configService, session), + () => new AdminSettingsAuthViewModel(authService, session, navigationService, configService, employeeLookupService, backendApiClient), + () => new ClientSettingsViewModel(configService, navigationService, backendApiClient, session), + () => new MealSchedulesViewModel(new BackendApiMealScheduleService(backendApiClient), navigationService, configService)); + + var mainWindow = new MainWindow { DataContext = new MainViewModel(navigationService) }; + mainWindow.WindowState = WindowState.Maximized; + mainWindow.Show(); + } + + protected override void OnExit(ExitEventArgs e) + { + _mutex?.ReleaseMutex(); + base.OnExit(e); + } +} diff --git a/UtopiaCanteen.Client/UtopiaCanteen.Client.csproj b/UtopiaCanteen.Client/UtopiaCanteen.Client.csproj new file mode 100644 index 0000000..7b7b848 --- /dev/null +++ b/UtopiaCanteen.Client/UtopiaCanteen.Client.csproj @@ -0,0 +1,88 @@ + + + WinExe + net8.0-windows + enable + enable + true + UtopiaCanteen.Client + UtopiaCanteen.Client + ..\assets\favicon.ico + + + + + + + + + + + + + MSBuild:Compile + Designer + + + MSBuild:Compile + Designer + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + assets\%(RecursiveDir)%(Filename)%(Extension) + PreserveNewest + + + diff --git a/UtopiaCanteen.Client/ViewModels/ClientSettingsViewModel.cs b/UtopiaCanteen.Client/ViewModels/ClientSettingsViewModel.cs new file mode 100644 index 0000000..4a5b1b3 --- /dev/null +++ b/UtopiaCanteen.Client/ViewModels/ClientSettingsViewModel.cs @@ -0,0 +1,340 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using UtopiaCanteenSystem.Services; + +namespace UtopiaCanteen.Client.ViewModels; + +public partial class ClientSettingsViewModel : ObservableObject +{ + public const string BackendUnavailableMessage = + "Backend server unavailable. Please check Backend Server Base URL or server service."; + + private readonly IConfigService _configService; + private readonly INavigationService _navigation; + private readonly ICanteenBackendApiClient _backendApi; + private readonly AppSession _session; + + [ObservableProperty] + private string _scanIntervalDays = "0"; + + [ObservableProperty] + private string _scanIntervalHours = "0"; + + [ObservableProperty] + private string _scanIntervalMinutes = "0"; + + [ObservableProperty] + private string _scanIntervalSeconds = "5"; + + [ObservableProperty] + private string _adminCardId = "ADMIN"; + + [ObservableProperty] + private string _applicationModeDisplay = "Client"; + + [ObservableProperty] + private string _backendBaseUrl = string.Empty; + + [ObservableProperty] + private string _deviceId = string.Empty; + + [ObservableProperty] + private string _siteId = string.Empty; + + [ObservableProperty] + private string _backendHealthDisplay = "Checking backend..."; + + [ObservableProperty] + private bool _isBackendOnline; + + [ObservableProperty] + private string _saveMessage = string.Empty; + + [ObservableProperty] + private bool _isError; + + [ObservableProperty] + private bool _isSaving; + + [ObservableProperty] + private bool _isPosting; + + [ObservableProperty] + private bool _isSyncingCache; + + [ObservableProperty] + private string _lastEmployeeRfidCacheSyncDisplay = "Never"; + + [ObservableProperty] + private string _lastMealMenuCacheSyncDisplay = "Never"; + + public bool HasBackendUrl => !string.IsNullOrWhiteSpace(BackendBaseUrl?.Trim()); + + public bool CanPostNow => !IsPosting && HasBackendUrl; + public string PostButtonText => IsPosting ? "Posting..." : "Post Data"; + + public bool CanSyncCacheNow => !IsSyncingCache && HasBackendUrl; + public string SyncCacheButtonText => IsSyncingCache ? "Syncing..." : "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 OnBackendBaseUrlChanged(string value) + { + OnPropertyChanged(nameof(HasBackendUrl)); + OnPropertyChanged(nameof(CanPostNow)); + OnPropertyChanged(nameof(CanSyncCacheNow)); + } + + public ClientSettingsViewModel( + IConfigService configService, + INavigationService navigation, + ICanteenBackendApiClient backendApi, + AppSession session) + { + _configService = configService; + _navigation = navigation; + _backendApi = backendApi; + _session = session; + LoadFromConfig(); + _ = InitializeAsync(); + } + + public void LoadFromConfig() + { + ScanIntervalDays = _configService.GetScanIntervalDays().ToString(); + ScanIntervalHours = _configService.GetScanIntervalHours().ToString(); + ScanIntervalMinutes = _configService.GetScanIntervalMinutes().ToString(); + ScanIntervalSeconds = _configService.GetScanIntervalSeconds().ToString(); + AdminCardId = AdminLoginHelper.ResolveAdminCardIdForDisplay(_configService, _session); + ApplicationModeDisplay = "Client"; + BackendBaseUrl = _configService.GetBackendBaseUrl(); + DeviceId = _configService.GetDeviceId(); + SiteId = AdminLoginHelper.FormatSiteIdForDisplay(_configService.GetSiteId()); + OnPropertyChanged(nameof(HasBackendUrl)); + } + + public async Task InitializeAsync() + { + await RefreshBackendStatusAsync().ConfigureAwait(true); + await RefreshCacheSyncTimestampsAsync().ConfigureAwait(true); + } + + public async Task RefreshBackendStatusAsync() + { + if (!HasBackendUrl) + { + IsBackendOnline = false; + BackendHealthDisplay = "Backend URL not configured."; + return; + } + + try + { + var health = await _backendApi.GetHealthAsync().ConfigureAwait(true); + if (health != null && string.Equals(health.Status, "ok", StringComparison.OrdinalIgnoreCase)) + { + IsBackendOnline = true; + BackendHealthDisplay = $"Backend online ({health.Mode}) — {health.Utc.ToLocalTime():MM/dd/yyyy hh:mm:ss tt}"; + } + else + { + IsBackendOnline = false; + BackendHealthDisplay = BackendUnavailableMessage; + } + } + catch (Exception ex) + { + Logger.Log(ex, "ClientSettingsViewModel.RefreshBackendStatusAsync"); + IsBackendOnline = false; + BackendHealthDisplay = BackendUnavailableMessage; + } + } + + public async Task RefreshCacheSyncTimestampsAsync() + { + if (!HasBackendUrl || !IsBackendOnline) + return; + + var status = await _backendApi.GetCacheStatusAsync().ConfigureAwait(true); + if (status == null) + return; + + LastEmployeeRfidCacheSyncDisplay = FormatSyncTime(status.LastEmployeeRfidCacheSyncUtc); + LastMealMenuCacheSyncDisplay = FormatSyncTime(status.LastMealMenuCacheSyncUtc); + } + + [RelayCommand] + private async Task Save() + { + IsSaving = true; + SaveMessage = string.Empty; + IsError = false; + + try + { + if (!int.TryParse(ScanIntervalDays, out var days) || days < 0 || days > 365) + { + SetSaveError("Scan interval Days must be 0–365."); + return; + } + if (!int.TryParse(ScanIntervalHours, out var hours) || hours < 0 || hours > 23) + { + SetSaveError("Scan interval Hours must be 0–23."); + return; + } + if (!int.TryParse(ScanIntervalMinutes, out var minutes) || minutes < 0 || minutes > 59) + { + SetSaveError("Scan interval Minutes must be 0–59."); + return; + } + if (!int.TryParse(ScanIntervalSeconds, out var seconds) || seconds < 0 || seconds > 59) + { + SetSaveError("Scan interval Seconds must be 0–59."); + return; + } + if (days == 0 && hours == 0 && minutes == 0 && seconds == 0) + { + SetSaveError("Scan interval cannot be zero. At least 1 second required."); + return; + } + + if (string.IsNullOrWhiteSpace(BackendBaseUrl?.Trim())) + { + SetSaveError("Backend Server Base URL is required."); + return; + } + + _configService.SetScanIntervalDays(days); + _configService.SetScanIntervalHours(hours); + _configService.SetScanIntervalMinutes(minutes); + _configService.SetScanIntervalSeconds(seconds); + _configService.SetAdminCardId(AdminCardId?.Trim() ?? string.Empty); + _configService.SetCentralServerBaseUrl(BackendBaseUrl.Trim()); + _configService.SetDeviceId(DeviceId?.Trim() ?? string.Empty); + var site = SiteId?.Trim() ?? string.Empty; + _configService.SetSiteId( + string.IsNullOrEmpty(site) + ? string.Empty + : AdminLoginHelper.FormatSiteIdForDisplay(site)); + + SaveMessage = "Settings saved."; + IsError = false; + await RefreshBackendStatusAsync().ConfigureAwait(true); + } + finally + { + IsSaving = false; + } + } + + [RelayCommand] + private void Back() => _navigation.NavigateBackFromSettings(_configService.GetScanInterval()); + + [RelayCommand] + private void OpenMealSchedules() => _navigation.NavigateToMealSchedules(); + + [RelayCommand] + private async Task SyncEmployeeAndMenuCacheNow() + { + if (IsSyncingCache) + return; + + SaveMessage = "Syncing..."; + IsError = false; + IsSyncingCache = true; + + try + { + if (!await EnsureBackendAvailableAsync().ConfigureAwait(true)) + return; + + var result = await _backendApi.SyncCacheNowAsync().ConfigureAwait(true); + await RefreshCacheSyncTimestampsAsync().ConfigureAwait(true); + + SaveMessage = result.Message; + IsError = !result.Success; + if (!string.IsNullOrWhiteSpace(result.Details) && result.Success) + SaveMessage += " " + result.Details; + } + catch (Exception ex) + { + Logger.Log(ex, "ClientSettingsViewModel.SyncEmployeeAndMenuCacheNow"); + SaveMessage = "Cache sync failed: " + ex.Message; + IsError = true; + } + finally + { + IsSyncingCache = false; + } + } + + [RelayCommand] + private async Task PostDataNow() + { + if (IsPosting) + return; + + SaveMessage = string.Empty; + IsError = false; + IsPosting = true; + + try + { + if (!await EnsureBackendAvailableAsync().ConfigureAwait(true)) + return; + + var result = await _backendApi.SyncOrdersNowAsync().ConfigureAwait(true); + SaveMessage = result.Message; + IsError = !result.Success; + if (!string.IsNullOrWhiteSpace(result.Details) && result.Success) + SaveMessage += " " + result.Details; + } + catch (Exception ex) + { + Logger.Log(ex, "ClientSettingsViewModel.PostDataNow"); + SaveMessage = "Failed to post data: " + ex.Message; + IsError = true; + } + finally + { + IsPosting = false; + } + } + + private async Task EnsureBackendAvailableAsync() + { + if (!HasBackendUrl) + { + SaveMessage = "Backend URL is not configured."; + IsError = true; + return false; + } + + await RefreshBackendStatusAsync().ConfigureAwait(true); + if (IsBackendOnline) + return true; + + SaveMessage = BackendUnavailableMessage; + IsError = true; + return false; + } + + private void SetSaveError(string message) + { + SaveMessage = message; + IsError = true; + } + + private static string FormatSyncTime(DateTime? utc) => + utc == null ? "Never" : utc.Value.ToLocalTime().ToString("MM/dd/yyyy, hh:mm:ss tt"); +} diff --git a/UtopiaCanteen.Client/Views/ClientSettingsView.xaml b/UtopiaCanteen.Client/Views/ClientSettingsView.xaml new file mode 100644 index 0000000..f61e07f --- /dev/null +++ b/UtopiaCanteen.Client/Views/ClientSettingsView.xaml @@ -0,0 +1,363 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/UtopiaCanteen.Client/Views/ClientSettingsView.xaml.cs b/UtopiaCanteen.Client/Views/ClientSettingsView.xaml.cs new file mode 100644 index 0000000..57af2c0 --- /dev/null +++ b/UtopiaCanteen.Client/Views/ClientSettingsView.xaml.cs @@ -0,0 +1,19 @@ +using System.Windows.Controls; +using UtopiaCanteen.Client.ViewModels; + +namespace UtopiaCanteen.Client.Views; + +public partial class ClientSettingsView : UserControl +{ + public ClientSettingsView() + { + InitializeComponent(); + } + + public ClientSettingsView(ClientSettingsViewModel viewModel) + { + InitializeComponent(); + DataContext = viewModel; + Loaded += (_, _) => _ = viewModel.InitializeAsync(); + } +}