Meters: add manual reading entry from the meter-detail Readings tab
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:
2026-08-01 10:25:05 +02:00
parent 1f575c9da2
commit af786c7b28
8 changed files with 782 additions and 12 deletions
+1 -1
View File
@@ -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;
+326 -4
View File
@@ -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();
+198
View File
@@ -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;
}
}
}