This commit is contained in:
EugeneTes
2026-03-19 09:03:37 +01:00
parent 14fe822962
commit 83597b8763
55 changed files with 2136 additions and 32 deletions

View File

@@ -0,0 +1,14 @@
using Inspectron.Settings;
using VisionBuilder.UI.Common;
namespace VisionBuilder.UI.Blazor;
public class BlazorUISettings : ISettings
{
public bool FullScreen { get; set; } = false;
public void RegisterSettings(InspectronSettings settings)
{
settings.RegisterSimple(this, () => this.FullScreen, "System", nameof(FullScreen));
}
}

View File

@@ -0,0 +1,17 @@
@implements IDisposable
<div class="error-preview-panel">
@foreach (var error in _errors)
{
<div class="error-card" @onclick="() => OnErrorClicked(error)">
@{
var uri = error.ImageAnalysis?.ToBase64DataUri(50);
}
@if (!string.IsNullOrEmpty(uri))
{
<img src="@uri" alt="Error" class="error-thumbnail" />
}
<span class="error-title">@error.Title</span>
</div>
}
</div>

View File

@@ -0,0 +1,59 @@
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using Microsoft.AspNetCore.Components;
using VisionBuilder.UI.Common.ViewModel;
using VisionBuilder.UI.Common.ViewModel.Classes;
namespace VisionBuilder.UI.Blazor.Components;
public partial class ErrorPreview : ComponentBase, IDisposable
{
[Parameter] public ErrorsVM? ViewModel { get; set; }
private List<ErrorData> _errors = new();
private ErrorsVM? _subscribedVm;
protected override void OnParametersSet()
{
if (_subscribedVm != ViewModel)
{
Unsubscribe();
Subscribe();
}
}
private void Subscribe()
{
_subscribedVm = ViewModel;
if (ViewModel != null)
{
ViewModel.Errors.CollectionChanged += OnCollectionChanged;
_errors = new List<ErrorData>(ViewModel.Errors);
}
}
private void Unsubscribe()
{
if (_subscribedVm != null)
_subscribedVm.Errors.CollectionChanged -= OnCollectionChanged;
_subscribedVm = null;
}
private async void OnCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
if (ViewModel != null)
_errors = new List<ErrorData>(ViewModel.Errors);
await InvokeAsync(StateHasChanged);
}
private async Task OnErrorClicked(ErrorData errorData)
{
if (ViewModel != null)
await ViewModel.ShowImage(errorData);
}
public void Dispose()
{
Unsubscribe();
}
}

View File

@@ -0,0 +1,12 @@
@implements IDisposable
<div class="preview-window @(_isError ? "preview-error" : "")">
@if (!string.IsNullOrEmpty(_dataUri))
{
<img src="@_dataUri" alt="Preview" class="preview-image" />
}
else
{
<div class="preview-placeholder">No image</div>
}
</div>

View File

@@ -0,0 +1,94 @@
using System.ComponentModel;
using Microsoft.AspNetCore.Components;
using OpenCvSharp;
using VisionBuilder.UI.Blazor.Helpers;
using VisionBuilder.UI.Common.ViewModel;
namespace VisionBuilder.UI.Blazor.Components;
public partial class PreviewWindow : ComponentBase, IDisposable
{
[Parameter] public PreviewVM? ViewModel { get; set; }
private string _dataUri = string.Empty;
private bool _isError;
private DateTime _lastRenderTime = DateTime.MinValue;
private Timer? _pendingTimer;
private PreviewVM? _subscribedVm;
protected override void OnParametersSet()
{
if (_subscribedVm != ViewModel)
{
Unsubscribe();
Subscribe();
}
}
private void Subscribe()
{
_subscribedVm = ViewModel;
if (ViewModel != null)
ViewModel.PropertyChanged += OnPropertyChanged;
}
private void Unsubscribe()
{
if (_subscribedVm != null)
_subscribedVm.PropertyChanged -= OnPropertyChanged;
_subscribedVm = null;
}
private async void OnPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName != nameof(PreviewVM.ImagePreview) && e.PropertyName != nameof(PreviewVM.IsError))
return;
var now = DateTime.UtcNow;
var elapsed = (now - _lastRenderTime).TotalMilliseconds;
if (elapsed < 100)
{
// Schedule a render for when the throttle window expires,
// so we always show the latest frame.
_pendingTimer?.Dispose();
_pendingTimer = new Timer(_ => RenderLatest(), null,
(int)(100 - elapsed) + 1, Timeout.Infinite);
return;
}
await RenderCurrentFrame();
}
private async void RenderLatest()
{
_pendingTimer?.Dispose();
_pendingTimer = null;
await RenderCurrentFrame();
}
private async Task RenderCurrentFrame()
{
_lastRenderTime = DateTime.UtcNow;
var mat = ViewModel?.ImagePreview;
var isError = ViewModel?.IsError ?? false;
string dataUri = string.Empty;
if (mat != null && !mat.Empty())
{
dataUri = mat.ToBase64DataUri();
}
_dataUri = dataUri;
_isError = isError;
await InvokeAsync(StateHasChanged);
}
public void Dispose()
{
_pendingTimer?.Dispose();
Unsubscribe();
}
}

View File

@@ -0,0 +1,75 @@
@using VisionBuilder.UI.Common
@implements IDisposable
<div class="single-camera-control">
<div class="camera-header">
<div class="camera-header-preview">
<span class="camera-label">@_cameraLabel</span>
</div>
<div class="camera-header-errors">
<span class="errors-label">Errors</span>
</div>
<div class="camera-header-stats"></div>
<div class="camera-header-buttons"></div>
</div>
<div class="camera-body">
<div class="camera-preview-area">
<PreviewWindow ViewModel="@ViewModel?.PreviewVm" />
</div>
<div class="camera-divider"></div>
<div class="camera-errors-area">
<ErrorPreview ViewModel="@ViewModel?.ErrorsVm" />
</div>
<div class="camera-divider"></div>
<div class="camera-stats-area">
<Stats ViewModel="@ViewModel?.StatisticsVm" />
</div>
<div class="camera-buttons-area">
<div class="camera-buttons-stack">
<MudButton Variant="Variant.Outlined" Color="Color.Primary" FullWidth="true"
Class="camera-btn"
Disabled="@(!_canSelectRecipe)"
OnClick="@OnSelectRecipe">
@_currentRecipeName
</MudButton>
<MudButton Variant="Variant.Filled" Color="Color.Primary" FullWidth="true"
Class="camera-btn"
Disabled="@(!_canStart)"
OnClick="@OnStart">
Start
</MudButton>
<MudButton Variant="Variant.Filled" Color="Color.Primary" FullWidth="true"
Class="camera-btn"
Disabled="@(!_canStop)"
OnClick="@OnStop">
Stop
</MudButton>
@if (_showPauseButton && _canPause)
{
<MudButton Variant="Variant.Outlined" Color="Color.Primary" FullWidth="true"
Class="camera-btn"
OnClick="@OnPause">
Pause
</MudButton>
}
@if (_showPauseButton && _canResume)
{
<MudButton Variant="Variant.Outlined" Color="Color.Primary" FullWidth="true"
Class="camera-btn"
OnClick="@OnResume">
Resume
</MudButton>
}
@if (_canHotReload)
{
<MudButton Variant="Variant.Outlined" Color="Color.Primary" FullWidth="true"
Class="camera-btn"
OnClick="@OnHotReload">
Hot Reload
</MudButton>
}
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,108 @@
using System.ComponentModel;
using Microsoft.AspNetCore.Components;
using VisionBuilder.UI.Common;
namespace VisionBuilder.UI.Blazor.Components;
public partial class SingleCameraControl : ComponentBase, IDisposable
{
[Parameter] public SingleCameraVM? ViewModel { get; set; }
private string _cameraLabel = "";
private string _currentRecipeName = "Select recipe";
private bool _canSelectRecipe;
private bool _canStart;
private bool _canStop;
private bool _canPause;
private bool _canResume;
private bool _canHotReload;
private bool _showPauseButton;
private SingleCameraVM? _subscribedVm;
protected override void OnParametersSet()
{
if (_subscribedVm != ViewModel)
{
Unsubscribe();
Subscribe();
}
}
private void Subscribe()
{
_subscribedVm = ViewModel;
if (ViewModel != null)
{
ViewModel.PropertyChanged += OnPropertyChanged;
UpdateState();
}
}
private void Unsubscribe()
{
if (_subscribedVm != null)
_subscribedVm.PropertyChanged -= OnPropertyChanged;
_subscribedVm = null;
}
private void UpdateState()
{
if (ViewModel == null) return;
_cameraLabel = ViewModel.CameraLabelAndStatus;
_currentRecipeName = ViewModel.CurrentRecipeName;
_canSelectRecipe = ViewModel.IsNotRunning;
_canStart = ViewModel.CanStart;
_canStop = ViewModel.CanStop;
_canPause = ViewModel.CanPause;
_canResume = ViewModel.CanResume;
_canHotReload = ViewModel.CanHotReload;
_showPauseButton = ViewModel.CanPause || ViewModel.CanResume;
}
private async void OnPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
UpdateState();
await InvokeAsync(StateHasChanged);
}
private async Task OnSelectRecipe()
{
if (ViewModel?.SelectRecipeCommand.CanExecute(null) == true)
await ViewModel.SelectRecipe();
}
private async Task OnStart()
{
if (ViewModel?.StartCommand.CanExecute(null) == true)
await ViewModel.Start();
}
private async Task OnStop()
{
if (ViewModel?.StopCommand.CanExecute(null) == true)
await ViewModel.Stop();
}
private void OnPause()
{
if (ViewModel?.PauseCommand.CanExecute(null) == true)
ViewModel.PauseCommand.Execute(null);
}
private void OnResume()
{
if (ViewModel?.ResumeCommand.CanExecute(null) == true)
ViewModel.ResumeCommand.Execute(null);
}
private void OnHotReload()
{
if (ViewModel?.HotReloadCommand.CanExecute(null) == true)
ViewModel.HotReloadCommand.Execute(null);
}
public void Dispose()
{
Unsubscribe();
}
}

View File

@@ -0,0 +1,34 @@
@implements IDisposable
<div class="stats-panel">
@if (!string.IsNullOrEmpty(_sessionStarted))
{
<div class="stats-field">
<span class="stats-field-label">Started at:</span>
<span class="stats-field-value stats-bold">@_sessionStarted</span>
</div>
<div class="stats-field">
<span class="stats-field-label">Processing Time:</span>
<span class="stats-field-value stats-bold">@_processingTime</span>
</div>
<div class="stats-field">
<span class="stats-field-label">Total:</span>
<span class="stats-field-value">@_total</span>
</div>
<div class="stats-field">
<span class="stats-field-label">Bad:</span>
<span class="stats-field-value">@_bad</span>
</div>
<div class="stats-field">
<span class="stats-field-label">Error rate:</span>
<span class="stats-field-value">@_errorRate</span>
</div>
@if (!string.IsNullOrEmpty(_statisticsDetails))
{
<div class="stats-field">
<span class="stats-field-label">Details:</span>
<pre class="stats-field-value stats-details-text">@_statisticsDetails</pre>
</div>
}
}
</div>

View File

@@ -0,0 +1,62 @@
using System.ComponentModel;
using Microsoft.AspNetCore.Components;
using VisionBuilder.UI.Common.ViewModel;
namespace VisionBuilder.UI.Blazor.Components;
public partial class Stats : ComponentBase, IDisposable
{
[Parameter] public StatisticsVM? ViewModel { get; set; }
private string _sessionStarted = "";
private string _recipeName = "";
private string _processingTime = "";
private int _good;
private int _bad;
private int _total;
private string _errorRate = "";
private string _statisticsDetails = "";
private StatisticsVM? _subscribedVm;
protected override void OnParametersSet()
{
if (_subscribedVm != ViewModel)
{
Unsubscribe();
Subscribe();
}
}
private void Subscribe()
{
_subscribedVm = ViewModel;
if (ViewModel != null)
ViewModel.PropertyChanged += OnPropertyChanged;
}
private void Unsubscribe()
{
if (_subscribedVm != null)
_subscribedVm.PropertyChanged -= OnPropertyChanged;
_subscribedVm = null;
}
private async void OnPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
_sessionStarted = ViewModel?.SessionStarted ?? "";
_recipeName = ViewModel?.RecipeName ?? "";
_processingTime = ViewModel?.ProcessingTime ?? "";
_good = ViewModel?.Good ?? 0;
_bad = ViewModel?.Bad ?? 0;
_total = ViewModel?.Total ?? 0;
_errorRate = ViewModel?.ErrorRate ?? "";
_statisticsDetails = ViewModel?.StatisticsDetails ?? "";
await InvokeAsync(StateHasChanged);
}
public void Dispose()
{
Unsubscribe();
}
}

View File

@@ -0,0 +1,71 @@
@using MudBlazor
@implements IDisposable
<MudDialog>
<DialogContent>
@if (ViewModel != null)
{
<div class="image-preview-dialog">
@{
var imageUri = ViewModel.ImageAnalysis?.ToBase64DataUri();
}
@if (!string.IsNullOrEmpty(imageUri))
{
<img src="@imageUri" alt="@ViewModel.ImageName" class="image-preview-img" />
}
<div class="image-preview-info">
<MudText Typo="Typo.subtitle1">@ViewModel.ImageName</MudText>
</div>
<div class="image-preview-actions">
<MudButton Variant="Variant.Outlined" Disabled="@(!ViewModel.IsPreviousImageEnabled)"
OnClick="OnPrevious">Previous</MudButton>
<MudButton Variant="Variant.Outlined"
OnClick="OnSwitchView">@(ViewModel.OriginalView ? "Analysis" : "Original")</MudButton>
<MudButton Variant="Variant.Outlined" Disabled="@(!ViewModel.IsNextImageEnabled)"
OnClick="OnNext">Next</MudButton>
@if (ViewModel.LearnButtonVisible)
{
<MudButton Variant="Variant.Filled" Color="Color.Primary"
OnClick="OnLearn">Learn</MudButton>
}
</div>
</div>
}
</DialogContent>
<DialogActions>
<MudButton OnClick="Close">Close</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter] private IMudDialogInstance MudDialog { get; set; } = null!;
[Parameter] public ErrorPreviewVM? ViewModel { get; set; }
protected override void OnParametersSet()
{
if (ViewModel != null)
ViewModel.PropertyChanged += OnPropertyChanged;
}
private async void OnPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
{
await InvokeAsync(StateHasChanged);
}
private void OnPrevious() => ViewModel?.PreviousImageCommand.Execute(null);
private void OnNext() => ViewModel?.NextImageCommand.Execute(null);
private void OnSwitchView() => ViewModel?.SwitchViewCommand.Execute(null);
private async Task OnLearn()
{
if (ViewModel != null)
await ViewModel.Learn();
}
private void Close() => MudDialog.Close();
public void Dispose()
{
if (ViewModel != null)
ViewModel.PropertyChanged -= OnPropertyChanged;
}
}

View File

@@ -0,0 +1,27 @@
@using MudBlazor
<MudDialog>
<DialogContent>
<MudTextField @bind-Value="_password" Label="Password" InputType="InputType.Password"
Immediate="true" OnKeyDown="OnKeyDown" AutoFocus="true" />
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel">Cancel</MudButton>
<MudButton Color="Color.Primary" OnClick="Submit">OK</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter] private IMudDialogInstance MudDialog { get; set; } = null!;
private string _password = "";
private void Submit() => MudDialog.Close(DialogResult.Ok(_password));
private void Cancel() => MudDialog.Cancel();
private void OnKeyDown(KeyboardEventArgs e)
{
if (e.Key == "Enter")
Submit();
}
}

View File

@@ -0,0 +1,44 @@
@using MudBlazor
<MudDialog>
<DialogContent>
@if (ViewModel != null)
{
<div class="recipe-grid">
@foreach (var recipe in ViewModel.Recipes)
{
var isSelected = recipe == ViewModel.SelectedRecipe;
<div class="recipe-item @(isSelected ? "recipe-selected" : "")"
@onclick="() => SelectRecipe(recipe)">
@if (recipe.Image != null && !recipe.Image.Empty())
{
<img src="@recipe.Image.ToBase64DataUri(60)" alt="@recipe.RecipeName" class="recipe-thumbnail" />
}
else
{
<div class="recipe-thumbnail recipe-no-image">No image</div>
}
<span class="recipe-name">@recipe.RecipeName</span>
</div>
}
</div>
}
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel">Cancel</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter] private IMudDialogInstance MudDialog { get; set; } = null!;
[Parameter] public RecipeSelectionVM? ViewModel { get; set; }
private void SelectRecipe(RecipeData recipe)
{
if (ViewModel != null)
ViewModel.SelectedRecipe = recipe;
MudDialog.Close(DialogResult.Ok(true));
}
private void Cancel() => MudDialog.Cancel();
}

View File

@@ -0,0 +1,226 @@
@using MudBlazor
@using System.ComponentModel
@using System.Reflection
@using Inspectron.Settings
<MudDialog>
<DialogContent>
<div class="settings-layout">
<div class="settings-tree">
<MudTreeView T="string" SelectedValueChanged="OnCategorySelected" Dense="true">
<SettingsTreeNode Nodes="_treeRoot" />
</MudTreeView>
</div>
<div class="settings-content">
@if (_selectedCategory != null && GetSettingsForCategory(_selectedCategory) is { Count: > 0 } settings)
{
@foreach (var setting in settings)
{
<div class="settings-field">
@if (setting.Property.PropertyType == typeof(bool))
{
var boolVal = (bool)(setting.Value ?? false);
<MudSwitch T="bool" Value="boolVal"
ValueChanged="@(v => setting.Value = v)"
Label="@setting.Description" Color="Color.Primary" />
}
else if (setting.Property.PropertyType == typeof(int))
{
var intVal = (int)(setting.Value ?? 0);
<MudNumericField T="int" Value="intVal"
ValueChanged="@(v => setting.Value = v)"
Label="@setting.Description" Variant="Variant.Outlined" />
}
else if (setting.Property.PropertyType == typeof(double))
{
var dblVal = (double)(setting.Value ?? 0.0);
<MudNumericField T="double" Value="dblVal"
ValueChanged="@(v => setting.Value = v)"
Label="@setting.Description" Variant="Variant.Outlined" />
}
else if (setting.Property.PropertyType.IsEnum)
{
<MudSelect T="object" Value="setting.Value"
ValueChanged="@(v => setting.Value = v)"
Label="@setting.Description" Variant="Variant.Outlined">
@foreach (var enumVal in Enum.GetValues(setting.Property.PropertyType))
{
<MudSelectItem T="object" Value="@enumVal">@enumVal.ToString()</MudSelectItem>
}
</MudSelect>
}
else
{
var strVal = setting.Value?.ToString() ?? "";
<MudTextField T="string" Value="strVal"
ValueChanged="@(v => setting.Value = v)"
Label="@setting.Description" Variant="Variant.Outlined" />
}
</div>
}
}
else
{
<MudText Typo="Typo.body2" Class="pa-4">Select a category to view settings.</MudText>
}
</div>
</div>
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel">Cancel</MudButton>
<MudButton Color="Color.Primary" OnClick="Save">Save</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter] private IMudDialogInstance MudDialog { get; set; } = null!;
[Parameter] public InspectronSettings? Settings { get; set; }
private Dictionary<string, List<SettingEntry>> _categories = new();
private string? _selectedCategory;
private List<CategoryNode> _treeRoot = new();
protected override void OnParametersSet()
{
if (Settings == null) return;
LoadSettings();
}
private void LoadSettings()
{
_categories.Clear();
_treeRoot = new List<CategoryNode>();
var treeView = Settings!.UserSettings;
var root = treeView.Root as Tree<object>;
if (root != null)
Traverse(root, "");
// Build the UI tree from category paths
foreach (var categoryPath in _categories.Keys)
{
var parts = categoryPath.Split('/');
var currentList = _treeRoot;
for (int i = 0; i < parts.Length; i++)
{
var existing = currentList.FirstOrDefault(n => n.Label == parts[i]);
if (existing == null)
{
existing = new CategoryNode
{
Label = parts[i],
FullPath = string.Join("/", parts.Take(i + 1))
};
currentList.Add(existing);
}
currentList = existing.ChildNodes;
}
}
}
private void Traverse(Tree<object> node, string path)
{
object value = node.Value;
if (value is string folderName)
{
string newPath = string.IsNullOrEmpty(path) ? folderName : $"{path}/{folderName}";
foreach (Tree<object> child in node.Children)
Traverse(child, newPath);
}
else if (value is InspectronSettings.UserSettingsInfo info)
{
string groupName = info.Name;
string groupPath = string.IsNullOrEmpty(path) ? groupName : $"{path}/{groupName}";
foreach (PropertyDescriptor pd in info.Settings)
{
if (pd is BoundPropertyDescriptor bpd)
{
var owner = bpd.Owner;
var description = string.IsNullOrEmpty(pd.Description) ? bpd.DisplayName : pd.Description;
var entry = new SettingEntry
{
Owner = owner,
Property = bpd.PropertyInfo,
Description = description,
Value = bpd.PropertyInfo.GetValue(owner)
};
if (!_categories.TryGetValue(groupPath, out var list))
{
list = new List<SettingEntry>();
_categories[groupPath] = list;
}
list.Add(entry);
}
}
}
}
private void OnCategorySelected(string? category)
{
_selectedCategory = category;
}
private List<SettingEntry> GetSettingsForCategory(string category)
{
// Exact match first
if (_categories.TryGetValue(category, out var exact))
return exact;
// Otherwise aggregate all sub-categories
var prefix = category + "/";
return _categories
.Where(kvp => kvp.Key.StartsWith(prefix))
.SelectMany(kvp => kvp.Value)
.ToList();
}
private void Save()
{
foreach (var list in _categories.Values)
{
foreach (var setting in list)
{
var currentValue = setting.Property.GetValue(setting.Owner);
if (!Equals(currentValue, setting.Value))
{
try
{
if (setting.Value != null && setting.Value.GetType() != setting.Property.PropertyType)
{
var converted = Convert.ChangeType(setting.Value, setting.Property.PropertyType);
setting.Property.SetValue(setting.Owner, converted);
}
else
{
setting.Property.SetValue(setting.Owner, setting.Value);
}
}
catch
{
// Skip properties that fail to convert
}
}
}
}
Settings?.SaveSettings();
MudDialog.Close(DialogResult.Ok(true));
}
private void Cancel() => MudDialog.Cancel();
private class SettingEntry
{
public object Owner { get; set; } = null!;
public PropertyInfo Property { get; set; } = null!;
public string Description { get; set; } = "";
public object? Value { get; set; }
}
public class CategoryNode
{
public string Label { get; set; } = "";
public string FullPath { get; set; } = "";
public List<CategoryNode> ChildNodes { get; set; } = new();
}
}

View File

@@ -0,0 +1,19 @@
@using MudBlazor
@foreach (var node in Nodes.OrderBy(n => n.Label))
{
@if (node.ChildNodes.Count > 0)
{
<MudTreeViewItem T="string" Value="@node.FullPath" Text="@node.Label" Expanded="true">
<SettingsTreeNode Nodes="node.ChildNodes" />
</MudTreeViewItem>
}
else
{
<MudTreeViewItem T="string" Value="@node.FullPath" Text="@node.Label" />
}
}
@code {
[Parameter] public List<SettingsDialog.CategoryNode> Nodes { get; set; } = new();
}

View File

@@ -0,0 +1,16 @@
using OpenCvSharp;
namespace VisionBuilder.UI.Blazor.Helpers;
public static class MatExtensions
{
public static string ToBase64DataUri(this Mat mat, int quality = 75)
{
if (mat == null || mat.Empty())
return string.Empty;
Cv2.ImEncode(".jpg", mat, out var buf,
new ImageEncodingParam(ImwriteFlags.JpegQuality, quality));
return $"data:image/jpeg;base64,{Convert.ToBase64String(buf)}";
}
}

View File

@@ -0,0 +1,24 @@
using Inspectron.Settings;
using Ninject;
using VisionBuilder.UI.Blazor.Services;
using VisionBuilder.UI.Common;
using VisionBuilder.UI.Common.Processing;
using VisionBuilder.UI.Common.Services;
using VisionBuilder.UI.Common.ViewModel.Interfaces.UI;
using ISettings = VisionBuilder.UI.Common.ISettings;
namespace VisionBuilder.UI.Blazor;
public static class ModuleExtensions
{
public static IKernel UseBlazorServices(this IKernel self)
{
self.Bind<ISettingsService>().To<BlazorSettingsService>().InSingletonScope();
self.Bind<ILoadingService>().To<BlazorLoadingService>().InSingletonScope();
self.Bind<IRecipeSelectionDialogService>().To<BlazorRecipeSelectionDialogService>().InSingletonScope();
self.Bind<IImagePreviewService>().To<BlazorImagePreviewService>().InSingletonScope();
self.Bind<IPasswordInputService>().To<BlazorPasswordInputService>().InSingletonScope();
self.Bind<BlazorUISettings, ISettings>().To<BlazorUISettings>().InSingletonScope();
return self;
}
}

View File

@@ -0,0 +1,34 @@
using MudBlazor;
using VisionBuilder.UI.Blazor.Dialogs;
using VisionBuilder.UI.Common.ViewModel;
using VisionBuilder.UI.Common.ViewModel.Interfaces.UI;
namespace VisionBuilder.UI.Blazor.Services;
public class BlazorImagePreviewService : IImagePreviewService
{
private IDialogService? _dialogService;
public void SetDialogService(IDialogService dialogService)
{
_dialogService = dialogService;
}
public async Task ShowImagePreviewAsync(ErrorPreviewVM errorPreviewVm)
{
if (_dialogService == null)
return;
var parameters = new DialogParameters<ImagePreviewDialog>
{
{ x => x.ViewModel, errorPreviewVm }
};
var options = new DialogOptions
{
MaxWidth = MaxWidth.Large,
FullWidth = true
};
var dialog = await _dialogService.ShowAsync<ImagePreviewDialog>("Image Preview", parameters, options);
await dialog.Result;
}
}

View File

@@ -0,0 +1,16 @@
using VisionBuilder.UI.Common.Processing;
namespace VisionBuilder.UI.Blazor.Services;
public class BlazorLoadingService : ILoadingService
{
public void StartLoading(string title)
{
// Loading indicator managed at the Blazor host level if needed.
}
public void StopLoading(string title)
{
// Loading indicator managed at the Blazor host level if needed.
}
}

View File

@@ -0,0 +1,27 @@
using MudBlazor;
using VisionBuilder.UI.Blazor.Dialogs;
using VisionBuilder.UI.Common.Services;
namespace VisionBuilder.UI.Blazor.Services;
public class BlazorPasswordInputService : IPasswordInputService
{
private IDialogService? _dialogService;
public void SetDialogService(IDialogService dialogService)
{
_dialogService = dialogService;
}
public async Task<string?> GetPasswordAsync()
{
if (_dialogService == null)
return null;
var dialog = await _dialogService.ShowAsync<PasswordInputDialog>("Password Required");
var result = await dialog.Result;
if (result != null && !result.Canceled)
return result.Data as string;
return null;
}
}

View File

@@ -0,0 +1,30 @@
using MudBlazor;
using VisionBuilder.UI.Blazor.Dialogs;
using VisionBuilder.UI.Common.ViewModel;
using VisionBuilder.UI.Common.ViewModel.Interfaces.UI;
namespace VisionBuilder.UI.Blazor.Services;
public class BlazorRecipeSelectionDialogService : IRecipeSelectionDialogService
{
private IDialogService? _dialogService;
public void SetDialogService(IDialogService dialogService)
{
_dialogService = dialogService;
}
public async Task<bool> SelectRecipeAsync(RecipeSelectionVM recipeSelectionVm)
{
if (_dialogService == null)
return false;
var parameters = new DialogParameters<RecipeSelectionDialog>
{
{ x => x.ViewModel, recipeSelectionVm }
};
var dialog = await _dialogService.ShowAsync<RecipeSelectionDialog>("Select Recipe", parameters);
var result = await dialog.Result;
return result != null && !result.Canceled;
}
}

View File

@@ -0,0 +1,40 @@
using Inspectron.Settings;
using MudBlazor;
using VisionBuilder.UI.Blazor.Dialogs;
using VisionBuilder.UI.Common.ViewModel.Interfaces.UI;
namespace VisionBuilder.UI.Blazor.Services;
public class BlazorSettingsService : ISettingsService
{
private readonly InspectronSettings _settings;
private IDialogService? _dialogService;
public BlazorSettingsService(InspectronSettings settings)
{
_settings = settings;
}
public void SetDialogService(IDialogService dialogService)
{
_dialogService = dialogService;
}
public async Task ShowSettingsDialogAsync()
{
if (_dialogService == null)
return;
var parameters = new DialogParameters<SettingsDialog>
{
{ x => x.Settings, _settings }
};
var options = new DialogOptions
{
MaxWidth = MaxWidth.Medium,
FullWidth = true
};
var dialog = await _dialogService.ShowAsync<SettingsDialog>("Settings", parameters, options);
await dialog.Result;
}
}

View File

@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk.Razor">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MudBlazor" Version="8.0.0" />
<PackageReference Include="OpenCvSharp4" Version="4.6.0.20220608" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\VisionBuilder.UI.Common\VisionBuilder.UI.Common.csproj" />
<ProjectReference Include="..\framework\Inspectron.Settings\Inspectron.Settings.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,9 @@
@using Microsoft.AspNetCore.Components
@using Microsoft.AspNetCore.Components.Web
@using MudBlazor
@using VisionBuilder.UI.Common
@using VisionBuilder.UI.Common.ViewModel
@using VisionBuilder.UI.Common.ViewModel.Classes
@using VisionBuilder.UI.Blazor.Helpers
@using VisionBuilder.UI.Blazor.Components
@using VisionBuilder.UI.Blazor.Dialogs

View File

@@ -0,0 +1,293 @@
/* Preview Window */
.preview-window {
position: relative;
width: 100%;
height: 100%;
background: #000;
display: flex;
align-items: center;
justify-content: center;
border: 5px solid transparent;
box-sizing: border-box;
}
.preview-window.preview-error {
border-color: #f44336;
}
.preview-image {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.preview-placeholder {
color: #666;
font-size: 1.2rem;
}
/* Stats Panel */
.stats-panel {
padding: 4px 8px;
font-family: 'Verdana', sans-serif;
font-size: 0.75rem;
}
.stats-field {
margin-bottom: 2px;
}
.stats-field-label {
font-weight: 700;
display: block;
margin-top: 6px;
}
.stats-field-value {
display: block;
}
.stats-bold {
font-weight: 700;
}
.stats-details-text {
margin: 0;
white-space: pre-wrap;
font-size: 0.7rem;
}
/* Error Preview Panel */
.error-preview-panel {
display: flex;
flex-wrap: wrap;
gap: 4px;
padding: 4px;
overflow-y: auto;
height: 100%;
align-content: flex-start;
}
.error-card {
display: flex;
flex-direction: column;
align-items: center;
cursor: pointer;
padding: 2px;
transition: background-color 0.2s;
width: 180px;
}
.error-card:hover {
background-color: #f0f0f0;
}
.error-thumbnail {
width: 170px;
height: 130px;
object-fit: cover;
}
.error-title {
font-size: 0.7rem;
text-align: center;
margin-top: 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
width: 100%;
}
/* Single Camera Control */
.single-camera-control {
display: flex;
flex-direction: column;
height: 100%;
background: #fff;
}
/* Header row */
.camera-header {
display: flex;
height: 28px;
align-items: center;
flex-shrink: 0;
}
.camera-header-preview {
flex: 45;
text-align: center;
}
.camera-header-errors {
flex: 25;
text-align: center;
}
.camera-header-stats {
flex: 14;
}
.camera-header-buttons {
flex: 14;
}
.camera-label {
font-weight: 700;
font-size: 1rem;
}
.errors-label {
font-size: 1rem;
}
/* Body row */
.camera-body {
display: flex;
flex: 1;
min-height: 0;
}
.camera-preview-area {
flex: 45;
min-width: 0;
min-height: 0;
}
.camera-divider {
width: 1px;
background-color: #37474f;
flex-shrink: 0;
}
.camera-errors-area {
flex: 25;
min-width: 0;
overflow-y: auto;
}
.camera-stats-area {
flex: 14;
overflow-y: auto;
border-left: none;
}
.camera-buttons-area {
flex: 14;
display: flex;
flex-direction: column;
padding: 8px;
}
.camera-buttons-stack {
display: flex;
flex-direction: column;
gap: 8px;
}
.camera-btn {
height: 64px !important;
font-weight: 700 !important;
font-size: 0.85rem !important;
}
/* Recipe Selection Dialog */
.recipe-grid {
display: flex;
flex-wrap: wrap;
gap: 12px;
padding: 8px;
}
.recipe-item {
display: flex;
flex-direction: column;
align-items: center;
cursor: pointer;
padding: 8px;
border: 2px solid #e0e0e0;
border-radius: 8px;
transition: border-color 0.2s;
width: 150px;
}
.recipe-item:hover {
border-color: #90caf9;
}
.recipe-item.recipe-selected {
border-color: #1976d2;
background-color: #e3f2fd;
}
.recipe-thumbnail {
width: 130px;
height: 100px;
object-fit: cover;
border-radius: 4px;
}
.recipe-no-image {
display: flex;
align-items: center;
justify-content: center;
background: #eee;
color: #999;
font-size: 0.8rem;
}
.recipe-name {
margin-top: 6px;
font-size: 0.85rem;
text-align: center;
word-break: break-word;
}
/* Image Preview Dialog */
.image-preview-dialog {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.image-preview-img {
max-width: 100%;
max-height: 60vh;
object-fit: contain;
}
.image-preview-info {
text-align: center;
}
.image-preview-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: center;
}
/* Settings Dialog */
.settings-layout {
display: flex;
gap: 16px;
min-height: 400px;
}
.settings-tree {
min-width: 200px;
border-right: 1px solid #e0e0e0;
padding-right: 8px;
overflow-y: auto;
}
.settings-content {
flex: 1;
padding: 0 8px;
overflow-y: auto;
}
.settings-field {
margin-bottom: 12px;
}