Meters: add manual reading entry from the meter-detail Readings tab
ci / build-test (push) Successful in 1m37s
ci / build-test (push) Successful in 1m37s
Entering a reading by hand previously meant POST /api/v1/readings with an API key, or a one-row CSV through the import wizard. SourceType.Manual existed in the enum but nothing was behind it. This adds the click path, built for the case it is actually used in: walking to each manual meter with a phone in hand. "Add reading" on the Readings tab opens a dialog prefilled with the meter's last register value and the current local time, both editable: - An on-screen keypad, because a register is read standing at the meter. It behaves like a calculator against the prefill - the first digit replaces it (a fresh register), while backspace edits it in place, which is the common case since only a register's last digits move. - Typed input accepts both separators (last one wins), so a German and an English phone keyboard both do the right thing. ReadingEntry owns that rule and is unit-tested; it deliberately differs from GermanNumber, where a lone dot really is a thousands separator. - A live parsed-value echo plus delta-since-last, which is the net that catches a mistyped digit before it is committed. - Decrease / replaces-existing / future / backdated surfaced before saving, and DST spring-forward gaps refused rather than shifted. The verdict line sits in a fixed-height, no-wrap slot above the keypad. That is load-bearing, not cosmetic: an alert that appears there when the value dips below the last reading moves the keys out from under the user's thumb mid-entry, which is a guaranteed mistype on a phone. The long-form explanation goes below the keypad, where reflow is harmless. Saving goes through IngestionService.IngestByMeterAsync, so the monotonic-decrease guard and inline renormalization apply exactly as for any other ingest. A new optional quality parameter stamps the row ReadingQuality.Manual; null preserves today's behaviour, so a source re-reporting the same timestamp updates the value without silently relabelling a hand-entered or imported reading. Also: the meter-detail tabs now render times in the instance timezone per SDD section 10, instead of raw UTC. Without it a reading entered at 18:00 reads back as 16:00. Side effect is that historic imported monthly rows show 01:00/02:00 rather than 00:00 - correct, if noisier. Claude-Session: https://claude.ai/code/session_01D4x3JbNKCSV4cBR9s7bJmX
This commit is contained in:
@@ -45,7 +45,7 @@ public static class ApiEndpoints
|
||||
{
|
||||
// Normalize once per meter after the batch, not per reading: a recompute rewrites the
|
||||
// meter's whole consumption series, so doing it inside the loop is quadratic.
|
||||
switch (await ingestion.IngestByMeterAsync(r.MeterId, r.Time, r.Value, renormalize: false, ct))
|
||||
switch (await ingestion.IngestByMeterAsync(r.MeterId, r.Time, r.Value, renormalize: false, cancellationToken: ct))
|
||||
{
|
||||
case IngestionOutcome.Written: written++; touched.Add(r.MeterId); break;
|
||||
case IngestionOutcome.Updated: updated++; touched.Add(r.MeterId); break;
|
||||
|
||||
@@ -5,7 +5,10 @@
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IDialogService DialogService
|
||||
@inject NavigationManager Nav
|
||||
@inject IServiceScopeFactory Scopes
|
||||
@inject Microsoft.Extensions.Options.IOptions<MeterVault.Infrastructure.Options.MeterVaultOptions> Options
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using Microsoft.Extensions.DependencyInjection
|
||||
@using MeterVault.Infrastructure.Ingestion
|
||||
@using MudBlazor
|
||||
|
||||
@@ -24,6 +27,18 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
@* Big touch targets and tabular digits for the manual-reading dialog: it is used standing at a
|
||||
meter on a phone, where the default input sizes are fiddly. *@
|
||||
<style>
|
||||
.mv-reading-value input { font-size: 1.9rem; text-align: right; font-variant-numeric: tabular-nums; }
|
||||
/* nowrap pins it to exactly two lines, so a long unit or a big delta cannot spill over and
|
||||
move the keypad; the full wording is repeated in the alert below the fold. */
|
||||
.mv-reading-verdict { display: flex; flex-direction: column; min-height: 2.6rem; }
|
||||
.mv-reading-verdict > * { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.mv-keypad { display: grid; grid-template-columns: repeat(3, 1fr); gap: .5rem; }
|
||||
.mv-keypad .mud-button { height: 56px; font-size: 1.35rem; }
|
||||
</style>
|
||||
|
||||
<div class="d-flex align-center mb-4" style="gap:.75rem">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.ArrowBack" Href="/meters" Size="Size.Small" />
|
||||
<MudText Typo="Typo.h4">@_detail.Name</MudText>
|
||||
@@ -138,20 +153,38 @@ else
|
||||
|
||||
<MudTabs Elevation="2" Rounded="true" ApplyEffectsToContainer="true" Class="mt-2">
|
||||
<MudTabPanel Text="@($"Readings ({_detail.ReadingCount})")">
|
||||
@if (_detail.Mode == MeterMode.Virtual)
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">
|
||||
A virtual meter is an expression over other meters, so it stores no readings of its own —
|
||||
enter the reading on the meter the expression refers to.
|
||||
</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="d-flex justify-end mb-2">
|
||||
<MudButton Size="Size.Small" Variant="Variant.Filled" Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Filled.Add" OnClick="OpenReading">
|
||||
Add reading
|
||||
</MudButton>
|
||||
</div>
|
||||
}
|
||||
@if (_detail.RecentReadings.Count == 0)
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">No raw readings.</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">Most recent @_detail.RecentReadings.Count (raw, immutable audit truth).</MudText>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
||||
Most recent @_detail.RecentReadings.Count (raw, immutable audit truth). Times in @_tz.Id.
|
||||
</MudText>
|
||||
<MudSimpleTable Dense="true" Hover="true" Class="mt-2">
|
||||
<thead><tr><th>Time</th><th style="text-align:right">Value</th><th>Quality</th><th>Flags</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var r in _detail.RecentReadings)
|
||||
{
|
||||
<tr>
|
||||
<td>@r.Time.ToString("yyyy-MM-dd HH:mm")</td>
|
||||
<td>@Local(r.Time).ToString("yyyy-MM-dd HH:mm")</td>
|
||||
<td style="text-align:right">@Format.Number(r.Value, 2) @_detail.Unit</td>
|
||||
<td>@QualityChip(r.Quality)</td>
|
||||
<td>@(r.Flags == MeterVault.Core.Domain.ReadingFlags.None ? "" : r.Flags.ToString())</td>
|
||||
@@ -176,7 +209,7 @@ else
|
||||
@foreach (var c in _detail.RecentConsumption)
|
||||
{
|
||||
<tr>
|
||||
<td>@c.Time.ToString("yyyy-MM-dd HH:mm")</td>
|
||||
<td>@Local(c.Time).ToString("yyyy-MM-dd HH:mm")</td>
|
||||
<td style="text-align:right">@Format.Number(c.Amount, 2) @_detail.Unit</td>
|
||||
<td>@c.Kind</td>
|
||||
<td>@QualityChip(c.Quality)</td>
|
||||
@@ -200,7 +233,7 @@ else
|
||||
@foreach (var e in _detail.Events)
|
||||
{
|
||||
<tr>
|
||||
<td>@e.Time.ToString("yyyy-MM-dd")</td>
|
||||
<td>@Local(e.Time).ToString("yyyy-MM-dd")</td>
|
||||
<td>@e.Type</td>
|
||||
<td style="text-align:right">@(e.Amount is { } a ? $"{Format.Number(a, 2)} {e.Unit}" : "—")</td>
|
||||
<td style="text-align:right">@(e.PrevValue is { } p ? $"{Format.Number(p, 0)}→{Format.Number(e.NewValue ?? 0, 0)}" : "—")</td>
|
||||
@@ -288,6 +321,93 @@ else
|
||||
</MudTabPanel>
|
||||
</MudTabs>
|
||||
|
||||
<MudDialog @bind-Visible="_readingOpen" Options="_dialogOptions">
|
||||
<TitleContent>
|
||||
<MudText Typo="Typo.h6">Add reading — @_detail.Name</MudText>
|
||||
</TitleContent>
|
||||
<DialogContent>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="d-block mb-2">@LastReadingCaption()</MudText>
|
||||
|
||||
<MudTextField T="string" Value="_entry.Text" ValueChanged="OnReadingTyped" Immediate="true"
|
||||
Label="@($"Reading ({_detail.Unit})")" Variant="Variant.Outlined"
|
||||
InputMode="DecimalKeyboard" Class="mv-reading-value" Clearable="true" />
|
||||
|
||||
@* Fixed height, and above the keypad on purpose. The verdict on a value has to be visible
|
||||
while it is being typed — the keypad pushes anything below it off a phone screen — but
|
||||
anything that grows or shrinks here would move the keys out from under the user's
|
||||
thumb mid-entry. So the slot is always the same size whether or not it says anything. *@
|
||||
<div class="mv-reading-verdict mt-1 mb-3">
|
||||
<MudText Typo="Typo.caption" Color="@(_entry.Value is null ? Color.Error : Color.Secondary)">
|
||||
@(_entry.Value is { } parsed ? $"= {Format.Number(parsed, 3)} {_detail.Unit}" : "Enter a value")
|
||||
</MudText>
|
||||
@if (ChangeSinceLast is { } change)
|
||||
{
|
||||
<MudText Typo="Typo.caption" Color="@(WouldBeRejected ? Color.Warning : Color.Secondary)">
|
||||
@ChangeSinceText(change)@(WouldBeRejected ? " — will be rejected" : "")
|
||||
</MudText>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="mv-keypad mb-3">
|
||||
@foreach (var key in Keypad)
|
||||
{
|
||||
var pressed = key;
|
||||
<MudButton Variant="Variant.Outlined" OnClick="@(() => PressKey(pressed))">@pressed</MudButton>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-center flex-wrap" style="gap:.75rem">
|
||||
<MudDatePicker @bind-Date="_readingDate" Label="Date" Variant="Variant.Outlined"
|
||||
Class="flex-grow-1" Style="min-width:150px" />
|
||||
<MudTimePicker @bind-Time="_readingTime" Label="Time" Variant="Variant.Outlined"
|
||||
Class="flex-grow-1" Style="min-width:130px" />
|
||||
<MudButton Size="Size.Small" Variant="Variant.Text"
|
||||
StartIcon="@Icons.Material.Filled.Schedule" OnClick="SetNow">Now</MudButton>
|
||||
</div>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="d-block mt-1">Local time in @_tz.Id.</MudText>
|
||||
|
||||
@* Everything below here can reflow freely: the dialog's buttons sit outside this scroll
|
||||
area, so nothing the user is aiming at moves. *@
|
||||
@if (EnteredTimeSkipped)
|
||||
{
|
||||
<MudAlert Severity="Severity.Error" Dense="true" Class="mt-3">
|
||||
That clock time never happened in @_tz.Id — the clocks moved forward. Pick another time.
|
||||
</MudAlert>
|
||||
}
|
||||
@if (WouldBeRejected)
|
||||
{
|
||||
<MudAlert Severity="Severity.Warning" Dense="true" Class="mt-3">
|
||||
Below the last reading (@Format.Number(_detail.LastReadingValue ?? 0, 2) @_detail.Unit) on a
|
||||
register that only counts up, so it will be rejected. If the meter was swapped or reset,
|
||||
record that on the Events tab first.
|
||||
</MudAlert>
|
||||
}
|
||||
@if (ReplacesRecentReading)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-3">
|
||||
This meter already has a reading at that time — saving replaces its value.
|
||||
</MudAlert>
|
||||
}
|
||||
@if (IsFuture)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-3">That time is in the future.</MudAlert>
|
||||
}
|
||||
else if (IsBackdated)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-3">
|
||||
Backdated before the latest reading — consumption from there on is recomputed.
|
||||
</MudAlert>
|
||||
}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="@(() => _readingOpen = false)" Disabled="_readingSaving">Cancel</MudButton>
|
||||
<MudButton Color="Color.Primary" Variant="Variant.Filled" Size="Size.Large"
|
||||
OnClick="SaveReadingAsync" Disabled="@(!CanSaveReading)">
|
||||
@(_readingSaving ? "Saving…" : "Save reading")
|
||||
</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
<MudDialog @bind-Visible="_sourceOpen" Options="_dialogOptions">
|
||||
<TitleContent>
|
||||
<MudText Typo="Typo.h6">@(_sourceEdit.Id == 0 ? "New source" : "Edit source")</MudText>
|
||||
@@ -366,11 +486,34 @@ else
|
||||
private SourceEdit _sourceEdit = new();
|
||||
private readonly DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.Small, FullWidth = true };
|
||||
|
||||
private bool _readingOpen;
|
||||
private bool _readingSaving;
|
||||
private readonly ReadingEntry _entry = new();
|
||||
private DateTime? _readingDate;
|
||||
private TimeSpan? _readingTime;
|
||||
private TimeZoneInfo _tz = TimeZoneInfo.Utc;
|
||||
|
||||
/// <summary>Phone-dialpad order, ending in the row the thumb reaches last: separator, zero, backspace.</summary>
|
||||
private static readonly string[] Keypad = ["7", "8", "9", "4", "5", "6", "1", "2", "3", ",", "0", "⌫"];
|
||||
|
||||
/// <summary>
|
||||
/// A decimal keyboard, so a phone offers the separator. Hoisted out of the markup because
|
||||
/// <c>decimal</c> is a C# keyword and Razor would read the required <c>@</c> escape in an
|
||||
/// attribute as a transition.
|
||||
/// </summary>
|
||||
private const InputMode DecimalKeyboard = InputMode.@decimal;
|
||||
|
||||
private static readonly MeterMode[] MonotonicModes =
|
||||
[MeterMode.CumulativeCounter, MeterMode.GenerationCounter, MeterMode.RuntimeCounter];
|
||||
|
||||
protected override void OnInitialized() => _tz = ResolveTimeZone(Options.Value.TimeZone);
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
_detail = null;
|
||||
_periods = null;
|
||||
_notFound = false;
|
||||
_readingOpen = false;
|
||||
_detail = await Details.GetAsync(Id);
|
||||
_notFound = _detail is null;
|
||||
if (_detail is not null)
|
||||
@@ -380,6 +523,22 @@ else
|
||||
}
|
||||
}
|
||||
|
||||
// Readings are stored UTC (SDD §10) and shown in the instance timezone, so a value entered at
|
||||
// 18:00 reads back as 18:00 rather than as its UTC instant.
|
||||
private static TimeZoneInfo ResolveTimeZone(string id)
|
||||
{
|
||||
try
|
||||
{
|
||||
return TimeZoneInfo.FindSystemTimeZoneById(id);
|
||||
}
|
||||
catch (Exception ex) when (ex is TimeZoneNotFoundException or InvalidTimeZoneException)
|
||||
{
|
||||
return TimeZoneInfo.Utc;
|
||||
}
|
||||
}
|
||||
|
||||
private DateTimeOffset Local(DateTimeOffset instant) => TimeZoneInfo.ConvertTime(instant, _tz);
|
||||
|
||||
/// <summary>
|
||||
/// "+12%" / "−4%" against the previous period. Less is better for consumption and worse for
|
||||
/// generation, so colour is left to the caller's context rather than hardcoded green/red here.
|
||||
@@ -406,6 +565,169 @@ else
|
||||
+ "background:var(--mud-palette-primary); border-radius:2px 2px 0 0";
|
||||
}
|
||||
|
||||
private void OpenReading()
|
||||
{
|
||||
if (_detail is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
SetNow();
|
||||
// Prefilling the last reading is what makes this quick standing at the meter: a register only
|
||||
// moves in its final digits, so backspace-and-retype beats keying six digits from scratch.
|
||||
// Falls back to the configured baseline while the meter has no readings at all.
|
||||
_entry.Prefill(_detail.LastReadingValue ?? _detail.InitialBaseline);
|
||||
_readingOpen = true;
|
||||
}
|
||||
|
||||
private void SetNow()
|
||||
{
|
||||
var now = TimeZoneInfo.ConvertTime(DateTimeOffset.UtcNow, _tz);
|
||||
_readingDate = now.Date;
|
||||
_readingTime = new TimeSpan(now.Hour, now.Minute, 0);
|
||||
}
|
||||
|
||||
private void OnReadingTyped(string? value) => _entry.SetText(value);
|
||||
|
||||
private void PressKey(string key)
|
||||
{
|
||||
switch (key)
|
||||
{
|
||||
case "⌫":
|
||||
_entry.Backspace();
|
||||
break;
|
||||
case ",":
|
||||
_entry.AppendSeparator();
|
||||
break;
|
||||
default:
|
||||
_entry.AppendDigit(key[0]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private string LastReadingCaption()
|
||||
{
|
||||
if (_detail is not { } detail)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return detail is { LastReadingValue: { } value, LastReadingTime: { } time }
|
||||
? $"Last reading {Format.Number(value, 2)} {detail.Unit} on {Local(time):yyyy-MM-dd HH:mm}."
|
||||
: $"No readings yet — prefilled with this meter's baseline ({Format.Number(detail.InitialBaseline, 2)} {detail.Unit}).";
|
||||
}
|
||||
|
||||
/// <summary>The wall-clock instant the two pickers describe, read in the instance timezone.</summary>
|
||||
private DateTime? EnteredWallClock =>
|
||||
_readingDate is { } date ? date.Date + (_readingTime ?? TimeSpan.Zero) : null;
|
||||
|
||||
/// <summary>
|
||||
/// True when the chosen local time falls in a spring-forward gap and so names no instant at all.
|
||||
/// Converting it would throw, so the dialog blocks the save and says why instead.
|
||||
/// </summary>
|
||||
private bool EnteredTimeSkipped =>
|
||||
EnteredWallClock is { } wall && _tz.IsInvalidTime(DateTime.SpecifyKind(wall, DateTimeKind.Unspecified));
|
||||
|
||||
/// <remarks>
|
||||
/// An ambiguous autumn hour resolves to standard time, <see cref="TimeZoneInfo"/>'s default. The
|
||||
/// two candidate instants are an hour apart on one hour of one night a year — well inside the
|
||||
/// precision of a timestamp somebody typed by hand.
|
||||
/// </remarks>
|
||||
private DateTimeOffset? EnteredUtc
|
||||
{
|
||||
get
|
||||
{
|
||||
if (EnteredWallClock is not { } wall || EnteredTimeSkipped)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var unspecified = DateTime.SpecifyKind(wall, DateTimeKind.Unspecified);
|
||||
return new DateTimeOffset(TimeZoneInfo.ConvertTimeToUtc(unspecified, _tz), TimeSpan.Zero);
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsMonotonic => _detail is not null && Array.IndexOf(MonotonicModes, _detail.Mode) >= 0;
|
||||
|
||||
private bool IsBackdated => EnteredUtc is { } entered && _detail?.LastReadingTime is { } last && entered < last;
|
||||
|
||||
// A minute of slack so "now" never trips the future warning on a slow round trip.
|
||||
private bool IsFuture => EnteredUtc is { } entered && entered > DateTimeOffset.UtcNow.AddMinutes(1);
|
||||
|
||||
private double? ChangeSinceLast =>
|
||||
!IsBackdated && _entry.Value is { } value && _detail?.LastReadingValue is { } last ? value - last : null;
|
||||
|
||||
/// <summary>
|
||||
/// Mirrors the ingestion guard closely enough to warn before saving rather than after. The
|
||||
/// service compares against the reading immediately before the entered time; this page only
|
||||
/// holds the latest one, so a backdated entry gets no verdict rather than a wrong one.
|
||||
/// </summary>
|
||||
private bool WouldBeRejected =>
|
||||
IsMonotonic && !IsBackdated && _entry.Value is { } value
|
||||
&& _detail?.LastReadingValue is { } last && value < last;
|
||||
|
||||
/// <summary>
|
||||
/// Whether saving would overwrite a reading the page already lists. Bounded to the loaded rows,
|
||||
/// so it is a heads-up rather than a guarantee — the save reports what actually happened.
|
||||
/// </summary>
|
||||
private bool ReplacesRecentReading =>
|
||||
EnteredUtc is { } entered && _detail is not null && _detail.RecentReadings.Any(r => r.Time == entered);
|
||||
|
||||
private bool CanSaveReading =>
|
||||
!_readingSaving && _entry.Value is not null && EnteredWallClock is not null && !EnteredTimeSkipped;
|
||||
|
||||
private string ChangeSinceText(double change) =>
|
||||
Math.Abs(change) < 1e-9
|
||||
? "no change since last reading"
|
||||
: $"{(change > 0 ? "+" : "−")}{Format.Number(Math.Abs(change), 2)} {_detail?.Unit} since last reading";
|
||||
|
||||
private async Task SaveReadingAsync()
|
||||
{
|
||||
if (_detail is null || _entry.Value is not { } value || EnteredUtc is not { } utc)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_readingSaving = true;
|
||||
try
|
||||
{
|
||||
// A scope per operation: IngestionService holds a scoped DbContext, and a Blazor circuit
|
||||
// long outlives the unit of work a single save should share one with.
|
||||
await using var scope = Scopes.CreateAsyncScope();
|
||||
var ingestion = scope.ServiceProvider.GetRequiredService<IngestionService>();
|
||||
var outcome = await ingestion.IngestByMeterAsync(
|
||||
Id, utc, value, renormalize: true, quality: ReadingQuality.Manual);
|
||||
|
||||
switch (outcome)
|
||||
{
|
||||
case IngestionOutcome.Written:
|
||||
Snackbar.Add($"Reading saved: {Format.Number(value, 2)} {_detail.Unit}.", Severity.Success);
|
||||
break;
|
||||
case IngestionOutcome.Updated:
|
||||
Snackbar.Add($"Replaced the reading at that time with {Format.Number(value, 2)} {_detail.Unit}.", Severity.Success);
|
||||
break;
|
||||
case IngestionOutcome.RejectedDecrease:
|
||||
// Leave the dialog open: the typed value is still on screen to correct, and the
|
||||
// alternative fix — recording a reset or swap — is a decision, not a retry.
|
||||
Snackbar.Add(
|
||||
"Rejected — below the previous reading on a register that only counts up. "
|
||||
+ "Record a counter reset or meter swap first.", Severity.Error);
|
||||
return;
|
||||
default:
|
||||
Snackbar.Add("This meter no longer exists.", Severity.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
_readingOpen = false;
|
||||
_detail = await Details.GetAsync(Id);
|
||||
_periods = _detail is null ? null : await Periods.GetAsync(Id);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_readingSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadSourcesAsync()
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace MeterVault.App;
|
||||
|
||||
/// <summary>
|
||||
/// The edit buffer behind the manual-reading keypad. Holds the value as text rather than a number
|
||||
/// so a half-typed entry ("12345," while the decimals are still coming) is representable and no
|
||||
/// intermediate state gets rounded away by a numeric binding.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Separator handling is deliberately lenient rather than culture-strict: the same field is driven
|
||||
/// by the on-screen keypad (always a comma), by an Android keyboard (comma on a German locale, dot
|
||||
/// on an English one) and by a desktop numpad, so a value has to survive either character. The rule
|
||||
/// is that only the <em>last</em> separator is decimal and earlier ones are grouping — "1.234,5"
|
||||
/// and "1,234.5" both give 1234.5. A lone separator is therefore always decimal ("1.234" → 1.234),
|
||||
/// which is what the keypad emits and what an English keyboard means; nobody types thousands
|
||||
/// separators into a meter register, and the dialog echoes the parsed value back formatted, so a
|
||||
/// misread is visible before saving. This differs from
|
||||
/// <see cref="Core.Parsing.GermanNumber"/> on purpose: that one parses spreadsheet exports, where a
|
||||
/// lone dot really is a thousands separator.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="IsPristine"/> gives the buffer calculator behaviour. The dialog opens prefilled with
|
||||
/// the meter's last reading; the first digit key then replaces it outright (a fresh reading), while
|
||||
/// backspace edits it in place (only the last few digits of a register usually move). Without that
|
||||
/// distinction one of the two workflows always costs a full retype on a phone.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class ReadingEntry
|
||||
{
|
||||
/// <summary>Wide enough for any real register plus decimals; stops a stuck key growing the string.</summary>
|
||||
public const int MaxLength = 18;
|
||||
|
||||
private const char Separator = ',';
|
||||
|
||||
public string Text { get; private set; } = string.Empty;
|
||||
|
||||
/// <summary>True while the buffer still holds the untouched prefill, so the next digit replaces it.</summary>
|
||||
public bool IsPristine { get; private set; }
|
||||
|
||||
/// <summary>The entered number, or null while the buffer is empty or not yet a valid number.</summary>
|
||||
public double? Value => TryParse(Text, out var value) ? value : null;
|
||||
|
||||
/// <summary>Seeds the buffer with a meter's last reading, marked pristine.</summary>
|
||||
public void Prefill(double value)
|
||||
{
|
||||
// "0.###" keeps a register readable (12345,6) without inventing precision the meter
|
||||
// never had; invariant then swapped so the buffer only ever contains one separator glyph.
|
||||
Text = value.ToString("0.###", CultureInfo.InvariantCulture).Replace('.', Separator);
|
||||
IsPristine = true;
|
||||
}
|
||||
|
||||
public void AppendDigit(char digit)
|
||||
{
|
||||
if (!char.IsAsciiDigit(digit))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ReplacePrefillOnFirstKey();
|
||||
|
||||
// A leading zero is never meaningful on a register, and letting it stand makes "0" then "5"
|
||||
// read as "05" — replace it instead, exactly like a calculator.
|
||||
if (Text == "0")
|
||||
{
|
||||
Text = digit.ToString();
|
||||
return;
|
||||
}
|
||||
|
||||
if (Text.Length < MaxLength)
|
||||
{
|
||||
Text += digit;
|
||||
}
|
||||
}
|
||||
|
||||
public void AppendSeparator()
|
||||
{
|
||||
ReplacePrefillOnFirstKey();
|
||||
|
||||
if (Text.Contains(Separator, StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// "," alone parses as nothing, so lead with the zero the user means.
|
||||
Text = Text.Length == 0 ? "0" + Separator : Text + Separator;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes the last character. Unlike a digit key this keeps the prefill rather than clearing
|
||||
/// it — reading a register usually means correcting its final digits, not retyping all of them.
|
||||
/// </summary>
|
||||
public void Backspace()
|
||||
{
|
||||
IsPristine = false;
|
||||
if (Text.Length > 0)
|
||||
{
|
||||
Text = Text[..^1];
|
||||
}
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
Text = string.Empty;
|
||||
IsPristine = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Accepts free text from the keyboard-bound field, keeping only characters that can form a
|
||||
/// number. Junk is dropped rather than rejected so typing never dead-ends mid-value.
|
||||
/// </summary>
|
||||
public void SetText(string? raw)
|
||||
{
|
||||
IsPristine = false;
|
||||
if (string.IsNullOrEmpty(raw))
|
||||
{
|
||||
Text = string.Empty;
|
||||
return;
|
||||
}
|
||||
|
||||
var builder = new StringBuilder(Math.Min(raw.Length, MaxLength));
|
||||
foreach (var c in raw)
|
||||
{
|
||||
if (builder.Length >= MaxLength)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (char.IsAsciiDigit(c) || c is '.' or ',' || (c == '-' && builder.Length == 0))
|
||||
{
|
||||
builder.Append(c);
|
||||
}
|
||||
}
|
||||
|
||||
Text = builder.ToString();
|
||||
}
|
||||
|
||||
/// <summary>Parses a buffer as described on the type: last separator decimal, earlier ones grouping.</summary>
|
||||
public static bool TryParse(string? text, out double value)
|
||||
{
|
||||
value = 0;
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var raw = text.Trim();
|
||||
var negative = raw[0] == '-';
|
||||
if (negative || raw[0] == '+')
|
||||
{
|
||||
raw = raw[1..];
|
||||
}
|
||||
|
||||
var lastSeparator = raw.LastIndexOfAny(['.', ',']);
|
||||
var builder = new StringBuilder(raw.Length);
|
||||
var digits = 0;
|
||||
for (var i = 0; i < raw.Length; i++)
|
||||
{
|
||||
var c = raw[i];
|
||||
if (char.IsAsciiDigit(c))
|
||||
{
|
||||
builder.Append(c);
|
||||
digits++;
|
||||
}
|
||||
else if (c is '.' or ',')
|
||||
{
|
||||
if (i == lastSeparator)
|
||||
{
|
||||
builder.Append('.');
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (digits == 0
|
||||
|| !double.TryParse(builder.ToString(), NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
value = negative ? -parsed : parsed;
|
||||
return true;
|
||||
}
|
||||
|
||||
private void ReplacePrefillOnFirstKey()
|
||||
{
|
||||
if (IsPristine)
|
||||
{
|
||||
Text = string.Empty;
|
||||
IsPristine = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user