feat: page-based navigation shell + Tests page wizard

Replace the monolithic MainWindow with a SelectedPage-driven shell
(Dashboard / Pump / Bench / Tests / Results / Settings). The Tests
page gets the Plan -> Preconditions -> Running -> Done wizard from
ui-structure.md \u00a74, backed by a 7-item precondition gate and
shared sub-views (PhaseCardView / TestSectionView / GraphicIndicatorView)
extracted from the now-deleted monolithic TestPanelView.

New VMs / views:
- Tests wizard: TestPreconditions, PhaseCard, GraphicIndicator,
  TestSection, TestPlan, TestRunning, TestDone
- Dashboard panels: DashboardConnection, DashboardReadings,
  DashboardAlarms, InterlockBanner, ResultHistory
- Pump / bench panels: PumpIdentificationPanel, PumpLiveData,
  UnlockPanel, BenchDriveControl, BenchReadings, RelayBank,
  TemperatureControl, DtcList, AuthGate
- Dialogs: generic ConfirmDialog, UserManageDialog, UserPromptDialog

Supporting changes:
- IsOilPumpOn exposed on MainViewModel for precondition evaluation
- RequiresAuth added to TestDefinition (XML round-trip)
- BipStatusDefinition + CompletedTestRun models
- ~35 new Test.* localization keys (en + es)
- Settings moved from modal dialog to full page
- Pause / Retry / Skip stubs in TestRunningView; full spec in
  docs/gap-test-running-controls.md for follow-up implementation
- docs/ui-structure.md captures the wizard design

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-18 13:11:34 +02:00
parent 37d099cdbd
commit 0280a2fad1
110 changed files with 8008 additions and 1115 deletions

View File

@@ -0,0 +1,66 @@
using CommunityToolkit.Mvvm.ComponentModel;
using HC_APTBS.Services;
namespace HC_APTBS.ViewModels.Pages
{
/// <summary>
/// ViewModel for the Bench navigation page.
///
/// <para>Groups the bench-related child ViewModels owned by
/// <see cref="MainViewModel"/> together with page-specific façades
/// (temperature control, relay bank, pressure trace, interlock banner)
/// that only the Bench page consumes.</para>
/// </summary>
public sealed class BenchPageViewModel : ObservableObject
{
/// <summary>Root ViewModel — owns services, live readings, and global commands.</summary>
public MainViewModel Root { get; }
/// <summary>Manual bench controls (direction, RPM start/stop, oil pump, counter).</summary>
public BenchControlViewModel BenchControl => Root.BenchControl;
/// <summary>Real-time flowmeter charts (Q-Delivery, Q-Over).</summary>
public FlowmeterChartViewModel FlowmeterChart => Root.FlowmeterChart;
/// <summary>Encoder angle monitoring (PSG, INJ, Manual, Lock Angle).</summary>
public AngleDisplayViewModel AngleDisplay => Root.AngleDisplay;
/// <summary>Temperature PID setpoint + heater / cooler relay toggles.</summary>
public TemperatureControlViewModel TempControl { get; }
/// <summary>Auxiliary relay toggle bank (Electronic, Flasher, Pulse4Signal).</summary>
public RelayBankViewModel RelayBank { get; }
/// <summary>Real-time pressure traces (P1, P2).</summary>
public PressureTraceChartViewModel PressureTrace { get; } = new();
/// <summary>Soft safety interlock banner state (oil pump / RPM limit).</summary>
public InterlockBannerViewModel Interlock { get; }
/// <param name="root">Root view-model providing live bench readings and child VMs.</param>
/// <param name="benchService">Bench service for setpoint and relay control.</param>
/// <param name="configService">Configuration service for safety limits and defaults.</param>
public BenchPageViewModel(
MainViewModel root,
IBenchService benchService,
IConfigurationService configService)
{
Root = root;
TempControl = new TemperatureControlViewModel(benchService, configService);
RelayBank = new RelayBankViewModel(benchService);
Interlock = new InterlockBannerViewModel(configService);
}
/// <summary>
/// Called from <see cref="MainViewModel.OnRefreshTick"/> to feed the
/// page-scoped VMs (pressure trace, interlock banner) from the latest
/// bench readings. Keeping this here avoids adding page-specific logic
/// to the root ViewModel.
/// </summary>
public void RefreshFromTick()
{
PressureTrace.AddSamples(Root.Pressure, Root.Pressure2);
Interlock.Update(Root.BenchRpm, BenchControl.IsOilPumpOn);
}
}
}

View File

@@ -0,0 +1,25 @@
using CommunityToolkit.Mvvm.ComponentModel;
namespace HC_APTBS.ViewModels.Pages
{
/// <summary>
/// ViewModel for the Dashboard navigation page.
///
/// <para>Thin façade — holds a <see cref="Root"/> reference so the Dashboard XAML
/// can bind to MainViewModel-owned live readings, connection state, test summary,
/// alarms, and commands via <c>{Binding Root.X}</c>.</para>
/// </summary>
public sealed class DashboardPageViewModel : ObservableObject
{
/// <summary>Root ViewModel — owns services, live readings, and global commands.</summary>
public MainViewModel Root { get; }
/// <summary>Active alarm aggregator bound to the Dashboard alarm list.</summary>
public DashboardAlarmsViewModel Alarms => Root.DashboardAlarms;
public DashboardPageViewModel(MainViewModel root)
{
Root = root;
}
}
}

View File

@@ -0,0 +1,132 @@
using System.ComponentModel;
using CommunityToolkit.Mvvm.ComponentModel;
using HC_APTBS.Models;
using HC_APTBS.ViewModels.Dialogs;
namespace HC_APTBS.ViewModels.Pages
{
/// <summary>Identifies the sub-section shown inside the Pump navigation page.</summary>
public enum PumpSubPage
{
/// <summary>§3.a — Pump selection and K-Line ECU read.</summary>
Identification = 0,
/// <summary>§3.b — Diagnostic Trouble Codes.</summary>
Dtcs = 1,
/// <summary>§3.c — Live pump CAN readings and status words.</summary>
LiveData = 2,
/// <summary>§3.d — DFI calibration and ME/FBKW/PreIn manual control (auth-gated).</summary>
Adaptation = 3,
/// <summary>§3.e — Ford VP44 immobilizer unlock (visible only when required).</summary>
Unlock = 4
}
/// <summary>
/// ViewModel for the Pump navigation page.
///
/// <para>Thin façade that groups the pump-related child ViewModels owned by
/// <see cref="MainViewModel"/> and adds sub-page navigation, banner flags,
/// and the Adaptation auth gate. Holds a <see cref="Root"/> reference so
/// page XAML can bind to MainViewModel-owned properties (PumpRpm, PumpTemp,
/// KLineState, …) via <c>{Binding Root.X}</c>.</para>
/// </summary>
public sealed partial class PumpPageViewModel : ObservableObject
{
/// <summary>Root ViewModel — owns services, live readings, and global commands.</summary>
public MainViewModel Root { get; }
// ── Child VM façades ──────────────────────────────────────────────────────
/// <summary>Pump selector and K-Line read (§3.a).</summary>
public PumpIdentificationViewModel Identification => Root.PumpIdentification;
/// <summary>Diagnostic Trouble Code list (§3.b).</summary>
public DtcListViewModel DtcList { get; }
/// <summary>Adaptation sub-section auth gate (§3.d).</summary>
public AuthGateViewModel AdaptationAuth { get; }
/// <summary>DFI management (§3.d).</summary>
public DfiManageViewModel DfiViewModel => Root.DfiViewModel;
/// <summary>Manual pump control sliders (§3.d).</summary>
public PumpControlViewModel PumpControl => Root.PumpControl;
/// <summary>First pump status display — Status word (§3.c).</summary>
public StatusDisplayViewModel StatusDisplay1 => Root.StatusDisplay1;
/// <summary>Second pump status display — Empf3 word (§3.c).</summary>
public StatusDisplayViewModel StatusDisplay2 => Root.StatusDisplay2;
/// <summary>Current immobilizer unlock VM (§3.e). Null when no unlock is in progress for this pump.</summary>
public UnlockProgressViewModel? UnlockVm => Root.CurrentUnlockVm;
// ── Navigation state ──────────────────────────────────────────────────────
/// <summary>Currently selected Pump sub-section.</summary>
[ObservableProperty] private PumpSubPage _selectedSubPage = PumpSubPage.Identification;
// ── Banner flags (derived from Root state) ────────────────────────────────
/// <summary>True when a pump has been loaded from the database.</summary>
[ObservableProperty] private bool _isPumpSelected;
/// <summary>True when the K-Line session is currently open.</summary>
[ObservableProperty] private bool _isKLineSessionOpen;
/// <summary>True when the K-Line session is in the failed state.</summary>
[ObservableProperty] private bool _isKLineSessionFailed;
/// <summary>True for pumps that require a Ford immobilizer unlock (Type 1 or 2).</summary>
[ObservableProperty] private bool _isUnlockApplicable;
/// <summary>Constructs the page VM and subscribes to relevant Root state changes.</summary>
public PumpPageViewModel(
MainViewModel root,
DtcListViewModel dtcList,
AuthGateViewModel adaptationAuth)
{
Root = root;
DtcList = dtcList;
AdaptationAuth = adaptationAuth;
// Initialise derived flags from the current Root state.
RefreshDerivedFlags();
// Keep the derived flags in sync with Root changes.
Root.PropertyChanged += OnRootPropertyChanged;
Root.PumpIdentification.PumpChanged += _ => RefreshDerivedFlags();
}
private void OnRootPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
switch (e.PropertyName)
{
case nameof(MainViewModel.KLineState):
IsKLineSessionOpen = Root.KLineState == KLineConnectionState.Connected;
IsKLineSessionFailed = Root.KLineState == KLineConnectionState.Failed;
break;
case nameof(MainViewModel.CurrentUnlockVm):
OnPropertyChanged(nameof(UnlockVm));
break;
}
}
private void RefreshDerivedFlags()
{
IsPumpSelected = Root.CurrentPump != null;
IsKLineSessionOpen = Root.KLineState == KLineConnectionState.Connected;
IsKLineSessionFailed = Root.KLineState == KLineConnectionState.Failed;
IsUnlockApplicable = Root.CurrentPump != null && Root.CurrentPump.UnlockType != 0;
OnPropertyChanged(nameof(UnlockVm));
// When the pump changes, re-lock the adaptation gate — a new operator
// may be handling a different pump.
if (AdaptationAuth.IsAuthenticated)
AdaptationAuth.LockCommand.Execute(null);
// Drop any stale DTCs from the previous pump.
DtcList.Reset();
}
}
}

View File

@@ -0,0 +1,273 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Windows;
using System.Xml.Linq;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using HC_APTBS.Models;
using HC_APTBS.Services;
using HC_APTBS.ViewModels.Dialogs;
using HC_APTBS.Views.Dialogs;
namespace HC_APTBS.ViewModels.Pages
{
/// <summary>
/// ViewModel for the Results navigation page (§5 in <c>docs/ui-structure.md</c>).
///
/// <para>Owns a session-only history of completed test runs, drives the embedded
/// <see cref="ResultDisplayViewModel"/> detail pane, hosts the operator
/// observations/notes field, and runs the PDF export flow against the selected
/// snapshot.</para>
/// </summary>
public sealed partial class ResultsPageViewModel : ObservableObject
{
// ── Services ──────────────────────────────────────────────────────────────
private readonly IPdfService _pdf;
private readonly IConfigurationService _config;
private readonly ILocalizationService _loc;
private readonly IAppLogger _log;
private const string LogId = "ResultsPage";
/// <summary>Remembers the last authenticated user to pre-fill the next auth dialog.</summary>
private string _lastAuthenticatedUser = string.Empty;
// ── Root reference ────────────────────────────────────────────────────────
/// <summary>Root ViewModel — lets page XAML bind app-global state when needed.</summary>
public MainViewModel Root { get; }
// ── Child VMs ─────────────────────────────────────────────────────────────
/// <summary>Detail-pane ViewModel driving the embedded <c>ResultDisplayView</c>.</summary>
public ResultDisplayViewModel Detail { get; }
// ── Observable state ──────────────────────────────────────────────────────
/// <summary>Newest-first list of captured completions for this session.</summary>
public ObservableCollection<CompletedTestRun> History { get; } = new();
/// <summary>Currently selected history entry, or null when the list is empty.</summary>
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(ExportPdfCommand))]
[NotifyCanExecuteChangedFor(nameof(RemoveEntryCommand))]
private CompletedTestRun? _selectedRun;
/// <summary>True while the history list has no entries — drives the empty-state UI.</summary>
public bool IsHistoryEmpty => History.Count == 0;
/// <summary>True when at least one entry exists — drives the Clear Session button's IsEnabled.</summary>
public bool HasAnyEntries => History.Count > 0;
// ── Constructor ───────────────────────────────────────────────────────────
/// <summary>Initialises the Results page VM with the services it needs.</summary>
public ResultsPageViewModel(
MainViewModel root,
IPdfService pdfService,
IConfigurationService configService,
ILocalizationService localizationService,
IAppLogger logger)
{
Root = root;
_pdf = pdfService;
_config = configService;
_loc = localizationService;
_log = logger;
Detail = new ResultDisplayViewModel(localizationService);
History.CollectionChanged += (_, _) =>
{
OnPropertyChanged(nameof(IsHistoryEmpty));
OnPropertyChanged(nameof(HasAnyEntries));
ClearHistoryCommand.NotifyCanExecuteChanged();
};
}
// ── Capture ───────────────────────────────────────────────────────────────
/// <summary>
/// Records a completed test session. Called from
/// <see cref="MainViewModel"/>'s <c>OnTestFinished</c> hook on the UI thread.
/// Captures a deep-cloned snapshot so that subsequent runs do not mutate
/// prior entries in place.
/// </summary>
public void CaptureRun(PumpDefinition pump, bool interrupted, bool success)
{
try
{
var snapshot = CloneForHistory(pump);
var entry = new CompletedTestRun
{
CompletedAt = DateTime.Now,
PumpModel = pump.Model,
PumpSerial = pump.SerialNumber,
Interrupted = interrupted,
OverallPassed = !interrupted && success && EvaluatePassed(snapshot.Tests),
PumpSnapshot = snapshot,
};
History.Insert(0, entry);
SelectedRun = entry;
}
catch (Exception ex)
{
_log.Error(LogId, $"CaptureRun: {ex.Message}");
}
}
partial void OnSelectedRunChanged(CompletedTestRun? value)
{
if (value == null)
{
Detail.Clear();
return;
}
Detail.LoadAllResults(value.PumpSnapshot.Tests);
}
// ── Commands ──────────────────────────────────────────────────────────────
/// <summary>Authenticates the operator, collects report details, then writes the PDF.</summary>
[RelayCommand(CanExecute = nameof(CanExport))]
private void ExportPdf()
{
if (SelectedRun == null) return;
// Step 1: Authenticate operator (same flow as Test page report export).
var authVm = new UserCheckViewModel(_config, _loc, _lastAuthenticatedUser);
var authDlg = new UserCheckDialog(authVm) { Owner = Application.Current.MainWindow };
authDlg.ShowDialog();
if (!authVm.Accepted) return;
_lastAuthenticatedUser = authVm.AuthenticatedUser;
// Step 2: Collect report details, pre-filling observations from the captured run.
var reportVm = new ReportViewModel(_config)
{
OperatorName = authVm.AuthenticatedUser,
Observations = SelectedRun.Observations,
};
var reportDlg = new ReportDialog(reportVm) { Owner = Application.Current.MainWindow };
reportDlg.ShowDialog();
if (!reportVm.Accepted) return;
SelectedRun.Observations = reportVm.Observations;
try
{
string desktop = Environment.GetFolderPath(Environment.SpecialFolder.Desktop);
string path = _pdf.GenerateReport(
SelectedRun.PumpSnapshot,
reportVm.OperatorName,
reportVm.SelectedClientName,
desktop,
clientInfo: reportVm.ClientInfo,
observations: reportVm.Observations);
_log.Info(LogId, $"Report saved: {path}");
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(path)
{ UseShellExecute = true });
}
catch (Exception ex)
{
_log.Error(LogId, $"ExportPdf: {ex.Message}");
MessageBox.Show(
string.Format(_loc.GetString("Error.ReportGeneration"), ex.Message),
_loc.GetString("Error.ReportTitle"),
MessageBoxButton.OK, MessageBoxImage.Error);
}
}
private bool CanExport() => SelectedRun != null;
/// <summary>Removes a single history entry by <see cref="CompletedTestRun.Id"/>.</summary>
[RelayCommand(CanExecute = nameof(CanRemoveEntry))]
private void RemoveEntry(Guid id)
{
var target = History.FirstOrDefault(e => e.Id == id);
if (target == null) return;
bool wasSelected = ReferenceEquals(target, SelectedRun);
History.Remove(target);
if (wasSelected) SelectedRun = History.FirstOrDefault();
}
private bool CanRemoveEntry(Guid id) => History.Any(e => e.Id == id);
/// <summary>Clears the whole session history after operator confirmation.</summary>
[RelayCommand(CanExecute = nameof(HasAnyEntries))]
private void ClearHistory()
{
var result = MessageBox.Show(
_loc.GetString("Results.ClearSessionConfirm"),
_loc.GetString("Results.ClearSessionButton"),
MessageBoxButton.YesNo, MessageBoxImage.Question);
if (result != MessageBoxResult.Yes) return;
History.Clear();
SelectedRun = null;
}
// ── Helpers ───────────────────────────────────────────────────────────────
/// <summary>
/// Builds a minimal deep copy of <paramref name="pump"/> that carries the
/// fields consumed by <see cref="IPdfService"/> and the detail view, with
/// <see cref="TestDefinition"/> entries deep-cloned via XML round-trip so
/// results cannot be mutated by subsequent runs.
/// </summary>
private static PumpDefinition CloneForHistory(PumpDefinition pump)
{
var clone = new PumpDefinition
{
Id = pump.Id,
Model = pump.Model,
SerialNumber = pump.SerialNumber,
Injector = pump.Injector,
Tube = pump.Tube,
Valve = pump.Valve,
Tension = pump.Tension,
Info = pump.Info,
EcuText = pump.EcuText,
Chaveta = pump.Chaveta,
LockAngle = pump.LockAngle,
LockAngleResult = pump.LockAngleResult,
HasPreInjection = pump.HasPreInjection,
Is4Cylinder = pump.Is4Cylinder,
UnlockType = pump.UnlockType,
Rotation = pump.Rotation,
KwpVersion = pump.KwpVersion,
};
foreach (var kv in pump.KlineInfo)
clone.KlineInfo[kv.Key] = kv.Value;
foreach (var test in pump.Tests)
{
XElement xml = test.ToXml();
clone.Tests.Add(TestDefinition.FromXml(xml));
}
return clone;
}
private static bool EvaluatePassed(IReadOnlyList<TestDefinition> tests)
{
bool anyEvaluated = false;
foreach (var t in tests)
{
foreach (var p in t.Phases)
{
if (!p.Enabled || p.Receives == null) continue;
foreach (var tp in p.Receives)
{
if (tp.Result == null) continue;
anyEvaluated = true;
if (!tp.Result.Passed) return false;
}
}
}
return anyEvaluated;
}
}
}

View File

@@ -0,0 +1,325 @@
using System;
using System.Collections.ObjectModel;
using System.Linq;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using System.Windows;
using HC_APTBS.Infrastructure.Kwp;
using HC_APTBS.Models;
using HC_APTBS.Services;
using HC_APTBS.ViewModels.Dialogs;
using HC_APTBS.Views.Dialogs;
namespace HC_APTBS.ViewModels.Pages
{
/// <summary>
/// ViewModel for the Settings navigation page.
/// Loads a local copy of every <see cref="AppSettings"/> property so that
/// Discard reverts all pending changes without touching persisted state.
/// </summary>
public sealed partial class SettingsPageViewModel : ObservableObject
{
private readonly IConfigurationService _config;
private readonly ILocalizationService _loc;
/// <summary>
/// Raised after <see cref="SaveCommand"/> successfully persists settings.
/// The shell subscribes to reseed timers or other settings-dependent state.
/// </summary>
public event Action? SettingsSaved;
// ── Collections ───────────────────────────────────────────────────────
/// <summary>Available language codes for the language dropdown.</summary>
public ObservableCollection<string> AvailableLanguages { get; } = new() { "ESP", "ENG" };
/// <summary>RPM-voltage lookup table, editable via DataGrid.</summary>
public ObservableCollection<RpmVoltageRelation> Relations { get; } = new();
/// <summary>Available FTDI device serial numbers for K-Line port selection.</summary>
public ObservableCollection<string> AvailablePorts { get; } = new();
// ── General ───────────────────────────────────────────────────────────
[ObservableProperty] private string _selectedLanguage = "ESP";
[ObservableProperty] private int _daysKeepLogs = 7;
// ── Safety ────────────────────────────────────────────────────────────
[ObservableProperty] private int _tempMax = 45;
[ObservableProperty] private int _tempMin = 35;
[ObservableProperty] private int _securityRpmLimit = 2500;
[ObservableProperty] private int _maxPressureBar = 26;
[ObservableProperty] private double _toleranceUpExtension = 0.08;
[ObservableProperty] private double _tolerancePfpExtension = 0.1;
[ObservableProperty] private bool _defaultIgnoreTin = true;
// ── PID ───────────────────────────────────────────────────────────────
[ObservableProperty] private double _pidP = 0.1;
[ObservableProperty] private double _pidI = 0.1;
[ObservableProperty] private double _pidD = 0.04;
[ObservableProperty] private int _pidLoopMs = 250;
// ── Motor ─────────────────────────────────────────────────────────────
[ObservableProperty] private int _encoderResolution = 4096;
[ObservableProperty] private double _voltageForMaxRpm = 10;
[ObservableProperty] private int _maxRpm = 2500;
[ObservableProperty] private bool _rightRelayValue = true;
// ── Company ───────────────────────────────────────────────────────────
[ObservableProperty] private string _companyName = string.Empty;
[ObservableProperty] private string _companyInfo = string.Empty;
[ObservableProperty] private string _reportLogoPath = string.Empty;
// ── K-Line ────────────────────────────────────────────────────────────
[ObservableProperty] private string _selectedKLinePort = string.Empty;
// ── Advanced (refresh intervals) ──────────────────────────────────────
[ObservableProperty] private int _refreshBenchInterfaceMs = 20;
[ObservableProperty] private int _refreshWhileReadingMs = 1500;
[ObservableProperty] private int _refreshCanBusReadMs = 2;
[ObservableProperty] private int _refreshPumpRequestMs = 250;
[ObservableProperty] private int _refreshPumpParamsMs = 4;
[ObservableProperty] private int _blinkIntervalMs = 1000;
[ObservableProperty] private int _flasherIntervalMs = 800;
// ── Constructor ───────────────────────────────────────────────────────
/// <param name="configService">Configuration service for loading/saving settings.</param>
/// <param name="localizationService">Localization service for language switching.</param>
public SettingsPageViewModel(IConfigurationService configService, ILocalizationService localizationService)
{
_config = configService;
_loc = localizationService;
LoadFromConfig();
EnumerateFtdiDevices();
}
// ── Commands ──────────────────────────────────────────────────────────
/// <summary>Copies all local values back to AppSettings, saves to disk, applies language if changed.</summary>
[RelayCommand]
private void Save()
{
var s = _config.Settings;
// General
s.DaysKeepLogs = DaysKeepLogs;
// Safety
s.TempMax = TempMax;
s.TempMin = TempMin;
s.SecurityRpmLimit = SecurityRpmLimit;
s.MaxPressureBar = MaxPressureBar;
s.ToleranceUpExtension = ToleranceUpExtension;
s.TolerancePfpExtension = TolerancePfpExtension;
s.DefaultIgnoreTin = DefaultIgnoreTin;
// PID
s.PidP = PidP;
s.PidI = PidI;
s.PidD = PidD;
s.PidLoopMs = PidLoopMs;
// Motor
s.EncoderResolution = EncoderResolution;
s.VoltageForMaxRpm = VoltageForMaxRpm;
s.MaxRpm = MaxRpm;
s.RightRelayValue = RightRelayValue;
s.Relations = Relations.Select(r => new RpmVoltageRelation(r.Voltage, r.Rpm)).ToList();
// Company
s.CompanyName = CompanyName;
s.CompanyInfo = CompanyInfo;
s.ReportLogoPath = ReportLogoPath;
// K-Line
s.KLinePort = SelectedKLinePort;
// Advanced
s.RefreshBenchInterfaceMs = RefreshBenchInterfaceMs;
s.RefreshWhileReadingMs = RefreshWhileReadingMs;
s.RefreshCanBusReadMs = RefreshCanBusReadMs;
s.RefreshPumpRequestMs = RefreshPumpRequestMs;
s.RefreshPumpParamsMs = RefreshPumpParamsMs;
s.BlinkIntervalMs = BlinkIntervalMs;
s.FlasherIntervalMs = FlasherIntervalMs;
// Language — switch if changed (also persists via LocalizationService)
if (SelectedLanguage != _loc.CurrentLanguage)
_loc.SetLanguage(SelectedLanguage);
_config.SaveSettings();
SettingsSaved?.Invoke();
}
/// <summary>Reverts all local fields to the currently persisted <see cref="AppSettings"/> values.</summary>
[RelayCommand]
private void Discard()
{
LoadFromConfig();
}
/// <summary>Opens a file dialog to select a company logo image.</summary>
[RelayCommand]
private void BrowseLogo()
{
var dlg = new Microsoft.Win32.OpenFileDialog
{
Title = _loc.GetString("Dialog.Settings.BrowseLogoTitle"),
Filter = "Image files|*.png;*.jpg;*.jpeg;*.bmp|All files|*.*"
};
if (!string.IsNullOrEmpty(ReportLogoPath))
{
try { dlg.InitialDirectory = System.IO.Path.GetDirectoryName(ReportLogoPath); }
catch { /* ignore invalid path */ }
}
if (dlg.ShowDialog() == true)
ReportLogoPath = dlg.FileName;
}
/// <summary>Re-enumerates connected FTDI devices into <see cref="AvailablePorts"/>.</summary>
[RelayCommand]
private void RefreshPorts()
{
EnumerateFtdiDevices();
}
/// <summary>Appends a new empty row to the RPM-voltage relation table.</summary>
[RelayCommand]
private void AddRelation()
{
Relations.Add(new RpmVoltageRelation(0.0, 0));
}
/// <summary>Removes the selected row from the RPM-voltage relation table.</summary>
[RelayCommand]
private void RemoveRelation(RpmVoltageRelation? relation)
{
if (relation != null)
Relations.Remove(relation);
}
/// <summary>
/// Opens the user management dialog after a successful admin authentication.
/// The auth dialog is re-prompted on every invocation — there is no session cache.
/// </summary>
[RelayCommand]
private void ManageUsers()
{
var owner = GetOwnerWindow();
// Step 1: admin authentication.
var authVm = new UserCheckViewModel(_config, _loc);
var authDlg = new UserCheckDialog(authVm) { Owner = owner };
authDlg.ShowDialog();
if (!authVm.Accepted) return;
// Step 2: management dialog.
var manageVm = new UserManageViewModel(_config, _loc);
var manageDlg = new UserManageDialog(manageVm) { Owner = owner };
manageDlg.ShowDialog();
}
/// <summary>Finds a plausible dialog owner (active window, else main window).</summary>
private static Window? GetOwnerWindow()
{
foreach (Window w in Application.Current.Windows)
{
if (w.IsActive) return w;
}
return Application.Current.MainWindow;
}
// ── Helpers ───────────────────────────────────────────────────────────
/// <summary>Copies every persisted setting into the local mirror fields.</summary>
private void LoadFromConfig()
{
var s = _config.Settings;
// General
SelectedLanguage = s.Language;
DaysKeepLogs = s.DaysKeepLogs;
// Safety
TempMax = s.TempMax;
TempMin = s.TempMin;
SecurityRpmLimit = s.SecurityRpmLimit;
MaxPressureBar = s.MaxPressureBar;
ToleranceUpExtension = s.ToleranceUpExtension;
TolerancePfpExtension = s.TolerancePfpExtension;
DefaultIgnoreTin = s.DefaultIgnoreTin;
// PID
PidP = s.PidP;
PidI = s.PidI;
PidD = s.PidD;
PidLoopMs = s.PidLoopMs;
// Motor
EncoderResolution = s.EncoderResolution;
VoltageForMaxRpm = s.VoltageForMaxRpm;
MaxRpm = s.MaxRpm;
RightRelayValue = s.RightRelayValue;
// Company
CompanyName = s.CompanyName;
CompanyInfo = s.CompanyInfo;
ReportLogoPath = s.ReportLogoPath;
// K-Line
SelectedKLinePort = s.KLinePort;
// Advanced
RefreshBenchInterfaceMs = s.RefreshBenchInterfaceMs;
RefreshWhileReadingMs = s.RefreshWhileReadingMs;
RefreshCanBusReadMs = s.RefreshCanBusReadMs;
RefreshPumpRequestMs = s.RefreshPumpRequestMs;
RefreshPumpParamsMs = s.RefreshPumpParamsMs;
BlinkIntervalMs = s.BlinkIntervalMs;
FlasherIntervalMs = s.FlasherIntervalMs;
// Deep-copy the RPM-voltage relation table
Relations.Clear();
foreach (var r in s.Relations)
Relations.Add(new RpmVoltageRelation(r.Voltage, r.Rpm));
}
/// <summary>
/// Populates <see cref="AvailablePorts"/> with serial numbers of connected
/// FTDI devices. Fails silently if the FTDI driver DLL is not present.
/// </summary>
private void EnumerateFtdiDevices()
{
AvailablePorts.Clear();
try
{
uint count = FtdiInterface.GetDevicesCount();
if (count == 0) return;
var list = new FT_DEVICE_INFO_NODE[count];
FtdiInterface.GetDeviceList(list);
foreach (var device in list)
{
if (!string.IsNullOrEmpty(device.SerialNumber))
AvailablePorts.Add(device.SerialNumber);
}
}
catch
{
// FTDI DLL not loaded or no devices — leave list empty.
}
}
}
}

View File

@@ -0,0 +1,233 @@
using System.ComponentModel;
using System.Linq;
using System.Windows;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using HC_APTBS.Models;
using HC_APTBS.Services;
using HC_APTBS.ViewModels.Dialogs;
using HC_APTBS.Views.Dialogs;
namespace HC_APTBS.ViewModels.Pages
{
/// <summary>Wrapper VM exposing <see cref="TestPanel"/> when the wizard is in the Plan step.</summary>
public sealed class PlanStateViewModel
{
/// <summary>Shared test panel (enable/disable phases).</summary>
public TestPanelViewModel TestPanel { get; }
/// <summary>Creates a new Plan-state wrapper around the shared test panel.</summary>
public PlanStateViewModel(TestPanelViewModel testPanel) => TestPanel = testPanel;
}
/// <summary>Wrapper VM exposing <see cref="TestPanel"/> when the wizard is in the Running step.</summary>
public sealed class RunningStateViewModel
{
/// <summary>Shared test panel (live phase updates).</summary>
public TestPanelViewModel TestPanel { get; }
/// <summary>Creates a new Running-state wrapper around the shared test panel.</summary>
public RunningStateViewModel(TestPanelViewModel testPanel) => TestPanel = testPanel;
}
/// <summary>
/// Orchestrator view-model for the Tests navigation page.
///
/// <para>Drives the <c>Plan → Preconditions → Running → Done</c> wizard defined in
/// <c>docs/ui-structure.md §4</c>. Exposes <see cref="CurrentStateVm"/>, which the
/// view's <c>ContentControl</c> routes through typed DataTemplates to the four
/// step views. Commands (<see cref="NextCommand"/>, <see cref="BackCommand"/>,
/// <see cref="AbortCommand"/>, <see cref="RunAgainCommand"/>,
/// <see cref="ViewFullResultsCommand"/>) form the wizard's state-machine edges.</para>
///
/// <para>Observes <see cref="MainViewModel.IsTestRunning"/> to perform the
/// Preconditions→Running (on true) and Running→Done (on false) transitions
/// automatically, so the page stays in sync regardless of which control fired
/// the underlying start/stop command.</para>
/// </summary>
public sealed partial class TestsPageViewModel : ObservableObject
{
private readonly IConfigurationService _config;
private readonly ILocalizationService _loc;
/// <summary>Root ViewModel — owns services, live readings, and global commands.</summary>
public MainViewModel Root { get; }
/// <summary>Test panel: sections, phase cards, live indicators.</summary>
public TestPanelViewModel TestPanel => Root.TestPanel;
/// <summary>Measurement results table (per-phase pass/fail).</summary>
public ResultDisplayViewModel ResultDisplay => Root.ResultDisplay;
/// <summary>Preconditions checklist — lazily instantiated on first entry into the step.</summary>
public TestPreconditionsViewModel Preconditions { get; }
/// <summary>Auth gate scoped to the Tests page (used by preconditions for auth-required tests).</summary>
public AuthGateViewModel TestAuth { get; }
private readonly PlanStateViewModel _planVm;
private readonly RunningStateViewModel _runningVm;
/// <summary>
/// Creates the Tests page orchestrator.
/// </summary>
/// <param name="root">Root coordinator.</param>
/// <param name="config">Configuration service (passed to the scoped auth gate).</param>
/// <param name="loc">Localisation service.</param>
public TestsPageViewModel(MainViewModel root, IConfigurationService config, ILocalizationService loc)
{
Root = root;
_config = config;
_loc = loc;
TestAuth = new AuthGateViewModel(config, loc);
Preconditions = new TestPreconditionsViewModel(root, loc, Root.TestPanel, TestAuth);
_planVm = new PlanStateViewModel(Root.TestPanel);
_runningVm = new RunningStateViewModel(Root.TestPanel);
CurrentStateVm = _planVm;
Root.PropertyChanged += OnRootPropertyChanged;
}
// ── State ─────────────────────────────────────────────────────────────────
/// <summary>Current wizard step.</summary>
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(NextCommand))]
[NotifyCanExecuteChangedFor(nameof(BackCommand))]
[NotifyCanExecuteChangedFor(nameof(AbortCommand))]
[NotifyCanExecuteChangedFor(nameof(RunAgainCommand))]
[NotifyCanExecuteChangedFor(nameof(ViewFullResultsCommand))]
private TestFlowState _currentState = TestFlowState.Plan;
/// <summary>
/// View-model currently rendered by the step <c>ContentControl</c>. Swaps to
/// <see cref="PlanStateViewModel"/>, <see cref="TestPreconditionsViewModel"/>,
/// <see cref="RunningStateViewModel"/>, or <c>this</c> (for the Done step).
/// </summary>
[ObservableProperty] private object _currentStateVm;
/// <summary>Convenience flag for view styling — true while a test is actively running.</summary>
public bool IsRunningStep => CurrentState == TestFlowState.Running;
/// <summary>Convenience flag for view styling — true when the page is on the Done step.</summary>
public bool IsDoneStep => CurrentState == TestFlowState.Done;
partial void OnCurrentStateChanged(TestFlowState oldValue, TestFlowState newValue)
{
if (oldValue == TestFlowState.Preconditions && newValue != TestFlowState.Preconditions)
Preconditions.Deactivate();
switch (newValue)
{
case TestFlowState.Plan:
CurrentStateVm = _planVm;
break;
case TestFlowState.Preconditions:
Preconditions.Activate();
Preconditions.OnEnabledPhasesChanged();
CurrentStateVm = Preconditions;
break;
case TestFlowState.Running:
CurrentStateVm = _runningVm;
break;
case TestFlowState.Done:
CurrentStateVm = this;
break;
}
OnPropertyChanged(nameof(IsRunningStep));
OnPropertyChanged(nameof(IsDoneStep));
}
// ── Commands ──────────────────────────────────────────────────────────────
/// <summary>
/// Advances Plan → Preconditions. No-op if no phases are enabled (defensive — the
/// Preconditions step would fail its auth-detection anyway so there's nothing to
/// start). The phase-enable state is not observed live, so the button itself is
/// only guarded by <see cref="CurrentState"/>.
/// </summary>
[RelayCommand(CanExecute = nameof(CanNext))]
private void Next()
{
if (CurrentState != TestFlowState.Plan) return;
if (!Root.TestPanel.Tests.Any(s => s.Phases.Any(p => p.IsEnabled))) return;
CurrentState = TestFlowState.Preconditions;
}
private bool CanNext() => CurrentState == TestFlowState.Plan;
/// <summary>Goes back Preconditions → Plan. Disabled during Running / Done.</summary>
[RelayCommand(CanExecute = nameof(CanBack))]
private void Back()
{
if (CurrentState == TestFlowState.Preconditions)
CurrentState = TestFlowState.Plan;
}
private bool CanBack() => CurrentState == TestFlowState.Preconditions;
/// <summary>
/// Opens a confirmation dialog and, if accepted, delegates to <see cref="MainViewModel.StopTestCommand"/>.
/// </summary>
[RelayCommand(CanExecute = nameof(CanAbort))]
private void Abort()
{
var vm = new ConfirmDialogViewModel
{
Title = _loc.GetString("Test.Abort.Title"),
Message = _loc.GetString("Test.Abort.Message"),
ConfirmText = _loc.GetString("Test.Abort.Confirm"),
CancelText = _loc.GetString("Test.Abort.Cancel"),
};
var dlg = new ConfirmDialog(vm) { Owner = Application.Current.MainWindow };
dlg.ShowDialog();
if (!vm.Accepted) return;
if (Root.StopTestCommand.CanExecute(null))
Root.StopTestCommand.Execute(null);
}
private bool CanAbort() => CurrentState == TestFlowState.Running;
/// <summary>Resets the page for a fresh run without reloading the pump.</summary>
[RelayCommand(CanExecute = nameof(CanRunAgain))]
private void RunAgain()
{
Root.TestPanel.ResetResults();
Root.ResultDisplay.Clear();
CurrentState = TestFlowState.Plan;
}
private bool CanRunAgain() => CurrentState == TestFlowState.Done;
/// <summary>Jumps to the Results navigation page.</summary>
[RelayCommand(CanExecute = nameof(CanViewFullResults))]
private void ViewFullResults()
{
Root.SelectedPage = AppPage.Results;
}
private bool CanViewFullResults() => CurrentState == TestFlowState.Done;
// ── IsTestRunning → wizard state sync ─────────────────────────────────────
private void OnRootPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName != nameof(MainViewModel.IsTestRunning)) return;
if (Root.IsTestRunning)
{
if (CurrentState == TestFlowState.Preconditions)
CurrentState = TestFlowState.Running;
}
else
{
if (CurrentState == TestFlowState.Running)
CurrentState = TestFlowState.Done;
}
}
}
}