Add standalone scanner client application

Adds a separate WPF scanner client app with its own startup, project file, client settings view model, and client settings UI.
feature/centralized-offline-canteen
SYED MUSTUFA AHMED NAQVI 2026-05-25 16:27:19 +05:00
parent eff36f0e9f
commit e85d465bf1
6 changed files with 906 additions and 0 deletions

View File

@ -0,0 +1,31 @@
<Application x:Class="UtopiaCanteen.Client.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:UtopiaCanteenSystem.ViewModels"
xmlns:clientVm="clr-namespace:UtopiaCanteen.Client.ViewModels"
xmlns:views="clr-namespace:UtopiaCanteenSystem.Views"
xmlns:clientViews="clr-namespace:UtopiaCanteen.Client.Views">
<Application.Resources>
<ResourceDictionary>
<BooleanToVisibilityConverter x:Key="BoolToVisibility" />
<DataTemplate DataType="{x:Type vm:AdminLoginViewModel}">
<views:AdminLoginView />
</DataTemplate>
<DataTemplate DataType="{x:Type vm:AdminSettingsAuthViewModel}">
<views:AdminSettingsAuthView />
</DataTemplate>
<DataTemplate DataType="{x:Type vm:ScannerDashboardViewModel}">
<views:ScannerDashboardView />
</DataTemplate>
<DataTemplate DataType="{x:Type vm:MainDashboardViewModel}">
<views:MainDashboardView />
</DataTemplate>
<DataTemplate DataType="{x:Type clientVm:ClientSettingsViewModel}">
<clientViews:ClientSettingsView />
</DataTemplate>
<DataTemplate DataType="{x:Type vm:MealSchedulesViewModel}">
<views:MealSchedulesView />
</DataTemplate>
</ResourceDictionary>
</Application.Resources>
</Application>

View File

@ -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);
}
}

View File

@ -0,0 +1,88 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UseWPF>true</UseWPF>
<RootNamespace>UtopiaCanteen.Client</RootNamespace>
<AssemblyName>UtopiaCanteen.Client</AssemblyName>
<ApplicationIcon Condition="Exists('..\assets\favicon.ico')">..\assets\favicon.ico</ApplicationIcon>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\UtopiaCanteen.Shared\UtopiaCanteen.Shared.csproj" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.2.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.11" />
<PackageReference Include="MySqlConnector" Version="2.3.5" />
</ItemGroup>
<ItemGroup>
<Page Include="..\MainWindow.xaml" Link="MainWindow.xaml">
<Generator>MSBuild:Compile</Generator>
<SubType>Designer</SubType>
</Page>
<Page Include="..\Views\**\*.xaml" LinkBase="Views" Exclude="..\Views\SettingsView.xaml">
<Generator>MSBuild:Compile</Generator>
<SubType>Designer</SubType>
</Page>
<Compile Include="..\MainWindow.xaml.cs" Link="MainWindow.xaml.cs" />
<Compile Include="..\Views\**\*.xaml.cs" LinkBase="Views" Exclude="..\Views\SettingsView.xaml.cs" />
<Compile Include="..\ViewModels\**\*.cs" LinkBase="ViewModels" Exclude="..\ViewModels\SettingsViewModel.cs" />
<Compile Include="..\Converters\**\*.cs" LinkBase="Converters" />
<Compile Include="..\Helpers\**\*.cs" LinkBase="Helpers" />
<Compile Include="..\Models\ScanRecord.cs" Link="Models\ScanRecord.cs" />
<Compile Include="..\Models\HrmsEmployeeInfo.cs" Link="Models\HrmsEmployeeInfo.cs" />
<Compile Include="..\Models\MealSession.cs" Link="Models\MealSession.cs" />
<Compile Include="..\Models\ResolvedMealSession.cs" Link="Models\ResolvedMealSession.cs" />
<Compile Include="..\Models\OrderHistoryItem.cs" Link="Models\OrderHistoryItem.cs" />
<Compile Include="..\Models\AdminLoginRecord.cs" Link="Models\AdminLoginRecord.cs" />
<Compile Include="..\Models\HrmsMenuItem.cs" Link="Models\HrmsMenuItem.cs" />
<Compile Include="..\Models\MealSchedule.cs" Link="Models\MealSchedule.cs" />
<Compile Include="..\Models\Labour.cs" Link="Models\Labour.cs" />
<Compile Include="..\Models\EmployeeRfidTagCache.cs" Link="Models\EmployeeRfidTagCache.cs" />
<Compile Include="..\Models\MealScheduleCache.cs" Link="Models\MealScheduleCache.cs" />
<Compile Include="..\Models\LunchMenuWeekCache.cs" Link="Models\LunchMenuWeekCache.cs" />
<Compile Include="..\Models\LunchMenuItemCache.cs" Link="Models\LunchMenuItemCache.cs" />
<Compile Include="..\Models\MenuItemCache.cs" Link="Models\MenuItemCache.cs" />
<Compile Include="..\Models\AppMode.cs" Link="Models\AppMode.cs" />
<Compile Include="..\Data\AppDbContext.cs" Link="Data\AppDbContext.cs" />
<Compile Include="..\Data\DbContextFactory.cs" Link="Data\DbContextFactory.cs" />
<Compile Include="..\Data\DatabasePath.cs" Link="Data\DatabasePath.cs" />
<Compile Include="..\Services\CanteenBackendApiClient.cs" Link="Services\CanteenBackendApiClient.cs" />
<Compile Include="..\Services\ICanteenBackendApiClient.cs" Link="Services\ICanteenBackendApiClient.cs" />
<Compile Include="..\Services\ClientConfigService.cs" Link="Services\ClientConfigService.cs" />
<Compile Include="..\Services\IConfigService.cs" Link="Services\IConfigService.cs" />
<Compile Include="..\Services\EmptyMenuLookupService.cs" Link="Services\EmptyMenuLookupService.cs" />
<Compile Include="..\Services\IMenuLookupService.cs" Link="Services\IMenuLookupService.cs" />
<Compile Include="..\Services\IRfidService.cs" Link="Services\IRfidService.cs" />
<Compile Include="..\Services\ScanResult.cs" Link="Services\ScanResult.cs" />
<Compile Include="..\Services\Logger.cs" Link="Services\Logger.cs" />
<Compile Include="..\Services\NavigationService.cs" Link="Services\NavigationService.cs" />
<Compile Include="..\Services\INavigationService.cs" Link="Services\INavigationService.cs" />
<Compile Include="..\Services\AppSession.cs" Link="Services\AppSession.cs" />
<Compile Include="..\Services\AuthService.cs" Link="Services\AuthService.cs" />
<Compile Include="..\Services\IAuthService.cs" Link="Services\IAuthService.cs" />
<Compile Include="..\Services\AuthResult.cs" Link="Services\AuthResult.cs" />
<Compile Include="..\Services\AdminAuditService.cs" Link="Services\AdminAuditService.cs" />
<Compile Include="..\Services\IAdminAuditService.cs" Link="Services\IAdminAuditService.cs" />
<Compile Include="..\Services\IEmployeeLookupService.cs" Link="Services\IEmployeeLookupService.cs" />
<Compile Include="..\Services\EmployeeLookupService.cs" Link="Services\EmployeeLookupService.cs" />
<Compile Include="..\Services\NoOpEmployeePhotoService.cs" Link="Services\NoOpEmployeePhotoService.cs" />
<Compile Include="..\Services\IEmployeePhotoService.cs" Link="Services\IEmployeePhotoService.cs" />
<Compile Include="..\Services\IMealScheduleService.cs" Link="Services\IMealScheduleService.cs" />
<Compile Include="..\Services\BackendApiMealScheduleService.cs" Link="Services\BackendApiMealScheduleService.cs" />
<Compile Include="..\Services\NoOpMealScheduleService.cs" Link="Services\NoOpMealScheduleService.cs" />
<Compile Include="..\Services\SiteIdHelper.cs" Link="Services\SiteIdHelper.cs" />
<Compile Include="..\Services\AdminLoginHelper.cs" Link="Services\AdminLoginHelper.cs" />
<Compile Include="..\Api\ApiDtoMapper.cs" Link="Api\ApiDtoMapper.cs" />
</ItemGroup>
<ItemGroup>
<Content Include="..\assets\**\*">
<Link>assets\%(RecursiveDir)%(Filename)%(Extension)</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
</Project>

View File

@ -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 0365.");
return;
}
if (!int.TryParse(ScanIntervalHours, out var hours) || hours < 0 || hours > 23)
{
SetSaveError("Scan interval Hours must be 023.");
return;
}
if (!int.TryParse(ScanIntervalMinutes, out var minutes) || minutes < 0 || minutes > 59)
{
SetSaveError("Scan interval Minutes must be 059.");
return;
}
if (!int.TryParse(ScanIntervalSeconds, out var seconds) || seconds < 0 || seconds > 59)
{
SetSaveError("Scan interval Seconds must be 059.");
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<bool> 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");
}

View File

@ -0,0 +1,363 @@
<UserControl x:Class="UtopiaCanteen.Client.Views.ClientSettingsView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
HorizontalContentAlignment="Stretch"
VerticalContentAlignment="Stretch">
<UserControl.Resources>
<BooleanToVisibilityConverter x:Key="BoolToVis"/>
<SolidColorBrush x:Key="AppBackground" Color="#F0F2F5"/>
<SolidColorBrush x:Key="CardBackground" Color="#FFFFFF"/>
<SolidColorBrush x:Key="PrimaryText" Color="#2D3748"/>
<SolidColorBrush x:Key="MutedText" Color="#718096"/>
<SolidColorBrush x:Key="PrimaryAccent" Color="#5BA3A0"/>
<SolidColorBrush x:Key="PrimaryHover" Color="#4a918e"/>
<SolidColorBrush x:Key="SuccessBrush" Color="#38A169"/>
<SolidColorBrush x:Key="ErrorBrush" Color="#E53E3E"/>
<SolidColorBrush x:Key="BorderBrush" Color="#E2E8F0"/>
<SolidColorBrush x:Key="OrangeAccent" Color="#ED8936"/>
<SolidColorBrush x:Key="CardHeaderBg" Color="#F7FAFC"/>
<Style x:Key="SectionCardStyle" TargetType="Border">
<Setter Property="Background" Value="{StaticResource CardBackground}"/>
<Setter Property="CornerRadius" Value="12"/>
<Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Padding" Value="24"/>
<Setter Property="Margin" Value="0,0,0,16"/>
<Setter Property="HorizontalAlignment" Value="Stretch"/>
</Style>
<Style x:Key="ModernTextBoxStyle" TargetType="TextBox">
<Setter Property="HorizontalAlignment" Value="Stretch"/>
<Setter Property="MinHeight" Value="48"/>
<Setter Property="Background" Value="White"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="BorderBrush" Value="#E2E8F0"/>
<Setter Property="Foreground" Value="#0A1628"/>
<Setter Property="FontSize" Value="16"/>
<Setter Property="Padding" Value="14,0"/>
<Setter Property="VerticalContentAlignment" Value="Center"/>
</Style>
<Style x:Key="PrimaryButtonStyle" TargetType="Button">
<Setter Property="Background" Value="{StaticResource PrimaryAccent}"/>
<Setter Property="Foreground" Value="White"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="FontSize" Value="15"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Padding" Value="20,10"/>
<Setter Property="MinHeight" Value="42"/>
<Setter Property="Cursor" Value="Hand"/>
</Style>
<Style x:Key="OutlineButtonStyle" TargetType="Button">
<Setter Property="Background" Value="White"/>
<Setter Property="Foreground" Value="{StaticResource PrimaryText}"/>
<Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="FontSize" Value="15"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Padding" Value="20,10"/>
<Setter Property="MinHeight" Value="42"/>
<Setter Property="Cursor" Value="Hand"/>
</Style>
<Style x:Key="SyncSubCardStyle" TargetType="Border">
<Setter Property="Background" Value="{StaticResource CardHeaderBg}"/>
<Setter Property="CornerRadius" Value="10"/>
<Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Padding" Value="20"/>
<Setter Property="HorizontalAlignment" Value="Stretch"/>
</Style>
</UserControl.Resources>
<Grid Background="{StaticResource AppBackground}" HorizontalAlignment="Stretch" VerticalAlignment="Stretch">
<Grid.RowDefinitions>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<ScrollViewer Grid.Row="0"
VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
Padding="32,24,32,16">
<Grid HorizontalAlignment="Stretch"
Width="{Binding ViewportWidth, RelativeSource={RelativeSource AncestorType=ScrollViewer}}"
MinWidth="320">
<StackPanel HorizontalAlignment="Stretch">
<!-- Page header -->
<StackPanel Orientation="Horizontal" Margin="0,0,0,8">
<Border Width="48" Height="48" Background="#E6F4F3" CornerRadius="24" Margin="0,0,16,0">
<Viewbox Margin="10">
<Canvas Width="24" Height="24">
<Path Data="M12,15.5A3.5,3.5 0 0,1 8.5,12A3.5,3.5 0 0,1 12,8.5A3.5,3.5 0 0,1 15.5,12A3.5,3.5 0 0,1 12,15.5M19.43,12.97C19.47,12.65 19.5,12.33 19.5,12C19.5,11.67 19.47,11.34 19.43,11.03L21.54,9.37C21.73,9.22 21.78,8.95 21.66,8.73L19.66,5.27C19.54,5.05 19.27,4.96 19.05,5.05L16.56,6.26C16.04,5.86 15.48,5.51 14.87,5.23L14.5,2.58C14.46,2.34 14.25,2.17 14,2.17H10C9.75,2.17 9.54,2.34 9.5,2.58L9.13,5.23C8.52,5.51 7.96,5.86 7.44,6.26L4.95,5.05C4.73,4.96 4.46,5.05 4.34,5.27L2.34,8.73C2.22,8.95 2.27,9.22 2.46,9.37L4.57,11.03C4.53,11.34 4.5,11.67 4.5,12C4.5,12.33 4.53,12.65 4.57,12.97L2.46,14.63C2.27,14.78 2.22,15.05 2.34,15.27L4.34,18.73C4.46,18.95 4.73,19.03 4.95,18.95L7.44,17.74C7.96,18.14 8.52,18.49 9.13,18.77L9.5,21.42C9.54,21.66 9.75,21.83 10,21.83H14C14.25,21.83 14.46,21.66 14.5,21.42L14.87,18.77C15.48,18.49 16.04,18.14 16.56,17.74L19.05,18.95C19.27,19.03 19.54,18.95 19.66,18.73L21.66,15.27C21.78,15.05 21.73,14.78 21.54,14.63L19.43,12.97Z"
Fill="{StaticResource PrimaryAccent}" Stretch="Uniform"/>
</Canvas>
</Viewbox>
</Border>
<StackPanel VerticalAlignment="Center">
<TextBlock Text="Settings" FontSize="32" FontWeight="Bold" Foreground="{StaticResource PrimaryText}"/>
<TextBlock Text="Configure scanning, access, connectivity, and sync settings."
FontSize="15" Foreground="{StaticResource MutedText}" Margin="0,4,0,0"/>
</StackPanel>
</StackPanel>
<!-- 1. Scan Interval -->
<Border Style="{StaticResource SectionCardStyle}">
<StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,12">
<Border Width="36" Height="36" Background="#E6F4F3" CornerRadius="18" Margin="0,0,12,0">
<TextBlock Text="&#xE121;" FontFamily="Segoe MDL2 Assets" FontSize="16"
Foreground="{StaticResource PrimaryAccent}" HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<StackPanel VerticalAlignment="Center">
<TextBlock Text="Scan Interval" FontSize="20" FontWeight="Bold" Foreground="{StaticResource PrimaryText}"/>
<TextBlock Text="Set the minimum time between scans to prevent duplicates."
FontSize="14" Foreground="{StaticResource MutedText}"/>
</StackPanel>
</StackPanel>
<Grid Margin="0,8,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/><ColumnDefinition Width="12"/>
<ColumnDefinition Width="*"/><ColumnDefinition Width="12"/>
<ColumnDefinition Width="*"/><ColumnDefinition Width="12"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0">
<TextBlock Text="Days (0365)" FontSize="13" Foreground="{StaticResource MutedText}" Margin="0,0,0,4"/>
<TextBox Text="{Binding ScanIntervalDays, UpdateSourceTrigger=PropertyChanged}" Style="{StaticResource ModernTextBoxStyle}"/>
</StackPanel>
<StackPanel Grid.Column="2">
<TextBlock Text="Hours (023)" FontSize="13" Foreground="{StaticResource MutedText}" Margin="0,0,0,4"/>
<TextBox Text="{Binding ScanIntervalHours, UpdateSourceTrigger=PropertyChanged}" Style="{StaticResource ModernTextBoxStyle}"/>
</StackPanel>
<StackPanel Grid.Column="4">
<TextBlock Text="Minutes (059)" FontSize="13" Foreground="{StaticResource MutedText}" Margin="0,0,0,4"/>
<TextBox Text="{Binding ScanIntervalMinutes, UpdateSourceTrigger=PropertyChanged}" Style="{StaticResource ModernTextBoxStyle}"/>
</StackPanel>
<StackPanel Grid.Column="6">
<TextBlock Text="Seconds (059)" FontSize="13" Foreground="{StaticResource MutedText}" Margin="0,0,0,4"/>
<TextBox Text="{Binding ScanIntervalSeconds, UpdateSourceTrigger=PropertyChanged}" Style="{StaticResource ModernTextBoxStyle}"/>
</StackPanel>
</Grid>
<StackPanel Orientation="Horizontal" Margin="0,12,0,0">
<TextBlock Text="&#xE946;" FontFamily="Segoe MDL2 Assets" FontSize="14"
Foreground="{StaticResource MutedText}" Margin="0,2,8,0"/>
<TextBlock Text="The minimum time between scans to prevent duplicates. At least 1 second required."
FontSize="13" Foreground="{StaticResource MutedText}" TextWrapping="Wrap"/>
</StackPanel>
</StackPanel>
</Border>
<!-- 2. Admin Access -->
<Border Style="{StaticResource SectionCardStyle}">
<StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,12">
<Border Width="36" Height="36" Background="#E6F4F3" CornerRadius="18" Margin="0,0,12,0">
<TextBlock Text="&#xE77B;" FontFamily="Segoe MDL2 Assets" FontSize="16"
Foreground="{StaticResource PrimaryAccent}" HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<TextBlock Text="Admin Access" FontSize="20" FontWeight="Bold" Foreground="{StaticResource PrimaryText}" VerticalAlignment="Center"/>
</StackPanel>
<TextBlock Text="Admin Employee ID" FontSize="14" FontWeight="SemiBold" Foreground="{StaticResource PrimaryText}"/>
<TextBox Text="{Binding AdminCardId, UpdateSourceTrigger=PropertyChanged}"
Style="{StaticResource ModernTextBoxStyle}" Margin="0,8,0,4"/>
<TextBlock Text="Card ID with admin access to settings." FontSize="13" Foreground="{StaticResource MutedText}"/>
</StackPanel>
</Border>
<!-- 3. Application Mode & Connectivity -->
<Border Style="{StaticResource SectionCardStyle}">
<StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,12">
<Border Width="36" Height="36" Background="#E6F4F3" CornerRadius="18" Margin="0,0,12,0">
<TextBlock Text="&#xE968;" FontFamily="Segoe MDL2 Assets" FontSize="16"
Foreground="{StaticResource PrimaryAccent}" HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<StackPanel VerticalAlignment="Center">
<TextBlock Text="Application Mode &amp; Connectivity" FontSize="20" FontWeight="Bold" Foreground="{StaticResource PrimaryText}"/>
<TextBlock Text="Configure how this application connects to servers."
FontSize="14" Foreground="{StaticResource MutedText}"/>
</StackPanel>
</StackPanel>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="24"/>
<ColumnDefinition Width="2*"/>
<ColumnDefinition Width="24"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Row="0" Grid.Column="0">
<TextBlock Text="Application Mode" FontSize="14" FontWeight="SemiBold" Foreground="{StaticResource PrimaryText}"/>
<TextBox Text="{Binding ApplicationModeDisplay}" IsReadOnly="True" IsTabStop="False"
Style="{StaticResource ModernTextBoxStyle}" Margin="0,8,0,4" Background="#F8FAFC"/>
<TextBlock Text="Client: this PC sends scans to the central backend server."
FontSize="12" Foreground="{StaticResource MutedText}" TextWrapping="Wrap"/>
</StackPanel>
<StackPanel Grid.Row="0" Grid.Column="2">
<TextBlock Text="Backend Server Base URL" FontSize="14" FontWeight="SemiBold" Foreground="{StaticResource PrimaryText}"/>
<TextBox Text="{Binding BackendBaseUrl, UpdateSourceTrigger=PropertyChanged}"
Style="{StaticResource ModernTextBoxStyle}" Margin="0,8,0,4"/>
<TextBlock Text="Example: http://192.168.1.10:5000" FontSize="12" Foreground="{StaticResource MutedText}"/>
</StackPanel>
<StackPanel Grid.Row="0" Grid.Column="4">
<TextBlock Text="Device ID" FontSize="14" FontWeight="SemiBold" Foreground="{StaticResource PrimaryText}"/>
<TextBox Text="{Binding DeviceId, UpdateSourceTrigger=PropertyChanged}"
Style="{StaticResource ModernTextBoxStyle}" Margin="0,8,0,4"/>
<TextBlock Text="Identifies this scanner PC in scan records." FontSize="12" Foreground="{StaticResource MutedText}"/>
</StackPanel>
<StackPanel Grid.Row="1" Grid.Column="0" Grid.ColumnSpan="3" Margin="0,16,0,0">
<TextBlock Text="Site ID" FontSize="14" FontWeight="SemiBold" Foreground="{StaticResource PrimaryText}"/>
<TextBox Text="{Binding SiteId, UpdateSourceTrigger=PropertyChanged}"
Style="{StaticResource ModernTextBoxStyle}" Margin="0,8,0,4" MaxWidth="480" HorizontalAlignment="Left"/>
<TextBlock Text="Canteen location site code sent with each scan." FontSize="12" Foreground="{StaticResource MutedText}"/>
</StackPanel>
</Grid>
<Border Margin="0,16,0,0" Padding="12" CornerRadius="8" Background="#EDF2F7">
<StackPanel Orientation="Horizontal">
<Ellipse Width="10" Height="10" Margin="0,4,10,0" VerticalAlignment="Top">
<Ellipse.Style>
<Style TargetType="Ellipse">
<Setter Property="Fill" Value="{StaticResource ErrorBrush}"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsBackendOnline}" Value="True">
<Setter Property="Fill" Value="{StaticResource SuccessBrush}"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Ellipse.Style>
</Ellipse>
<TextBlock Text="{Binding BackendHealthDisplay}" FontSize="13" Foreground="{StaticResource PrimaryText}" TextWrapping="Wrap"/>
</StackPanel>
</Border>
</StackPanel>
</Border>
<!-- 4. Data Sync -->
<Border Style="{StaticResource SectionCardStyle}" Margin="0,0,0,8">
<StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,16">
<Border Width="36" Height="36" Background="#E6F4F3" CornerRadius="18" Margin="0,0,12,0">
<TextBlock Text="&#xE895;" FontFamily="Segoe MDL2 Assets" FontSize="16"
Foreground="{StaticResource PrimaryAccent}" HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<StackPanel VerticalAlignment="Center">
<TextBlock Text="Data Sync" FontSize="20" FontWeight="Bold" Foreground="{StaticResource PrimaryText}"/>
<TextBlock Text="Keep your offline data up to date and send transactions to production."
FontSize="14" Foreground="{StaticResource MutedText}"/>
</StackPanel>
</StackPanel>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/><ColumnDefinition Width="16"/><ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<!-- Download Offline Data -->
<Border Grid.Column="0" Style="{StaticResource SyncSubCardStyle}">
<StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,8">
<TextBlock Text="&#xE896;" FontFamily="Segoe MDL2 Assets" FontSize="18"
Foreground="{StaticResource PrimaryAccent}" Margin="0,0,8,0"/>
<TextBlock Text="Download Offline Data" FontSize="17" FontWeight="SemiBold" Foreground="{StaticResource PrimaryText}"/>
</StackPanel>
<TextBlock Text="Download employee RFID tags, meal schedules, and menu data for offline scanning."
FontSize="13" Foreground="{StaticResource MutedText}" TextWrapping="Wrap" Margin="0,0,0,16"/>
<Button Content="{Binding SyncCacheButtonText}"
Command="{Binding SyncEmployeeAndMenuCacheNowCommand}"
Style="{StaticResource PrimaryButtonStyle}"
HorizontalAlignment="Left"
IsEnabled="{Binding CanSyncCacheNow}"/>
</StackPanel>
</Border>
<!-- Post Data to Production -->
<Border Grid.Column="2" Style="{StaticResource SyncSubCardStyle}">
<StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,8">
<TextBlock Text="&#xE898;" FontFamily="Segoe MDL2 Assets" FontSize="18"
Foreground="{StaticResource OrangeAccent}" Margin="0,0,8,0"/>
<TextBlock Text="Post Data to Production" FontSize="17" FontWeight="SemiBold" Foreground="{StaticResource PrimaryText}"/>
</StackPanel>
<TextBlock Text="Post captured scan and transaction data to the production server."
FontSize="13" Foreground="{StaticResource MutedText}" TextWrapping="Wrap" Margin="0,0,0,16"/>
<Button Content="{Binding PostButtonText}"
Command="{Binding PostDataNowCommand}"
Style="{StaticResource PrimaryButtonStyle}"
HorizontalAlignment="Left"
IsEnabled="{Binding CanPostNow}"/>
</StackPanel>
</Border>
</Grid>
<TextBlock Text="{Binding LastEmployeeRfidCacheSyncDisplay, StringFormat=Last employee RFID cache sync: {0}}"
FontSize="13" Foreground="{StaticResource MutedText}" Margin="0,16,0,4"/>
<TextBlock Text="{Binding LastMealMenuCacheSyncDisplay, StringFormat=Last meal/menu cache sync: {0}}"
FontSize="13" Foreground="{StaticResource MutedText}"/>
</StackPanel>
</Border>
<!-- Status message -->
<Border Padding="14" CornerRadius="8" Margin="0,0,0,8">
<Border.Style>
<Style TargetType="Border">
<Setter Property="Background" Value="#dcfce7"/>
<Setter Property="BorderBrush" Value="#86efac"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Visibility" Value="Visible"/>
<Style.Triggers>
<DataTrigger Binding="{Binding SaveMessage}" Value="">
<Setter Property="Visibility" Value="Collapsed"/>
</DataTrigger>
<DataTrigger Binding="{Binding IsError}" Value="True">
<Setter Property="Background" Value="#fee2e2"/>
<Setter Property="BorderBrush" Value="#fca5a5"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Border.Style>
<TextBlock Text="{Binding SaveMessage}" FontSize="15" FontWeight="SemiBold" TextWrapping="Wrap">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Foreground" Value="{StaticResource SuccessBrush}"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsError}" Value="True">
<Setter Property="Foreground" Value="{StaticResource ErrorBrush}"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</Border>
</StackPanel>
</Grid>
</ScrollViewer>
<!-- Sticky bottom action bar (full width) -->
<Border Grid.Row="1" Background="#F9FBFB" BorderBrush="{StaticResource BorderBrush}" BorderThickness="0,1,0,0" Padding="32,16">
<Grid HorizontalAlignment="Stretch">
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
<Button Command="{Binding OpenMealSchedulesCommand}" Style="{StaticResource OutlineButtonStyle}" MinWidth="160" Margin="0,0,12,0">
<StackPanel Orientation="Horizontal">
<TextBlock Text="&#xE787;" FontFamily="Segoe MDL2 Assets" FontSize="14" Margin="0,0,8,0" VerticalAlignment="Center"/>
<TextBlock Text="Meal Schedules" VerticalAlignment="Center"/>
</StackPanel>
</Button>
<Button Content="Back" Command="{Binding BackCommand}" Style="{StaticResource OutlineButtonStyle}" MinWidth="100" Margin="0,0,12,0"/>
<Button Command="{Binding SaveCommand}" Style="{StaticResource PrimaryButtonStyle}" MinWidth="100">
<StackPanel Orientation="Horizontal">
<TextBlock Text="&#xE74E;" FontFamily="Segoe MDL2 Assets" FontSize="14" Margin="0,0,8,0" VerticalAlignment="Center"/>
<TextBlock Text="Save" VerticalAlignment="Center"/>
</StackPanel>
</Button>
</StackPanel>
</Grid>
</Border>
</Grid>
</UserControl>

View File

@ -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();
}
}