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:
2026-04-19 22:25:00 +02:00
parent 0280a2fad1
commit 197e9d1775
26 changed files with 1638 additions and 515 deletions

View 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");
}
}

View File

@@ -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);
}
}
}

View File

@@ -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();
}