refactor: address code review findings across all projects

Apply comprehensive fixes from code reviews including:
- Extract shared utilities (SqlFormatHelper, CellValueConverter, DbDestinationBase)
- Add interface abstractions (IAuthenticationService, IDatabaseMigrator, IMisQueryBuilder)
- Implement SecureStore for encrypted secrets storage
- Fix error handling with proper HTTP status codes and logging
- Optimize double enumeration in DevEtlRegistry
- Add DataSync.Dev README for developer onboarding
- Extract filter panel base classes to reduce duplication
- Update code review docs to mark all issues as fixed
This commit is contained in:
Joseph Doherty
2026-01-19 11:05:36 -05:00
parent 08f5aa1447
commit 604bfe919c
148 changed files with 8696 additions and 1538 deletions
@@ -0,0 +1,337 @@
using System.Windows.Input;
namespace JdeScoping.SecureStoreManager.ViewModels;
/// <summary>
/// View model for creating a new store.
/// </summary>
public class NewStoreDialogViewModel : ViewModelBase
{
private string _storePath = string.Empty;
private string _keyFilePath = string.Empty;
private string _password = string.Empty;
private string _confirmPassword = string.Empty;
private bool _useKeyFile = true;
private bool _usePassword;
public NewStoreDialogViewModel()
{
BrowseStorePathCommand = new RelayCommand(BrowseStorePath);
BrowseKeyFilePathCommand = new RelayCommand(BrowseKeyFilePath);
}
public string StorePath
{
get => _storePath;
set => SetProperty(ref _storePath, value);
}
public string KeyFilePath
{
get => _keyFilePath;
set => SetProperty(ref _keyFilePath, value);
}
public string Password
{
get => _password;
set => SetProperty(ref _password, value);
}
public string ConfirmPassword
{
get => _confirmPassword;
set => SetProperty(ref _confirmPassword, value);
}
public bool UseKeyFile
{
get => _useKeyFile;
set
{
if (SetProperty(ref _useKeyFile, value))
{
if (value) UsePassword = false;
}
}
}
public bool UsePassword
{
get => _usePassword;
set
{
if (SetProperty(ref _usePassword, value))
{
if (value) UseKeyFile = false;
}
}
}
public ICommand BrowseStorePathCommand { get; }
public ICommand BrowseKeyFilePathCommand { get; }
public bool IsValid
{
get
{
if (string.IsNullOrWhiteSpace(StorePath))
return false;
if (UseKeyFile)
return !string.IsNullOrWhiteSpace(KeyFilePath);
if (UsePassword)
return !string.IsNullOrWhiteSpace(Password) && Password == ConfirmPassword;
return false;
}
}
public string? ValidationError
{
get
{
if (string.IsNullOrWhiteSpace(StorePath))
return "Store path is required.";
if (UseKeyFile && string.IsNullOrWhiteSpace(KeyFilePath))
return "Key file path is required.";
if (UsePassword)
{
if (string.IsNullOrWhiteSpace(Password))
return "Password is required.";
if (Password != ConfirmPassword)
return "Passwords do not match.";
}
return null;
}
}
/// <summary>
/// Event raised to request save file dialog for store path.
/// Parameters: title, fileTypeName, pattern, defaultExtension
/// Returns: selected file path or null
/// </summary>
public event Func<string, string, string, string, Task<string?>>? OnShowSaveFileDialog;
private async void BrowseStorePath()
{
if (OnShowSaveFileDialog == null)
return;
var path = await OnShowSaveFileDialog("Choose Store Location", "SecureStore Files", "*.json", ".json");
if (!string.IsNullOrEmpty(path))
{
StorePath = path;
}
}
private async void BrowseKeyFilePath()
{
if (OnShowSaveFileDialog == null)
return;
var path = await OnShowSaveFileDialog("Choose Key File Location", "Key Files", "*.key", ".key");
if (!string.IsNullOrEmpty(path))
{
KeyFilePath = path;
}
}
}
/// <summary>
/// View model for opening an existing store.
/// </summary>
public class OpenStoreDialogViewModel : ViewModelBase
{
private string _storePath = string.Empty;
private string _keyFilePath = string.Empty;
private string _password = string.Empty;
private bool _useKeyFile = true;
private bool _usePassword;
public OpenStoreDialogViewModel()
{
BrowseStorePathCommand = new RelayCommand(BrowseStorePath);
BrowseKeyFilePathCommand = new RelayCommand(BrowseKeyFilePath);
}
public string StorePath
{
get => _storePath;
set => SetProperty(ref _storePath, value);
}
public string KeyFilePath
{
get => _keyFilePath;
set => SetProperty(ref _keyFilePath, value);
}
public string Password
{
get => _password;
set => SetProperty(ref _password, value);
}
public bool UseKeyFile
{
get => _useKeyFile;
set
{
if (SetProperty(ref _useKeyFile, value))
{
if (value) UsePassword = false;
}
}
}
public bool UsePassword
{
get => _usePassword;
set
{
if (SetProperty(ref _usePassword, value))
{
if (value) UseKeyFile = false;
}
}
}
public ICommand BrowseStorePathCommand { get; }
public ICommand BrowseKeyFilePathCommand { get; }
public bool IsValid
{
get
{
if (string.IsNullOrWhiteSpace(StorePath))
return false;
if (UseKeyFile)
return !string.IsNullOrWhiteSpace(KeyFilePath);
if (UsePassword)
return !string.IsNullOrWhiteSpace(Password);
return false;
}
}
public string? ValidationError
{
get
{
if (string.IsNullOrWhiteSpace(StorePath))
return "Store path is required.";
if (!System.IO.File.Exists(StorePath))
return "Store file does not exist.";
if (UseKeyFile)
{
if (string.IsNullOrWhiteSpace(KeyFilePath))
return "Key file path is required.";
if (!System.IO.File.Exists(KeyFilePath))
return "Key file does not exist.";
}
if (UsePassword && string.IsNullOrWhiteSpace(Password))
return "Password is required.";
return null;
}
}
/// <summary>
/// Event raised to request open file dialog for store path.
/// Parameters: title, fileTypeName, pattern
/// Returns: selected file path or null
/// </summary>
public event Func<string, string, string, Task<string?>>? OnShowOpenFileDialog;
private async void BrowseStorePath()
{
if (OnShowOpenFileDialog == null)
return;
var path = await OnShowOpenFileDialog("Select Store File", "SecureStore Files", "*.json");
if (!string.IsNullOrEmpty(path))
{
StorePath = path;
}
}
private async void BrowseKeyFilePath()
{
if (OnShowOpenFileDialog == null)
return;
var path = await OnShowOpenFileDialog("Select Key File", "Key Files", "*.key");
if (!string.IsNullOrEmpty(path))
{
KeyFilePath = path;
}
}
}
/// <summary>
/// View model for adding or editing a secret.
/// </summary>
public class SecretEditDialogViewModel : ViewModelBase
{
private string _key = string.Empty;
private string _value = string.Empty;
private bool _isNewSecret = true;
public SecretEditDialogViewModel()
{
}
public SecretEditDialogViewModel(string key, string value)
{
_key = key;
_value = value;
_isNewSecret = false;
}
public string Key
{
get => _key;
set => SetProperty(ref _key, value);
}
public string Value
{
get => _value;
set => SetProperty(ref _value, value);
}
public bool IsNewSecret
{
get => _isNewSecret;
set => SetProperty(ref _isNewSecret, value);
}
public bool IsKeyEditable => _isNewSecret;
public string DialogTitle => _isNewSecret ? "Add Secret" : "Edit Secret";
public bool IsValid => !string.IsNullOrWhiteSpace(Key);
public string? ValidationError
{
get
{
if (string.IsNullOrWhiteSpace(Key))
return "Key is required.";
return null;
}
}
}
@@ -0,0 +1,417 @@
using System.Collections.ObjectModel;
using System.Windows.Input;
using JdeScoping.SecureStoreManager.Services;
namespace JdeScoping.SecureStoreManager.ViewModels;
/// <summary>
/// Main window view model containing all application logic.
/// </summary>
public class MainWindowViewModel : ViewModelBase
{
private readonly ISecureStoreManager _storeManager;
private SecretItemViewModel? _selectedSecret;
private string _statusMessage = "Ready";
public MainWindowViewModel() : this(new Services.SecureStoreManager())
{
}
public MainWindowViewModel(ISecureStoreManager storeManager)
{
_storeManager = storeManager;
Secrets = new ObservableCollection<SecretItemViewModel>();
// File commands
NewStoreCommand = new RelayCommand(ExecuteNewStore);
OpenStoreCommand = new RelayCommand(ExecuteOpenStore);
SaveCommand = new RelayCommand(ExecuteSave, CanSave);
CloseStoreCommand = new RelayCommand(ExecuteCloseStore, () => _storeManager.IsStoreOpen);
ExitCommand = new RelayCommand(ExecuteExit);
// Secret commands
AddSecretCommand = new RelayCommand(ExecuteAddSecret, () => _storeManager.IsStoreOpen);
EditSecretCommand = new RelayCommand(ExecuteEditSecret, CanEditOrDeleteSecret);
DeleteSecretCommand = new RelayCommand(ExecuteDeleteSecret, CanEditOrDeleteSecret);
// Tools commands
GenerateKeyFileCommand = new RelayCommand(ExecuteGenerateKeyFile);
ExportKeyCommand = new RelayCommand(ExecuteExportKey, () => _storeManager.IsStoreOpen);
}
/// <summary>
/// Gets the collection of secrets in the current store.
/// </summary>
public ObservableCollection<SecretItemViewModel> Secrets { get; }
/// <summary>
/// Gets or sets the currently selected secret.
/// </summary>
public SecretItemViewModel? SelectedSecret
{
get => _selectedSecret;
set => SetProperty(ref _selectedSecret, value);
}
/// <summary>
/// Gets the current status message.
/// </summary>
public string StatusMessage
{
get => _statusMessage;
private set => SetProperty(ref _statusMessage, value);
}
/// <summary>
/// Gets the window title including the current store path.
/// </summary>
public string WindowTitle
{
get
{
var title = "SecureStore Manager";
if (_storeManager.IsStoreOpen)
{
title += $" - {_storeManager.CurrentStorePath}";
if (_storeManager.HasUnsavedChanges)
title += " *";
}
return title;
}
}
/// <summary>
/// Gets whether a store is currently open.
/// </summary>
public bool IsStoreOpen => _storeManager.IsStoreOpen;
/// <summary>
/// Gets whether there are unsaved changes.
/// </summary>
public bool HasUnsavedChanges => _storeManager.HasUnsavedChanges;
// File Commands
public ICommand NewStoreCommand { get; }
public ICommand OpenStoreCommand { get; }
public ICommand SaveCommand { get; }
public ICommand CloseStoreCommand { get; }
public ICommand ExitCommand { get; }
// Secret Commands
public ICommand AddSecretCommand { get; }
public ICommand EditSecretCommand { get; }
public ICommand DeleteSecretCommand { get; }
// Tools Commands
public ICommand GenerateKeyFileCommand { get; }
public ICommand ExportKeyCommand { get; }
/// <summary>
/// Creates a new store. Called by the dialog.
/// </summary>
public async Task CreateNewStoreAsync(string storePath, string? keyFilePath, string? password)
{
try
{
if (!string.IsNullOrEmpty(keyFilePath))
{
_storeManager.CreateStore(storePath, keyFilePath);
StatusMessage = $"Created store with key file: {keyFilePath}";
}
else if (!string.IsNullOrEmpty(password))
{
_storeManager.CreateStoreWithPassword(storePath, password);
StatusMessage = "Created password-protected store";
}
else
{
throw new ArgumentException("Either key file path or password must be provided.");
}
RefreshSecrets();
NotifyStoreChanged();
}
catch (Exception ex)
{
StatusMessage = $"Error creating store: {ex.Message}";
await (OnShowError?.Invoke($"Failed to create store:\n\n{ex.Message}", "Error") ?? Task.CompletedTask);
}
}
/// <summary>
/// Opens an existing store. Called by the dialog.
/// </summary>
public async Task OpenExistingStoreAsync(string storePath, string? keyFilePath, string? password)
{
try
{
if (!string.IsNullOrEmpty(keyFilePath))
{
_storeManager.OpenStore(storePath, keyFilePath);
StatusMessage = "Opened store with key file";
}
else if (!string.IsNullOrEmpty(password))
{
_storeManager.OpenStoreWithPassword(storePath, password);
StatusMessage = "Opened password-protected store";
}
else
{
throw new ArgumentException("Either key file path or password must be provided.");
}
RefreshSecrets();
NotifyStoreChanged();
}
catch (Exception ex)
{
StatusMessage = $"Error opening store: {ex.Message}";
await (OnShowError?.Invoke($"Failed to open store:\n\n{ex.Message}", "Error") ?? Task.CompletedTask);
}
}
/// <summary>
/// Adds or updates a secret. Called by the dialog.
/// </summary>
public async Task SaveSecretAsync(string key, string value, bool isNew)
{
try
{
_storeManager.SetSecret(key, value);
RefreshSecrets();
NotifyStoreChanged();
StatusMessage = isNew ? $"Added secret: {key}" : $"Updated secret: {key}";
}
catch (Exception ex)
{
StatusMessage = $"Error saving secret: {ex.Message}";
await (OnShowError?.Invoke($"Failed to save secret:\n\n{ex.Message}", "Error") ?? Task.CompletedTask);
}
}
/// <summary>
/// Checks for unsaved changes and prompts the user.
/// </summary>
/// <returns>True if it's safe to proceed, false if the user cancelled.</returns>
public async Task<bool> PromptForUnsavedChangesAsync()
{
if (!_storeManager.HasUnsavedChanges)
return true;
if (OnShowUnsavedChangesPrompt == null)
return true;
var result = await OnShowUnsavedChangesPrompt();
switch (result)
{
case UnsavedChangesResult.Save:
await ExecuteSaveAsync();
return true;
case UnsavedChangesResult.DontSave:
return true;
case UnsavedChangesResult.Cancel:
default:
return false;
}
}
private async void ExecuteNewStore()
{
if (!await PromptForUnsavedChangesAsync())
return;
// The view will show the NewStoreDialog
OnRequestNewStoreDialog?.Invoke();
}
private async void ExecuteOpenStore()
{
if (!await PromptForUnsavedChangesAsync())
return;
// The view will show the OpenStoreDialog
OnRequestOpenStoreDialog?.Invoke();
}
private async void ExecuteSave()
{
await ExecuteSaveAsync();
}
private async Task ExecuteSaveAsync()
{
try
{
_storeManager.Save();
NotifyStoreChanged();
StatusMessage = "Store saved";
}
catch (Exception ex)
{
StatusMessage = $"Error saving: {ex.Message}";
await (OnShowError?.Invoke($"Failed to save store:\n\n{ex.Message}", "Error") ?? Task.CompletedTask);
}
}
private bool CanSave() => _storeManager.IsStoreOpen && _storeManager.HasUnsavedChanges;
private async void ExecuteCloseStore()
{
if (!await PromptForUnsavedChangesAsync())
return;
_storeManager.CloseStore();
Secrets.Clear();
NotifyStoreChanged();
StatusMessage = "Store closed";
}
private async void ExecuteExit()
{
if (!await PromptForUnsavedChangesAsync())
return;
OnRequestClose?.Invoke();
}
private void ExecuteAddSecret()
{
// The view will show the SecretEditDialog
OnRequestAddSecretDialog?.Invoke();
}
private void ExecuteEditSecret()
{
if (SelectedSecret == null)
return;
// The view will show the SecretEditDialog with existing values
OnRequestEditSecretDialog?.Invoke(SelectedSecret.Key, SelectedSecret.ActualValue);
}
private async void ExecuteDeleteSecret()
{
if (SelectedSecret == null)
return;
if (OnShowDeleteConfirmation == null)
return;
var confirmed = await OnShowDeleteConfirmation(SelectedSecret.Key);
if (!confirmed)
return;
try
{
var key = SelectedSecret.Key;
_storeManager.RemoveSecret(key);
RefreshSecrets();
NotifyStoreChanged();
StatusMessage = $"Deleted secret: {key}";
}
catch (Exception ex)
{
StatusMessage = $"Error deleting secret: {ex.Message}";
await (OnShowError?.Invoke($"Failed to delete secret:\n\n{ex.Message}", "Error") ?? Task.CompletedTask);
}
}
private bool CanEditOrDeleteSecret() => _storeManager.IsStoreOpen && SelectedSecret != null;
private async void ExecuteGenerateKeyFile()
{
if (OnShowSaveFileDialog == null)
return;
var filePath = await OnShowSaveFileDialog("Generate Key File", "Key Files", "*.key", ".key");
if (string.IsNullOrEmpty(filePath))
return;
try
{
_storeManager.GenerateKeyFile(filePath);
StatusMessage = $"Generated key file: {filePath}";
await (OnShowInfo?.Invoke($"Key file generated successfully:\n\n{filePath}", "Key Generated") ?? Task.CompletedTask);
}
catch (Exception ex)
{
StatusMessage = $"Error generating key: {ex.Message}";
await (OnShowError?.Invoke($"Failed to generate key file:\n\n{ex.Message}", "Error") ?? Task.CompletedTask);
}
}
private async void ExecuteExportKey()
{
if (OnShowSaveFileDialog == null)
return;
var filePath = await OnShowSaveFileDialog("Export Key", "Key Files", "*.key", ".key");
if (string.IsNullOrEmpty(filePath))
return;
try
{
_storeManager.ExportKey(filePath);
StatusMessage = $"Exported key to: {filePath}";
await (OnShowInfo?.Invoke($"Key exported successfully:\n\n{filePath}", "Key Exported") ?? Task.CompletedTask);
}
catch (Exception ex)
{
StatusMessage = $"Error exporting key: {ex.Message}";
await (OnShowError?.Invoke($"Failed to export key:\n\n{ex.Message}", "Error") ?? Task.CompletedTask);
}
}
private void RefreshSecrets()
{
Secrets.Clear();
if (!_storeManager.IsStoreOpen)
return;
foreach (var key in _storeManager.GetKeys())
{
var value = _storeManager.GetSecret(key);
Secrets.Add(new SecretItemViewModel(key, value));
}
}
private void NotifyStoreChanged()
{
OnPropertyChanged(nameof(IsStoreOpen));
OnPropertyChanged(nameof(HasUnsavedChanges));
OnPropertyChanged(nameof(WindowTitle));
// Manually raise CanExecuteChanged for all commands
(SaveCommand as RelayCommand)?.RaiseCanExecuteChanged();
(CloseStoreCommand as RelayCommand)?.RaiseCanExecuteChanged();
(AddSecretCommand as RelayCommand)?.RaiseCanExecuteChanged();
(EditSecretCommand as RelayCommand)?.RaiseCanExecuteChanged();
(DeleteSecretCommand as RelayCommand)?.RaiseCanExecuteChanged();
(ExportKeyCommand as RelayCommand)?.RaiseCanExecuteChanged();
}
// Events for view to show dialogs (sync)
public event Action? OnRequestNewStoreDialog;
public event Action? OnRequestOpenStoreDialog;
public event Action? OnRequestAddSecretDialog;
public event Action<string, string>? OnRequestEditSecretDialog;
public event Action? OnRequestClose;
// Events for view to show dialogs (async)
public event Func<string, string, Task>? OnShowError;
public event Func<string, string, Task>? OnShowInfo;
public event Func<Task<UnsavedChangesResult>>? OnShowUnsavedChangesPrompt;
public event Func<string, Task<bool>>? OnShowDeleteConfirmation;
public event Func<string, string, string, string, Task<string?>>? OnShowSaveFileDialog;
}
/// <summary>
/// Result from unsaved changes prompt.
/// </summary>
public enum UnsavedChangesResult
{
Save,
DontSave,
Cancel
}
@@ -0,0 +1,76 @@
using System.Windows.Input;
namespace JdeScoping.SecureStoreManager.ViewModels;
/// <summary>
/// A command implementation that delegates to action methods.
/// </summary>
public class RelayCommand : ICommand
{
private readonly Action<object?> _execute;
private readonly Predicate<object?>? _canExecute;
private EventHandler? _canExecuteChanged;
public event EventHandler? CanExecuteChanged
{
add => _canExecuteChanged += value;
remove => _canExecuteChanged -= value;
}
/// <summary>
/// Creates a new RelayCommand that can always execute.
/// </summary>
/// <param name="execute">The action to execute.</param>
public RelayCommand(Action<object?> execute)
: this(execute, null)
{
}
/// <summary>
/// Creates a new RelayCommand with a CanExecute predicate.
/// </summary>
/// <param name="execute">The action to execute.</param>
/// <param name="canExecute">The predicate to determine if the command can execute.</param>
public RelayCommand(Action<object?> execute, Predicate<object?>? canExecute)
{
_execute = execute ?? throw new ArgumentNullException(nameof(execute));
_canExecute = canExecute;
}
/// <summary>
/// Creates a new RelayCommand from a parameterless action.
/// </summary>
/// <param name="execute">The action to execute.</param>
public RelayCommand(Action execute)
: this(_ => execute(), null)
{
}
/// <summary>
/// Creates a new RelayCommand from a parameterless action with a CanExecute predicate.
/// </summary>
/// <param name="execute">The action to execute.</param>
/// <param name="canExecute">The predicate to determine if the command can execute.</param>
public RelayCommand(Action execute, Func<bool>? canExecute)
: this(_ => execute(), canExecute != null ? _ => canExecute() : null)
{
}
public bool CanExecute(object? parameter)
{
return _canExecute == null || _canExecute(parameter);
}
public void Execute(object? parameter)
{
_execute(parameter);
}
/// <summary>
/// Raises the CanExecuteChanged event.
/// </summary>
public void RaiseCanExecuteChanged()
{
_canExecuteChanged?.Invoke(this, EventArgs.Empty);
}
}
@@ -0,0 +1,87 @@
using System.Windows.Input;
namespace JdeScoping.SecureStoreManager.ViewModels;
/// <summary>
/// View model for an individual secret item with show/hide toggle.
/// </summary>
public class SecretItemViewModel : ViewModelBase
{
private readonly string _actualValue;
private bool _isValueVisible;
private const string MaskedValue = "********";
public SecretItemViewModel(string key, string value)
{
Key = key;
_actualValue = value;
ToggleVisibilityCommand = new RelayCommand(ToggleVisibility);
CopyToClipboardCommand = new RelayCommand(CopyToClipboard);
}
/// <summary>
/// Gets the secret key.
/// </summary>
public string Key { get; }
/// <summary>
/// Gets the displayed value (masked or actual based on visibility).
/// </summary>
public string DisplayValue => _isValueVisible ? _actualValue : MaskedValue;
/// <summary>
/// Gets the actual unmasked value.
/// </summary>
public string ActualValue => _actualValue;
/// <summary>
/// Gets or sets whether the value is visible.
/// </summary>
public bool IsValueVisible
{
get => _isValueVisible;
set
{
if (SetProperty(ref _isValueVisible, value))
{
OnPropertyChanged(nameof(DisplayValue));
}
}
}
/// <summary>
/// Command to toggle visibility of the secret value.
/// </summary>
public ICommand ToggleVisibilityCommand { get; }
/// <summary>
/// Command to copy the secret value to clipboard.
/// </summary>
public ICommand CopyToClipboardCommand { get; }
/// <summary>
/// Event raised when clipboard copy is requested.
/// The view subscribes to this to perform the actual clipboard operation.
/// </summary>
public event Func<string, Task>? OnCopyToClipboard;
private void ToggleVisibility()
{
IsValueVisible = !IsValueVisible;
}
private async void CopyToClipboard()
{
try
{
if (OnCopyToClipboard != null)
{
await OnCopyToClipboard(_actualValue);
}
}
catch
{
// Clipboard operations can fail in some scenarios
}
}
}
@@ -0,0 +1,39 @@
using System.ComponentModel;
using System.Runtime.CompilerServices;
namespace JdeScoping.SecureStoreManager.ViewModels;
/// <summary>
/// Base class for all view models providing INotifyPropertyChanged implementation.
/// </summary>
public abstract class ViewModelBase : INotifyPropertyChanged
{
public event PropertyChangedEventHandler? PropertyChanged;
/// <summary>
/// Raises the PropertyChanged event for the specified property.
/// </summary>
/// <param name="propertyName">The name of the property that changed.</param>
protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
/// <summary>
/// Sets a property value and raises PropertyChanged if the value changed.
/// </summary>
/// <typeparam name="T">The type of the property.</typeparam>
/// <param name="field">Reference to the backing field.</param>
/// <param name="value">The new value.</param>
/// <param name="propertyName">The name of the property.</param>
/// <returns>True if the value changed, false otherwise.</returns>
protected bool SetProperty<T>(ref T field, T value, [CallerMemberName] string? propertyName = null)
{
if (EqualityComparer<T>.Default.Equals(field, value))
return false;
field = value;
OnPropertyChanged(propertyName);
return true;
}
}