feat(ui): constrain decimal input and add stacked timed toasts with uniform actions
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
<input id="@Id" type="text" inputmode="decimal" class="form-control" data-amount-guard
|
||||
data-allow-negative="@AllowNegative.ToString().ToLowerInvariant()"
|
||||
@bind:get="Value" @bind:set="SetValue" @bind:event="oninput" disabled="@Disabled"
|
||||
aria-describedby="@(Id + "-rejected")" />
|
||||
<span id="@(Id + "-rejected")" class="input-rejection text-danger" role="status">@rejection</span>
|
||||
|
||||
@code {
|
||||
[Parameter] public string Id { get; set; } = "amount";
|
||||
[Parameter] public string Value { get; set; } = string.Empty;
|
||||
[Parameter] public EventCallback<string> ValueChanged { get; set; }
|
||||
[Parameter] public bool Disabled { get; set; }
|
||||
[Parameter] public bool AllowNegative { get; set; }
|
||||
private string? rejection;
|
||||
private async Task SetValue(string value)
|
||||
{
|
||||
if (!BookingInput.IsAmountDraft(value, AllowNegative))
|
||||
{
|
||||
rejection = "Diese Eingabe wurde nicht übernommen. Bitte einen Betrag wie 12,50 eingeben.";
|
||||
return;
|
||||
}
|
||||
rejection = null;
|
||||
await ValueChanged.InvokeAsync(value);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
@inject ToastService Toasts
|
||||
@inject IEntryService EntryService
|
||||
|
||||
<div class="dialog-backdrop">
|
||||
@@ -36,8 +37,8 @@
|
||||
|
||||
<label class="form-label" for="entry-amount">Betrag (€)</label>
|
||||
<div>
|
||||
<input id="entry-amount" type="text" inputmode="decimal" class="form-control" @bind="amountText" @bind:event="oninput" placeholder="12,50" aria-describedby="entry-amount-help" aria-invalid="@(!ValidAmount ? "true" : "false")" />
|
||||
<p id="entry-amount-help" class="@(!ValidAmount ? "text-danger" : "text-muted")">Betrag ohne Minuszeichen, größer als 0. Komma oder Punkt, höchstens zwei Nachkommastellen. Keine Tausendertrenner oder Währungszeichen, auch beim Einfügen. Es wird nicht gerundet.</p>
|
||||
<AmountInput Id="entry-amount" @bind-Value="amountText" />
|
||||
<p class="text-muted">Betrag ohne Minuszeichen.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -50,13 +51,6 @@
|
||||
}
|
||||
|
||||
</fieldset>
|
||||
@if (isSaving) { <p role="status" aria-live="polite">Wird gespeichert …</p> }
|
||||
@if (saveError != null)
|
||||
{
|
||||
<div class="alert alert-danger" role="alert">@saveError
|
||||
<details><summary>Technische Details</summary>@saveDetails</details>
|
||||
</div>
|
||||
}
|
||||
<div class="d-flex justify-content-end gap-2">
|
||||
<button type="submit" class="btn btn-success" disabled="@(isSaving || completed || (committedResult == null && !CanSave))">
|
||||
@(isSaving ? "Wird gespeichert …" : committedResult != null ? "Ansicht aktualisieren" : "Speichern")
|
||||
|
||||
@@ -73,6 +73,7 @@ public partial class AddEntryDialog
|
||||
private async Task Save()
|
||||
{
|
||||
if (isSaving || completed || (committedResult == null && !CanSave)) return;
|
||||
var busyToast = Toasts.Show("Wird gespeichert …", persistent: true);
|
||||
isSaving = true;
|
||||
saveError = null;
|
||||
saveDetails = null;
|
||||
@@ -98,8 +99,9 @@ public partial class AddEntryDialog
|
||||
? "Speichern fehlgeschlagen. Die Eingaben bleiben erhalten. Bitte erneut versuchen."
|
||||
: "Die Buchung ist gespeichert. Die Ansicht konnte nicht aktualisiert werden. Bitte Ansicht aktualisieren; es wird keine weitere Buchung erzeugt.";
|
||||
saveDetails = ex.Message;
|
||||
Toasts.Show(saveError, Duempelkas.App.Services.ToastKind.Error, saveDetails);
|
||||
}
|
||||
finally { isSaving = false; }
|
||||
finally { isSaving = false; Toasts.Dismiss(busyToast); }
|
||||
}
|
||||
|
||||
private async Task Cancel()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
@inject ToastService Toasts
|
||||
@inject IEntryService EntryService
|
||||
@inject IAccountService AccountService
|
||||
|
||||
@@ -26,8 +27,8 @@
|
||||
<input id="transfer-title" type="text" class="form-control" @bind="title" @bind:event="oninput" placeholder="Beschreibung der Umbuchung" />
|
||||
|
||||
<label class="form-label">Betrag (€)</label>
|
||||
<div><input id="transfer-amount" type="text" inputmode="decimal" class="form-control" @bind="amountText" @bind:event="oninput" aria-describedby="transfer-amount-help" />
|
||||
<p id="transfer-amount-help" class="@(!ValidAmount ? "text-danger" : "text-muted")">Betrag ohne Minuszeichen, größer als 0; Komma oder Punkt, höchstens zwei Nachkommastellen. Keine Tausendertrenner oder Währungszeichen; keine Rundung.</p></div>
|
||||
<div><AmountInput Id="transfer-amount" @bind-Value="amountText" />
|
||||
<p class="text-muted">Betrag ohne Minuszeichen.</p></div>
|
||||
</div>
|
||||
|
||||
@if (OutsideYear)
|
||||
@@ -39,13 +40,6 @@
|
||||
}
|
||||
|
||||
</fieldset>
|
||||
@if (isSaving) { <p role="status" aria-live="polite">Wird gespeichert …</p> }
|
||||
@if (saveError != null)
|
||||
{
|
||||
<div class="alert alert-danger" role="alert">@saveError
|
||||
<details><summary>Technische Details</summary>@saveDetails</details>
|
||||
</div>
|
||||
}
|
||||
<div class="d-flex justify-content-end gap-2">
|
||||
<button type="submit" class="btn btn-success" disabled="@(isSaving || completed || (committedResult == null && !CanSave))">
|
||||
@(isSaving ? "Wird gespeichert …" : committedResult != null ? "Ansicht aktualisieren" : "Speichern")
|
||||
|
||||
@@ -81,6 +81,7 @@ public partial class AddTransferDialog
|
||||
private async Task Save()
|
||||
{
|
||||
if (isSaving || completed || (committedResult == null && !CanSave)) return;
|
||||
var busyToast = Toasts.Show("Wird gespeichert …", persistent: true);
|
||||
isSaving = true;
|
||||
saveError = null;
|
||||
saveDetails = null;
|
||||
@@ -106,8 +107,9 @@ public partial class AddTransferDialog
|
||||
? "Speichern fehlgeschlagen. Die Eingaben bleiben erhalten. Bitte erneut versuchen."
|
||||
: "Die Buchung ist gespeichert. Die Ansicht konnte nicht aktualisiert werden. Bitte Ansicht aktualisieren; es wird keine weitere Buchung erzeugt.";
|
||||
saveDetails = ex.Message;
|
||||
Toasts.Show(saveError, Duempelkas.App.Services.ToastKind.Error, saveDetails);
|
||||
}
|
||||
finally { isSaving = false; }
|
||||
finally { isSaving = false; Toasts.Dismiss(busyToast); }
|
||||
}
|
||||
|
||||
private async Task Cancel()
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
@inherits LayoutComponentBase
|
||||
|
||||
<ToastHost />
|
||||
|
||||
<div class="d-flex flex-column vh-100">
|
||||
<nav class="app-navbar d-flex justify-content-between align-items-center">
|
||||
<a class="navbar-brand" href="">
|
||||
|
||||
@@ -1,40 +1,19 @@
|
||||
@inject ToastService Toasts
|
||||
@inject IFileSaveService FileSaveService
|
||||
|
||||
@if (Result != null)
|
||||
{
|
||||
<div class="alert @(Result.Status == FileOperationStatus.Error ? "alert-danger" : Result.Status == FileOperationStatus.Success ? "alert-success" : "alert-info")" role="status" aria-live="polite" aria-atomic="true">
|
||||
<p>@Result.Message</p>
|
||||
@if (Result.Path != null)
|
||||
{
|
||||
<p class="text-break">@Result.Path</p>
|
||||
<button class="btn btn-outline-secondary" disabled="@busy" @onclick="OpenFolder">Ordner öffnen</button>
|
||||
}
|
||||
@if (Result.Details != null)
|
||||
{
|
||||
<details><summary>Technische Details</summary><p class="text-break">@Result.Details</p></details>
|
||||
}
|
||||
@if (openResult != null)
|
||||
{
|
||||
<p role="alert">@openResult.Message</p>
|
||||
@if (openResult.Details != null) { <details><summary>Technische Details</summary>@openResult.Details</details> }
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public FileOperationResult? Result { get; set; }
|
||||
private FileOperationResult? openResult;
|
||||
private FileOperationResult? previous;
|
||||
private bool busy;
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (previous != Result) { openResult = null; previous = Result; }
|
||||
}
|
||||
private async Task OpenFolder()
|
||||
{
|
||||
if (busy || Result?.Path == null) return;
|
||||
busy = true;
|
||||
try { openResult = await FileSaveService.OpenFolderAsync(Result.Path); }
|
||||
finally { busy = false; }
|
||||
if (Result == null || ReferenceEquals(previous, Result)) return;
|
||||
previous = Result;
|
||||
var result = Result;
|
||||
Toasts.Show(result.Message + (result.Path != null ? " " + result.Path : ""),
|
||||
result.Status == FileOperationStatus.Error ? ToastKind.Error : result.Status == FileOperationStatus.Success ? ToastKind.Success : ToastKind.Info,
|
||||
result.Details, result.Path != null ? "Ordner öffnen" : null,
|
||||
result.Path != null ? async () => {
|
||||
var opened = await FileSaveService.OpenFolderAsync(result.Path);
|
||||
Toasts.Show(opened.Message, opened.Status == FileOperationStatus.Error ? ToastKind.Error : ToastKind.Info, opened.Details);
|
||||
} : null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
@implements IDisposable
|
||||
@inject ToastService Toasts
|
||||
|
||||
<div class="toast-stack" aria-label="Meldungen">
|
||||
@foreach (var toast in Toasts.Messages)
|
||||
{
|
||||
<section @key="toast.Id" class="app-toast toast-@toast.Kind.ToString().ToLowerInvariant()"
|
||||
role="@(toast.Kind == ToastKind.Error ? "alert" : "status")" aria-live="@(toast.Kind == ToastKind.Error ? "assertive" : "polite")" aria-atomic="true"
|
||||
@onmouseenter="() => Toasts.Pause(toast.Id)" @onmouseleave="() => Toasts.Resume(toast.Id)"
|
||||
@onfocusin="() => Toasts.Pause(toast.Id)" @onfocusout="() => Toasts.Resume(toast.Id)">
|
||||
<button type="button" class="toast-close" aria-label="Meldung schließen" @onclick="() => Toasts.Dismiss(toast.Id)">×</button>
|
||||
<p>@toast.Message</p>
|
||||
@if (toast.Action != null)
|
||||
{
|
||||
<button type="button" class="btn btn-outline-secondary" @onclick="() => RunAction(toast)">@toast.ActionLabel</button>
|
||||
}
|
||||
@if (toast.Details != null)
|
||||
{
|
||||
<details><summary>Technische Details</summary><p class="text-break">@toast.Details</p></details>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private readonly HashSet<Guid> running = [];
|
||||
protected override void OnInitialized() => Toasts.Changed += Refresh;
|
||||
private void Refresh() => _ = InvokeAsync(StateHasChanged);
|
||||
private async Task RunAction(ToastMessage toast)
|
||||
{
|
||||
if (!running.Add(toast.Id)) return;
|
||||
Toasts.Pause(toast.Id);
|
||||
try { await toast.Action!(); }
|
||||
catch (Exception ex) { Toasts.Show("Die Aktion konnte nicht abgeschlossen werden. Bitte erneut versuchen.", ToastKind.Error, ex.Message); }
|
||||
finally { running.Remove(toast.Id); Toasts.Resume(toast.Id); }
|
||||
}
|
||||
public void Dispose() => Toasts.Changed -= Refresh;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
@inject ToastService Toasts
|
||||
@inject ReportingPeriod Period
|
||||
@page "/accounts/{AccountId:int}"
|
||||
@inject IAccountService AccountService
|
||||
@@ -9,21 +10,7 @@
|
||||
|
||||
<div class="container-fluid">
|
||||
<OperationNotice Result="fileResult" />
|
||||
@if (fileBusy) { <p role="status" aria-live="polite">Dateiaktion läuft … Bitte warten.</p> }
|
||||
|
||||
@if (lastSaved != null)
|
||||
{
|
||||
<div class="alert alert-success" role="status" aria-live="polite">
|
||||
@lastSaved.Kind über @FormatAmount(lastSaved.Amount) @(lastSaved.Edited ? "aktualisiert" : "gespeichert").
|
||||
</div>
|
||||
}
|
||||
@if (savedOutsideYear.HasValue)
|
||||
{
|
||||
<div class="alert alert-info" role="status">
|
||||
Die Buchung wurde im Jahr @savedOutsideYear gespeichert und liegt außerhalb der aktuellen Ansicht.
|
||||
<button class="btn btn-outline-primary" @onclick="ShowSavedYear">Buchung im Jahr @savedOutsideYear anzeigen</button>
|
||||
</div>
|
||||
}
|
||||
@if (account == null)
|
||||
{
|
||||
<div class="text-center py-5">
|
||||
@@ -41,21 +28,21 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-items-center gap-2 py-3 flex-wrap">
|
||||
<div class="action-toolbar">
|
||||
<button class="btn btn-secondary btn-nav" @onclick="NavigateBack">
|
||||
<i class="bi bi-arrow-left"></i> Zurück
|
||||
<i class="bi bi-arrow-left"></i><span>Zurück</span>
|
||||
</button>
|
||||
<button class="btn btn-success btn-nav" @onclick="() => showAddEntry = true">
|
||||
<i class="bi bi-plus-lg"></i> Einnahme oder Ausgabe erfassen
|
||||
<i class="bi bi-plus-lg"></i> <span>Einnahme oder<br /> Ausgabe erfassen</span>
|
||||
</button>
|
||||
<button class="btn btn-info btn-nav" @onclick="() => showAddTransfer = true">
|
||||
<i class="bi bi-arrow-left-right"></i> Geld zwischen Konten verschieben
|
||||
<i class="bi bi-arrow-left-right"></i> <span>Geld zwischen<br /> Konten verschieben</span>
|
||||
</button>
|
||||
<button class="btn btn-dark btn-nav" @onclick="HandleExport" disabled="@fileBusy">
|
||||
<i class="bi bi-file-earmark-pdf"></i> Als PDF speichern
|
||||
<i class="bi bi-file-earmark-pdf"></i> <span>Als PDF<br /> speichern</span>
|
||||
</button>
|
||||
<button class="btn btn-nav btn-primary" @onclick="OpenYearFilterDialog">
|
||||
<i class="bi bi-funnel"></i> @GetFilterLabel()
|
||||
<i class="bi bi-funnel"></i><span>@GetFilterLabel()</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Duempelkas.App.Services;
|
||||
using Duempelkas.App.Services.Models;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
@@ -10,6 +11,10 @@ public partial class AccountDetail
|
||||
[Parameter]
|
||||
public int AccountId { get; set; }
|
||||
|
||||
[SupplyParameterFromQuery(Name = "jahr")]
|
||||
public int? QueryYear { get; set; }
|
||||
private int? previousQueryYear;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Fields
|
||||
@@ -53,6 +58,8 @@ public partial class AccountDetail
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
if (QueryYear.HasValue && QueryYear != previousQueryYear) selectedYear = QueryYear;
|
||||
previousQueryYear = QueryYear;
|
||||
await LoadAll();
|
||||
}
|
||||
|
||||
@@ -115,6 +122,12 @@ public partial class AccountDetail
|
||||
await LoadAll();
|
||||
lastSaved = saved;
|
||||
savedOutsideYear = selectedYear.HasValue && selectedYear.Value != saved.Date.Year ? saved.Date.Year : null;
|
||||
var nav = Navigation;
|
||||
var period = Period;
|
||||
var accountId = AccountId;
|
||||
Toasts.Show($"{saved.Kind} über {FormatAmount(saved.Amount)} {(saved.Edited ? "aktualisiert" : "gespeichert")}.", ToastKind.Success,
|
||||
actionLabel: savedOutsideYear.HasValue ? $"Buchung im Jahr {saved.Date.Year} anzeigen" : null,
|
||||
action: savedOutsideYear.HasValue ? () => { period.Year = saved.Date.Year; nav.NavigateTo($"/accounts/{accountId}?jahr={saved.Date.Year}"); return Task.CompletedTask; } : null);
|
||||
showAddEntry = false;
|
||||
showAddTransfer = false;
|
||||
editingEntry = null;
|
||||
@@ -187,6 +200,7 @@ public partial class AccountDetail
|
||||
private async Task HandleExport()
|
||||
{
|
||||
if (fileBusy) return;
|
||||
var busyToast = Toasts.Show("Dateiaktion läuft … Bitte warten.", persistent: true);
|
||||
fileBusy = true;
|
||||
fileResult = null;
|
||||
try
|
||||
@@ -199,12 +213,13 @@ public partial class AccountDetail
|
||||
|
||||
}
|
||||
catch (Exception ex) { fileResult = FileOperationResult.Error(ex); }
|
||||
finally { fileBusy = false; }
|
||||
finally { fileBusy = false; Toasts.Dismiss(busyToast); }
|
||||
}
|
||||
|
||||
private async Task HandleOpenSavedPdf()
|
||||
{
|
||||
if (fileBusy) return;
|
||||
var busyToast = Toasts.Show("Dateiaktion läuft … Bitte warten.", persistent: true);
|
||||
fileBusy = true;
|
||||
fileResult = null;
|
||||
try
|
||||
@@ -218,7 +233,7 @@ public partial class AccountDetail
|
||||
|
||||
}
|
||||
catch (Exception ex) { fileResult = FileOperationResult.Error(ex); }
|
||||
finally { fileBusy = false; }
|
||||
finally { fileBusy = false; Toasts.Dismiss(busyToast); }
|
||||
}
|
||||
|
||||
private void CancelOpenSavedPdf()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
@inject ToastService Toasts
|
||||
@inject ReportingPeriod Period
|
||||
@page "/"
|
||||
@inject IAccountService AccountService
|
||||
@@ -10,7 +11,6 @@
|
||||
|
||||
<div class="container-fluid">
|
||||
<OperationNotice Result="fileResult" />
|
||||
@if (fileBusy) { <p role="status" aria-live="polite">Dateiaktion läuft … Bitte warten.</p> }
|
||||
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
@@ -24,25 +24,25 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-items-center gap-2 py-3 flex-wrap">
|
||||
<div class="action-toolbar">
|
||||
<button class="btn-nav btn-primary" @onclick="() => showAddAccount = true">
|
||||
<i class="bi bi-plus-lg"></i> Neues Konto
|
||||
<i class="bi bi-plus-lg"></i><span>Neues Konto</span>
|
||||
</button>
|
||||
<button class="btn-nav btn-success" @onclick="HandleBackupAsync" disabled="@fileBusy">
|
||||
<i class="bi bi-save"></i> Sicherung erstellen
|
||||
<i class="bi bi-save"></i> <span>Sicherung<br /> erstellen</span>
|
||||
</button>
|
||||
|
||||
<button class="btn-nav btn-dark" @onclick="HandleDashboardExportAsync" disabled="@fileBusy">
|
||||
<i class="bi bi-file-earmark-pdf"></i> Als PDF speichern
|
||||
<i class="bi bi-file-earmark-pdf"></i> <span>Als PDF<br /> speichern</span>
|
||||
</button>
|
||||
<button class="btn btn-nav btn-primary" @onclick="OpenYearFilterDialog">
|
||||
<i class="bi bi-funnel"></i> @GetFilterLabel()
|
||||
<i class="bi bi-funnel"></i><span>@GetFilterLabel()</span>
|
||||
</button>
|
||||
<button class="btn btn-nav btn-secondary" @onclick="ToggleThemeAsync" title="Farbmodus wechseln">
|
||||
<i class="@ThemeButtonIconClass"></i> Modus<br>@ThemeButtonLabel
|
||||
<i class="@ThemeButtonIconClass"></i><span>Modus<br />@ThemeButtonLabel</span>
|
||||
</button>
|
||||
<details class="more-actions">
|
||||
<summary class="btn btn-outline-secondary">Weitere Aktionen</summary>
|
||||
<summary class="btn btn-nav btn-outline-secondary"><i class="bi bi-three-dots"></i><span>Weitere<br /> Aktionen</span></summary>
|
||||
<button class="btn btn-warning mt-2" @onclick="HandleRestoreAsync" disabled="@fileBusy">
|
||||
<i class="bi bi-arrow-counterclockwise"></i> Sicherung zurückspielen
|
||||
</button>
|
||||
|
||||
@@ -101,6 +101,7 @@ public partial class Dashboard
|
||||
private async Task HandleBackupAsync()
|
||||
{
|
||||
if (fileBusy) return;
|
||||
var busyToast = Toasts.Show("Dateiaktion läuft … Bitte warten.", persistent: true);
|
||||
fileBusy = true;
|
||||
fileResult = null;
|
||||
try
|
||||
@@ -109,7 +110,7 @@ public partial class Dashboard
|
||||
|
||||
}
|
||||
catch (Exception ex) { fileResult = FileOperationResult.Error(ex); }
|
||||
finally { fileBusy = false; }
|
||||
finally { fileBusy = false; Toasts.Dismiss(busyToast); }
|
||||
}
|
||||
|
||||
private Task HandleRestoreAsync()
|
||||
@@ -126,6 +127,7 @@ public partial class Dashboard
|
||||
private async Task HandleConfirmRestore()
|
||||
{
|
||||
if (fileBusy) return;
|
||||
var busyToast = Toasts.Show("Dateiaktion läuft … Bitte warten.", persistent: true);
|
||||
fileBusy = true;
|
||||
fileResult = null;
|
||||
try
|
||||
@@ -136,12 +138,13 @@ public partial class Dashboard
|
||||
|
||||
}
|
||||
catch (Exception ex) { fileResult = FileOperationResult.Error(ex); }
|
||||
finally { fileBusy = false; }
|
||||
finally { fileBusy = false; Toasts.Dismiss(busyToast); }
|
||||
}
|
||||
|
||||
private async Task HandleDashboardExportAsync()
|
||||
{
|
||||
if (fileBusy) return;
|
||||
var busyToast = Toasts.Show("Dateiaktion läuft … Bitte warten.", persistent: true);
|
||||
fileBusy = true;
|
||||
fileResult = null;
|
||||
try
|
||||
@@ -154,7 +157,7 @@ public partial class Dashboard
|
||||
|
||||
}
|
||||
catch (Exception ex) { fileResult = FileOperationResult.Error(ex); }
|
||||
finally { fileBusy = false; }
|
||||
finally { fileBusy = false; Toasts.Dismiss(busyToast); }
|
||||
}
|
||||
|
||||
private void OpenYearFilterDialog()
|
||||
@@ -177,6 +180,7 @@ public partial class Dashboard
|
||||
private async Task HandleOpenSavedPdf()
|
||||
{
|
||||
if (fileBusy) return;
|
||||
var busyToast = Toasts.Show("Dateiaktion läuft … Bitte warten.", persistent: true);
|
||||
fileBusy = true;
|
||||
fileResult = null;
|
||||
try
|
||||
@@ -190,7 +194,7 @@ public partial class Dashboard
|
||||
|
||||
}
|
||||
catch (Exception ex) { fileResult = FileOperationResult.Error(ex); }
|
||||
finally { fileBusy = false; }
|
||||
finally { fileBusy = false; Toasts.Dismiss(busyToast); }
|
||||
}
|
||||
|
||||
private void CancelOpenSavedPdf()
|
||||
|
||||
@@ -8,6 +8,9 @@ public static partial class BookingInput
|
||||
[GeneratedRegex(@"^\d+([,.]\d{1,2})?$", RegexOptions.CultureInvariant)]
|
||||
private static partial Regex AmountPattern();
|
||||
|
||||
public static bool IsAmountDraft(string value, bool allowNegative = false) =>
|
||||
Regex.IsMatch(value, allowNegative ? @"^-?\d*([,.]\d{0,2})?$" : @"^\d*([,.]\d{0,2})?$", RegexOptions.CultureInvariant);
|
||||
|
||||
public static bool TryAmount(string text, out decimal amount)
|
||||
{
|
||||
amount = 0;
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
namespace Duempelkas.App.Services;
|
||||
|
||||
public enum ToastKind { Info, Success, Error }
|
||||
public sealed record ToastMessage(Guid Id, string Message, ToastKind Kind, string? Details,
|
||||
string? ActionLabel, Func<Task>? Action);
|
||||
|
||||
public sealed class ToastService : IDisposable
|
||||
{
|
||||
private readonly object sync = new();
|
||||
private readonly Dictionary<Guid, (ToastMessage Message, Timer? Timer, TimeSpan? Remaining, DateTimeOffset Started)> items = [];
|
||||
public event Action? Changed;
|
||||
public IReadOnlyList<ToastMessage> Messages { get { lock (sync) return items.Values.Select(x => x.Message).ToArray(); } }
|
||||
|
||||
public Guid Show(string message, ToastKind kind = ToastKind.Info, string? details = null,
|
||||
string? actionLabel = null, Func<Task>? action = null, TimeSpan? duration = null, bool persistent = false)
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
var lifetime = persistent ? (TimeSpan?)null : duration ?? TimeSpan.FromSeconds(kind == ToastKind.Error ? 15 : 9);
|
||||
var toast = new ToastMessage(id, message, kind, details, actionLabel, action);
|
||||
lock (sync)
|
||||
{
|
||||
Timer? timer = lifetime.HasValue ? new Timer(_ => Dismiss(id), null, Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan) : null;
|
||||
items[id] = (toast, timer, lifetime, DateTimeOffset.UtcNow);
|
||||
if (lifetime.HasValue) timer!.Change(lifetime.Value, Timeout.InfiniteTimeSpan);
|
||||
}
|
||||
Changed?.Invoke();
|
||||
return id;
|
||||
}
|
||||
|
||||
public void Pause(Guid id)
|
||||
{
|
||||
lock (sync)
|
||||
{
|
||||
if (!items.TryGetValue(id, out var item) || item.Remaining == null || item.Started == default) return;
|
||||
item.Timer?.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan);
|
||||
var left = item.Remaining.Value - (DateTimeOffset.UtcNow - item.Started);
|
||||
items[id] = (item.Message, item.Timer, left > TimeSpan.Zero ? left : TimeSpan.FromMilliseconds(1), default);
|
||||
}
|
||||
}
|
||||
|
||||
public void Resume(Guid id)
|
||||
{
|
||||
lock (sync)
|
||||
{
|
||||
if (!items.TryGetValue(id, out var item) || item.Remaining == null || item.Started != default) return;
|
||||
items[id] = (item.Message, item.Timer, item.Remaining, DateTimeOffset.UtcNow);
|
||||
item.Timer?.Change(item.Remaining.Value, Timeout.InfiniteTimeSpan);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dismiss(Guid id)
|
||||
{
|
||||
lock (sync)
|
||||
{
|
||||
if (!items.Remove(id, out var item)) return;
|
||||
item.Timer?.Dispose();
|
||||
}
|
||||
Changed?.Invoke();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
lock (sync) { foreach (var item in items.Values) item.Timer?.Dispose(); items.Clear(); }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user