feat: redesign dashboard with Fluent KPI tiles, connection strip, and devices column
- Replace LCD-style readings with a 3×2 KPI tile grid (Fluent card surfaces, 52pt values) - Add persistent top connection strip with horizontal chips + pump name badge - Add elapsed test timer (DispatcherTimer, mm:ss) to Test Summary card - Restyle Test Summary and Active Alarms with Fluent brushes/iconography - Add Devices column (CAN / K-Line / Bench tiles) between KPI grid and test/alarms - Enumerates attached PCAN USB channels via PCAN_ATTACHED_CHANNELS API - Enumerates FTDI K-Line adapters via existing FtdiInterface helpers - Click to connect/disconnect; confirmation dialog when session active or test running - Hover tint: blue = will connect, red = will disconnect; Bench row is read-only stub - Extend ICanService with SelectedChannel + EnumerateAttachedChannels() - Expose IKwpService.ConnectedPort for active session device tracking - Add DeviceRow button style with MultiDataTrigger hover colour logic - Add 30+ new localization keys (ES + EN) for KPI labels, devices, confirmations Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,7 @@ using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows;
|
||||
using System.Windows.Threading;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using HC_APTBS.Models;
|
||||
@@ -65,6 +66,14 @@ namespace HC_APTBS.ViewModels
|
||||
|
||||
private CancellationTokenSource? _testCts;
|
||||
|
||||
// ── Test elapsed timer ────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Ticks every second while a test is running to update <see cref="TestElapsed"/>.</summary>
|
||||
private DispatcherTimer? _testTimer;
|
||||
|
||||
/// <summary>UTC start time of the current test; used by the timer to compute elapsed duration.</summary>
|
||||
private DateTime _testStartedUtc;
|
||||
|
||||
// ── Unlock tracking ──────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>CTS for the currently running immobilizer unlock, if any.</summary>
|
||||
@@ -139,9 +148,6 @@ namespace HC_APTBS.ViewModels
|
||||
/// <summary>Diagnostic Trouble Code list for the Pump page §3.b sub-section.</summary>
|
||||
public DtcListViewModel DtcList { get; }
|
||||
|
||||
/// <summary>Auth gate for the Pump page §3.d Adaptation sub-section.</summary>
|
||||
public AuthGateViewModel AdaptationAuth { get; }
|
||||
|
||||
// ── Page ViewModels (thin façades over the child VMs above) ───────────────
|
||||
|
||||
/// <summary>Dashboard navigation page VM.</summary>
|
||||
@@ -203,14 +209,12 @@ namespace HC_APTBS.ViewModels
|
||||
AngleDisplay = new AngleDisplayViewModel(configService);
|
||||
DashboardAlarms = new DashboardAlarmsViewModel(configService.Settings.Alarms);
|
||||
DtcList = new DtcListViewModel(kwpService, localizationService, logger);
|
||||
AdaptationAuth = new AuthGateViewModel(configService, localizationService);
|
||||
|
||||
// Page ViewModels are thin façades over the child VMs above; they hold a
|
||||
// reference back to this coordinator so page XAML can bind MainViewModel-owned
|
||||
// values via {Binding Root.X}.
|
||||
DashboardPage = new DashboardPageViewModel(this);
|
||||
DashboardPage = new DashboardPageViewModel(this, canService, kwpService);
|
||||
BenchPage = new BenchPageViewModel(this, benchService, configService);
|
||||
PumpPage = new PumpPageViewModel(this, DtcList, AdaptationAuth);
|
||||
PumpPage = new PumpPageViewModel(this, DtcList);
|
||||
TestsPage = new TestsPageViewModel(this, configService, localizationService);
|
||||
SettingsPage = new SettingsPageViewModel(configService, localizationService);
|
||||
SettingsPage.SettingsSaved += OnSettingsSaved;
|
||||
@@ -548,6 +552,9 @@ namespace HC_APTBS.ViewModels
|
||||
/// <summary>True when the current test results have been saved to a report.</summary>
|
||||
[ObservableProperty] private bool _isTestSaved = true;
|
||||
|
||||
/// <summary>Elapsed time since the current test started. Updated every second; retains last value when idle.</summary>
|
||||
[ObservableProperty] private TimeSpan _testElapsed;
|
||||
|
||||
// ── Commands: test ────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Starts the test sequence for the current pump.</summary>
|
||||
@@ -806,6 +813,14 @@ namespace HC_APTBS.ViewModels
|
||||
{
|
||||
IsTestRunning = true;
|
||||
VerboseStatus = _loc.GetString("Test.Started");
|
||||
|
||||
_testStartedUtc = DateTime.UtcNow;
|
||||
TestElapsed = TimeSpan.Zero;
|
||||
_testTimer = new DispatcherTimer(
|
||||
TimeSpan.FromSeconds(1),
|
||||
DispatcherPriority.Normal,
|
||||
(_, _) => TestElapsed = DateTime.UtcNow - _testStartedUtc,
|
||||
App.Current.Dispatcher);
|
||||
TestPanel.IsRunning = true;
|
||||
TestPanel.ResetResults();
|
||||
ResultDisplay.Clear();
|
||||
@@ -817,6 +832,9 @@ namespace HC_APTBS.ViewModels
|
||||
private void OnTestFinished(bool interrupted, bool success)
|
||||
=> App.Current.Dispatcher.Invoke(() =>
|
||||
{
|
||||
_testTimer?.Stop();
|
||||
_testTimer = null;
|
||||
|
||||
IsTestRunning = false;
|
||||
LastTestSuccess = !interrupted && success;
|
||||
VerboseStatus = interrupted ? _loc.GetString("Test.Stopped") : (success ? _loc.GetString("Common.Pass") : _loc.GetString("Common.Fail"));
|
||||
|
||||
287
ViewModels/Pages/DashboardDevicesViewModel.cs
Normal file
287
ViewModels/Pages/DashboardDevicesViewModel.cs
Normal file
@@ -0,0 +1,287 @@
|
||||
using System;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
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>Kind of physical device represented by a <see cref="DeviceItem"/>.</summary>
|
||||
public enum DeviceKind { Can, KLine, Bench }
|
||||
|
||||
/// <summary>
|
||||
/// Represents a single detected device row in the Devices column.
|
||||
/// </summary>
|
||||
public sealed partial class DeviceItem : ObservableObject
|
||||
{
|
||||
/// <summary>What kind of adapter this row represents.</summary>
|
||||
public DeviceKind Kind { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Device identity string: PCAN handle as hex for CAN, FTDI serial for K-Line,
|
||||
/// empty for the single Bench placeholder row.
|
||||
/// </summary>
|
||||
public string Id { get; init; } = "";
|
||||
|
||||
/// <summary>PCAN channel handle (only meaningful when <see cref="Kind"/> is <see cref="DeviceKind.Can"/>).</summary>
|
||||
public ushort CanHandle { get; init; }
|
||||
|
||||
/// <summary>Display name shown in the tile row.</summary>
|
||||
[ObservableProperty] private string _name = "";
|
||||
|
||||
/// <summary>Short state label (right-aligned in the row).</summary>
|
||||
[ObservableProperty] private string _stateLabel = "";
|
||||
|
||||
/// <summary>True when this device is the currently active connection.</summary>
|
||||
[ObservableProperty] private bool _isConnected;
|
||||
|
||||
/// <summary>False for the Bench placeholder, which cannot be clicked.</summary>
|
||||
public bool IsEnabled { get; init; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ViewModel for the Devices column on the Dashboard.
|
||||
///
|
||||
/// <para>Owns three observable collections of <see cref="DeviceItem"/> (one per kind)
|
||||
/// and exposes toggle/refresh commands. Communicates with CAN and K-Line services
|
||||
/// through <see cref="ICanService"/> and <see cref="IKwpService"/> and reads
|
||||
/// cross-cutting state (test running, connection flags) via <see cref="MainViewModel"/>.</para>
|
||||
/// </summary>
|
||||
public sealed partial class DashboardDevicesViewModel : ObservableObject
|
||||
{
|
||||
private readonly MainViewModel _root;
|
||||
private readonly ICanService _can;
|
||||
private readonly IKwpService _kwp;
|
||||
|
||||
/// <summary>Detected PCAN USB channels.</summary>
|
||||
public ObservableCollection<DeviceItem> CanDevices { get; } = new();
|
||||
|
||||
/// <summary>Detected FTDI K-Line adapters.</summary>
|
||||
public ObservableCollection<DeviceItem> KLineDevices { get; } = new();
|
||||
|
||||
/// <summary>Single bench-controller placeholder row.</summary>
|
||||
public ObservableCollection<DeviceItem> BenchDevices { get; } = new();
|
||||
|
||||
public DashboardDevicesViewModel(MainViewModel root, ICanService can, IKwpService kwp)
|
||||
{
|
||||
_root = root;
|
||||
_can = can;
|
||||
_kwp = kwp;
|
||||
|
||||
root.PropertyChanged += OnRootPropertyChanged;
|
||||
|
||||
RefreshCanDevices();
|
||||
RefreshKLineDevices();
|
||||
RefreshBenchDevices();
|
||||
}
|
||||
|
||||
// ── Refresh commands ──────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Re-enumerates attached PCAN USB channels.</summary>
|
||||
[RelayCommand]
|
||||
private void RefreshCanDevices()
|
||||
{
|
||||
CanDevices.Clear();
|
||||
try
|
||||
{
|
||||
var channels = _can.EnumerateAttachedChannels();
|
||||
foreach (var ch in channels)
|
||||
{
|
||||
CanDevices.Add(new DeviceItem
|
||||
{
|
||||
Kind = DeviceKind.Can,
|
||||
Id = ch.Handle.ToString("X"),
|
||||
CanHandle = ch.Handle,
|
||||
Name = ch.DisplayName,
|
||||
IsConnected = _root.IsCanConnected && _can.SelectedChannel == ch.Handle,
|
||||
StateLabel = GetCanStateLabel(_root.IsCanConnected && _can.SelectedChannel == ch.Handle),
|
||||
});
|
||||
}
|
||||
}
|
||||
catch { /* PCAN DLL missing — leave list empty */ }
|
||||
}
|
||||
|
||||
/// <summary>Re-enumerates connected FTDI K-Line adapters.</summary>
|
||||
[RelayCommand]
|
||||
private void RefreshKLineDevices()
|
||||
{
|
||||
KLineDevices.Clear();
|
||||
try
|
||||
{
|
||||
uint count = FtdiInterface.GetDevicesCount();
|
||||
if (count == 0) return;
|
||||
|
||||
var list = new FT_DEVICE_INFO_NODE[count];
|
||||
FtdiInterface.GetDeviceList(list);
|
||||
|
||||
foreach (var dev in list)
|
||||
{
|
||||
if (string.IsNullOrEmpty(dev.SerialNumber)) continue;
|
||||
bool connected = _kwp.KLineState == KLineConnectionState.Connected
|
||||
&& _kwp.ConnectedPort == dev.SerialNumber;
|
||||
bool failed = _kwp.KLineState == KLineConnectionState.Failed
|
||||
&& _kwp.ConnectedPort == dev.SerialNumber;
|
||||
KLineDevices.Add(new DeviceItem
|
||||
{
|
||||
Kind = DeviceKind.KLine,
|
||||
Id = dev.SerialNumber,
|
||||
Name = string.IsNullOrEmpty(dev.Description)
|
||||
? dev.SerialNumber
|
||||
: $"{dev.Description} ({dev.SerialNumber})",
|
||||
IsConnected = connected,
|
||||
StateLabel = GetKLineStateLabel(connected, failed),
|
||||
});
|
||||
}
|
||||
}
|
||||
catch { /* FTDI DLL not loaded — leave list empty */ }
|
||||
}
|
||||
|
||||
private void RefreshBenchDevices()
|
||||
{
|
||||
BenchDevices.Clear();
|
||||
BenchDevices.Add(new DeviceItem
|
||||
{
|
||||
Kind = DeviceKind.Bench,
|
||||
Id = "bench",
|
||||
Name = Str("Dashboard.Devices.BenchRow"),
|
||||
IsConnected = _root.IsBenchConnected,
|
||||
StateLabel = GetBenchStateLabel(_root.IsBenchConnected),
|
||||
IsEnabled = false,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Toggle command ────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Connects or disconnects the device represented by <paramref name="item"/>.
|
||||
/// Shows a confirmation dialog when a session is active or a test is running.
|
||||
/// </summary>
|
||||
[RelayCommand]
|
||||
private async Task ToggleDevice(DeviceItem? item)
|
||||
{
|
||||
if (item is null || !item.IsEnabled) return;
|
||||
|
||||
bool testRunning = _root.IsTestRunning;
|
||||
bool sessionActive = item.IsConnected;
|
||||
|
||||
if (testRunning)
|
||||
{
|
||||
if (!Confirm(Str("Devices.Confirm.Title"), Str("Devices.Confirm.Body.TestRunning")))
|
||||
return;
|
||||
}
|
||||
else if (sessionActive)
|
||||
{
|
||||
string body = string.Format(Str("Devices.Confirm.Body.Active"),
|
||||
item.Kind == DeviceKind.Can ? "CAN" : "K-Line");
|
||||
if (!Confirm(Str("Devices.Confirm.Title"), body))
|
||||
return;
|
||||
}
|
||||
|
||||
switch (item.Kind)
|
||||
{
|
||||
case DeviceKind.Can:
|
||||
await ToggleCanAsync(item);
|
||||
break;
|
||||
|
||||
case DeviceKind.KLine:
|
||||
await ToggleKLineAsync(item);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ToggleCanAsync(DeviceItem item)
|
||||
{
|
||||
if (item.IsConnected)
|
||||
{
|
||||
_root.DisconnectCanCommand.Execute(null);
|
||||
}
|
||||
else
|
||||
{
|
||||
try { _can.SelectedChannel = item.CanHandle; }
|
||||
catch { return; }
|
||||
_root.ConnectCanCommand.Execute(null);
|
||||
}
|
||||
await Task.Delay(600); // allow liveness event propagation
|
||||
RefreshCanDevices();
|
||||
}
|
||||
|
||||
private async Task ToggleKLineAsync(DeviceItem item)
|
||||
{
|
||||
if (item.IsConnected)
|
||||
{
|
||||
_kwp.Disconnect();
|
||||
}
|
||||
else
|
||||
{
|
||||
try { await _kwp.ConnectAsync(item.Id, CancellationToken.None); }
|
||||
catch { /* ConnectAsync throws on init failure — leave state as-is */ }
|
||||
}
|
||||
RefreshKLineDevices();
|
||||
}
|
||||
|
||||
// ── State change wiring ───────────────────────────────────────────────────
|
||||
|
||||
private void OnRootPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
|
||||
{
|
||||
switch (e.PropertyName)
|
||||
{
|
||||
case nameof(MainViewModel.IsCanConnected):
|
||||
App.Current.Dispatcher.Invoke(RefreshCanDevices);
|
||||
break;
|
||||
case nameof(MainViewModel.KLineState):
|
||||
App.Current.Dispatcher.Invoke(RefreshKLineDevices);
|
||||
break;
|
||||
case nameof(MainViewModel.IsBenchConnected):
|
||||
App.Current.Dispatcher.Invoke(SyncBenchState);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void SyncBenchState()
|
||||
{
|
||||
if (BenchDevices.Count == 0) return;
|
||||
var row = BenchDevices[0];
|
||||
row.IsConnected = _root.IsBenchConnected;
|
||||
row.StateLabel = GetBenchStateLabel(_root.IsBenchConnected);
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
private static bool Confirm(string title, string message)
|
||||
{
|
||||
var vm = new ConfirmDialogViewModel
|
||||
{
|
||||
Title = title,
|
||||
Message = message,
|
||||
ConfirmText = Str("Common.Yes"),
|
||||
CancelText = Str("Common.Cancel"),
|
||||
};
|
||||
var dlg = new ConfirmDialog(vm) { Owner = Application.Current.MainWindow };
|
||||
dlg.ShowDialog();
|
||||
return vm.Accepted;
|
||||
}
|
||||
|
||||
private static string Str(string key)
|
||||
=> Application.Current.TryFindResource(key) as string ?? key;
|
||||
|
||||
private static string GetCanStateLabel(bool connected)
|
||||
=> connected ? Str("Dashboard.Devices.State.Connected") : Str("Dashboard.Devices.State.Idle");
|
||||
|
||||
private static string GetKLineStateLabel(bool connected, bool failed)
|
||||
{
|
||||
if (connected) return Str("Dashboard.Devices.State.Active");
|
||||
if (failed) return Str("Dashboard.Devices.State.Failed");
|
||||
return Str("Dashboard.Devices.State.Idle");
|
||||
}
|
||||
|
||||
private static string GetBenchStateLabel(bool connected)
|
||||
=> connected ? Str("Dashboard.Devices.State.Connected") : Str("Dashboard.Devices.State.Idle");
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using HC_APTBS.Services;
|
||||
|
||||
namespace HC_APTBS.ViewModels.Pages
|
||||
{
|
||||
@@ -17,9 +18,13 @@ namespace HC_APTBS.ViewModels.Pages
|
||||
/// <summary>Active alarm aggregator bound to the Dashboard alarm list.</summary>
|
||||
public DashboardAlarmsViewModel Alarms => Root.DashboardAlarms;
|
||||
|
||||
public DashboardPageViewModel(MainViewModel root)
|
||||
/// <summary>Devices column ViewModel — CAN, K-Line, and Bench device tiles.</summary>
|
||||
public DashboardDevicesViewModel Devices { get; }
|
||||
|
||||
public DashboardPageViewModel(MainViewModel root, ICanService can, IKwpService kwp)
|
||||
{
|
||||
Root = root;
|
||||
Root = root;
|
||||
Devices = new DashboardDevicesViewModel(root, can, kwp);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,9 +42,6 @@ namespace HC_APTBS.ViewModels.Pages
|
||||
/// <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;
|
||||
|
||||
@@ -82,12 +79,10 @@ namespace HC_APTBS.ViewModels.Pages
|
||||
/// <summary>Constructs the page VM and subscribes to relevant Root state changes.</summary>
|
||||
public PumpPageViewModel(
|
||||
MainViewModel root,
|
||||
DtcListViewModel dtcList,
|
||||
AuthGateViewModel adaptationAuth)
|
||||
DtcListViewModel dtcList)
|
||||
{
|
||||
Root = root;
|
||||
DtcList = dtcList;
|
||||
AdaptationAuth = adaptationAuth;
|
||||
Root = root;
|
||||
DtcList = dtcList;
|
||||
|
||||
// Initialise derived flags from the current Root state.
|
||||
RefreshDerivedFlags();
|
||||
@@ -120,11 +115,6 @@ namespace HC_APTBS.ViewModels.Pages
|
||||
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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user