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.
feature/centralized-offline-canteen
SYED MUSTUFA AHMED NAQVI 2026-05-21 09:33:17 +05:00
parent b2c99017c9
commit 2abd38cfcc
7 changed files with 412 additions and 45 deletions

View File

@ -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;
/// <summary>
/// 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).
/// </summary>
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");
}
}
}

10
Models/AppMode.cs Normal file
View File

@ -0,0 +1,10 @@
namespace UtopiaCanteenSystem.Models;
/// <summary>
/// Deployment role: central PC hosts SQLite and HTTP API; scanner PCs call the API only.
/// </summary>
public enum AppMode
{
Server = 0,
Client = 1
}

View File

@ -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; }
/// <summary>Server (default) or Client.</summary>
public string AppMode { get; set; } = "Server";
/// <summary>Scanner PCs: base URL of central app API (e.g. http://192.168.1.10:5000).</summary>
public string CentralServerBaseUrl { get; set; } = string.Empty;
/// <summary>Central PC: Kestrel listen URL(s), e.g. http://0.0.0.0:5000</summary>
public string LocalServerListenUrls { get; set; } = "http://0.0.0.0:5000";
}
}

View File

@ -1,3 +1,5 @@
using UtopiaCanteenSystem.Models;
namespace UtopiaCanteenSystem.Services;
/// <summary>
@ -45,4 +47,20 @@ public interface IConfigService
/// <summary>Connection string for local HRMS MySQL (employee lookup by RFID). Separate from production sync.</summary>
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);
/// <summary>Kestrel listen URL(s) when <see cref="AppMode.Server"/> (e.g. http://0.0.0.0:5000).</summary>
string GetLocalServerListenUrls();
void SetLocalServerListenUrls(string urls);
}

View File

@ -1,11 +1,12 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using UtopiaCanteenSystem.Models;
using UtopiaCanteenSystem.Services;
namespace UtopiaCanteenSystem.ViewModels;
/// <summary>
/// ViewModel for SettingsView: editable API endpoint (UIND sync URL), save, load from config.
/// ViewModel for SettingsView: scan settings, production post, and offline cache sync.
/// </summary>
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<string> AppModeOptions { get; } = new[] { "Server", "Client" };
/// <summary>Central PC: runs SQLite, sync jobs, and Kestrel API.</summary>
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();
}
/// <summary>Loads config from service.</summary>
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());
}
/// <summary>Opens Meal Schedules admin screen (Phase 2.5).</summary>
[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();
}

View File

@ -2,6 +2,7 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<UserControl.Resources>
<BooleanToVisibilityConverter x:Key="BoolToVis" />
<SolidColorBrush x:Key="AppBackground" Color="#F0F2F5"/>
<SolidColorBrush x:Key="CardBackground" Color="#FFFFFF"/>
<SolidColorBrush x:Key="PrimaryText" Color="#2D3748"/>
@ -273,6 +274,35 @@
Foreground="{StaticResource MutedText}"
Margin="0,0,0,20" />
<TextBlock Text="Application mode" FontSize="16" Foreground="{StaticResource PrimaryText}" FontWeight="SemiBold" />
<TextBlock Text="Server: this PC hosts SQLite, sync jobs, and the local HTTP API for scanners. Client: this PC only sends scans to the central server."
FontSize="14"
Foreground="{StaticResource MutedText}"
Margin="0,4,0,8" />
<ComboBox ItemsSource="{Binding AppModeOptions}"
SelectedItem="{Binding SelectedAppMode, Mode=TwoWay}"
MinHeight="44"
FontSize="16"
Margin="0,0,0,12"
Padding="12,8"/>
<TextBlock Text="Central server base URL (client PCs)" FontSize="16" Foreground="{StaticResource PrimaryText}" FontWeight="SemiBold" />
<TextBox Text="{Binding CentralServerBaseUrl, UpdateSourceTrigger=PropertyChanged}"
Style="{StaticResource ModernTextBoxStyle}" Margin="0,8,0,4" />
<TextBlock Text="Example: http://192.168.1.10:5000 — used when this PC is in Client mode."
FontSize="14"
Foreground="{StaticResource MutedText}"
Margin="0,0,0,12" />
<TextBlock Text="Local API listen URL(s) (central server only)" FontSize="16" Foreground="{StaticResource PrimaryText}" FontWeight="SemiBold" />
<TextBox Text="{Binding LocalServerListenUrls, UpdateSourceTrigger=PropertyChanged}"
Style="{StaticResource ModernTextBoxStyle}" Margin="0,8,0,4" />
<TextBlock Text="Example: http://0.0.0.0:5000 — Kestrel bind address on the server PC."
FontSize="14"
Foreground="{StaticResource MutedText}"
Margin="0,0,0,20" />
<StackPanel Visibility="{Binding IsCentralServerMode, Converter={StaticResource BoolToVis}}">
<!--<TextBlock Text="Sync API Endpoint (UIND)" FontSize="16" Foreground="{StaticResource PrimaryText}" FontWeight="SemiBold" />
<TextBox Text="{Binding SyncApiEndpoint, UpdateSourceTrigger=PropertyChanged}"
Style="{StaticResource ModernTextBoxStyle}" Margin="0,8,0,8" />
@ -308,11 +338,30 @@
IsEnabled="{Binding CanPostNow}" />
</Grid>
<TextBlock Text=""
<TextBlock Text="Download employee RFID tags, meal schedules, and menu data for offline scanning."
FontSize="14"
Foreground="{StaticResource MutedText}"
Margin="0,0,0,8" />
<Button Content="{Binding SyncCacheButtonText}"
Command="{Binding SyncEmployeeAndMenuCacheNowCommand}"
Style="{StaticResource PrimaryButtonStyle}"
MinWidth="280"
HorizontalAlignment="Left"
Margin="0,0,0,8"
IsEnabled="{Binding CanSyncCacheNow}" />
<TextBlock Text="{Binding LastEmployeeRfidCacheSyncDisplay, StringFormat=Last employee RFID cache sync: {0}}"
FontSize="14"
Foreground="{StaticResource MutedText}"
Margin="0,0,0,4" />
<TextBlock Text="{Binding LastMealMenuCacheSyncDisplay, StringFormat=Last meal/menu cache sync: {0}}"
FontSize="14"
Foreground="{StaticResource MutedText}"
Margin="0,0,0,20" />
</StackPanel>
<!-- Message area -->
<Border Margin="0,0,0,20" Padding="16" CornerRadius="8">
<Border.Style>
@ -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}}" />
<Button Content="Back"
Command="{Binding BackCommand}"
Style="{StaticResource OutlineButtonStyle}"

View File

@ -18,5 +18,6 @@ public partial class SettingsView : UserControl
{
InitializeComponent();
DataContext = viewModel;
Loaded += (_, _) => viewModel.RefreshCacheSyncTimestamps();
}
}