feat: add brand calibration view and viewmodel

This commit is contained in:
2026-06-17 10:32:09 +03:00
parent 0676c0be75
commit 80856ba60d
3 changed files with 706 additions and 0 deletions
@@ -0,0 +1,366 @@
using System.Collections.ObjectModel;
using Microsoft.Extensions.Logging;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using GSS2.Core.Analysis.AmineContent.Database;
using GSS2.UI.Core.ViewModels;
using GSS2.UI.Core.Services;
namespace GSS2.ViewModels.AmineContent;
public partial class BrandCalibrationViewModel : ViewModelBase
{
/// <summary>
/// Внутренняя модель элементов меню
/// </summary>
public partial class MenuItemViewModel : ViewModelBase
{
[ObservableProperty] public partial int Id { get; set; }
[ObservableProperty] public partial string BrandName { get; set; }
[ObservableProperty] public partial string MixtureName { get; set; }
public MenuItemViewModel(int id, string brandName, string mixtureName)
{
Id = id;
BrandName = brandName;
MixtureName = mixtureName;
}
}
/// <summary>
/// Логгер
/// </summary>
private readonly ILogger<BrandCalibrationViewModel> _logger;
/// <summary>
/// КОнтекст базы данных
/// </summary>
private readonly Context _context;
/// <summary>
/// ViewLocator для получения других View по их моделям
/// </summary>
private readonly DependencyInjectionViewLocator _viewLocator;
/// <summary>
/// Сервис навигации между экранами
/// </summary>
private readonly NavigationService _navigation;
/// <summary>
/// Меню открыто
/// </summary>
[ObservableProperty] public partial bool MenuOpened { get; set; } = false;
/// <summary>
/// Элементы меню
/// </summary>
[ObservableProperty] public partial ObservableCollection<MenuItemViewModel> MenuItems { get; set; }
/// <summary>
/// Текущий выбранный элемент меню. Равен -1 если элемент не выбран
/// </summary>
[ObservableProperty] public partial int MenuSelectedIndex { get; set; }
/// <summary>
/// <see cref="BrandRecord.Id"/> выбранного элемента
/// </summary>
[ObservableProperty] public partial int SelectedId { get; set; }
/// <summary>
/// <see cref="BrandRecord.SelectedUuid"/> выбранного элемента
/// </summary>
[ObservableProperty] public partial string SelectedUuid { get; set; }
/// <summary>
/// <see cref="BrandRecord.SelectedBrandName"/> выбранного элемента
/// </summary>
[ObservableProperty] public partial string SelectedBrandName { get; set; }
/// <summary>
/// <see cref="BrandRecord.SelectedMixtureName"/> выбранного элемента
/// </summary>
[ObservableProperty] public partial string SelectedMixtureName { get; set; }
/// <summary>
/// <see cref="BrandRecord.SelectedMixtureNormalRate"/> выбранного элемента
/// </summary>
[ObservableProperty] public partial double SelectedMixtureNormalRate { get; set; }
/// <summary>
/// Можно ли производить перемещение между экранами
/// </summary>
[ObservableProperty] public partial bool CanScroll { get; set; }
public BrandCalibrationViewModel(
ILogger<BrandCalibrationViewModel> logger,
Context context,
DependencyInjectionViewLocator viewLocator,
NavigationService navigation
)
{
// Устанавливаем readonly поля
_logger = logger;
_context = context;
_viewLocator = viewLocator;
_navigation = navigation;
_logger.LogInformation("Инициализация");
// Заполняем свойства пустыми значениями
MenuOpened = new();
MenuItems = new();
MenuSelectedIndex = -1;
CanScroll = true;
SelectedId = 0;
SelectedUuid = Guid.NewGuid().ToString();
SelectedBrandName = "";
SelectedMixtureName = "";
SelectedMixtureNormalRate = -1;
// Получаем записи из базы данных
MenuItems = new ObservableCollection<MenuItemViewModel>(_context.BrandRecords.Select(r => new MenuItemViewModel(r.Id, r.BrandName, r.MixtureName)));
if (MenuItems.Count() > 0) // Если записи есть,
MenuSelectedIndex = 0; // Выбираем первую запись
else // Если записей нет,
MenuSelectedIndex = -1; // Устанавливаем, что запись не выбрана
_logger.LogInformation("Инициализировано");
}
/// <summary>
/// Вызывается при изменении <see cref="MenuSelectedIndex"/>
/// </summary>
partial void OnMenuSelectedIndexChanged(int value)
{
// Если значение выходит за верхнюю границу списка элементов меню, то логгируем как ошибку
if (value >= MenuItems.Count())
_logger.LogError("Значение {}({}) больше количества элементов {}({})", nameof(MenuSelectedIndex), MenuSelectedIndex, nameof(MenuItems), MenuItems.Count());
// Если значение больше или равно 0, то значит выбран один из элементов меню
if (value >= 0 && value < MenuItems.Count())
{
_logger.LogTrace("Выбран элемент из списка: {}", value);
// Находим запись в базе данных
var selectedRecord = _context.BrandRecords.Find(MenuItems[value].Id);
// Если запись найдена, то заполняем поля из базы данных
if (selectedRecord is not null)
{
SelectedId = selectedRecord.Id;
SelectedUuid = selectedRecord.Uuid;
SelectedBrandName = selectedRecord.BrandName;
SelectedMixtureName = selectedRecord.MixtureName;
SelectedMixtureNormalRate = selectedRecord.MixtureNormalRate;
return;
}
// Если значение не найдено в базе данных
else
{
// Логгируем как ошибку
_logger.LogError("Не удалось найти элемент с Id({}) в таблице {}", MenuItems[value].Id, nameof(_context.BrandRecords));
var nonExistingIndex = MenuSelectedIndex; // Сохраняем несуществующий индекс
// Устанавливаем, что запись не выбрана, этот метод вызовется повторно и заполнит поля пустыми значениями
MenuSelectedIndex = -1;
// Удаляем несуществующий элемент из списка
MenuItems.RemoveAt(nonExistingIndex);
return;
}
}
// Если элемент не выбран, заполняем поля пустыми значениями
_logger.LogTrace("Выбран новый элемент");
SelectedId = 0;
SelectedUuid = Guid.NewGuid().ToString();
SelectedBrandName = "";
SelectedMixtureName = "";
SelectedMixtureNormalRate = -1;
}
/// <summary>
/// Вызывается при изменении <see cref="MenuOpened"/>
/// </summary>
partial void OnMenuOpenedChanged(bool value)
{
CanScroll = !value;
}
// Команды управления меню
/// <summary>
/// Открыть меню
/// </summary>
[RelayCommand] private void OpenMenu() => MenuOpened = true;
/// <summary>
/// Закрыть меню
/// </summary>
[RelayCommand] private void CloseMenu() => MenuOpened = false;
/// <summary>
/// Переключить видимость меню
/// </summary>
[RelayCommand] private void ToggleMenu() => MenuOpened = !MenuOpened;
/// <summary>
/// Создать новую запись
/// </summary>
[RelayCommand]
private void CreateRecord()
{
_logger.LogInformation("Создание новой записи");
// Если уже создана новая запись, то MenuSelectedIndex может быть равен -1 и
// при простом присвоении MenuSelectedIndex = -1 событие OnMenuSelectedIndexChanged не вызовется,
// потому что событие срабатывает только при изменении значения, а значение было -1 и привилось ему -1,
// поэтому если SelectedRecord != -1, то нужно явно вызвать метод OnMenuSelectedIndexChanged
if (MenuSelectedIndex != -1)
MenuSelectedIndex = -1;
else
OnMenuSelectedIndexChanged(-1);
}
/// <summary>
/// Удалить выбранную запись из базы данных или очистить значения, если запись не выбрана
/// </summary>
[RelayCommand]
private void DeleteRecord()
{
try
{
_logger.LogTrace("Удаление записи: {}={}, {}={}, {}={}", nameof(BrandRecord.Id), SelectedId, nameof(BrandRecord.BrandName), SelectedBrandName, nameof(BrandRecord.MixtureName), SelectedMixtureName);
// Если запись не выбрана, то просто создаём новую запись
if (MenuSelectedIndex < 0)
{
_logger.LogTrace("Запись не выбрана, создаём новую");
CreateRecord();
return;
}
// Создаём модель для экрана подтверждения
var confirmViewModel = new DeleteConfirmViewModel(_navigation)
{
Question = "Вы уверены, что хотите удалить запись?"
};
// Создаём коллбек который вызовется при экрана подтверждения
void OnConfirmCloses(object? sender, bool result)
{
// Если пользователь отменил удаление, то выходим из коллбека
if (!result)
{
_logger.LogTrace("Пользователь отменил удаление");
return;
}
// Если запись не выбрана, то просто создаём новую запись
if (MenuSelectedIndex < 0)
{
_logger.LogTrace("Запись не выбрана, создаём новую");
CreateRecord();
return;
}
// Сохраняем индекс удалённый записи и находим её в базе данных
var deletedMenuIndex = MenuSelectedIndex;
var deletedRecordId = SelectedId;
var deletedRecord = _context.BrandRecords.Find(deletedRecordId);
// Создаём новую запись
CreateRecord();
// Удаляем запись из списка
MenuItems.RemoveAt(deletedMenuIndex);
// Если удалённая запись не найдена в базе данных, то логгируем как ошибку
if (deletedRecord is null)
{
_logger.LogError("Не удалось найти элемент с Id({}) в таблице {}", deletedRecordId, nameof(_context.BrandRecords));
return;
}
// Удаляем запись из базы данных и сохраняем изменения в базе данных
_context.BrandRecords.Remove(deletedRecord);
_context.SaveChanges();
_logger.LogTrace("Запись удалена");
confirmViewModel.Closes -= OnConfirmCloses; // Отписываемся от события закрытия экрана подтверждения
}
// Создаём View экрана подтверждения
var confirmView = _viewLocator.Build(confirmViewModel);
// Если View не найден, то логгируем как ошибку
if (confirmView is null)
{
_logger.LogError("Не удалось найти View для экрана подтверждения");
return;
}
_logger.LogTrace("Задаём вопрос");
// Подписываемся на событие закрытия экрана подтверждения
confirmViewModel.Closes += OnConfirmCloses;
// Преходим на экран подтверждения, при закрытии он сам вернёт экран обратно на текущий
_navigation.NavigateTo(confirmView);
}
catch (Exception ex)
{
_logger.LogError(ex, "Исключение при удалении записи");
}
}
/// <summary>
/// Сохранить текущие значения в новую запись или перезаписать значения в выдранной записи
/// </summary>
[RelayCommand]
private void SaveRecord()
{
try
{
_logger.LogTrace("Сохранение записи: {}={}, {}={}, {}={}", nameof(BrandRecord.Id), SelectedId, nameof(BrandRecord.BrandName), SelectedBrandName, nameof(BrandRecord.MixtureName), SelectedMixtureName);
// Если запись не выбрана
if (MenuSelectedIndex < 0)
{
_logger.LogTrace("Создаём новую запись");
// Создаём новую запись
var newRecord = new BrandRecord(
uuid: SelectedUuid,
brandName: SelectedBrandName,
mixtureName: SelectedMixtureName,
mixtureNormalRate: SelectedMixtureNormalRate
);
// При добавляем запись в базу данных
var attachedRecord = _context.BrandRecords.Add(newRecord);
// Сохраняем изменения в базе данных
_context.SaveChanges();
_logger.LogTrace("Запись сохранена");
// Добавляем новый элемент в меню
MenuItems.Add(new MenuItemViewModel(attachedRecord.Entity.Id, attachedRecord.Entity.BrandName, attachedRecord.Entity.MixtureName));
// Выбираем последний элемент из меню
MenuSelectedIndex = MenuItems.Count() - 1;
}
// Если запись выбрана
else
{
// Находим запись в базе данных
var attachedRecord = _context.BrandRecords.Find(SelectedId);
// Если запись не найдена, то логгируем как ошибку
if (attachedRecord is null)
{
_logger.LogError("Не удалось найти элемент с Id({}) в таблице {}", SelectedId, nameof(_context.BrandRecords));
return;
}
_logger.LogTrace("Записываем изменения в базу данных");
// Если в данных есть изменения, то записываем их
if (attachedRecord.BrandName != SelectedBrandName)
attachedRecord.BrandName = SelectedBrandName;
if (attachedRecord.MixtureName != SelectedMixtureName)
attachedRecord.MixtureName = SelectedMixtureName;
if (attachedRecord.MixtureNormalRate != SelectedMixtureNormalRate)
attachedRecord.MixtureNormalRate = SelectedMixtureNormalRate;
// Записываем изменения в меню
_logger.LogTrace("Записываем изменения в меню");
MenuItems[MenuSelectedIndex].BrandName = SelectedBrandName;
MenuItems[MenuSelectedIndex].MixtureName = SelectedMixtureName;
// Сохраняем изменения в базе данных
_context.SaveChanges();
_logger.LogTrace("Запись сохранена");
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Исключение при сохранении записи");
}
}
}
@@ -0,0 +1,329 @@
<UserControl
x:Class="GSS2.Views.AmineContent.BrandCalibrationView"
xmlns:controls="using:GSS2.Controls"
xmlns:core="using:GSS2.UI.Core"
xmlns:i="using:Avalonia.Xaml.Interactivity"
xmlns:ia="using:Avalonia.Xaml.Interactions.Custom"
xmlns:lvc="using:LiveChartsCore.SkiaSharpView.Avalonia"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="using:GSS2.ViewModels.AmineContent" x:DataType="vm:BrandCalibrationViewModel"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns="https://github.com/avaloniaui"
>
<Grid Background="{StaticResource MainBackground}">
<!-- Основная форма -->
<Grid IsEnabled="{Binding !MenuOpened}">
<Grid.RowSpacing>5</Grid.RowSpacing>
<Grid.RowDefinitions>
<RowDefinition Height="*" /> <!-- Главная панель -->
<RowDefinition Height="Auto" /> <!-- Кнопки главной панели -->
</Grid.RowDefinitions>
<Grid.ColumnSpacing>5</Grid.ColumnSpacing>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" /> <!-- Кнопка "Удалить" -->
<ColumnDefinition Width="*" /> <!-- Кнопка "Сохранить" -->
</Grid.ColumnDefinitions>
<!-- Главная панель -->
<Grid
Grid.Column="0"
Grid.ColumnSpan="3"
Grid.Row="0"
>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" /> <!-- Кнопка меню -->
<ColumnDefinition Width="*" /> <!-- Информация -->
</Grid.ColumnDefinitions>
<Grid.ColumnSpacing>5</Grid.ColumnSpacing>
<Grid.RowDefinitions>
<RowDefinition Height="*" /> <!-- Информация -->
</Grid.RowDefinitions>
<Grid.RowSpacing>5</Grid.RowSpacing>
<!-- Кнопка меню -->
<Button
Grid.Column="0"
VerticalAlignment="Stretch"
Classes="vertical"
Command="{Binding ToggleMenuCommand}"
>
<TextBlock Text="☰" />
</Button>
<!-- Информация -->
<Grid Grid.Column="1">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Grid.ColumnSpacing>5</Grid.ColumnSpacing>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" /> <!-- Id (подпись) | Uuid (подпись) -->
<RowDefinition Height="Auto" /> <!-- Id (поле ) | Uuid (поле ) -->
<RowDefinition Height="Auto" /> <!-- BrandName (подпись) | MixtureName (подпись) -->
<RowDefinition Height="Auto" /> <!-- BrandName (поле ) | MixtureName (поле ) -->
<RowDefinition Height="Auto" /> <!-- MixtureNormalRate (подпись) -->
<RowDefinition Height="Auto" /> <!-- MixtureNormalRate (поле ) -->
<RowDefinition Height="*" /> <!-- Пустое пространство -->
</Grid.RowDefinitions>
<Grid.RowSpacing>5</Grid.RowSpacing>
<!-- Id (подпись) -->
<TextBlock
Classes="mini"
Grid.Column="0"
Grid.Row="0"
Text="ИД:"
/>
<!-- Id (поле) -->
<TextBox
Classes="singleline"
Grid.Column="0"
Grid.Row="1"
IsReadOnly="True"
PlaceholderText="ИД"
Text="{Binding SelectedId}"
/>
<!-- Uuid (подпись) -->
<TextBlock
Classes="mini"
Grid.Column="1"
Grid.Row="0"
Text="Уникальный ИД:"
/>
<!-- Uuid (поле) -->
<TextBox
Classes="singleline"
Grid.Column="1"
Grid.Row="1"
IsReadOnly="True"
PlaceholderText="Уникальный ИД"
Text="{Binding SelectedUuid}"
/>
<!-- BrandName (подпись) -->
<TextBlock
Classes="mini"
Grid.Column="0"
Grid.Row="2"
Text="Марка продукта:"
/>
<!-- BrandName (поле) -->
<controls:TouchTextBox
Classes="singleline"
Grid.Column="0"
Grid.Row="3"
PlaceholderText="Марка продукта"
Text="{Binding SelectedBrandName}"
/>
<!-- MixtureName (подпись) -->
<TextBlock
Classes="mini"
Grid.Column="1"
Grid.Row="2"
Text="Марка смеси:"
/>
<!-- MixtureName (поле) -->
<controls:TouchTextBox
Classes="singleline"
Grid.Column="1"
Grid.Row="3"
PlaceholderText="Марка смеси"
Text="{Binding SelectedMixtureName}"
/>
<!-- MixtureNormalRate (подпись) -->
<TextBlock
Classes="mini" Text="Норма расхода смеси (кг/т | гр/кг):"
Grid.Column="0"
Grid.ColumnSpan="2"
Grid.Row="4"
/>
<!-- MixtureNormalRate (поле) -->
<controls:TouchNumericUpDown
Classes="singleline"
FormatString="0.0000"
Grid.Column="0"
Grid.ColumnSpan="2"
Grid.Row="5"
Increment="0.0001"
PlaceholderText="Норма расхода смеси (кг/т | гр/кг)"
Value="{Binding SelectedMixtureNormalRate}"
/>
</Grid>
</Grid>
<!-- Кнопки главной панели -->
<!-- Кнопка "Удалить" -->
<Button
Classes="danger"
Command="{Binding DeleteRecordCommand}"
Grid.Column="0"
Grid.Row="1"
HorizontalAlignment="Stretch"
>
<TextBlock Text="Удалить" />
</Button>
<!-- Кнопка "Сохранить" -->
<Button
Command="{Binding SaveRecordCommand}"
Content="Сохранить"
Grid.Column="1"
Grid.Row="1"
HorizontalAlignment="Stretch"
>
<TextBlock Text="Сохранить" />
</Button>
</Grid>
<!-- Полупрозрачный оверлей -->
<Border
Background="{StaticResource Overlay}"
IsEnabled="{Binding MenuOpened}"
IsVisible="{Binding MenuOpened}"
>
<Interaction.Behaviors>
<TappedTrigger>
<InvokeCommandAction Command="{Binding CloseMenuCommand}" />
</TappedTrigger>
</Interaction.Behaviors>
</Border>
<!-- Выезжающая боковая панель -->
<Border
Background="{StaticResource MenuBackground}"
BorderBrush="{StaticResource MenuBorder}"
BorderThickness="0,0,1,0"
HorizontalAlignment="Left"
IsEnabled="{Binding MenuOpened}"
IsVisible="{Binding MenuOpened}"
>
<!-- Переходы -->
<Border.Transitions>
<Transitions>
<TransformOperationsTransition Duration="0:0:0.25" Property="RenderTransform" />
</Transitions>
</Border.Transitions>
<!-- Поведения -->
<Interaction.Behaviors>
<!-- Поведение при открытии меню -->
<DataTriggerBehavior
Binding="{Binding MenuOpened}"
ComparisonCondition="Equal"
Value="True"
>
<ChangePropertyAction PropertyName="RenderTransform" Value="translateX(0px)" />
</DataTriggerBehavior>
<!-- Поведение при закрытии меню -->
<DataTriggerBehavior
Binding="{Binding MenuOpened}"
ComparisonCondition="Equal"
Value="False"
>
<ChangePropertyAction PropertyName="RenderTransform" Value="translateX(-320px)" />
</DataTriggerBehavior>
</Interaction.Behaviors>
<!-- Содержимое меню -->
<Grid Margin="10">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" /> <!-- Кнопка "Новая запись" -->
<RowDefinition Height="*" /> <!-- Элементы меню -->
</Grid.RowDefinitions>
<Grid.RowSpacing>10</Grid.RowSpacing>
<!-- Кнопка "Новая запись" -->
<Button
Command="{Binding CreateRecordCommand}"
Grid.Row="0"
HorizontalAlignment="Stretch"
MinWidth="200"
>
<TextBlock Text="Новая запись" />
</Button>
<!-- Элементы меню -->
<ListBox
Background="{StaticResource MenuBackground}"
Grid.Row="1"
ItemsSource="{Binding MenuItems}"
SelectedIndex="{Binding MenuSelectedIndex}"
>
<!-- Шаблон списка элементов -->
<ListBox.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel />
</ItemsPanelTemplate>
</ListBox.ItemsPanel>
<!-- Стили элементов -->
<ListBox.Styles>
<!-- Общий стиль -->
<Style Selector="ListBoxItem">
<Setter Property="Background" Value="{StaticResource ButtonBackground}" />
</Style>
<!-- Стиль выбранного элемента -->
<Style Selector="ListBoxItem:selected /template/ ContentPresenter">
<Setter Property="Background" Value="{StaticResource MainBackground}" />
</Style>
<!-- Стиль элемента над которым находится указатель -->
<Style Selector="ListBoxItem:pointerover /template/ ContentPresenter">
<Setter Property="Background" Value="{StaticResource MainBackground}" />
</Style>
</ListBox.Styles>
<!-- Шаблон элемента -->
<ListBox.ItemTemplate>
<DataTemplate>
<Border Margin="3" CornerRadius="5">
<TextBlock
Classes="mini"
HorizontalAlignment="Center"
VerticalAlignment="Center"
>
<Run Text="{Binding Id, StringFormat='[{0}] '}" />
<Run Text="{Binding BrandName}" />
<Run Text=" - " />
<Run Text="{Binding MixtureName}" />
</TextBlock>
</Border>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Grid>
</Border>
</Grid>
</UserControl>
@@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace GSS2.Views.AmineContent;
public partial class BrandCalibrationView : UserControl
{
public BrandCalibrationView()
{
InitializeComponent();
}
}