Example: Settings and Preferences Management
Overview
This example demonstrates the comprehensive settings and preferences management system implemented in the AR Payment Reversal dashboard. The system provides user-specific preferences, application-wide settings, environment-specific configurations, and secure storage of sensitive settings, all with a flexible architecture that supports runtime updates and validation.
Business Value
The settings management system addresses critical operational needs:
- Personalization: User-specific preferences for improved productivity
- Configuration Management: Centralized control of application behavior
- Security: Encrypted storage of sensitive information
- Flexibility: Runtime configuration changes without restarts
- Compliance: Audit trail of configuration changes
Implementation Architecture
Settings Service Interface
Core contracts for settings management:
// MepApps.Dash.Ar.Maint.PaymentReversal/Services/IMepSettingsService.cs
public interface IMepSettingsService
{
T GetSetting<T>(string key, T defaultValue = default);
Task<T> GetSettingAsync<T>(string key, T defaultValue = default);
void SetSetting<T>(string key, T value);
Task SetSettingAsync<T>(string key, T value);
void SaveSettings();
Task SaveSettingsAsync();
void ReloadSettings();
event EventHandler<SettingChangedEventArgs> SettingChanged;
}
public interface IUserPreferencesService
{
UserPreferences GetUserPreferences(string userId = null);
Task<UserPreferences> GetUserPreferencesAsync(string userId = null);
void SaveUserPreferences(UserPreferences preferences);
Task SaveUserPreferencesAsync(UserPreferences preferences);
void ResetToDefaults();
event EventHandler<PreferencesChangedEventArgs> PreferencesChanged;
}
public class MepSettings
{
// Application Settings
public GeneralSettings General { get; set; }
public SysproSettings Syspro { get; set; }
public DatabaseSettings Database { get; set; }
public UISettings UI { get; set; }
public SecuritySettings Security { get; set; }
public PerformanceSettings Performance { get; set; }
// Feature Toggles
public Dictionary<string, bool> Features { get; set; }
// Custom Settings
public Dictionary<string, object> Custom { get; set; }
}
public class UserPreferences
{
public string UserId { get; set; }
public DisplayPreferences Display { get; set; }
public GridPreferences Grids { get; set; }
public NotificationPreferences Notifications { get; set; }
public WorkflowPreferences Workflow { get; set; }
public Dictionary<string, object> Custom { get; set; }
public DateTime LastModified { get; set; }
}
Settings Service Implementation
Comprehensive settings management with caching and persistence:
// MepApps.Dash.Ar.Maint.PaymentReversal/Services/MepSettingsService.cs
public class MepSettingsService : IMepSettingsService
{
private readonly ILogger<MepSettingsService> _logger;
private readonly IConfiguration _configuration;
private readonly IMemoryCache _cache;
private readonly IEncryptionService _encryption;
private readonly string _settingsPath;
private MepSettings _settings;
private readonly SemaphoreSlim _settingsLock = new(1, 1);
public MepSettingsService(
ILogger<MepSettingsService> logger,
IConfiguration configuration,
IMemoryCache cache,
IEncryptionService encryption)
{
_logger = logger;
_configuration = configuration;
_cache = cache;
_encryption = encryption;
_settingsPath = GetSettingsPath();
LoadSettings();
// Watch for file changes
WatchSettingsFile();
}
private void LoadSettings()
{
try
{
_settingsLock.Wait();
// Load from multiple sources in priority order
_settings = new MepSettings();
// 1. Load defaults
LoadDefaultSettings();
// 2. Load from configuration file
if (File.Exists(_settingsPath))
{
var json = File.ReadAllText(_settingsPath);
var fileSettings = JsonSerializer.Deserialize<MepSettings>(json);
MergeSettings(_settings, fileSettings);
}
// 3. Load from app.config/appsettings.json
var configSettings = _configuration.Get<MepSettings>();
if (configSettings != null)
{
MergeSettings(_settings, configSettings);
}
// 4. Load from environment variables
LoadEnvironmentSettings();
// 5. Decrypt sensitive settings
DecryptSensitiveSettings();
// 6. Validate settings
ValidateSettings();
_logger.LogInformation("Settings loaded successfully from {Path}", _settingsPath);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error loading settings");
LoadDefaultSettings(); // Fallback to defaults
}
finally
{
_settingsLock.Release();
}
}
public T GetSetting<T>(string key, T defaultValue = default)
{
// Check cache first
var cacheKey = $"setting_{key}";
if (_cache.TryGetValue<T>(cacheKey, out var cachedValue))
{
return cachedValue;
}
// Navigate nested properties
var value = NavigateSettingPath(key);
if (value == null)
{
return defaultValue;
}
// Convert to requested type
T result;
if (value is JsonElement jsonElement)
{
result = JsonSerializer.Deserialize<T>(jsonElement.GetRawText());
}
else
{
result = (T)Convert.ChangeType(value, typeof(T));
}
// Cache the result
_cache.Set(cacheKey, result, TimeSpan.FromMinutes(5));
return result;
}
public void SetSetting<T>(string key, T value)
{
_settingsLock.Wait();
try
{
// Set the value
SetNestedProperty(_settings, key, value);
// Clear cache
_cache.Remove($"setting_{key}");
// Raise event
OnSettingChanged(new SettingChangedEventArgs
{
Key = key,
OldValue = GetSetting<T>(key),
NewValue = value,
Timestamp = DateTime.Now
});
// Mark as dirty for auto-save
MarkDirty();
}
finally
{
_settingsLock.Release();
}
}
public async Task SaveSettingsAsync()
{
await _settingsLock.WaitAsync();
try
{
// Encrypt sensitive settings
var settingsToSave = CloneSettings(_settings);
EncryptSensitiveSettings(settingsToSave);
// Serialize with formatting
var options = new JsonSerializerOptions
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
Converters = { new JsonStringEnumConverter() }
};
var json = JsonSerializer.Serialize(settingsToSave, options);
// Create backup
if (File.Exists(_settingsPath))
{
var backupPath = $"{_settingsPath}.{DateTime.Now:yyyyMMddHHmmss}.bak";
File.Copy(_settingsPath, backupPath, true);
// Keep only last 5 backups
CleanupOldBackups();
}
// Save to file
await File.WriteAllTextAsync(_settingsPath, json);
_logger.LogInformation("Settings saved successfully");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error saving settings");
throw;
}
finally
{
_settingsLock.Release();
}
}
private void ValidateSettings()
{
var validator = new SettingsValidator();
var results = validator.Validate(_settings);
if (!results.IsValid)
{
foreach (var error in results.Errors)
{
_logger.LogWarning("Setting validation: {Property} - {Error}",
error.PropertyName, error.ErrorMessage);
}
}
}
public event EventHandler<SettingChangedEventArgs> SettingChanged;
protected virtual void OnSettingChanged(SettingChangedEventArgs e)
{
SettingChanged?.Invoke(this, e);
}
}
User Preferences Implementation
User-specific settings management:
// MepApps.Dash.Ar.Maint.PaymentReversal/Services/UserPreferencesService.cs
public class UserPreferencesService : IUserPreferencesService
{
private readonly ILogger<UserPreferencesService> _logger;
private readonly string _preferencesDirectory;
private readonly Dictionary<string, UserPreferences> _cache = new();
public async Task<UserPreferences> GetUserPreferencesAsync(string userId = null)
{
userId ??= GetCurrentUserId();
// Check cache
if (_cache.TryGetValue(userId, out var cached))
{
return cached;
}
// Load from file
var filePath = GetUserPreferencesPath(userId);
UserPreferences preferences;
if (File.Exists(filePath))
{
try
{
var json = await File.ReadAllTextAsync(filePath);
preferences = JsonSerializer.Deserialize<UserPreferences>(json);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error loading user preferences for {UserId}", userId);
preferences = CreateDefaultPreferences(userId);
}
}
else
{
preferences = CreateDefaultPreferences(userId);
}
// Cache and return
_cache[userId] = preferences;
return preferences;
}
private UserPreferences CreateDefaultPreferences(string userId)
{
return new UserPreferences
{
UserId = userId,
Display = new DisplayPreferences
{
Theme = "Default",
DateFormat = "MM/dd/yyyy",
TimeFormat = "hh:mm:ss tt",
NumberFormat = "#,##0.00",
CurrencySymbol = "$",
PageSize = 50,
ShowGridLines = true,
CompactMode = false
},
Grids = new GridPreferences
{
AutoSizeColumns = true,
ShowFilters = true,
ShowFooters = true,
AlternateRowColors = true,
RememberColumnWidths = true,
RememberSortOrder = true,
ColumnSettings = new Dictionary<string, GridColumnSettings>()
},
Notifications = new NotificationPreferences
{
ShowToasts = true,
PlaySounds = false,
EmailNotifications = true,
NotificationTypes = new List<string>
{
"Errors",
"Warnings",
"PostCompletion"
}
},
Workflow = new WorkflowPreferences
{
AutoSave = true,
AutoSaveInterval = 300, // seconds
ConfirmDelete = true,
ConfirmPost = true,
DefaultPostPeriod = "Current",
RememberLastSearch = true,
MaxRecentItems = 10
},
Custom = new Dictionary<string, object>(),
LastModified = DateTime.Now
};
}
public async Task SaveUserPreferencesAsync(UserPreferences preferences)
{
try
{
preferences.LastModified = DateTime.Now;
var filePath = GetUserPreferencesPath(preferences.UserId);
var directory = Path.GetDirectoryName(filePath);
if (!Directory.Exists(directory))
{
Directory.CreateDirectory(directory);
}
var options = new JsonSerializerOptions
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
var json = JsonSerializer.Serialize(preferences, options);
await File.WriteAllTextAsync(filePath, json);
// Update cache
_cache[preferences.UserId] = preferences;
// Raise event
OnPreferencesChanged(new PreferencesChangedEventArgs
{
UserId = preferences.UserId,
Preferences = preferences,
Timestamp = DateTime.Now
});
_logger.LogInformation("User preferences saved for {UserId}", preferences.UserId);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error saving user preferences");
throw;
}
}
public event EventHandler<PreferencesChangedEventArgs> PreferencesChanged;
protected virtual void OnPreferencesChanged(PreferencesChangedEventArgs e)
{
PreferencesChanged?.Invoke(this, e);
}
}
Settings Dialog UI
User interface for managing settings:
<!-- Settings Dialog -->
<Window x:Class="MepApps.Dash.Ar.Maint.PaymentReversal.Dialogs.SettingsDialog"
Title="Settings"
Width="800" Height="600">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="200"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<!-- Category List -->
<ListBox Grid.Column="0"
ItemsSource="{Binding SettingsCategories}"
SelectedItem="{Binding SelectedCategory}"
Background="LightGray">
<ListBox.ItemTemplate>
<DataTemplate>
<StackPanel Orientation="Horizontal" Margin="5">
<Image Source="{Binding Icon}" Width="16" Height="16" Margin="0,0,5,0"/>
<TextBlock Text="{Binding Name}" VerticalAlignment="Center"/>
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<!-- Settings Content -->
<ScrollViewer Grid.Column="1" VerticalScrollBarVisibility="Auto">
<ContentControl Content="{Binding SelectedCategory}">
<ContentControl.Resources>
<!-- General Settings Template -->
<DataTemplate DataType="{x:Type local:GeneralSettingsViewModel}">
<StackPanel Margin="10">
<TextBlock Text="General Settings" FontSize="18" FontWeight="Bold" Margin="0,0,0,10"/>
<GroupBox Header="Application" Margin="0,0,0,10">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="150"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Label Grid.Row="0" Grid.Column="0" Content="Company Name:"/>
<TextBox Grid.Row="0" Grid.Column="1"
Text="{Binding CompanyName}"
Margin="0,2"/>
<Label Grid.Row="1" Grid.Column="0" Content="Default Currency:"/>
<ComboBox Grid.Row="1" Grid.Column="1"
ItemsSource="{Binding Currencies}"
SelectedItem="{Binding DefaultCurrency}"
Margin="0,2"/>
<Label Grid.Row="2" Grid.Column="0" Content="Auto-save:"/>
<CheckBox Grid.Row="2" Grid.Column="1"
IsChecked="{Binding AutoSaveEnabled}"
Content="Enable auto-save"
Margin="0,2"/>
</Grid>
</GroupBox>
<GroupBox Header="Regional Settings" Margin="0,0,0,10">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="150"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Label Grid.Row="0" Grid.Column="0" Content="Date Format:"/>
<ComboBox Grid.Row="0" Grid.Column="1"
ItemsSource="{Binding DateFormats}"
SelectedItem="{Binding DateFormat}"
Margin="0,2"/>
<Label Grid.Row="1" Grid.Column="0" Content="Time Format:"/>
<ComboBox Grid.Row="1" Grid.Column="1"
ItemsSource="{Binding TimeFormats}"
SelectedItem="{Binding TimeFormat}"
Margin="0,2"/>
<Label Grid.Row="2" Grid.Column="0" Content="Number Format:"/>
<ComboBox Grid.Row="2" Grid.Column="1"
ItemsSource="{Binding NumberFormats}"
SelectedItem="{Binding NumberFormat}"
Margin="0,2"/>
</Grid>
</GroupBox>
</StackPanel>
</DataTemplate>
<!-- SYSPRO Settings Template -->
<DataTemplate DataType="{x:Type local:SysproSettingsViewModel}">
<StackPanel Margin="10">
<TextBlock Text="SYSPRO Settings" FontSize="18" FontWeight="Bold" Margin="0,0,0,10"/>
<GroupBox Header="Connection" Margin="0,0,0,10">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="150"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Label Grid.Row="0" Grid.Column="0" Content="Server:"/>
<TextBox Grid.Row="0" Grid.Column="1"
Text="{Binding SysproServer}"
Margin="0,2"/>
<Button Grid.Row="0" Grid.Column="2"
Content="Test"
Command="{Binding TestConnectionCommand}"
Width="60"
Margin="5,2,0,2"/>
<Label Grid.Row="1" Grid.Column="0" Content="Company:"/>
<ComboBox Grid.Row="1" Grid.Column="1"
ItemsSource="{Binding Companies}"
SelectedItem="{Binding SelectedCompany}"
Margin="0,2"/>
<Label Grid.Row="2" Grid.Column="0" Content="Timeout (seconds):"/>
<TextBox Grid.Row="2" Grid.Column="1"
Text="{Binding ConnectionTimeout}"
Margin="0,2"/>
</Grid>
</GroupBox>
<GroupBox Header="Posting Defaults" Margin="0,0,0,10">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="150"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Label Grid.Row="0" Grid.Column="0" Content="Default Period:"/>
<ComboBox Grid.Row="0" Grid.Column="1"
ItemsSource="{Binding PeriodOptions}"
SelectedItem="{Binding DefaultPeriod}"
Margin="0,2"/>
<Label Grid.Row="1" Grid.Column="0" Content="Validate Before Post:"/>
<CheckBox Grid.Row="1" Grid.Column="1"
IsChecked="{Binding ValidateBeforePost}"
Content="Always validate before posting"
Margin="0,2"/>
<Label Grid.Row="2" Grid.Column="0" Content="Max Batch Size:"/>
<TextBox Grid.Row="2" Grid.Column="1"
Text="{Binding MaxBatchSize}"
Margin="0,2"/>
</Grid>
</GroupBox>
</StackPanel>
</DataTemplate>
<!-- Performance Settings Template -->
<DataTemplate DataType="{x:Type local:PerformanceSettingsViewModel}">
<StackPanel Margin="10">
<TextBlock Text="Performance Settings" FontSize="18" FontWeight="Bold" Margin="0,0,0,10"/>
<GroupBox Header="Caching" Margin="0,0,0,10">
<StackPanel>
<CheckBox Content="Enable caching"
IsChecked="{Binding CachingEnabled}"
Margin="0,2"/>
<Grid Margin="20,5,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="150"/>
<ColumnDefinition Width="100"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Label Grid.Row="0" Grid.Column="0" Content="Cache Duration:"/>
<TextBox Grid.Row="0" Grid.Column="1"
Text="{Binding CacheDuration}"
IsEnabled="{Binding CachingEnabled}"/>
<Label Grid.Row="1" Grid.Column="0" Content="Max Cache Size (MB):"/>
<TextBox Grid.Row="1" Grid.Column="1"
Text="{Binding MaxCacheSize}"
IsEnabled="{Binding CachingEnabled}"/>
</Grid>
<Button Content="Clear Cache"
Command="{Binding ClearCacheCommand}"
HorizontalAlignment="Left"
Margin="20,5,0,0"
Width="100"/>
</StackPanel>
</GroupBox>
<GroupBox Header="Database" Margin="0,0,0,10">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="150"/>
<ColumnDefinition Width="100"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Label Grid.Row="0" Grid.Column="0" Content="Connection Pool Size:"/>
<TextBox Grid.Row="0" Grid.Column="1"
Text="{Binding ConnectionPoolSize}"/>
<Label Grid.Row="1" Grid.Column="0" Content="Query Timeout:"/>
<TextBox Grid.Row="1" Grid.Column="1"
Text="{Binding QueryTimeout}"/>
</Grid>
</GroupBox>
</StackPanel>
</DataTemplate>
</ContentControl.Resources>
</ContentControl>
</ScrollViewer>
<!-- Buttons -->
<StackPanel Orientation="Horizontal"
HorizontalAlignment="Right"
Grid.Column="1"
VerticalAlignment="Bottom"
Margin="10">
<Button Content="Apply"
Command="{Binding ApplyCommand}"
Width="80"
Margin="0,0,5,0"/>
<Button Content="Save"
Command="{Binding SaveCommand}"
Width="80"
Margin="0,0,5,0"/>
<Button Content="Cancel"
IsCancel="True"
Width="80"/>
</StackPanel>
</Grid>
</Window>
Settings Validation
Ensuring settings are valid before application:
public class SettingsValidator : AbstractValidator<MepSettings>
{
public SettingsValidator()
{
RuleFor(s => s.General.CompanyName)
.NotEmpty().WithMessage("Company name is required");
RuleFor(s => s.Syspro.ConnectionTimeout)
.GreaterThan(0).WithMessage("Connection timeout must be positive")
.LessThanOrEqualTo(300).WithMessage("Connection timeout cannot exceed 5 minutes");
RuleFor(s => s.Performance.MaxBatchSize)
.InclusiveBetween(1, 1000).WithMessage("Batch size must be between 1 and 1000");
RuleFor(s => s.Database.ConnectionString)
.Must(BeValidConnectionString).WithMessage("Invalid connection string");
RuleFor(s => s.Security.EncryptionKey)
.Must(BeValidEncryptionKey).When(s => s.Security.EnableEncryption)
.WithMessage("Invalid encryption key");
}
private bool BeValidConnectionString(string connectionString)
{
try
{
var builder = new SqlConnectionStringBuilder(connectionString);
return !string.IsNullOrEmpty(builder.DataSource);
}
catch
{
return false;
}
}
}
Settings Migration
Handling settings upgrades between versions:
public class SettingsMigrator
{
public async Task MigrateSettingsAsync(string fromVersion, string toVersion)
{
_logger.LogInformation("Migrating settings from {From} to {To}",
fromVersion, toVersion);
var migrations = GetMigrations(fromVersion, toVersion);
foreach (var migration in migrations)
{
await migration.ApplyAsync(_settings);
}
}
}
Benefits
- Flexibility: Runtime configuration without restarts
- Personalization: User-specific preferences
- Security: Encrypted sensitive settings
- Maintainability: Centralized configuration
- Auditability: Change tracking and history
Related Documentation
Summary
The settings and preferences management system provides a robust, flexible framework for configuring the AR Payment Reversal dashboard. Through layered configuration sources, secure storage, and comprehensive validation, the system ensures that both application-wide settings and user preferences are managed effectively while maintaining security and auditability.