diff --git a/.vscode/launch.json b/.vscode/launch.json
index cce1b42..0d92f7f 100644
--- a/.vscode/launch.json
+++ b/.vscode/launch.json
@@ -12,6 +12,17 @@
"console": "integratedTerminal",
"stopAtEntry": false
},
+ {
+ "name": "WPF (Debug)",
+ "type": "coreclr",
+ "request": "launch",
+ "preLaunchTask": "build-wpf",
+ "program": "${workspaceFolder}/src/Sms.TaskTwo.Wpf/bin/Debug/net8.0-windows/Sms.TaskTwo.Wpf.dll",
+ "args": [],
+ "cwd": "${workspaceFolder}/src/Sms.TaskTwo.Wpf/bin/Debug/net8.0-windows",
+ "console": "integratedTerminal",
+ "stopAtEntry": false
+ },
{
"name": "Attach",
"type": "coreclr",
diff --git a/.vscode/tasks.json b/.vscode/tasks.json
index ad2a49b..083bd9b 100644
--- a/.vscode/tasks.json
+++ b/.vscode/tasks.json
@@ -41,6 +41,33 @@
},
"problemMatcher": "$msCompile"
},
+ {
+ "label": "build-wpf",
+ "command": "dotnet",
+ "type": "process",
+ "args": [
+ "build",
+ "${workspaceFolder}/src/Sms.TaskTwo.Wpf/Sms.TaskTwo.Wpf.csproj",
+ "/property:GenerateFullPaths=true",
+ "/consoleloggerparameters:NoSummary;ForceNoAlign"
+ ],
+ "group": "build",
+ "problemMatcher": "$msCompile"
+ },
+ {
+ "label": "run-wpf",
+ "command": "dotnet",
+ "type": "process",
+ "args": [
+ "run",
+ "--project",
+ "${workspaceFolder}/src/Sms.TaskTwo.Wpf/Sms.TaskTwo.Wpf.csproj",
+ "--no-build"
+ ],
+ "group": "none",
+ "problemMatcher": "$msCompile",
+ "dependsOn": "build-wpf"
+ },
{
"label": "run-avalonia",
"command": "dotnet",
diff --git a/README.md b/README.md
index ecb7654..9bf921d 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# SMS Task Two — редактор переменных среды
-Десктопное приложение на **Avalonia** (.NET 8) для чтения и изменения пользовательских переменных среды. Ядро, ViewModels и модуль окружения вынесены в переносимые проекты для последующего порта на **WPF**.
+Десктопное приложение на **Avalonia** и **WPF** (.NET 8) для чтения и изменения пользовательских переменных среды. Ядро, ViewModels и модуль окружения вынесены в общие проекты.
## Solution
@@ -15,7 +15,8 @@
| `Sms.Environment.Linux` | `~/.config/environment.d/99-sms-task-two.conf` |
| `Sms.TaskTwo.Core` | Конфигурация, сервис, логирование |
| `Sms.TaskTwo.ViewModels` | MVVM (`CommunityToolkit.Mvvm`) |
-| `Sms.TaskTwo.Avalonia` | UI-хост |
+| `Sms.TaskTwo.Avalonia` | UI-хост (Avalonia) |
+| `Sms.TaskTwo.Wpf` | UI-хост (WPF) |
## Сборка и запуск
@@ -83,6 +84,10 @@ dotnet run --project src/Sms.TaskTwo.Avalonia/Sms.TaskTwo.Avalonia.csproj
2. Значения по умолчанию показываются в UI, в ОС записываются при первом изменении пользователем.
3. Pixel-perfect вёрстка не требуется; элементы стилизованы по макету (заголовок, DataGrid, кнопки «−» / «×»).
-## Порт на WPF
+## WPF
-Создать `Sms.TaskTwo.Wpf`, подключить `Sms.TaskTwo.Core`, `Sms.TaskTwo.ViewModels`, зарегистрировать `IEnvironmentVariableStore` так же, как в `App.axaml.cs` Avalonia-проекта.
+```bash
+dotnet run --project src/Sms.TaskTwo.Wpf/Sms.TaskTwo.Wpf.csproj
+```
+
+Конфигурация: [`src/Sms.TaskTwo.Wpf/appsettings.json`](src/Sms.TaskTwo.Wpf/appsettings.json) (аналогично Avalonia).
diff --git a/Sms.TaskTwo.slnx b/Sms.TaskTwo.slnx
index d2ddeab..9b4f5d6 100644
--- a/Sms.TaskTwo.slnx
+++ b/Sms.TaskTwo.slnx
@@ -4,6 +4,7 @@
+
diff --git a/src/Sms.TaskTwo.Avalonia/Views/MainWindow.axaml b/src/Sms.TaskTwo.Avalonia/Views/MainWindow.axaml
index 5e848a1..15cef0b 100644
--- a/src/Sms.TaskTwo.Avalonia/Views/MainWindow.axaml
+++ b/src/Sms.TaskTwo.Avalonia/Views/MainWindow.axaml
@@ -99,6 +99,11 @@
Binding="{Binding RequiredValue, Mode=TwoWay}"
Width="2*"
MinWidth="140" />
+
ResolveRequiredValue(name);
+ public string? GetProcessValue(string name) =>
+ _store.GetProcessEnvironment().TryGetValue(name, out var value) ? value : null;
+
public void SaveValue(string name, string value)
{
var previous = _store.GetUserPersistedValue(name);
diff --git a/src/Sms.TaskTwo.ViewModels/EnvironmentVariableRowViewModel.cs b/src/Sms.TaskTwo.ViewModels/EnvironmentVariableRowViewModel.cs
index ab03e15..ed3f66b 100644
--- a/src/Sms.TaskTwo.ViewModels/EnvironmentVariableRowViewModel.cs
+++ b/src/Sms.TaskTwo.ViewModels/EnvironmentVariableRowViewModel.cs
@@ -21,6 +21,7 @@ public sealed partial class EnvironmentVariableRowViewModel : ObservableObject
_service = service;
_onCustomRemoved = onCustomRemoved;
ApplySnapshot(row, suppressSave: true);
+ RefreshActualValue(_service.GetProcessValue(Field));
}
public string Field { get; }
@@ -34,6 +35,12 @@ public sealed partial class EnvironmentVariableRowViewModel : ObservableObject
[ObservableProperty]
private bool _useUserStore;
+ [ObservableProperty]
+ private string _actualValue = string.Empty;
+
+ public string ActualValueDisplay =>
+ string.IsNullOrEmpty(ActualValue) ? "<нет>" : ActualValue;
+
[ObservableProperty]
private string _requiredValue = string.Empty;
@@ -60,6 +67,7 @@ public sealed partial class EnvironmentVariableRowViewModel : ObservableObject
if (value)
{
_service.SaveValue(Field, RequiredValue);
+ RefreshActualValue(_service.GetProcessValue(Field));
return;
}
@@ -74,8 +82,11 @@ public sealed partial class EnvironmentVariableRowViewModel : ObservableObject
BeginLoad();
RequiredValue = _service.GetDisplayValue(Field);
EndLoad();
+ RefreshActualValue(_service.GetProcessValue(Field));
}
+ partial void OnActualValueChanged(string value) => OnPropertyChanged(nameof(ActualValueDisplay));
+
partial void OnRequiredValueChanged(string value)
{
if (_isLoading || !UseUserStore)
@@ -84,6 +95,7 @@ public sealed partial class EnvironmentVariableRowViewModel : ObservableObject
}
_service.SaveValue(Field, value);
+ RefreshActualValue(_service.GetProcessValue(Field));
}
public event EventHandler? RowAppearanceChanged;
@@ -105,6 +117,11 @@ public sealed partial class EnvironmentVariableRowViewModel : ObservableObject
}
}
+ public void RefreshActualValue(string? processValue)
+ {
+ ActualValue = processValue ?? string.Empty;
+ }
+
public void BeginLoad() => _isLoading = true;
public void EndLoad() => _isLoading = false;
diff --git a/src/Sms.TaskTwo.ViewModels/MainWindowViewModel.cs b/src/Sms.TaskTwo.ViewModels/MainWindowViewModel.cs
index 0375353..a1ce390 100644
--- a/src/Sms.TaskTwo.ViewModels/MainWindowViewModel.cs
+++ b/src/Sms.TaskTwo.ViewModels/MainWindowViewModel.cs
@@ -127,6 +127,16 @@ public sealed partial class MainWindowViewModel : ObservableObject
MoveItem(Rows, currentIndex, targetIndex);
}
}
+
+ RefreshProcessStates();
+ }
+
+ private void RefreshProcessStates()
+ {
+ foreach (var row in Rows)
+ {
+ row.RefreshActualValue(_service.GetProcessValue(row.Field));
+ }
}
private static void MoveItem(
diff --git a/src/Sms.TaskTwo.Wpf/App.xaml b/src/Sms.TaskTwo.Wpf/App.xaml
new file mode 100644
index 0000000..d523f36
--- /dev/null
+++ b/src/Sms.TaskTwo.Wpf/App.xaml
@@ -0,0 +1,24 @@
+
+
+
+
+
+
diff --git a/src/Sms.TaskTwo.Wpf/App.xaml.cs b/src/Sms.TaskTwo.Wpf/App.xaml.cs
new file mode 100644
index 0000000..88878b1
--- /dev/null
+++ b/src/Sms.TaskTwo.Wpf/App.xaml.cs
@@ -0,0 +1,54 @@
+using System.Windows;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Sms.Environment;
+using Sms.Environment.Linux;
+using Sms.Environment.Windows;
+using Sms.TaskTwo.Core.DependencyInjection;
+using Sms.TaskTwo.Core.Logging;
+using Sms.TaskTwo.ViewModels;
+using Sms.TaskTwo.Wpf.Views;
+
+namespace Sms.TaskTwo.Wpf;
+
+public partial class App : Application
+{
+ public static IServiceProvider Services { get; private set; } = null!;
+
+ private void OnStartup(object sender, StartupEventArgs e)
+ {
+ Services = ConfigureServices();
+ var mainViewModel = Services.GetRequiredService();
+ MainWindow = new MainWindow(mainViewModel);
+ MainWindow.Show();
+ }
+
+ private void OnExit(object sender, ExitEventArgs e)
+ {
+ if (Services.GetService() is IDisposable log)
+ {
+ log.Dispose();
+ }
+ }
+
+ private static IServiceProvider ConfigureServices()
+ {
+ var configuration = new ConfigurationBuilder()
+ .SetBasePath(AppContext.BaseDirectory)
+ .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true)
+ .Build();
+
+ var services = new ServiceCollection();
+ services.AddSingleton(configuration);
+ services.AddSingleton(CreateEnvironmentStore);
+ services.AddTaskTwoCore(configuration);
+ services.AddSingleton();
+
+ return services.BuildServiceProvider();
+ }
+
+ private static IEnvironmentVariableStore CreateEnvironmentStore(IServiceProvider _) =>
+ OperatingSystem.IsWindows()
+ ? new WindowsEnvironmentVariableStore()
+ : new LinuxEnvironmentVariableStore();
+}
diff --git a/src/Sms.TaskTwo.Wpf/AppResources.cs b/src/Sms.TaskTwo.Wpf/AppResources.cs
new file mode 100644
index 0000000..2d2dbab
--- /dev/null
+++ b/src/Sms.TaskTwo.Wpf/AppResources.cs
@@ -0,0 +1,6 @@
+namespace Sms.TaskTwo.Wpf;
+
+public static class AppResources
+{
+ public const string WindowTitle = "Тестовое WPF-приложение для SmartMealService";
+}
diff --git a/src/Sms.TaskTwo.Wpf/Converters/StringNotEmptyToVisibilityConverter.cs b/src/Sms.TaskTwo.Wpf/Converters/StringNotEmptyToVisibilityConverter.cs
new file mode 100644
index 0000000..ab5b3e7
--- /dev/null
+++ b/src/Sms.TaskTwo.Wpf/Converters/StringNotEmptyToVisibilityConverter.cs
@@ -0,0 +1,16 @@
+using System.Globalization;
+using System.Windows;
+using System.Windows.Data;
+
+namespace Sms.TaskTwo.Wpf.Converters;
+
+public sealed class StringNotEmptyToVisibilityConverter : IValueConverter
+{
+ public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
+ value is string text && !string.IsNullOrEmpty(text)
+ ? Visibility.Visible
+ : Visibility.Collapsed;
+
+ public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) =>
+ throw new NotSupportedException();
+}
diff --git a/src/Sms.TaskTwo.Wpf/Sms.TaskTwo.Wpf.csproj b/src/Sms.TaskTwo.Wpf/Sms.TaskTwo.Wpf.csproj
new file mode 100644
index 0000000..61bd3ac
--- /dev/null
+++ b/src/Sms.TaskTwo.Wpf/Sms.TaskTwo.Wpf.csproj
@@ -0,0 +1,28 @@
+
+
+ WinExe
+ net8.0-windows
+ enable
+ enable
+ true
+ app.manifest
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ PreserveNewest
+
+
+
diff --git a/src/Sms.TaskTwo.Wpf/Views/MainWindow.xaml b/src/Sms.TaskTwo.Wpf/Views/MainWindow.xaml
new file mode 100644
index 0000000..2cce772
--- /dev/null
+++ b/src/Sms.TaskTwo.Wpf/Views/MainWindow.xaml
@@ -0,0 +1,156 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Sms.TaskTwo.Wpf/Views/MainWindow.xaml.cs b/src/Sms.TaskTwo.Wpf/Views/MainWindow.xaml.cs
new file mode 100644
index 0000000..acaa3ab
--- /dev/null
+++ b/src/Sms.TaskTwo.Wpf/Views/MainWindow.xaml.cs
@@ -0,0 +1,128 @@
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Media;
+using Sms.TaskTwo.ViewModels;
+
+namespace Sms.TaskTwo.Wpf.Views;
+
+public partial class MainWindow : Window
+{
+ private static readonly Brush AppSettingsBrush = new SolidColorBrush(Color.FromRgb(0xE3, 0xF2, 0xFD));
+ private static readonly Brush CustomBrush = new SolidColorBrush(Color.FromRgb(0xFF, 0xF8, 0xE1));
+ private static readonly Brush AppSettingsUserStoreBrush = new SolidColorBrush(Color.FromRgb(0xC8, 0xE6, 0xC9));
+ private static readonly Brush UserStoreBorderBrush = new SolidColorBrush(Color.FromRgb(0x2E, 0x7D, 0x32));
+ private static readonly Brush DefaultRowBrush = Brushes.White;
+
+ private readonly Dictionary _gridRows = new();
+
+ public MainWindow()
+ {
+ InitializeComponent();
+ }
+
+ public MainWindow(MainWindowViewModel viewModel) : this()
+ {
+ DataContext = viewModel;
+ }
+
+ private void OnLoadingRow(object sender, DataGridRowEventArgs e)
+ {
+ if (e.Row.DataContext is not EnvironmentVariableRowViewModel row)
+ {
+ return;
+ }
+
+ if (_gridRows.TryGetValue(row, out var previousRow) && previousRow != e.Row)
+ {
+ UnsubscribeRow(previousRow, row);
+ }
+
+ _gridRows[row] = e.Row;
+ ApplyRowStyle(e.Row, row);
+
+ row.RowAppearanceChanged -= OnRowAppearanceChanged;
+ row.RowAppearanceChanged += OnRowAppearanceChanged;
+
+ e.Row.DataContextChanged -= OnRowDataContextChanged;
+ e.Row.DataContextChanged += OnRowDataContextChanged;
+ }
+
+ private void OnUnloadingRow(object sender, DataGridRowEventArgs e)
+ {
+ var row = _gridRows.FirstOrDefault(pair => pair.Value == e.Row).Key;
+ if (row is null)
+ {
+ return;
+ }
+
+ UnsubscribeRow(e.Row, row);
+ _gridRows.Remove(row);
+ }
+
+ private void UnsubscribeRow(DataGridRow gridRow, EnvironmentVariableRowViewModel row)
+ {
+ row.RowAppearanceChanged -= OnRowAppearanceChanged;
+ gridRow.DataContextChanged -= OnRowDataContextChanged;
+ }
+
+ private void OnRowDataContextChanged(object sender, DependencyPropertyChangedEventArgs e)
+ {
+ if (sender is not DataGridRow gridRow)
+ {
+ return;
+ }
+
+ var row = _gridRows.FirstOrDefault(pair => pair.Value == gridRow).Key;
+ if (row is null)
+ {
+ return;
+ }
+
+ UnsubscribeRow(gridRow, row);
+ _gridRows.Remove(row);
+ }
+
+ private void OnRowAppearanceChanged(object? sender, EventArgs e)
+ {
+ if (sender is not EnvironmentVariableRowViewModel row)
+ {
+ return;
+ }
+
+ if (_gridRows.TryGetValue(row, out var gridRow))
+ {
+ ApplyRowStyle(gridRow, row);
+ }
+ }
+
+ private static void ApplyRowStyle(DataGridRow gridRow, EnvironmentVariableRowViewModel row)
+ {
+ if (row.IsFromAppSettings && row.UseUserStore)
+ {
+ gridRow.Background = AppSettingsUserStoreBrush;
+ }
+ else if (row.IsFromAppSettings)
+ {
+ gridRow.Background = AppSettingsBrush;
+ }
+ else if (row.IsCustom)
+ {
+ gridRow.Background = CustomBrush;
+ }
+ else
+ {
+ gridRow.Background = DefaultRowBrush;
+ }
+
+ if (row.UseUserStore)
+ {
+ gridRow.BorderBrush = UserStoreBorderBrush;
+ gridRow.BorderThickness = new Thickness(0, 0, 0, 2);
+ }
+ else
+ {
+ gridRow.BorderBrush = Brushes.Transparent;
+ gridRow.BorderThickness = new Thickness(0);
+ }
+ }
+}
diff --git a/src/Sms.TaskTwo.Wpf/app.manifest b/src/Sms.TaskTwo.Wpf/app.manifest
new file mode 100644
index 0000000..0783830
--- /dev/null
+++ b/src/Sms.TaskTwo.Wpf/app.manifest
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Sms.TaskTwo.Wpf/appsettings.json b/src/Sms.TaskTwo.Wpf/appsettings.json
new file mode 100644
index 0000000..046e912
--- /dev/null
+++ b/src/Sms.TaskTwo.Wpf/appsettings.json
@@ -0,0 +1,16 @@
+{
+ "EnvironmentVariables": {
+ "Names": [
+ "SMS_MEAL_SERVER_URL",
+ "SMS_MEAL_API_KEY"
+ ],
+ "CommentsVariableName": "SMS_TASK_TWO_COMMENTS",
+ "CustomVariablesVariableName": "SMS_TASK_TWO_CUSTOM_VARS",
+ "Defaults": {
+ "SMS_MEAL_SERVER_URL": "https://localhost"
+ }
+ },
+ "Logging": {
+ "LogDirectory": "logs"
+ }
+}