feat: add learning UI with kid runner, exam countdown, parent editor and review

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
JannisandClaude Sonnet 5.5 committed 2026-10-10 14:11:27 +02:00
1 parent 42406598c7
commit bab50cfe90
20 files changed
+3020 -3

No files matched your search

@@ -0,0 +1,76 @@
@using FamilyPlanner.Domain.Learning
@using FamilyPlanner.Shared.Learning
@* Ergebnis eines abgegebenen Versuchs aus Kindersicht. Lösungen, Erklärungen und Bewertungen erscheinen nur, wenn der Server sie mitschickt (LearningRules.VisibilityFor). *@
<section class="fp-learning-result-view" aria-label="Ergebnis">
@if (Attempt.Result is { } result)
{
<MudPaper Outlined="true" Class="pa-4 mb-3 fp-learning-summary">
<MudText Typo="Typo.h5" HtmlTag="h2">
@(Attempt.AutoSubmitted ? "Die Zeit ist abgelaufen – abgegeben" : "Abgegeben – gut gemacht!")
</MudText>
<MudText Typo="Typo.h4" Class="mt-2 fp-learning-score">@LearningFormat.Points(result.EarnedPoints) von @result.TotalItemPoints</MudText>
@if (result.Grade is { } grade)
{
<MudText Typo="Typo.h5" Color="Color.Primary" Class="fp-learning-grade">@LearningFormat.Grade(grade)</MudText>
}
@if (result.IsFinal)
{
<MudChip T="string" Color="Color.Success" Icon="@Icons.Material.Filled.Verified" Class="fp-learning-final">Von Mama oder Papa bestätigt</MudChip>
}
else
{
<MudChip T="string" Color="Color.Warning" Icon="@Icons.Material.Filled.HourglassTop" Class="fp-learning-pending">
@(result.OpenCount > 0
? $"{(result.OpenCount == 1 ? "1 Antwort wird" : $"{result.OpenCount} Antworten werden")} noch von Mama oder Papa angeschaut"
: "Mama oder Papa bestätigen das Ergebnis noch")
</MudChip>
}
</MudPaper>
}
<ol class="fp-learning-answers">
@foreach (var item in Attempt.Items)
{
<li class="fp-learning-answer" data-item-id="@item.ItemId">
<div class="d-flex align-center gap-2 flex-wrap">
<MudText Typo="Typo.subtitle2" HtmlTag="h3">Aufgabe @(item.Position + 1)</MudText>
<MudSpacer />
@if (item.Feedback is { } feedback)
{
<MudChip T="string" Size="Size.Small" Color="@LearningFormat.VerdictColor(feedback.Verdict)" Icon="@LearningFormat.VerdictIcon(feedback.Verdict)"
Class="@($"fp-learning-verdict verdict-{feedback.Verdict.ToString().ToLowerInvariant()}")">
@LearningFormat.Verdict(feedback.Verdict)@(feedback.Verdict is LearningVerdict.Partial ? $" ({feedback.AwardedPoints} von {item.Points})" : string.Empty)
</MudChip>
}
</div>
<p class="fp-learning-question">@item.Question</p>
<p class="fp-learning-given">Deine Antwort: <strong>@LearningFormat.AnswerText(item.AnswerType, item.Options, item.Answer)</strong></p>
@if (item.Feedback?.CorrectAnswer is { } correct)
{
<p class="fp-learning-correct">Richtige Lösung: <strong>@LearningFormat.AnswerText(item.AnswerType, item.Options, correct)</strong></p>
}
@if (!string.IsNullOrWhiteSpace(item.Feedback?.Explanation))
{
<p class="fp-learning-explanation">@item.Feedback!.Explanation</p>
}
@if (!string.IsNullOrWhiteSpace(item.Feedback?.Reason))
{
<p class="fp-learning-reason">Kommentar: @item.Feedback!.Reason</p>
}
</li>
}
</ol>
<MudButton Href="lernen" Variant="Variant.Filled" Color="Color.Primary" Size="Size.Large" StartIcon="@Icons.Material.Filled.ArrowBack" Class="fp-learning-back">
Zu meinen Blättern
</MudButton>
</section>
@code {
[Parameter, EditorRequired]
public LearningAttemptDto Attempt { get; set; } = default!;
}
@@ -0,0 +1,316 @@
@using FamilyPlanner.Domain.Learning
@using FamilyPlanner.Shared.Learning
@implements IDisposable
@inject ILearningApi Api
@inject ILiveConnection Live
@inject ISnackbar Snackbar
@* Eltern prüfen einen abgegebenen Versuch: Freitext bewerten, Automatik korrigieren, Teilpunkte und Begründung, Bestätigen oder erneut öffnen. *@
<section class="fp-learning-review" aria-label="Versuch prüfen">
<div class="d-flex align-center flex-wrap gap-2 mb-2">
<MudIconButton Icon="@Icons.Material.Filled.ArrowBack" Href="lernen" aria-label="Zurück zur Übersicht" Class="fp-learning-editor-back" />
<MudText Typo="Typo.h4" HtmlTag="h1">Versuch prüfen</MudText>
</div>
@if (_review is null)
{
@if (_missing)
{
<MudAlert Severity="Severity.Info" Class="fp-learning-missing">@(_notice ?? "Diesen Versuch gibt es nicht (mehr).")</MudAlert>
}
else if (_error is not null)
{
<MudAlert Severity="Severity.Warning" Class="fp-learning-error">
@_error
<MudButton Variant="Variant.Text" Color="Color.Inherit" OnClick="LoadAsync">Erneut versuchen</MudButton>
</MudAlert>
}
else
{
<LoadingIndicator />
}
}
else
{
var review = _review;
var confirmed = review.Status == LearningAttemptStatus.Reviewed;
<MudPaper Outlined="true" Class="pa-3 mb-3 fp-review-summary">
<MudText Typo="Typo.h6" HtmlTag="h2">@review.ChildName – @review.SetTitle</MudText>
<MudText Typo="Typo.body2">
@review.Subject · @LearningFormat.Mode(review.Mode) · Versuch @review.Number
@if (review.SubmittedAt is { } at)
{
<span> · abgegeben @LearningFormat.Local(at)</span>
}
</MudText>
@if (review.AutoSubmitted)
{
<MudAlert Severity="Severity.Warning" Dense="true" Class="mt-2 fp-review-auto">Die Zeit ist abgelaufen – der Versuch wurde automatisch abgegeben. Nur rechtzeitig gespeicherte Antworten zählen.</MudAlert>
}
<div class="d-flex flex-wrap align-center gap-2 mt-2">
<MudChip T="string" Color="Color.Primary" Class="fp-review-score">@LearningFormat.Points(review.EarnedPoints) von @review.TotalItemPoints</MudChip>
@if (review.OpenCount > 0)
{
<MudChip T="string" Color="Color.Info" Class="fp-review-open">@(review.OpenCount == 1 ? "1 Antwort offen" : $"{review.OpenCount} Antworten offen")</MudChip>
}
@if (review.Grade is { } grade)
{
<MudChip T="string" Color="Color.Secondary" Class="fp-review-grade">@(confirmed ? string.Empty : "Vorschau: ")@LearningFormat.Grade(grade)</MudChip>
}
@if (confirmed)
{
<MudChip T="string" Color="Color.Success" Icon="@Icons.Material.Filled.Verified" Class="fp-review-confirmed">Bestätigt</MudChip>
}
</div>
</MudPaper>
<ol class="fp-review-answers">
@foreach (var answer in review.Answers)
{
var item = answer;
<li class="fp-review-answer" data-item-id="@item.ItemId">
<div class="d-flex align-center flex-wrap gap-2">
<MudText Typo="Typo.subtitle2" HtmlTag="h3">Aufgabe @(item.Position + 1)</MudText>
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined">@LearningFormat.Points(item.ItemPoints)</MudChip>
<MudSpacer />
<MudChip T="string" Size="Size.Small" Color="@LearningFormat.VerdictColor(item.Verdict)" Icon="@LearningFormat.VerdictIcon(item.Verdict)"
Class="@($"fp-review-verdict verdict-{item.Verdict.ToString().ToLowerInvariant()}")">
@LearningFormat.Verdict(item.Verdict)@(item.Verdict == LearningVerdict.Partial ? $" ({item.AwardedPoints} von {item.ItemPoints})" : string.Empty)
</MudChip>
</div>
<p class="fp-learning-question">@item.Question</p>
<p class="fp-review-given">Antwort: <strong>@LearningFormat.AnswerText(item.AnswerType, item.Options, item.Answer)</strong></p>
<p class="fp-review-correct">Musterlösung: <strong>@LearningFormat.AnswerText(item.AnswerType, item.Options, item.CorrectAnswer)</strong></p>
@if (!string.IsNullOrWhiteSpace(item.Explanation))
{
<p class="fp-learning-explanation">@item.Explanation</p>
}
<p class="fp-review-grader">@LearningFormat.Grader(item.Grader)@(string.IsNullOrWhiteSpace(item.Reason) ? string.Empty : $": {item.Reason}")</p>
@if (!confirmed)
{
<div class="d-flex flex-wrap align-center gap-2 fp-review-actions" role="group" aria-label="@($"Bewertung Aufgabe {item.Position + 1}")">
<MudButton Variant="@(item.Verdict == LearningVerdict.Correct ? Variant.Filled : Variant.Outlined)" Color="Color.Success" Disabled="_busy"
OnClick="() => ReviewAsync(item, LearningVerdict.Correct)" Class="fp-review-correct-button" aria-pressed="@(item.Verdict == LearningVerdict.Correct ? "true" : "false")">Richtig</MudButton>
@if (item.ItemPoints >= 2)
{
<MudButton Variant="@(item.Verdict == LearningVerdict.Partial ? Variant.Filled : Variant.Outlined)" Color="Color.Warning" Disabled="_busy"
OnClick="() => ReviewAsync(item, LearningVerdict.Partial)" Class="fp-review-partial-button" aria-pressed="@(item.Verdict == LearningVerdict.Partial ? "true" : "false")">Teilweise</MudButton>
}
<MudButton Variant="@(item.Verdict == LearningVerdict.Wrong ? Variant.Filled : Variant.Outlined)" Color="Color.Error" Disabled="_busy"
OnClick="() => ReviewAsync(item, LearningVerdict.Wrong)" Class="fp-review-wrong-button" aria-pressed="@(item.Verdict == LearningVerdict.Wrong ? "true" : "false")">Falsch</MudButton>
</div>
<div class="d-flex flex-wrap gap-2 mt-1">
@if (item.ItemPoints >= 2)
{
<MudNumericField T="int" Value="@PartialPoints(item)" ValueChanged="v => SetPartialPoints(item, v)" Label="Teilpunkte" Min="1" Max="item.ItemPoints - 1"
Variant="Variant.Outlined" Margin="Margin.Dense" Class="fp-review-points" />
}
<MudTextField T="string" Value="@ReasonOf(item)" ValueChanged="v => SetReason(item, v)" Label="Begründung (optional)" Variant="Variant.Outlined" Margin="Margin.Dense"
Class="flex-grow-1 fp-review-reason" MaxLength="LearningAnswer.ReasonMaxLength" />
</div>
}
</li>
}
</ol>
@if (_error is not null)
{
<MudAlert Severity="Severity.Error" role="alert" Class="mb-2 fp-dialog-error">@_error</MudAlert>
}
@if (!confirmed)
{
<MudTextField T="string" @bind-Value="_note" Label="Kommentar an das Kind (optional)" Variant="Variant.Outlined" Lines="2" MaxLength="LearningAttempt.NoteMaxLength" Class="mb-2 fp-review-note" />
@if (review.OpenCount > 0)
{
<MudText Typo="Typo.body2" Class="mb-2 fp-review-hint">Bitte zuerst alle offenen Antworten bewerten.</MudText>
}
<MudButton Variant="Variant.Filled" Color="Color.Success" Size="Size.Large" StartIcon="@Icons.Material.Filled.Verified" OnClick="ConfirmAsync"
Disabled="@(_busy || review.OpenCount > 0)" Class="fp-review-confirm">Ergebnis bestätigen</MudButton>
}
else
{
<MudAlert Severity="Severity.Success" Class="mb-2 fp-review-credited">
Bestätigt@(review.SetTotalPoints > 0 ? $" – insgesamt {LearningFormat.Points(review.PointsCredited)} für dieses Blatt gutgeschrieben" : string.Empty).
</MudAlert>
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.LockOpen" OnClick="ReopenAsync" Disabled="_busy" Class="fp-review-reopen">Erneut öffnen (Punkte werden neu berechnet)</MudButton>
}
}
</section>
@code {
private readonly Dictionary<Guid, int> _partial = [];
private readonly Dictionary<Guid, string?> _reasons = [];
private LearningAttemptReviewDto? _review;
private Guid _loadedId;
private string? _note;
private bool _busy;
private bool _missing;
private string? _error;
private string? _notice;
[Parameter, EditorRequired]
public Guid AttemptId { get; set; }
protected override async Task OnParametersSetAsync()
{
if (AttemptId == _loadedId)
{
return;
}
_loadedId = AttemptId;
Live.LearningChanged -= OnLearningChanged;
Live.LearningChanged += OnLearningChanged;
await LoadAsync();
}
private Task OnLearningChanged(LearningChangedDto _) => _busy ? Task.CompletedTask : ReloadQuietAsync();
private async Task ReloadQuietAsync()
{
var result = await Api.GetReviewAttemptAsync(AttemptId);
if (result.Succeeded && result.Value is { } review)
{
_review = review;
}
await InvokeAsync(StateHasChanged);
}
private async Task LoadAsync()
{
_error = null;
_missing = false;
var result = await Api.GetReviewAttemptAsync(AttemptId);
if (result.Succeeded && result.Value is { } review)
{
_review = review;
_note = review.ReviewNote;
}
else if (result.Problem?.Status == 404)
{
_missing = true;
}
else if (result.Problem?.Status == 409)
{
_missing = true;
_notice = result.Problem.Detail ?? "Der Versuch läuft noch.";
}
else
{
_error = result.Problem?.Message ?? "Der Versuch konnte nicht geladen werden.";
}
}
private int PartialPoints(LearningReviewAnswerDto item) =>
_partial.TryGetValue(item.ItemId, out var points) ? points : item.Verdict == LearningVerdict.Partial ? item.AwardedPoints : Math.Max(1, item.ItemPoints / 2);
private void SetPartialPoints(LearningReviewAnswerDto item, int value) => _partial[item.ItemId] = value;
private string? ReasonOf(LearningReviewAnswerDto item) =>
_reasons.TryGetValue(item.ItemId, out var reason) ? reason : item.Reason;
private void SetReason(LearningReviewAnswerDto item, string? value) => _reasons[item.ItemId] = value;
private async Task ReviewAsync(LearningReviewAnswerDto item, LearningVerdict verdict)
{
if (_busy)
{
return;
}
_busy = true;
_error = null;
try
{
var points = verdict == LearningVerdict.Partial ? PartialPoints(item) : (int?)null;
var result = await Api.ReviewAnswerAsync(AttemptId, item.ItemId, new ReviewAnswerRequest(verdict, points, ReasonOf(item)));
if (result.Succeeded && result.Value is { } review)
{
_review = review;
_partial.Remove(item.ItemId);
_reasons.Remove(item.ItemId);
}
else
{
_error = result.Problem?.Message ?? "Die Bewertung konnte nicht gespeichert werden.";
if (result.Problem?.Status == 409)
{
await ReloadQuietAsync();
}
}
}
finally
{
_busy = false;
}
}
private async Task ConfirmAsync()
{
if (_busy)
{
return;
}
_busy = true;
_error = null;
try
{
var result = await Api.ConfirmAsync(AttemptId, new ConfirmAttemptRequest(string.IsNullOrWhiteSpace(_note) ? null : _note.Trim()));
if (result.Succeeded && result.Value is { } confirmed)
{
_review = confirmed.Attempt;
Snackbar.Add(confirmed.PointsDelta > 0 ? $"Bestätigt – {LearningFormat.Points(confirmed.PointsDelta)} gutgeschrieben." : "Bestätigt.", Severity.Success);
}
else
{
_error = result.Problem?.Message ?? "Das Ergebnis konnte nicht bestätigt werden.";
}
}
finally
{
_busy = false;
}
}
private async Task ReopenAsync()
{
if (_busy)
{
return;
}
_busy = true;
_error = null;
try
{
var result = await Api.ReopenAsync(AttemptId);
if (result.Succeeded && result.Value is { } reopened)
{
_review = reopened.Attempt;
Snackbar.Add(reopened.PointsDelta < 0 ? $"Wieder geöffnet – {LearningFormat.Points(-reopened.PointsDelta)} zurückgebucht." : "Wieder geöffnet.", Severity.Info);
}
else
{
_error = result.Problem?.Message ?? "Der Versuch konnte nicht wieder geöffnet werden.";
}
}
finally
{
_busy = false;
}
}
public void Dispose() => Live.LearningChanged -= OnLearningChanged;
}
@@ -0,0 +1,496 @@
@using FamilyPlanner.Domain.Learning
@using FamilyPlanner.Shared.Learning
@implements IAsyncDisposable
@inject ILearningApi Api
@inject TimeProvider TimeProvider
@inject IDialogService DialogService
@inject ISnackbar Snackbar
@* Lösen eines Blatts (Übung und Prüfung). Die Uhr der Prüfung richtet sich nach der Frist des Servers (Abgleich über ServerNow), nicht nach dem Gerät. *@
<section class="fp-learning-runner" aria-label="Blatt lösen">
@if (_attempt is null)
{
@if (_missing)
{
<MudAlert Severity="Severity.Info" Class="fp-learning-missing">Dieses Blatt gibt es nicht oder du hast keinen Zugriff.</MudAlert>
<MudButton Href="lernen" Variant="Variant.Text">Zu meinen Blättern</MudButton>
}
else if (_error is not null)
{
<MudAlert Severity="Severity.Warning" Class="fp-learning-error">
@_error
<MudButton Variant="Variant.Text" Color="Color.Inherit" OnClick="LoadAsync">Erneut versuchen</MudButton>
</MudAlert>
}
else
{
<LoadingIndicator />
}
}
else if (_attempt.Status != LearningAttemptStatus.InProgress)
{
<MudText Typo="Typo.h4" HtmlTag="h1" Class="mb-2">@_attempt.SetTitle</MudText>
<AttemptResult Attempt="_attempt" />
}
else
{
var items = _attempt.Items;
var item = items[Math.Clamp(_index, 0, items.Count - 1)];
var practice = _attempt.Mode == LearningMode.Practice;
var answered = items.Count(i => Normalize(Value(i.ItemId)) is not null);
<div class="fp-learning-run-header">
<div class="d-flex align-center flex-wrap gap-2">
<MudText Typo="Typo.h5" HtmlTag="h1" Class="fp-learning-run-title">@_attempt.SetTitle</MudText>
<MudChip T="string" Size="Size.Small" Color="@(practice ? Color.Default : Color.Warning)" Variant="Variant.Outlined" Class="fp-learning-mode">
@LearningFormat.Mode(_attempt.Mode)
</MudChip>
<MudSpacer />
@if (Remaining is { } remaining)
{
<span class="@($"fp-learning-timer {(remaining <= TimeSpan.FromMinutes(1) ? "is-urgent" : string.Empty)}")" role="timer" aria-label="Verbleibende Zeit">
<MudIcon Icon="@Icons.Material.Filled.Timer" Size="Size.Small" />
<span class="fp-learning-timer-text">@LearningFormat.Countdown(remaining)</span>
</span>
}
</div>
<div class="fp-learning-progress mt-2">
<MudProgressLinear Value="answered" Max="items.Count" Color="Color.Success" Rounded="true" Size="Size.Medium"
aria-label="Fortschritt" aria-valuemin="0" aria-valuemax="@items.Count" aria-valuenow="@answered" />
<span class="fp-learning-progress-text">@answered von @items.Count beantwortet</span>
</div>
<div class="mud-visually-hidden fp-learning-announce" role="status" aria-live="polite">@_announcement</div>
</div>
<nav aria-label="Aufgaben">
<ol class="fp-learning-dots">
@for (var i = 0; i < items.Count; i++)
{
var index = i;
var isAnswered = Normalize(Value(items[i].ItemId)) is not null;
<li>
<button type="button" class="@($"fp-learning-dot {(index == _index ? "is-current" : string.Empty)} {(isAnswered ? "is-answered" : string.Empty)}")"
aria-label="@($"Aufgabe {index + 1}{(isAnswered ? ", beantwortet" : ", offen")}")"
aria-current="@(index == _index ? "step" : null)" @onclick="() => GoToAsync(index)">@(index + 1)</button>
</li>
}
</ol>
</nav>
<MudPaper Outlined="true" Class="pa-4 my-3 fp-learning-question-card" data-item-id="@item.ItemId">
<div class="d-flex align-center flex-wrap gap-2 mb-2">
<MudText Typo="Typo.overline" HtmlTag="h2">Aufgabe @(_index + 1) von @items.Count</MudText>
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined">@LearningFormat.Points(item.Points)</MudChip>
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined">@LearningFormat.Difficulty(item.Difficulty)</MudChip>
</div>
<p class="fp-learning-question" id="@QuestionId">@item.Question</p>
@switch (item.AnswerType)
{
case LearningAnswerType.SingleChoice:
<div role="radiogroup" aria-labelledby="@QuestionId" class="fp-learning-choices">
@for (var o = 0; o < item.Options.Count; o++)
{
var option = o;
var selected = Value(item.ItemId) == option.ToString();
<button type="button" role="radio" aria-checked="@(selected ? "true" : "false")" disabled="@_submitting"
class="@($"fp-learning-choice {(selected ? "is-selected" : string.Empty)}")" @onclick="() => SelectAsync(item, option)">
<span class="fp-learning-choice-mark" aria-hidden="true">@((char)('A' + option))</span>
<span>@item.Options[option]</span>
</button>
}
</div>
break;
case LearningAnswerType.Number:
<MudTextField T="string" Value="@Value(item.ItemId)" ValueChanged="v => TextChangedAsync(item, v)" Label="Deine Antwort (Zahl)"
Variant="Variant.Outlined" InputMode="@(InputMode.@decimal)" Disabled="_submitting" Class="fp-learning-input fp-learning-number"
InputId="@InputId" Clearable="false" />
break;
default:
<MudTextField T="string" Value="@Value(item.ItemId)" ValueChanged="v => TextChangedAsync(item, v)" Label="Deine Antwort"
Variant="Variant.Outlined" Lines="3" Disabled="_submitting" Class="fp-learning-input fp-learning-text" InputId="@InputId" />
break;
}
<div class="fp-learning-save-state" role="status" aria-live="polite">
@switch (_saveState)
{
case SaveState.Saving:
<span class="fp-learning-saving">Speichert …</span>
break;
case SaveState.Saved:
<span class="fp-learning-saved">Gespeichert ✓</span>
break;
case SaveState.Failed:
<span class="fp-learning-save-failed">Nicht gespeichert – <button type="button" class="fp-link-button" @onclick="RetrySaveAsync">erneut versuchen</button></span>
break;
}
</div>
@if (practice && _verdicts.TryGetValue(item.ItemId, out var verdict) && verdict is { } v)
{
<MudAlert Severity="@(v == LearningVerdict.Correct ? Severity.Success : v == LearningVerdict.Wrong ? Severity.Warning : Severity.Info)" Dense="true"
Class="mt-2 fp-learning-feedback">
@(v switch
{
LearningVerdict.Correct => "Richtig! 🎉",
LearningVerdict.Wrong => "Nicht ganz – versuch es noch einmal.",
_ => "Das schauen sich Mama oder Papa an.",
})
</MudAlert>
}
@if (item.Hint is { } hint)
{
@if (_hints.Contains(item.ItemId))
{
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-2 fp-learning-hint" Icon="@Icons.Material.Filled.Lightbulb">@hint</MudAlert>
}
else
{
<MudButton Variant="Variant.Text" StartIcon="@Icons.Material.Filled.Lightbulb" OnClick="() => _hints.Add(item.ItemId)" Class="mt-1 fp-learning-hint-button">
Hinweis anzeigen
</MudButton>
}
}
</MudPaper>
<div class="d-flex flex-wrap gap-2 align-center fp-learning-nav">
<MudButton Variant="Variant.Outlined" Size="Size.Large" StartIcon="@Icons.Material.Filled.ArrowBack" Disabled="@(_index == 0 || _submitting)"
OnClick="() => GoToAsync(_index - 1)" Class="fp-learning-prev">Zurück</MudButton>
@if (_index < items.Count - 1)
{
<MudButton Variant="Variant.Filled" Color="Color.Primary" Size="Size.Large" EndIcon="@Icons.Material.Filled.ArrowForward" Disabled="_submitting"
OnClick="() => GoToAsync(_index + 1)" Class="fp-learning-next">Weiter</MudButton>
}
<MudSpacer />
<MudButton Variant="@(_index == items.Count - 1 ? Variant.Filled : Variant.Outlined)" Color="Color.Success" Size="Size.Large"
StartIcon="@Icons.Material.Filled.Send" Disabled="_submitting" OnClick="RequestSubmitAsync" Class="fp-learning-submit">Abgeben</MudButton>
</div>
}
</section>
@code {
private enum SaveState
{
Idle,
Saving,
Saved,
Failed,
}
private static readonly TimeSpan[] Announcements = [TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(1), TimeSpan.FromSeconds(30), TimeSpan.FromSeconds(10)];
private readonly SemaphoreSlim _saveLock = new(1, 1);
private readonly Dictionary<Guid, string?> _values = [];
private readonly Dictionary<Guid, string?> _saved = [];
private readonly Dictionary<Guid, LearningVerdict?> _verdicts = [];
private readonly HashSet<Guid> _hints = [];
private readonly HashSet<TimeSpan> _announced = [];
private LearningAttemptDto? _attempt;
private Guid _loadedId;
private ITimer? _timer;
private TimeSpan _serverOffset;
private int _index;
private bool _submitting;
private bool _missing;
private bool _disposed;
private string? _error;
private string _announcement = string.Empty;
private SaveState _saveState;
[Parameter, EditorRequired]
public Guid AttemptId { get; set; }
private string QuestionId => $"fp-learning-q-{_index}";
private string InputId => $"fp-learning-answer-{_index}";
/// <summary>Verbleibende Zeit nach Serveruhr; <c>null</c> ohne Zeitlimit.</summary>
private TimeSpan? Remaining => _attempt?.DeadlineAt is { } deadline
? LearningRules.Remaining(deadline, TimeProvider.GetUtcNow() + _serverOffset)
: null;
protected override async Task OnParametersSetAsync()
{
if (AttemptId != _loadedId)
{
_loadedId = AttemptId;
await LoadAsync();
}
}
private async Task LoadAsync()
{
_error = null;
_missing = false;
var result = await Api.GetAttemptAsync(AttemptId);
if (_disposed)
{
return;
}
if (result.Succeeded && result.Value is { } attempt)
{
Apply(attempt);
}
else if (result.Problem?.Status == 404)
{
_missing = true;
}
else
{
_error = result.Problem?.Message ?? "Das Blatt konnte nicht geladen werden.";
}
}
/// <summary>Übernimmt den Serverstand: Antworten, Rückmeldungen, Uhrenabgleich und ggf. den Countdown.</summary>
private void Apply(LearningAttemptDto attempt)
{
_attempt = attempt;
_serverOffset = attempt.ServerNow - TimeProvider.GetUtcNow();
_values.Clear();
_saved.Clear();
_verdicts.Clear();
foreach (var item in attempt.Items)
{
_values[item.ItemId] = item.Answer;
_saved[item.ItemId] = item.Answer;
if (attempt.Mode == LearningMode.Practice && item.Feedback is { } feedback)
{
_verdicts[item.ItemId] = feedback.Verdict;
}
}
_index = Math.Clamp(_index, 0, Math.Max(0, attempt.Items.Count - 1));
if (attempt.Status == LearningAttemptStatus.InProgress && attempt.DeadlineAt is not null)
{
_timer ??= TimeProvider.CreateTimer(_ => _ = InvokeAsync(TickAsync), null, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(1));
}
else
{
StopTimer();
}
}
private string? Value(Guid itemId) => _values.GetValueOrDefault(itemId);
private static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
// ---------- Uhr ----------
/// <summary>Jede Sekunde: Restzeit anzeigen, Hinweise vorlesen lassen und nach Ablauf automatisch abgeben (bei Netzfehlern erneut versuchen).</summary>
internal async Task TickAsync()
{
if (_disposed || _attempt is not { Status: LearningAttemptStatus.InProgress } || Remaining is not { } remaining)
{
return;
}
foreach (var threshold in Announcements)
{
if (remaining <= threshold && _announced.Add(threshold))
{
_announcement = LearningFormat.RemainingSpoken(remaining);
}
}
StateHasChanged();
if (remaining <= TimeSpan.Zero && !_submitting)
{
await SubmitAsync(auto: true);
}
}
private void StopTimer()
{
_timer?.Dispose();
_timer = null;
}
// ---------- Antworten speichern ----------
private Task SelectAsync(LearningAttemptItemDto item, int option)
{
_values[item.ItemId] = option.ToString(System.Globalization.CultureInfo.InvariantCulture);
return SaveAsync(item.ItemId);
}
private Task TextChangedAsync(LearningAttemptItemDto item, string? value)
{
_values[item.ItemId] = value;
return SaveAsync(item.ItemId);
}
/// <summary>
/// Speichert den aktuellen Wert einer Aufgabe. Alle Speicherungen laufen nacheinander (Sperre), damit eine ältere Antwort nie eine neuere
/// überschreibt; gespeichert wird immer der zuletzt eingegebene Wert.
/// </summary>
private async Task SaveAsync(Guid itemId)
{
await _saveLock.WaitAsync();
try
{
if (_disposed || _attempt is not { Status: LearningAttemptStatus.InProgress })
{
return;
}
var current = Normalize(Value(itemId));
if (current == Normalize(_saved.GetValueOrDefault(itemId)))
{
return;
}
_saveState = SaveState.Saving;
StateHasChanged();
var result = await Api.SaveAnswerAsync(AttemptId, itemId, new SaveAnswerRequest(current));
if (_disposed)
{
return;
}
if (result.Succeeded)
{
_saved[itemId] = current;
_verdicts[itemId] = _attempt.Mode == LearningMode.Practice ? result.Value?.Verdict : null;
_saveState = SaveState.Saved;
}
else if (result.Problem?.Status == 409)
{
// Abgegeben oder Zeit abgelaufen: Serverstand laden (zeigt das Ergebnis).
Snackbar.Add(result.Problem.Detail ?? "Der Versuch ist bereits abgegeben.", Severity.Warning);
await LoadAsync();
}
else
{
_saveState = SaveState.Failed;
}
}
finally
{
_saveLock.Release();
if (!_disposed)
{
await InvokeAsync(StateHasChanged);
}
}
}
private async Task RetrySaveAsync()
{
if (_attempt is { } attempt)
{
await FlushAsync();
}
}
/// <summary>Speichert alle noch nicht gespeicherten Antworten; <c>false</c>, wenn etwas nicht gespeichert werden konnte.</summary>
private async Task<bool> FlushAsync()
{
if (_attempt is null)
{
return true;
}
foreach (var item in _attempt.Items.ToList())
{
await SaveAsync(item.ItemId);
if (_attempt is not { Status: LearningAttemptStatus.InProgress })
{
return true; // inzwischen abgegeben – nichts mehr zu speichern
}
}
return _saveState != SaveState.Failed;
}
private async Task GoToAsync(int index)
{
if (_attempt is null)
{
return;
}
await FlushAsync();
_index = Math.Clamp(index, 0, _attempt.Items.Count - 1);
}
// ---------- Abgabe ----------
private async Task RequestSubmitAsync()
{
if (_attempt is null || _submitting)
{
return;
}
var open = _attempt.Items.Count(i => Normalize(Value(i.ItemId)) is null);
var message = open == 0
? "Du hast alle Aufgaben beantwortet. Nach der Abgabe kannst du nichts mehr ändern."
: open == 1
? "Eine Aufgabe ist noch ohne Antwort. Nach der Abgabe kannst du nichts mehr ändern."
: $"{open} Aufgaben sind noch ohne Antwort. Nach der Abgabe kannst du nichts mehr ändern.";
var confirmed = await DialogService.ShowMessageBoxAsync("Wirklich abgeben?", message, yesText: "Ja, abgeben", cancelText: "Weiterarbeiten");
if (confirmed == true)
{
await SubmitAsync(auto: false);
}
}
private async Task SubmitAsync(bool auto)
{
if (_submitting || _attempt is not { Status: LearningAttemptStatus.InProgress })
{
return;
}
_submitting = true;
StateHasChanged();
try
{
await FlushAsync();
if (_attempt is not { Status: LearningAttemptStatus.InProgress })
{
return; // beim Speichern stellte sich heraus, dass der Versuch schon abgegeben ist
}
var result = await Api.SubmitAsync(AttemptId);
if (_disposed)
{
return;
}
if (result.Succeeded && result.Value is { } submitted)
{
Apply(submitted);
if (auto)
{
Snackbar.Add("Die Zeit ist abgelaufen – deine Antworten wurden abgegeben.", Severity.Warning);
}
}
else if (!auto || result.Problem?.Status is not null)
{
Snackbar.Add(result.Problem?.Message ?? "Die Abgabe hat nicht geklappt.", Severity.Warning);
}
// Bei Netzfehlern nach Zeitablauf läuft der Timer weiter und versucht es in der nächsten Sekunde erneut.
}
finally
{
_submitting = false;
if (!_disposed)
{
await InvokeAsync(StateHasChanged);
}
}
}
public ValueTask DisposeAsync()
{
_disposed = true;
StopTimer();
return ValueTask.CompletedTask;
}
}
@@ -0,0 +1,292 @@
@using System.Globalization
@using FamilyPlanner.Domain.Learning
@using FamilyPlanner.Shared.Learning
@inject ILearningApi Api
@* Aufgabe anlegen oder ändern. Der Dialog ruft die Api selbst auf und liefert den neuen Stand des Blatts (inkl. aktueller Version) zurück. *@
<MudDialog>
<DialogContent>
<EditForm Model="this" OnSubmit="SaveAsync" FormName="learning-item-form" id="learning-item-form">
<MudStack Spacing="2">
@if (_conflict)
{
<MudAlert Severity="Severity.Warning" role="alert" Class="fp-learning-conflict">
Die Aufgabe wurde inzwischen von jemand anderem geändert. Der aktuelle Stand wurde geladen – bitte erneut speichern.
</MudAlert>
}
@if (_generalError is not null)
{
<MudAlert Severity="Severity.Error" role="alert" Class="fp-dialog-error">@_generalError</MudAlert>
}
<MudTextField @bind-Value="_question" Label="Frage" InputId="item-question" Variant="Variant.Outlined" Lines="3" AutoFocus="true"
Error="@HasError("Question")" ErrorText="@ErrorFor("Question")" />
<MudSelect T="LearningAnswerType" @bind-Value="_type" Label="Antworttyp" Variant="Variant.Outlined" Class="fp-item-type"
ToStringFunc="@(t => LearningFormat.AnswerType(t))" Error="@HasError("AnswerType")" ErrorText="@ErrorFor("AnswerType")">
@foreach (var type in Types)
{
<MudSelectItem T="LearningAnswerType" Value="@type">@LearningFormat.AnswerType(type)</MudSelectItem>
}
</MudSelect>
@switch (_type)
{
case LearningAnswerType.Number:
<MudTextField @bind-Value="_correct" Label="Musterlösung (Zahl)" InputId="item-correct" Variant="Variant.Outlined" HelperText="Zum Beispiel 12,5"
Error="@HasError("CorrectAnswer")" ErrorText="@ErrorFor("CorrectAnswer")" />
<MudTextField @bind-Value="_tolerance" Label="Toleranz (optional)" InputId="item-tolerance" Variant="Variant.Outlined"
HelperText="Erlaubte Abweichung, z. B. 0,5 – leer heißt exakt" Error="@HasError("NumberTolerance")" ErrorText="@ErrorFor("NumberTolerance")" />
break;
case LearningAnswerType.Text:
<MudTextField @bind-Value="_correct" Label="Musterlösung" InputId="item-correct" Variant="Variant.Outlined"
HelperText="Mehrere richtige Schreibweisen mit | trennen. Weicht die Antwort ab, entscheiden die Eltern."
Error="@HasError("CorrectAnswer")" ErrorText="@ErrorFor("CorrectAnswer")" />
break;
case LearningAnswerType.SingleChoice:
<fieldset class="fp-item-options" role="radiogroup">
<legend class="mud-typography mud-typography-caption">Antwortmöglichkeiten – die richtige markieren</legend>
@for (var i = 0; i < _options.Count; i++)
{
var index = i;
<div class="d-flex align-center gap-1 mb-1 fp-item-option">
<MudTextField T="string" Value="@_options[index]" ValueChanged="v => _options[index] = v ?? string.Empty" Label="@($"Option {index + 1}")"
Variant="Variant.Outlined" Margin="Margin.Dense" Class="flex-grow-1" />
<button type="button" role="radio" aria-checked="@(_correctIndex == index ? "true" : "false")"
class="@($"fp-item-correct {(_correctIndex == index ? "is-correct" : string.Empty)}")"
aria-label="@($"Option {index + 1} ist richtig")" title="Richtige Antwort" @onclick="() => _correctIndex = index">
<MudIcon Icon="@(_correctIndex == index ? Icons.Material.Filled.CheckCircle : Icons.Material.Outlined.RadioButtonUnchecked)"
Color="@(_correctIndex == index ? Color.Success : Color.Default)" />
</button>
<MudIconButton Icon="@Icons.Material.Filled.Delete" OnClick="() => RemoveOption(index)" Disabled="@(_options.Count <= LearningItem.MinOptions)"
aria-label="@($"Option {index + 1} entfernen")" Class="fp-item-option-remove" />
</div>
}
@if (HasError("CorrectAnswer"))
{
<MudText Typo="Typo.caption" Color="Color.Error">@ErrorFor("CorrectAnswer")</MudText>
}
<MudButton Variant="Variant.Text" StartIcon="@Icons.Material.Filled.Add" OnClick="AddOption" Disabled="@(_options.Count >= LearningItem.MaxOptions)"
Class="fp-item-option-add">Option hinzufügen</MudButton>
</fieldset>
break;
}
<div class="d-flex flex-wrap gap-2">
<MudSelect T="int" Value="_difficulty" ValueChanged="OnDifficultyChanged" Label="Schwierigkeit" Variant="Variant.Outlined" Class="fp-task-field fp-item-difficulty"
ToStringFunc="@(d => LearningFormat.Difficulty(d))">
@for (var d = LearningItem.MinDifficulty; d <= LearningItem.MaxDifficulty; d++)
{
<MudSelectItem T="int" Value="@d">@LearningFormat.Difficulty(d)</MudSelectItem>
}
</MudSelect>
<MudNumericField T="int" Value="_points" ValueChanged="OnPointsChanged" Label="Punkte" Min="LearningItem.MinPoints" Max="LearningItem.MaxPoints"
Variant="Variant.Outlined" Class="fp-task-field fp-item-points" Error="@HasError("Points")" ErrorText="@ErrorFor("Points")" />
</div>
<MudTextField @bind-Value="_hint" Label="Hinweis für das Kind (optional, nur in der Übung sichtbar)" InputId="item-hint" Variant="Variant.Outlined" Lines="2"
Error="@HasError("Hint")" ErrorText="@ErrorFor("Hint")" />
<MudTextField @bind-Value="_explanation" Label="Erklärung zur Lösung (optional, nach der Abgabe)" InputId="item-explanation" Variant="Variant.Outlined" Lines="2"
Error="@HasError("Explanation")" ErrorText="@ErrorFor("Explanation")" />
</MudStack>
</EditForm>
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel" Disabled="_busy">Abbrechen</MudButton>
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync" Disabled="_busy" Class="fp-item-save">
@(Item is null ? "Aufgabe hinzufügen" : "Speichern")
</MudButton>
</DialogActions>
</MudDialog>
@code {
private static readonly LearningAnswerType[] Types = [LearningAnswerType.Number, LearningAnswerType.Text, LearningAnswerType.SingleChoice];
[CascadingParameter]
private IMudDialogInstance? Dialog { get; set; }
[Parameter, EditorRequired]
public Guid SetId { get; set; }
/// <summary>Vorhandene Aufgabe zum Bearbeiten; <c>null</c> = neue Aufgabe.</summary>
[Parameter]
public LearningItemDto? Item { get; set; }
private string _question = string.Empty;
private LearningAnswerType _type = LearningAnswerType.Number;
private string? _correct;
private string? _tolerance;
private List<string> _options = ["", ""];
private int _correctIndex;
private string? _hint;
private string? _explanation;
private int _difficulty = 1;
private int _points = 1;
private bool _pointsTouched;
private byte[]? _version;
private bool _busy;
private bool _conflict;
private string? _generalError;
private Dictionary<string, string> _errors = [];
protected override void OnInitialized()
{
if (Item is { } item)
{
Load(item);
}
}
private void Load(LearningItemDto item)
{
_question = item.Question;
_type = item.AnswerType == LearningAnswerType.Paper ? LearningAnswerType.Text : item.AnswerType;
_tolerance = item.NumberTolerance?.ToString(CultureInfo.GetCultureInfo("de-DE"));
if (item.AnswerType == LearningAnswerType.SingleChoice)
{
_options = [.. item.Options];
_correctIndex = int.TryParse(item.CorrectAnswer, NumberStyles.None, CultureInfo.InvariantCulture, out var index) ? index : 0;
}
else
{
_correct = item.CorrectAnswer;
}
_hint = item.Hint;
_explanation = item.Explanation;
_difficulty = item.Difficulty;
_points = item.Points;
_pointsTouched = true;
_version = item.Version;
}
private void OnDifficultyChanged(int difficulty)
{
_difficulty = difficulty;
if (!_pointsTouched)
{
_points = LearningItemRules.DefaultPoints(difficulty); // Vorschlag: Punkte nach Schwierigkeit
}
}
private void OnPointsChanged(int points)
{
_points = points;
_pointsTouched = true;
}
private void AddOption()
{
if (_options.Count < LearningItem.MaxOptions)
{
_options.Add(string.Empty);
}
}
private void RemoveOption(int index)
{
if (_options.Count <= LearningItem.MinOptions)
{
return;
}
_options.RemoveAt(index);
if (_correctIndex == index)
{
_correctIndex = 0;
}
else if (_correctIndex > index)
{
_correctIndex--;
}
}
private bool HasError(string field) => _errors.ContainsKey(field);
private string? ErrorFor(string field) => _errors.GetValueOrDefault(field);
private LearningItemInput Build()
{
decimal? tolerance = null;
if (_type == LearningAnswerType.Number && LearningNumbers.ParseFirst(_tolerance) is { } parsed)
{
tolerance = Math.Abs(parsed);
}
return new LearningItemInput(
_question,
_type,
_type == LearningAnswerType.SingleChoice ? _correctIndex.ToString(CultureInfo.InvariantCulture) : _correct,
tolerance,
_type == LearningAnswerType.SingleChoice ? _options : null,
_hint,
_explanation,
_difficulty,
_points);
}
private async Task SaveAsync()
{
if (_busy)
{
return;
}
_generalError = null;
_conflict = false;
_errors = [];
var input = Build();
// Sofortiges Feedback mit denselben Regeln wie die Api (verbindlich ist die serverseitige Prüfung).
if (!string.IsNullOrWhiteSpace(_tolerance) && _type == LearningAnswerType.Number && LearningNumbers.ParseFirst(_tolerance) is null)
{
_errors["NumberTolerance"] = "Bitte eine Zahl eingeben.";
}
var validation = new LearningItemInputValidator().Validate(input);
foreach (var error in validation.Errors)
{
_errors.TryAdd(error.PropertyName, error.ErrorMessage);
}
if (_errors.Count > 0)
{
return;
}
_busy = true;
var result = Item is null
? await Api.AddItemAsync(SetId, input)
: await Api.UpdateItemAsync(SetId, Item.Id, new UpdateLearningItemRequest(
input.Question, input.AnswerType, input.CorrectAnswer, input.NumberTolerance, input.Options, input.Hint, input.Explanation,
input.Difficulty, input.Points, _version));
_busy = false;
if (result.Succeeded && result.Value is { } detail)
{
Dialog?.Close(DialogResult.Ok(detail));
}
else if (result.Problem?.Status == 409 && result.Value is { } current && Item is { } edited)
{
// Jemand anderes war schneller: aktuellen Stand der Aufgabe laden, Eingaben des Benutzers bleiben stehen.
_conflict = true;
_version = current.Items.FirstOrDefault(i => i.Id == edited.Id)?.Version ?? _version;
}
else if (result.Problem?.Errors is { Count: > 0 })
{
foreach (var (field, messages) in result.Problem.Errors)
{
_errors[field] = string.Join(" ", messages);
}
}
else
{
_generalError = result.Problem?.Message ?? "Die Aufgabe konnte nicht gespeichert werden.";
}
}
private void Cancel() => Dialog?.Cancel();
}
@@ -0,0 +1,199 @@
@using FamilyPlanner.Domain.Common
@using FamilyPlanner.Domain.Learning
@using FamilyPlanner.Shared.Learning
@implements IDisposable
@inject ILearningApi Api
@inject ILiveConnection Live
@inject NavigationManager Navigation
@inject IDialogService DialogService
@inject ISnackbar Snackbar
@inject TimeProvider TimeProvider
@inject ApiAuthenticationStateProvider AuthState
@* Kinderansicht „Lernen“: offene Blätter mit großen Karten, Fortschritt und Ergebnis. *@
<section class="fp-learning-kid" aria-label="Meine Lernblätter">
<MudText Typo="Typo.h4" HtmlTag="h1" Class="mb-2">Lernen</MudText>
<MudText Typo="Typo.subtitle1" Class="mb-3 fp-learning-greeting">Hallo @(AuthState.CurrentUser?.DisplayName)! Hier sind deine Übungsblätter.</MudText>
@if (_loading)
{
<LoadingIndicator />
}
else if (_error is not null)
{
<MudAlert Severity="Severity.Warning" Class="fp-learning-error">
@_error
<MudButton Variant="Variant.Text" Color="Color.Inherit" OnClick="LoadAsync">Erneut versuchen</MudButton>
</MudAlert>
}
else if (_sets.Count == 0)
{
<MudText Typo="Typo.h6" Class="my-6 text-center fp-learning-empty">Gerade gibt es kein Übungsblatt für dich. 🎉</MudText>
}
else
{
var open = _sets.Where(s => s.Status is LearningSetStatus.Assigned or LearningSetStatus.InProgress).ToList();
var finished = _sets.Where(s => s.Status is LearningSetStatus.Submitted or LearningSetStatus.Reviewed).ToList();
<MudText Typo="Typo.overline" HtmlTag="h2" Class="fp-category-header">Zu erledigen</MudText>
@if (open.Count == 0)
{
<MudText Class="mb-3 fp-learning-nothing-open">Alles erledigt – super gemacht!</MudText>
}
else
{
<ul class="fp-learning-cards" aria-label="Offene Blätter">
@foreach (var set in open)
{
@Card(set)
}
</ul>
}
@if (finished.Count > 0)
{
<MudText Typo="Typo.overline" HtmlTag="h2" Class="fp-category-header mt-3">Abgegeben und geprüft</MudText>
<ul class="fp-learning-cards" aria-label="Abgegebene Blätter">
@foreach (var set in finished)
{
@Card(set)
}
</ul>
}
}
</section>
@code {
private List<MyLearningSetDto> _sets = [];
private bool _loading = true;
private string? _error;
private Guid? _starting;
private DateOnly Today => FamilyTime.Today(TimeProvider);
protected override async Task OnInitializedAsync()
{
Live.LearningChanged += OnLearningChanged;
Live.Connected += LoadAsync;
await Live.EnsureStartedAsync();
await LoadAsync();
}
private Task OnLearningChanged(LearningChangedDto _) => LoadAsync();
private async Task LoadAsync()
{
var result = await Api.GetMineAsync();
if (result.Succeeded && result.Value is { } sets)
{
_sets = sets;
_error = null;
}
else if (_sets.Count == 0)
{
_error = result.Problem?.Message ?? "Die Übungsblätter konnten nicht geladen werden.";
}
_loading = false;
await InvokeAsync(StateHasChanged);
}
private RenderFragment Card(MyLearningSetDto set) => @<li class="@($"fp-learning-card {(LearningFormat.IsOverdue(set.DueDate, Today) && set.Status is LearningSetStatus.Assigned or LearningSetStatus.InProgress ? "is-overdue" : string.Empty)}")" data-set-id="@set.Id">
<div class="fp-learning-card-text">
<span class="fp-learning-title">@(set.Mode == LearningMode.Exam ? "📝 " : "✏️ ")@set.Title</span>
<span class="fp-learning-meta">
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined">@set.Subject</MudChip>
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined">@LearningFormat.GradeLevel(set.GradeLevel)</MudChip>
<MudChip T="string" Size="Size.Small" Color="@(set.Mode == LearningMode.Exam ? Color.Warning : Color.Default)" Variant="Variant.Outlined" Class="fp-learning-mode">
@LearningFormat.Mode(set.Mode)@(set.Mode == LearningMode.Exam && set.TimeLimitMinutes is { } minutes ? $" · {minutes} Min." : string.Empty)
</MudChip>
<span class="fp-learning-count">@(set.ItemCount == 1 ? "1 Aufgabe" : $"{set.ItemCount} Aufgaben")</span>
@if (set.DueDate is not null)
{
<span class="fp-learning-due">@LearningFormat.Due(set.DueDate, Today)</span>
}
</span>
@if (set.MaxAttempts > 0)
{
<span class="fp-learning-meta fp-learning-attempts">Versuch @Math.Min(set.AttemptsUsed + (set.RunningAttemptId is null ? 1 : 0), set.MaxAttempts) von @set.MaxAttempts</span>
}
@if (set.Results.Count > 0)
{
var latest = set.Results[0];
<span class="fp-learning-result" aria-label="Letztes Ergebnis">
@(latest.IsFinal ? "Geprüft" : "Abgegeben"): @LearningFormat.Points(latest.EarnedPoints) von @latest.TotalItemPoints@(latest.Grade is { } grade ? $" · {LearningFormat.Grade(grade)}" : string.Empty)@(!latest.IsFinal ? " (wird von Mama oder Papa geprüft)" : string.Empty)
</span>
}
@if (set.PointsCredited > 0)
{
<span class="fp-learning-points">⭐ @LearningFormat.Points(set.PointsCredited) gutgeschrieben</span>
}
</div>
<div class="fp-learning-card-actions">
@if (set.RunningAttemptId is { } running)
{
<MudButton Variant="Variant.Filled" Color="Color.Primary" Size="Size.Large" StartIcon="@Icons.Material.Filled.PlayArrow"
Href="@($"lernen/versuch/{running}")" Class="fp-learning-continue" aria-label="@($"„{set.Title}“ weitermachen")">Weitermachen</MudButton>
}
else if (set.CanStart)
{
<MudButton Variant="Variant.Filled" Color="Color.Success" Size="Size.Large" StartIcon="@Icons.Material.Filled.PlayArrow"
OnClick="() => StartAsync(set)" Disabled="@(_starting is not null)" Class="fp-learning-start"
aria-label="@($"„{set.Title}“ {(set.Results.Count == 0 ? "starten" : "noch einmal versuchen")}")">@(set.Results.Count == 0 ? "Los geht's" : "Nochmal versuchen")</MudButton>
}
@if (set.Results.Count > 0)
{
<MudButton Variant="Variant.Outlined" Size="Size.Large" StartIcon="@Icons.Material.Filled.Assessment"
Href="@($"lernen/versuch/{set.Results[0].AttemptId}")" Class="fp-learning-result-link"
aria-label="@($"Ergebnis von „{set.Title}“ ansehen")">Ergebnis</MudButton>
}
</div>
</li>;
private async Task StartAsync(MyLearningSetDto set)
{
if (_starting is not null)
{
return;
}
if (set.Mode == LearningMode.Exam)
{
var confirmed = await DialogService.ShowMessageBoxAsync(
"Prüfung beginnen?",
LearningFormat.ExamRules(set.TimeLimitMinutes),
yesText: "Prüfung beginnen",
cancelText: "Noch nicht");
if (confirmed != true)
{
return;
}
}
_starting = set.Id;
try
{
var result = await Api.StartAsync(set.Id);
if (result.Succeeded && result.Value is { } attempt)
{
Navigation.NavigateTo($"lernen/versuch/{attempt.Id}");
}
else
{
Snackbar.Add(result.Problem?.Message ?? "Das Blatt konnte nicht gestartet werden.", Severity.Warning);
await LoadAsync();
}
}
finally
{
_starting = null;
}
}
public void Dispose()
{
Live.LearningChanged -= OnLearningChanged;
Live.Connected -= LoadAsync;
}
}
@@ -0,0 +1,12 @@
using FamilyPlanner.Domain.Authorization;
using FamilyPlanner.Shared.Auth;
namespace FamilyPlanner.Web.Client.Components.Learning;
/// <summary>Nur zum Ein-/Ausblenden der Oberfläche; erzwungen wird serverseitig (LearningManage/LearningSolve).</summary>
public static class LearningAccess
{
public static bool CanManage(CurrentUserDto? user) => user?.Permissions?.ContainsKey(Permission.LearningManage) == true;
public static bool CanSolve(CurrentUserDto? user) => user?.Permissions?.ContainsKey(Permission.LearningSolve) == true;
}
@@ -0,0 +1,163 @@
using System.Globalization;
using FamilyPlanner.Domain.Common;
using FamilyPlanner.Domain.Learning;
using MudBlazor;
namespace FamilyPlanner.Web.Client.Components.Learning;
/// <summary>Anzeigehilfen der Lernaufgaben (deutsche Texte, Zeiten in Europe/Berlin).</summary>
public static class LearningFormat
{
private static readonly CultureInfo German = CultureInfo.GetCultureInfo("de-DE");
public static string Status(LearningSetStatus status) => status switch
{
LearningSetStatus.Draft => "Entwurf",
LearningSetStatus.Assigned => "Zugewiesen",
LearningSetStatus.InProgress => "In Arbeit",
LearningSetStatus.Submitted => "Abgegeben",
LearningSetStatus.Reviewed => "Geprüft",
_ => status.ToString(),
};
public static Color StatusColor(LearningSetStatus status) => status switch
{
LearningSetStatus.Draft => Color.Default,
LearningSetStatus.Assigned => Color.Info,
LearningSetStatus.InProgress => Color.Primary,
LearningSetStatus.Submitted => Color.Warning,
LearningSetStatus.Reviewed => Color.Success,
_ => Color.Default,
};
public static string Mode(LearningMode mode) => mode == LearningMode.Exam ? "Prüfung" : "Übung";
public static string AnswerType(LearningAnswerType type) => type switch
{
LearningAnswerType.Number => "Zahl",
LearningAnswerType.Text => "Text",
LearningAnswerType.SingleChoice => "Auswahl",
LearningAnswerType.Paper => "Papier",
_ => type.ToString(),
};
public static string Verdict(LearningVerdict verdict) => verdict switch
{
LearningVerdict.Correct => "Richtig",
LearningVerdict.Partial => "Teilweise richtig",
LearningVerdict.Wrong => "Falsch",
_ => "Wird geprüft",
};
public static Color VerdictColor(LearningVerdict verdict) => verdict switch
{
LearningVerdict.Correct => Color.Success,
LearningVerdict.Partial => Color.Warning,
LearningVerdict.Wrong => Color.Error,
_ => Color.Info,
};
public static string VerdictIcon(LearningVerdict verdict) => verdict switch
{
LearningVerdict.Correct => Icons.Material.Filled.CheckCircle,
LearningVerdict.Partial => Icons.Material.Filled.Adjust,
LearningVerdict.Wrong => Icons.Material.Filled.Cancel,
_ => Icons.Material.Filled.HourglassTop,
};
public static string Grader(LearningGrader grader) => grader switch
{
LearningGrader.Parent => "von den Eltern bewertet",
LearningGrader.Ai => "von der KI vorgeschlagen",
_ => "automatisch bewertet",
};
public static string Difficulty(int difficulty) => difficulty switch
{
<= 1 => "Leicht",
2 => "Mittel",
_ => "Schwer",
};
public static string GradeLevel(int level) => $"{level}. Klasse";
public static string Points(int points) => points == 1 ? "1 Punkt" : $"{points} Punkte";
/// <summary>„Note 2 – gut“.</summary>
public static string Grade(int grade) => $"Note {grade} – {LearningGrades.Label(grade)}";
public static string Attempts(int maxAttempts) => maxAttempts switch
{
<= 0 => "beliebig oft",
1 => "1 Versuch",
_ => $"{maxAttempts} Versuche",
};
public static string TimeLimit(int? minutes) => minutes is > 0 ? $"{minutes} Min." : "ohne Zeitlimit";
/// <summary>Restzeit als <c>m:ss</c> bzw. <c>h:mm:ss</c>.</summary>
public static string Countdown(TimeSpan remaining)
{
if (remaining < TimeSpan.Zero)
{
remaining = TimeSpan.Zero;
}
var totalSeconds = (long)Math.Ceiling(remaining.TotalSeconds);
var hours = totalSeconds / 3600;
var minutes = totalSeconds % 3600 / 60;
var seconds = totalSeconds % 60;
return hours > 0 ? $"{hours}:{minutes:00}:{seconds:00}" : $"{minutes}:{seconds:00}";
}
/// <summary>Zeitangabe zum Vorlesen: „noch 4 Minuten“ / „noch 30 Sekunden“.</summary>
public static string RemainingSpoken(TimeSpan remaining)
{
var seconds = (int)Math.Ceiling(Math.Max(0, remaining.TotalSeconds));
return seconds >= 120
? $"Noch {(int)Math.Ceiling(seconds / 60.0)} Minuten."
: seconds >= 60 ? "Noch eine Minute." : $"Noch {seconds} Sekunden.";
}
/// <summary>„fällig heute“, „fällig morgen“, „fällig Fr., 16.10.“ bzw. „überfällig“.</summary>
public static string Due(DateOnly? due, DateOnly today)
{
if (due is not { } date)
{
return string.Empty;
}
return (date.DayNumber - today.DayNumber) switch
{
< 0 => $"überfällig seit {date.ToString("dd.MM.", German)}",
0 => "fällig heute",
1 => "fällig morgen",
_ when date.Year == today.Year => $"fällig {date.ToString("ddd", German).TrimEnd('.')}., {date.ToString("dd.MM.", German)}",
_ => $"fällig {date.ToString("dd.MM.yyyy", German)}",
};
}
public static bool IsOverdue(DateOnly? due, DateOnly today) => due is { } date && date < today;
/// <summary>Datum und Uhrzeit in der Familienzeitzone („12.10.2026 10:30“).</summary>
public static string Local(DateTimeOffset utc) => FamilyTime.ToFamilyTime(utc).ToString("dd.MM.yyyy HH:mm", German);
/// <summary>Antwort lesbar machen: bei Auswahlaufgaben den Text der gewählten Option statt des Index.</summary>
public static string AnswerText(LearningAnswerType type, IReadOnlyList<string> options, string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return "keine Antwort";
}
return type == LearningAnswerType.SingleChoice && int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out var index) && index < options.Count
? options[index]
: value;
}
/// <summary>Kurze Beschreibung der Prüfungsregeln für das Kind vor dem Start.</summary>
public static string ExamRules(int? timeLimitMinutes) =>
timeLimitMinutes is > 0
? $"Du hast {timeLimitMinutes} Minuten. Die Uhr startet sofort und läuft weiter, auch wenn du die Seite schließt. Hinweise und Lösungen gibt es erst nach der Abgabe."
: "Hinweise und Lösungen gibt es erst nach der Abgabe.";
}
@@ -0,0 +1,222 @@
@using FamilyPlanner.Domain.Common
@using FamilyPlanner.Domain.Learning
@using FamilyPlanner.Shared.Admin
@using FamilyPlanner.Shared.Learning
@implements IDisposable
@inject ILearningApi Api
@inject ILiveConnection Live
@inject IDialogService DialogService
@inject ISnackbar Snackbar
@inject TimeProvider TimeProvider
@* Elternansicht „Lernen“: Blätter verwalten und Prüf-Warteschlange (ADR-0021). *@
<section class="fp-learning-parent" aria-label="Lernen verwalten">
<div class="d-flex align-center flex-wrap gap-2 mb-2">
<MudText Typo="Typo.h4" HtmlTag="h1">Lernen</MudText>
<MudSpacer />
<MudButton Href="lernen/neu" Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" Class="fp-learning-new">Neues Blatt</MudButton>
</div>
<div class="d-flex align-center flex-wrap gap-1 mb-3" role="tablist" aria-label="Bereiche">
<MudChip T="string" Color="@(_tab == Tab.Sets ? Color.Primary : Color.Default)" Variant="@(_tab == Tab.Sets ? Variant.Filled : Variant.Outlined)" OnClick="() => _tab = Tab.Sets"
role="tab" aria-selected="@(_tab == Tab.Sets ? "true" : "false")" Class="fp-learning-tab-sets">Blätter (@_sets.Count)</MudChip>
<MudChip T="string" Color="@(_tab == Tab.Review ? Color.Primary : _queue.Count > 0 ? Color.Warning : Color.Default)" Variant="@(_tab == Tab.Review ? Variant.Filled : Variant.Outlined)"
OnClick="() => _tab = Tab.Review" role="tab" aria-selected="@(_tab == Tab.Review ? "true" : "false")" Class="fp-learning-tab-review">Zu prüfen (@_queue.Count)</MudChip>
</div>
@if (_loading)
{
<LoadingIndicator />
}
else if (_error is not null)
{
<MudAlert Severity="Severity.Warning" Class="fp-learning-error">
@_error
<MudButton Variant="Variant.Text" Color="Color.Inherit" OnClick="LoadAsync">Erneut versuchen</MudButton>
</MudAlert>
}
else if (_tab == Tab.Review)
{
@if (_queue.Count == 0)
{
<MudText Class="my-6 text-center fp-learning-queue-empty">Nichts zu prüfen – alle Abgaben sind bestätigt. 🎉</MudText>
}
else
{
<ul class="fp-learning-queue" aria-label="Zu prüfen">
@foreach (var entry in _queue)
{
<li class="fp-learning-queue-item" data-attempt-id="@entry.AttemptId">
<div class="fp-learning-card-text">
<span class="fp-learning-title">@entry.ChildName – @entry.SetTitle</span>
<span class="fp-learning-meta">
<span>Versuch @entry.Number · @LearningFormat.Mode(entry.Mode) · abgegeben @LearningFormat.Local(entry.SubmittedAt)</span>
@if (entry.AutoSubmitted)
{
<MudChip T="string" Size="Size.Small" Color="Color.Warning" Variant="Variant.Outlined" Class="fp-learning-auto">Zeit abgelaufen</MudChip>
}
<span>@LearningFormat.Points(entry.EarnedPoints) von @entry.TotalItemPoints</span>
@if (entry.OpenCount > 0)
{
<MudChip T="string" Size="Size.Small" Color="Color.Info" Class="fp-learning-open">@(entry.OpenCount == 1 ? "1 Antwort offen" : $"{entry.OpenCount} Antworten offen")</MudChip>
}
</span>
</div>
<MudButton Href="@($"lernen/pruefen/{entry.AttemptId}")" Variant="Variant.Filled" Color="Color.Primary" Class="fp-learning-review-button"
aria-label="@($"{entry.SetTitle} von {entry.ChildName} prüfen")">Prüfen</MudButton>
</li>
}
</ul>
}
}
else
{
<div class="d-flex flex-wrap gap-2 mb-2 fp-learning-filters">
<MudSelect T="LearningSetStatus?" @bind-Value="_statusFilter" Label="Status" Variant="Variant.Outlined" Margin="Margin.Dense" Clearable="true" Class="fp-filter"
ToStringFunc="@(s => s is { } v ? LearningFormat.Status(v) : string.Empty)">
@foreach (var status in Enum.GetValues<LearningSetStatus>())
{
<MudSelectItem T="LearningSetStatus?" Value="@status">@LearningFormat.Status(status)</MudSelectItem>
}
</MudSelect>
<MudSelect T="Guid?" @bind-Value="_childFilter" Label="Kind" Variant="Variant.Outlined" Margin="Margin.Dense" Clearable="true" Class="fp-filter"
ToStringFunc="@(id => id is { } v ? NameOf(v) : string.Empty)">
@foreach (var child in _children)
{
<MudSelectItem T="Guid?" Value="@child.Id">@child.DisplayName</MudSelectItem>
}
</MudSelect>
</div>
var visible = _sets.Where(s => (_statusFilter is null || s.Status == _statusFilter) && (_childFilter is null || s.TargetUserId == _childFilter)).ToList();
@if (visible.Count == 0)
{
<MudText Class="my-6 text-center fp-learning-empty">
@(_sets.Count == 0 ? "Noch keine Übungsblätter. Lege das erste Blatt an." : "Keine Blätter für diesen Filter.")
</MudText>
}
else
{
<ul class="fp-learning-list" aria-label="Übungsblätter">
@foreach (var set in visible)
{
<li class="fp-learning-list-item" data-set-id="@set.Id">
<div class="fp-learning-card-text">
<a href="@($"lernen/blatt/{set.Id}")" class="fp-learning-title">@set.Title</a>
<span class="fp-learning-meta">
<MudChip T="string" Size="Size.Small" Color="@LearningFormat.StatusColor(set.Status)" Class="fp-learning-status">@LearningFormat.Status(set.Status)</MudChip>
<span>@NameOf(set.TargetUserId)</span>
<span>@set.Subject · @LearningFormat.GradeLevel(set.GradeLevel)</span>
<span>@LearningFormat.Mode(set.Mode)@(set.TimeLimitMinutes is { } minutes ? $" · {minutes} Min." : string.Empty)</span>
<span>@(set.ItemCount == 1 ? "1 Aufgabe" : $"{set.ItemCount} Aufgaben")</span>
@if (set.DueDate is not null)
{
<span class="@(LearningFormat.IsOverdue(set.DueDate, Today) && set.Status is LearningSetStatus.Assigned or LearningSetStatus.InProgress ? "fp-learning-overdue" : null)">@LearningFormat.Due(set.DueDate, Today)</span>
}
@if (set.PendingReviewCount > 0)
{
<MudChip T="string" Size="Size.Small" Color="Color.Warning" Class="fp-learning-pending">@(set.PendingReviewCount == 1 ? "1 zu prüfen" : $"{set.PendingReviewCount} zu prüfen")</MudChip>
}
@if (set.PointsCredited != 0)
{
<span>@LearningFormat.Points(set.PointsCredited) gutgeschrieben</span>
}
</span>
</div>
<div class="fp-learning-card-actions">
<MudIconButton Icon="@Icons.Material.Filled.Edit" Href="@($"lernen/blatt/{set.Id}")" aria-label="@($"„{set.Title}“ bearbeiten")" Class="fp-learning-edit" />
<MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error" OnClick="() => DeleteAsync(set)" aria-label="@($"„{set.Title}“ löschen")" Class="fp-learning-delete" />
</div>
</li>
}
</ul>
}
}
</section>
@code {
private enum Tab
{
Sets,
Review,
}
private List<LearningSetDto> _sets = [];
private List<LearningReviewQueueItemDto> _queue = [];
private List<UserDirectoryEntry> _children = [];
private Dictionary<Guid, string> _names = [];
private Tab _tab = Tab.Sets;
private LearningSetStatus? _statusFilter;
private Guid? _childFilter;
private bool _loading = true;
private string? _error;
private DateOnly Today => FamilyTime.Today(TimeProvider);
protected override async Task OnInitializedAsync()
{
Live.LearningChanged += OnLearningChanged;
Live.Connected += LoadAsync;
await Live.EnsureStartedAsync();
await LoadAsync();
}
private Task OnLearningChanged(LearningChangedDto _) => LoadAsync();
private async Task LoadAsync()
{
var sets = await Api.GetSetsAsync();
var queue = await Api.GetReviewQueueAsync();
var users = await Api.GetUsersAsync();
if (users.Succeeded && users.Value is { } list)
{
_names = list.ToDictionary(u => u.Id, u => u.DisplayName);
_children = list.Where(u => u.Role == Roles.Child).OrderBy(u => u.DisplayName, StringComparer.CurrentCultureIgnoreCase).ToList();
}
if (sets.Succeeded && sets.Value is { } loaded)
{
_sets = loaded;
_queue = queue.Succeeded && queue.Value is { } q ? q : _queue;
_error = null;
}
else if (_sets.Count == 0)
{
_error = sets.Problem?.Message ?? "Die Übungsblätter konnten nicht geladen werden.";
}
_loading = false;
await InvokeAsync(StateHasChanged);
}
private string NameOf(Guid userId) => _names.GetValueOrDefault(userId, "Unbekannt");
private async Task DeleteAsync(LearningSetDto set)
{
var hint = set.AttemptCount > 0 ? " Bereits vergebene Punkte bleiben im Punktebuch bestehen." : string.Empty;
var confirmed = await DialogService.ShowMessageBoxAsync(
"Blatt löschen?", $"„{set.Title}“ wirklich löschen?{hint}", yesText: "Löschen", cancelText: "Abbrechen");
if (confirmed != true)
{
return;
}
var result = await Api.DeleteSetAsync(set.Id);
if (result.Succeeded)
{
_sets.RemoveAll(s => s.Id == set.Id);
_queue.RemoveAll(q => q.SetId == set.Id);
Snackbar.Add("Das Blatt ist gelöscht.", Severity.Success);
}
else
{
Snackbar.Add(result.Problem?.Message ?? "Das Blatt konnte nicht gelöscht werden.", Severity.Warning);
}
}
public void Dispose()
{
Live.LearningChanged -= OnLearningChanged;
Live.Connected -= LoadAsync;
}
}
@@ -0,0 +1,569 @@
@using System.Globalization
@using FamilyPlanner.Domain.Learning
@using FamilyPlanner.Shared.Admin
@using FamilyPlanner.Shared.Learning
@inject ILearningApi Api
@inject NavigationManager Navigation
@inject IDialogService DialogService
@inject ISnackbar Snackbar
@* Editor für ein Übungsblatt (Eltern): Einstellungen, Aufgaben, Zuweisen. Einstellungen zu Wiederholen/Zeitlimit/Modus/Punkten (ADR-0021). *@
<section class="fp-learning-editor" aria-label="Übungsblatt bearbeiten">
<div class="d-flex align-center flex-wrap gap-2 mb-2">
<MudIconButton Icon="@Icons.Material.Filled.ArrowBack" Href="lernen" aria-label="Zurück zur Übersicht" Class="fp-learning-editor-back" />
<MudText Typo="Typo.h4" HtmlTag="h1">@(SetId is null ? "Neues Übungsblatt" : "Übungsblatt")</MudText>
@if (_detail is { } head)
{
<MudChip T="string" Color="@LearningFormat.StatusColor(head.Set.Status)" Size="Size.Small" Class="fp-learning-status">@LearningFormat.Status(head.Set.Status)</MudChip>
}
</div>
@if (_loading)
{
<LoadingIndicator />
}
else if (_missing)
{
<MudAlert Severity="Severity.Info" Class="fp-learning-missing">Dieses Blatt gibt es nicht (mehr).</MudAlert>
}
else
{
@if (_conflict)
{
<MudAlert Severity="Severity.Warning" role="alert" Class="mb-2 fp-learning-conflict">
Das Blatt wurde inzwischen von jemand anderem geändert. Der aktuelle Stand ist geladen – bitte noch einmal speichern, wenn deine Änderungen bleiben sollen.
</MudAlert>
}
@if (_error is not null)
{
<MudAlert Severity="Severity.Error" role="alert" Class="mb-2 fp-dialog-error">@_error</MudAlert>
}
@if (Locked)
{
<MudAlert Severity="Severity.Info" Dense="true" Class="mb-2 fp-learning-locked">
Es gibt schon Versuche: Aufgaben, Kind, Modus, Mischen, Punkte und Notenschlüssel lassen sich nicht mehr ändern.
</MudAlert>
}
<EditForm Model="this" OnSubmit="SaveAsync" FormName="learning-set-form" id="learning-set-form">
<MudPaper Outlined="true" Class="pa-3 mb-3">
<MudStack Spacing="2">
<MudTextField @bind-Value="_title" Label="Titel" InputId="set-title" Variant="Variant.Outlined" AutoFocus="@(SetId is null)"
Error="@HasError("Title")" ErrorText="@ErrorFor("Title")" />
<div class="d-flex flex-wrap gap-2">
<MudSelect T="string" @bind-Value="_subject" Label="Fach" Variant="Variant.Outlined" Class="fp-task-field fp-set-subject"
Error="@HasError("Subject")" ErrorText="@ErrorFor("Subject")">
@foreach (var subject in LearningSubjects.All)
{
<MudSelectItem T="string" Value="@subject">@subject</MudSelectItem>
}
</MudSelect>
<MudSelect T="int" @bind-Value="_gradeLevel" Label="Klassenstufe" Variant="Variant.Outlined" Class="fp-task-field fp-set-grade"
ToStringFunc="@(g => LearningFormat.GradeLevel(g))" Error="@HasError("GradeLevel")" ErrorText="@ErrorFor("GradeLevel")">
@for (var level = LearningSet.MinGradeLevel; level <= LearningSet.MaxGradeLevel; level++)
{
<MudSelectItem T="int" Value="@level">@LearningFormat.GradeLevel(level)</MudSelectItem>
}
</MudSelect>
<MudSelect T="Guid" @bind-Value="_target" Label="Kind" Variant="Variant.Outlined" Class="fp-task-field fp-set-target" Disabled="Locked"
ToStringFunc="@(id => _children.FirstOrDefault(c => c.Id == id)?.DisplayName ?? string.Empty)"
Error="@HasError("TargetUserId")" ErrorText="@ErrorFor("TargetUserId")">
@foreach (var child in _children)
{
<MudSelectItem T="Guid" Value="@child.Id">@child.DisplayName</MudSelectItem>
}
</MudSelect>
<MudTextField T="string" @bind-Value="_dueDate" Label="Fällig am (optional)" InputType="InputType.Date" InputId="set-due" Variant="Variant.Outlined"
Class="fp-task-field" Clearable="true" Error="@HasError("DueDate")" ErrorText="@ErrorFor("DueDate")" />
</div>
</MudStack>
</MudPaper>
<MudPaper Outlined="true" Class="pa-3 mb-3 fp-set-settings">
<MudText Typo="Typo.h6" HtmlTag="h2" Class="mb-2">Ablauf</MudText>
<MudStack Spacing="2">
<div role="radiogroup" aria-label="Modus" class="d-flex flex-wrap gap-2">
<MudChip T="string" Color="@(_mode == LearningMode.Practice ? Color.Primary : Color.Default)" Variant="@(_mode == LearningMode.Practice ? Variant.Filled : Variant.Outlined)"
OnClick="() => SetMode(LearningMode.Practice)" role="radio" aria-checked="@(_mode == LearningMode.Practice ? "true" : "false")"
Disabled="Locked" Class="fp-set-mode-practice">Übung – Sofort-Rückmeldung und Hinweise</MudChip>
<MudChip T="string" Color="@(_mode == LearningMode.Exam ? Color.Primary : Color.Default)" Variant="@(_mode == LearningMode.Exam ? Variant.Filled : Variant.Outlined)"
OnClick="() => SetMode(LearningMode.Exam)" role="radio" aria-checked="@(_mode == LearningMode.Exam ? "true" : "false")"
Disabled="Locked" Class="fp-set-mode-exam">Prüfung – keine Rückmeldung und keine Hilfen bis zur Abgabe</MudChip>
</div>
<div class="d-flex flex-wrap gap-2 align-start">
<MudNumericField T="int" Value="_maxAttempts" ValueChanged="OnAttemptsChanged" Label="Versuche (0 = unbegrenzt)" Min="0" Max="LearningSet.AttemptsLimit"
Variant="Variant.Outlined" Class="fp-task-field fp-set-attempts" Error="@HasError("MaxAttempts")" ErrorText="@ErrorFor("MaxAttempts")"
HelperText="@LearningFormat.Attempts(_maxAttempts)" />
<div class="fp-task-field">
<MudSwitch T="bool" Value="_hasTimeLimit" ValueChanged="OnTimeLimitToggled" Label="Zeitlimit" Color="Color.Primary" Class="fp-set-timelimit-switch" />
@if (_hasTimeLimit)
{
<MudNumericField T="int" @bind-Value="_timeLimit" Label="Minuten" Min="1" Max="LearningSet.MaxTimeLimitMinutes" Variant="Variant.Outlined"
Class="fp-set-timelimit" Error="@HasError("TimeLimitMinutes")" ErrorText="@ErrorFor("TimeLimitMinutes")"
HelperText="Die Uhr läuft auf dem Server und startet mit dem Versuch." />
}
</div>
</div>
<MudSwitch T="bool" @bind-Value="_showSolutions" Label="Lösungen nach der Abgabe zeigen" Color="Color.Primary" Class="fp-set-solutions" />
<MudSwitch T="bool" @bind-Value="_shuffle" Label="Aufgaben mischen (je Versuch stabil)" Color="Color.Primary" Disabled="Locked" Class="fp-set-shuffle" />
</MudStack>
</MudPaper>
<MudPaper Outlined="true" Class="pa-3 mb-3 fp-set-points">
<MudText Typo="Typo.h6" HtmlTag="h2" Class="mb-2">Punkte und Note</MudText>
<MudStack Spacing="2">
<MudNumericField T="int" @bind-Value="_totalPoints" Label="Punkte für das Blatt (0 = keine)" Min="0" Max="LearningSet.MaxTotalPoints" Variant="Variant.Outlined"
Disabled="Locked" Class="fp-set-total" Error="@HasError("TotalPoints")" ErrorText="@ErrorFor("TotalPoints")"
HelperText="Gutschrift anteilig nach erreichten Punkten, erst nach deiner Bestätigung; bei Wiederholung zählt die beste Wertung." />
@if (_mode == LearningMode.Exam)
{
<MudTextField @bind-Value="_thresholds" Label="Notenschlüssel (Mindestprozent für Note 1 bis 5)" Variant="Variant.Outlined" Disabled="Locked" Class="fp-set-thresholds"
Placeholder="@string.Join(';', LearningGrades.DefaultThresholds)" HelperText="Leer = Schulnoten 92;81;67;50;30. Darunter gibt es die 6."
Error="@HasError("GradeThresholds")" ErrorText="@ErrorFor("GradeThresholds")" />
}
</MudStack>
</MudPaper>
<div class="d-flex flex-wrap gap-2 mb-4">
<MudButton ButtonType="ButtonType.Submit" Variant="Variant.Filled" Color="Color.Primary" Disabled="_busy" Class="fp-set-save">
@(SetId is null ? "Blatt anlegen" : "Einstellungen speichern")
</MudButton>
<MudButton Href="lernen" Variant="Variant.Text">@(SetId is null ? "Abbrechen" : "Zur Übersicht")</MudButton>
</div>
</EditForm>
@if (_detail is { } detail)
{
<MudPaper Outlined="true" Class="pa-3 mb-3 fp-set-items">
<div class="d-flex align-center flex-wrap gap-2 mb-2">
<MudText Typo="Typo.h6" HtmlTag="h2">Aufgaben (@detail.Items.Count)</MudText>
<MudSpacer />
@if (!Locked)
{
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Add" OnClick="() => EditItemAsync(null)" Class="fp-set-add-item">Aufgabe hinzufügen</MudButton>
}
</div>
@if (detail.Items.Count == 0)
{
<MudText Class="fp-set-no-items">Noch keine Aufgaben – füge die erste hinzu.</MudText>
}
else
{
<ol class="fp-set-item-list">
@for (var i = 0; i < detail.Items.Count; i++)
{
var item = detail.Items[i];
var index = i;
<li class="fp-set-item" data-item-id="@item.Id">
<div class="fp-set-item-text">
<span class="fp-set-item-question">@item.Question</span>
<span class="fp-set-item-meta">
@LearningFormat.AnswerType(item.AnswerType) · @LearningFormat.Difficulty(item.Difficulty) · @LearningFormat.Points(item.Points)
· Lösung: @LearningFormat.AnswerText(item.AnswerType, item.Options, item.CorrectAnswer)
</span>
</div>
@if (!Locked)
{
<div class="fp-set-item-actions">
<MudIconButton Icon="@Icons.Material.Filled.ArrowUpward" Disabled="@(index == 0 || _busy)" OnClick="() => MoveAsync(index, -1)"
aria-label="@($"Aufgabe {index + 1} nach oben")" Class="fp-item-up" />
<MudIconButton Icon="@Icons.Material.Filled.ArrowDownward" Disabled="@(index == detail.Items.Count - 1 || _busy)" OnClick="() => MoveAsync(index, 1)"
aria-label="@($"Aufgabe {index + 1} nach unten")" Class="fp-item-down" />
<MudIconButton Icon="@Icons.Material.Filled.Edit" OnClick="() => EditItemAsync(item)" aria-label="@($"Aufgabe {index + 1} bearbeiten")" Class="fp-item-edit" />
<MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error" OnClick="() => DeleteItemAsync(item, index)"
aria-label="@($"Aufgabe {index + 1} löschen")" Class="fp-item-delete" />
</div>
}
</li>
}
</ol>
}
</MudPaper>
<MudPaper Outlined="true" Class="pa-3 mb-3 fp-set-assign">
<MudText Typo="Typo.h6" HtmlTag="h2" Class="mb-2">Zuweisen</MudText>
@if (detail.Set.Status == LearningSetStatus.Draft)
{
<MudText Typo="Typo.body2" Class="mb-2">Das Blatt ist noch ein Entwurf – das Kind sieht es erst nach dem Zuweisen.</MudText>
<MudButton Variant="Variant.Filled" Color="Color.Success" StartIcon="@Icons.Material.Filled.Send" OnClick="AssignAsync" Disabled="_busy" Class="fp-set-assign-button">
@(_children.FirstOrDefault(c => c.Id == detail.Set.TargetUserId) is { } child ? $"An {child.DisplayName} zuweisen" : "Zuweisen")
</MudButton>
}
else if (detail.Set.Status == LearningSetStatus.Assigned && detail.Set.AttemptCount == 0)
{
<MudText Typo="Typo.body2" Class="mb-2">Zugewiesen@(detail.Set.AssignedAt is { } at ? $" am {LearningFormat.Local(at)}" : string.Empty). Noch kein Versuch.</MudText>
<MudButton Variant="Variant.Outlined" OnClick="UnassignAsync" Disabled="_busy" Class="fp-set-unassign-button">Zurück zum Entwurf</MudButton>
}
else
{
<MudText Typo="Typo.body2">Zugewiesen@(detail.Set.AssignedAt is { } assigned ? $" am {LearningFormat.Local(assigned)}" : string.Empty).</MudText>
}
</MudPaper>
@if (detail.Attempts.Count > 0)
{
<MudPaper Outlined="true" Class="pa-3 mb-3 fp-set-attempt-list">
<MudText Typo="Typo.h6" HtmlTag="h2" Class="mb-2">Versuche</MudText>
<ul class="fp-set-attempts-list">
@foreach (var attempt in detail.Attempts)
{
<li>
<span>Versuch @attempt.Number – @AttemptText(attempt)</span>
@if (attempt.Status != LearningAttemptStatus.InProgress)
{
<MudButton Variant="Variant.Text" Size="Size.Small" Href="@($"lernen/pruefen/{attempt.Id}")" Class="fp-set-review-link">
@(attempt.Status == LearningAttemptStatus.Submitted ? "Prüfen" : "Ansehen")
</MudButton>
}
</li>
}
</ul>
</MudPaper>
}
}
}
</section>
@code {
private readonly List<UserDirectoryEntry> _children = [];
private LearningSetDetailDto? _detail;
private byte[]? _version;
private Guid? _loadedId;
private bool _loading = true;
private bool _missing;
private bool _busy;
private bool _conflict;
private string? _error;
private Dictionary<string, string> _errors = [];
private string _title = string.Empty;
private string _subject = LearningSubjects.Math;
private int _gradeLevel = 3;
private Guid _target;
private string? _dueDate;
private LearningMode _mode = LearningMode.Practice;
private int _maxAttempts;
private bool _attemptsTouched;
private bool _hasTimeLimit;
private int _timeLimit = 20;
private bool _showSolutions = true;
private bool _shuffle;
private int _totalPoints = 10;
private string? _thresholds;
[Parameter]
public Guid? SetId { get; set; }
private bool Locked => _detail?.Set.AttemptCount > 0;
protected override async Task OnParametersSetAsync()
{
if (_loadedId == SetId && !_loading)
{
return;
}
_loadedId = SetId;
await LoadAsync();
}
private async Task LoadAsync()
{
_loading = true;
_missing = false;
var users = await Api.GetUsersAsync();
_children.Clear();
if (users.Succeeded && users.Value is { } list)
{
_children.AddRange(list.Where(u => u.Role == Roles.Child).OrderBy(u => u.DisplayName, StringComparer.CurrentCultureIgnoreCase));
}
if (SetId is { } id)
{
var result = await Api.GetSetAsync(id);
if (result.Succeeded && result.Value is { } detail)
{
_detail = detail;
LoadForm(detail.Set);
}
else
{
_missing = true;
}
}
else
{
_detail = null;
_version = null;
if (_children.Count == 1)
{
_target = _children[0].Id;
}
}
_loading = false;
}
private void LoadForm(LearningSetDto set)
{
_title = set.Title;
_subject = set.Subject;
_gradeLevel = set.GradeLevel;
_target = set.TargetUserId;
_dueDate = set.DueDate?.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
_mode = set.Mode;
_maxAttempts = set.MaxAttempts;
_attemptsTouched = true;
_hasTimeLimit = set.TimeLimitMinutes is not null;
_timeLimit = set.TimeLimitMinutes ?? 20;
_showSolutions = set.ShowSolutionsAfterSubmit;
_shuffle = set.ShuffleItems;
_totalPoints = set.TotalPoints;
_thresholds = set.GradeThresholds;
_version = set.Version;
}
/// <summary>Voreinstellungen je Modus für neue Blätter: Übung beliebig oft mit Lösungen, Prüfung einmalig ohne Lösungen.</summary>
private void SetMode(LearningMode mode)
{
if (Locked || _mode == mode)
{
return;
}
_mode = mode;
if (SetId is null && !_attemptsTouched)
{
_maxAttempts = mode == LearningMode.Exam ? 1 : 0;
_showSolutions = mode == LearningMode.Practice;
}
}
private void OnAttemptsChanged(int value)
{
_maxAttempts = value;
_attemptsTouched = true;
}
private void OnTimeLimitToggled(bool on) => _hasTimeLimit = on;
private bool HasError(string field) => _errors.ContainsKey(field);
private string? ErrorFor(string field) => _errors.GetValueOrDefault(field);
private DateOnly? DueDate => DateOnly.TryParseExact(_dueDate, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var date) ? date : null;
private async Task SaveAsync()
{
if (_busy)
{
return;
}
_error = null;
_conflict = false;
_errors = [];
int? timeLimit = _hasTimeLimit ? _timeLimit : null;
string? thresholds = _mode == LearningMode.Exam && !string.IsNullOrWhiteSpace(_thresholds) ? _thresholds.Trim() : null;
var validation = SetId is null
? new CreateLearningSetRequestValidator().Validate(new CreateLearningSetRequest(
_title, _subject, _gradeLevel, _target, DueDate, _mode, _maxAttempts, timeLimit, _showSolutions, _shuffle, _totalPoints, thresholds))
: new UpdateLearningSetRequestValidator().Validate(new UpdateLearningSetRequest(
_title, _subject, _gradeLevel, _target, DueDate, _mode, _maxAttempts, timeLimit, _showSolutions, _shuffle, _totalPoints, thresholds, _version));
foreach (var error in validation.Errors)
{
_errors.TryAdd(error.PropertyName, error.ErrorMessage);
}
if (!string.IsNullOrWhiteSpace(_dueDate) && DueDate is null)
{
_errors["DueDate"] = "Bitte ein gültiges Datum eingeben.";
}
if (_errors.Count > 0)
{
return;
}
_busy = true;
try
{
if (SetId is null)
{
var created = await Api.CreateSetAsync(new CreateLearningSetRequest(
_title, _subject, _gradeLevel, _target, DueDate, _mode, _maxAttempts, timeLimit, _showSolutions, _shuffle, _totalPoints, thresholds));
if (created.Succeeded && created.Value is { } detail)
{
Snackbar.Add("Das Blatt ist angelegt – jetzt kannst du Aufgaben hinzufügen.", Severity.Success);
Navigation.NavigateTo($"lernen/blatt/{detail.Set.Id}");
}
else
{
ShowProblem(created.Problem, "Das Blatt konnte nicht angelegt werden.");
}
return;
}
var updated = await Api.UpdateSetAsync(SetId.Value, new UpdateLearningSetRequest(
_title, _subject, _gradeLevel, _target, DueDate, _mode, _maxAttempts, timeLimit, _showSolutions, _shuffle, _totalPoints, thresholds, _version));
if (updated.Succeeded && updated.Value is { } saved)
{
_detail = saved;
LoadForm(saved.Set);
Snackbar.Add("Gespeichert.", Severity.Success);
}
else if (updated.Problem?.Status == 409 && updated.Value is { } current)
{
// Jemand anderes war schneller: Version und Anzeige aktualisieren, Eingaben bleiben stehen.
_conflict = true;
_detail = current;
_version = current.Set.Version;
}
else
{
ShowProblem(updated.Problem, "Das Blatt konnte nicht gespeichert werden.");
}
}
finally
{
_busy = false;
}
}
private void ShowProblem(ApiProblem? problem, string fallback)
{
if (problem?.Errors is { Count: > 0 })
{
foreach (var (field, messages) in problem.Errors)
{
_errors[field] = string.Join(" ", messages);
}
}
else
{
_error = problem?.Message ?? fallback;
}
}
/// <summary>Übernimmt den Stand nach einer Aufgaben-Änderung, ohne ungespeicherte Eingaben des Formulars zu überschreiben.</summary>
private void ApplyDetail(LearningSetDetailDto detail)
{
_detail = detail;
_version = detail.Set.Version;
}
private async Task EditItemAsync(LearningItemDto? item)
{
if (_detail is not { } detail)
{
return;
}
var parameters = new DialogParameters<ItemEditDialog> { { x => x.SetId, detail.Set.Id }, { x => x.Item, item } };
var dialog = await DialogService.ShowAsync<ItemEditDialog>(
item is null ? "Aufgabe hinzufügen" : "Aufgabe bearbeiten", parameters, new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true, CloseOnEscapeKey = true });
var result = await dialog.Result;
if (result is { Canceled: false, Data: LearningSetDetailDto updated })
{
ApplyDetail(updated);
}
}
private async Task DeleteItemAsync(LearningItemDto item, int index)
{
var confirmed = await DialogService.ShowMessageBoxAsync(
"Aufgabe löschen?", $"Aufgabe {index + 1} wirklich löschen?", yesText: "Löschen", cancelText: "Abbrechen");
if (confirmed != true || _detail is not { } detail)
{
return;
}
await RunAsync(() => Api.DeleteItemAsync(detail.Set.Id, item.Id), "Die Aufgabe konnte nicht gelöscht werden.");
}
private async Task MoveAsync(int index, int delta)
{
if (_detail is not { } detail)
{
return;
}
var ids = detail.Items.Select(i => i.Id).ToList();
var target = index + delta;
if (target < 0 || target >= ids.Count)
{
return;
}
(ids[index], ids[target]) = (ids[target], ids[index]);
await RunAsync(() => Api.ReorderItemsAsync(detail.Set.Id, ids), "Die Reihenfolge konnte nicht geändert werden.");
}
private async Task AssignAsync()
{
if (_detail is not { } detail)
{
return;
}
if (await RunAsync(() => Api.AssignAsync(detail.Set.Id), "Das Blatt konnte nicht zugewiesen werden."))
{
Snackbar.Add("Das Blatt ist zugewiesen.", Severity.Success);
}
}
private async Task UnassignAsync()
{
if (_detail is { } detail)
{
await RunAsync(() => Api.UnassignAsync(detail.Set.Id), "Das Blatt konnte nicht zurückgenommen werden.");
}
}
private async Task<bool> RunAsync(Func<Task<ApiResult<LearningSetDetailDto>>> action, string fallback)
{
if (_busy)
{
return false;
}
_busy = true;
_error = null;
try
{
var result = await action();
if (result.Succeeded && result.Value is { } detail)
{
ApplyDetail(detail);
return true;
}
if (result.Value is { } current)
{
ApplyDetail(current);
}
_error = result.Problem?.Message ?? fallback;
return false;
}
finally
{
_busy = false;
}
}
private static string AttemptText(LearningAttemptSummaryDto attempt) => attempt.Status switch
{
LearningAttemptStatus.InProgress => "läuft",
LearningAttemptStatus.Submitted => attempt.AutoSubmitted ? "abgegeben (Zeit abgelaufen), wartet auf Prüfung" : "abgegeben, wartet auf Prüfung",
_ => $"geprüft: {attempt.EarnedPoints} von {attempt.TotalItemPoints} Punkten{(attempt.Grade is { } g ? $", Note {g}" : string.Empty)}",
};
}
@@ -3,7 +3,7 @@ using MudBlazor;
namespace FamilyPlanner.Web.Client.Layout;
public sealed record NavItem(string Href, string Title, string ShortTitle, string Icon, bool InBottomNav, string? Policy = null);
public sealed record NavItem(string Href, string Title, string ShortTitle, string Icon, bool InBottomNav, string? Policy = null, bool Separated = false);
/// <summary>Zentrale Navigationseinträge für Drawer (Desktop) und Bottom-Navigation (Smartphone).</summary>
public static class NavItems
@@ -16,6 +16,7 @@ public static class NavItems
new("aufgaben", "Aufgaben", "Aufgaben", Icons.Material.Filled.TaskAlt, InBottomNav: true),
new("kalender", "Kalender", "Kalender", Icons.Material.Filled.CalendarMonth, InBottomNav: true),
new("chat", "Chat", "Chat", Icons.Material.Filled.Chat, InBottomNav: true),
new("admin", "Verwaltung", "Admin", Icons.Material.Filled.AdminPanelSettings, InBottomNav: false, Policies.Admin),
new("lernen", "Lernen", "Lernen", Icons.Material.Filled.School, InBottomNav: false, Policies.FamilyMember),
new("admin", "Verwaltung", "Admin", Icons.Material.Filled.AdminPanelSettings, InBottomNav: false, Policies.Admin, Separated: true),
];
}
@@ -19,7 +19,10 @@
else
{
<AuthorizeView Policy="@item.Policy">
<MudDivider Class="my-2" />
@if (item.Separated)
{
<MudDivider Class="my-2" />
}
<MudNavLink Href="@item.Href" Icon="@item.Icon" Match="NavLinkMatch.Prefix">@item.Title</MudNavLink>
</AuthorizeView>
}
@@ -217,6 +217,56 @@ else
</MudItem>
}
@if (_data.Learning is { } learning)
{
<MudItem xs="12" sm="6" lg="4">
<MudCard Outlined="true" Class="fp-tile fp-dash-learning">
<MudCardHeader>
<CardHeaderAvatar><MudIcon Icon="@Icons.Material.Filled.School" Color="Color.Primary" /></CardHeaderAvatar>
<CardHeaderContent><MudText Typo="Typo.h6" HtmlTag="h2">Lernen</MudText></CardHeaderContent>
</MudCardHeader>
<MudCardContent>
@if (learning.ForParents)
{
<MudText Typo="Typo.body2" Class="mb-2 fp-dash-learning-summary">
@(learning.ToReviewCount == 0 ? "Nichts zu prüfen." : DashboardFormat.Count(learning.ToReviewCount, "Abgabe", "Abgaben") + " zu prüfen")
@if (learning.OpenCount > 0)
{
<MudChip T="string" Size="Size.Small" Class="ml-1 fp-dash-learning-open">@DashboardFormat.Count(learning.OpenCount, "Blatt", "Blätter") offen</MudChip>
}
</MudText>
}
else
{
<MudText Typo="Typo.body2" Class="mb-2 fp-dash-learning-summary">
@(learning.OpenCount == 0 ? "Keine offenen Übungsblätter – super!" : DashboardFormat.Count(learning.OpenCount, "offenes Übungsblatt", "offene Übungsblätter"))
</MudText>
}
@if (learning.Items.Count > 0)
{
<ul class="fp-dash-list fp-dash-learning-list">
@foreach (var entry in learning.Items)
{
<li>
<a href="@LearningHref(learning, entry)" class="fp-dash-main">@(entry.ChildName is { } child ? $"{child}: " : string.Empty)@entry.Title</a>
<span class="fp-dash-sub">
@entry.Subject@(entry.DueDate is { } due ? $" · {Components.Learning.LearningFormat.Due(due, Today)}" : string.Empty)
@if (entry.IsOverdue)
{
<MudChip T="string" Size="Size.Small" Color="Color.Error" Class="ml-1 fp-dash-overdue">überfällig</MudChip>
}
</span>
</li>
}
</ul>
}
</MudCardContent>
<MudCardActions><MudButton Href="lernen" Variant="Variant.Text" Color="Color.Primary">Lernen</MudButton></MudCardActions>
</MudCard>
</MudItem>
}
@if (_data.Chat is { } chat)
{
<MudItem xs="12" sm="6" lg="4">
@@ -271,6 +321,7 @@ else
Live.TaskRemoved += OnTaskRemoved;
Live.TaskPointsChanged += OnLiveEvent;
Live.CalendarChanged += OnCalendarChanged;
Live.LearningChanged += OnLearningChanged;
Live.ChatMessageReceived += OnChatMessage;
Live.ChatMessageRemoved += OnChatRemoved;
Live.ChatReadChanged += OnChatRead;
@@ -340,6 +391,12 @@ else
private Task OnCalendarChanged(Guid _) => ScheduleRefreshAsync();
private Task OnLearningChanged(FamilyPlanner.Shared.Learning.LearningChangedDto _) => ScheduleRefreshAsync();
private static string LearningHref(DashboardLearningDto learning, DashboardLearningItemDto entry) => entry.AttemptId is { } attempt
? learning.ForParents ? $"lernen/pruefen/{attempt}" : $"lernen/versuch/{attempt}"
: "lernen";
private Task OnChatMessage(FamilyPlanner.Shared.Chat.ChatMessageDto _) => ScheduleRefreshAsync();
private Task OnChatRemoved(Guid _, Guid __) => ScheduleRefreshAsync();
@@ -388,6 +445,7 @@ else
Live.TaskRemoved -= OnTaskRemoved;
Live.TaskPointsChanged -= OnLiveEvent;
Live.CalendarChanged -= OnCalendarChanged;
Live.LearningChanged -= OnLearningChanged;
Live.ChatMessageReceived -= OnChatMessage;
Live.ChatMessageRemoved -= OnChatRemoved;
Live.ChatReadChanged -= OnChatRead;
@@ -0,0 +1,19 @@
@page "/lernen"
@using FamilyPlanner.Web.Client.Components.Learning
@inject ApiAuthenticationStateProvider AuthState
<PageTitle>Lernen – Familienplaner</PageTitle>
@if (LearningAccess.CanManage(AuthState.CurrentUser))
{
<ParentOverview />
}
else if (LearningAccess.CanSolve(AuthState.CurrentUser))
{
<KidOverview />
}
else
{
<MudText Typo="Typo.h4" HtmlTag="h1" Class="mb-2">Lernen</MudText>
<MudAlert Severity="Severity.Info" Class="fp-learning-no-access">Dieser Bereich ist für dein Konto nicht freigeschaltet.</MudAlert>
}
@@ -0,0 +1,19 @@
@page "/lernen/versuch/{AttemptId:guid}"
@using FamilyPlanner.Web.Client.Components.Learning
@inject ApiAuthenticationStateProvider AuthState
<PageTitle>Blatt lösen – Familienplaner</PageTitle>
@if (LearningAccess.CanSolve(AuthState.CurrentUser))
{
<AttemptRunner @key="AttemptId" AttemptId="AttemptId" />
}
else
{
<MudAlert Severity="Severity.Info" Class="fp-learning-no-access">Dieser Bereich ist für dein Konto nicht freigeschaltet.</MudAlert>
}
@code {
[Parameter]
public Guid AttemptId { get; set; }
}
@@ -0,0 +1,19 @@
@page "/lernen/pruefen/{AttemptId:guid}"
@using FamilyPlanner.Web.Client.Components.Learning
@inject ApiAuthenticationStateProvider AuthState
<PageTitle>Versuch prüfen – Familienplaner</PageTitle>
@if (LearningAccess.CanManage(AuthState.CurrentUser))
{
<AttemptReview AttemptId="AttemptId" />
}
else
{
<MudAlert Severity="Severity.Info" Class="fp-learning-no-access">Übungsblätter prüfen können nur Eltern.</MudAlert>
}
@code {
[Parameter]
public Guid AttemptId { get; set; }
}
@@ -0,0 +1,20 @@
@page "/lernen/neu"
@page "/lernen/blatt/{SetId:guid}"
@using FamilyPlanner.Web.Client.Components.Learning
@inject ApiAuthenticationStateProvider AuthState
<PageTitle>Übungsblatt – Familienplaner</PageTitle>
@if (LearningAccess.CanManage(AuthState.CurrentUser))
{
<SetEditor SetId="SetId" />
}
else
{
<MudAlert Severity="Severity.Info" Class="fp-learning-no-access">Übungsblätter können nur Eltern anlegen und bearbeiten.</MudAlert>
}
@code {
[Parameter]
public Guid? SetId { get; set; }
}
+1
View File
@@ -34,6 +34,7 @@ builder.Services.AddScoped<ICalendarApi, CalendarApi>();
builder.Services.AddScoped<IDashboardApi, DashboardApi>();
builder.Services.AddScoped<IChatApi, ChatApi>();
builder.Services.AddScoped<IAiApi, AiApi>();
builder.Services.AddScoped<ILearningApi, LearningApi>();
builder.Services.AddScoped<IPushService, PushService>();
builder.Services.AddScoped<ILocalStore, BrowserLocalStore>();
builder.Services.AddScoped<ILiveConnection, LiveConnection>();
@@ -0,0 +1,197 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using FamilyPlanner.Shared.Admin;
using FamilyPlanner.Shared.Learning;
namespace FamilyPlanner.Web.Client.Services;
/// <summary>
/// Api der Lernaufgaben (ADR-0021). Eltern-Aufrufe (<c>api/learning</c>) brauchen <c>LearningManage</c>, Kinder-Aufrufe
/// (<c>api/learning/my</c>) <c>LearningSolve</c>; erzwungen wird serverseitig.
/// </summary>
public interface ILearningApi
{
// ---- Eltern ----
Task<ApiResult<List<LearningSetDto>>> GetSetsAsync(CancellationToken ct = default);
Task<ApiResult<LearningSetDetailDto>> GetSetAsync(Guid id, CancellationToken ct = default);
Task<ApiResult<LearningSetDetailDto>> CreateSetAsync(CreateLearningSetRequest request, CancellationToken ct = default);
/// <summary>Bei 409 wegen veralteter Version enthält <see cref="ApiResult{T}.Value"/> den aktuellen Stand.</summary>
Task<ApiResult<LearningSetDetailDto>> UpdateSetAsync(Guid id, UpdateLearningSetRequest request, CancellationToken ct = default);
Task<ApiResult<bool>> DeleteSetAsync(Guid id, CancellationToken ct = default);
Task<ApiResult<LearningSetDetailDto>> AssignAsync(Guid id, CancellationToken ct = default);
Task<ApiResult<LearningSetDetailDto>> UnassignAsync(Guid id, CancellationToken ct = default);
Task<ApiResult<LearningSetDetailDto>> AddItemAsync(Guid setId, LearningItemInput request, CancellationToken ct = default);
/// <summary>Bei 409 wegen veralteter Version enthält <see cref="ApiResult{T}.Value"/> den aktuellen Stand des Blatts.</summary>
Task<ApiResult<LearningSetDetailDto>> UpdateItemAsync(Guid setId, Guid itemId, UpdateLearningItemRequest request, CancellationToken ct = default);
Task<ApiResult<LearningSetDetailDto>> DeleteItemAsync(Guid setId, Guid itemId, CancellationToken ct = default);
Task<ApiResult<LearningSetDetailDto>> ReorderItemsAsync(Guid setId, IReadOnlyList<Guid> itemIds, CancellationToken ct = default);
Task<ApiResult<List<LearningReviewQueueItemDto>>> GetReviewQueueAsync(CancellationToken ct = default);
Task<ApiResult<LearningAttemptReviewDto>> GetReviewAttemptAsync(Guid attemptId, CancellationToken ct = default);
Task<ApiResult<LearningAttemptReviewDto>> ReviewAnswerAsync(Guid attemptId, Guid itemId, ReviewAnswerRequest request, CancellationToken ct = default);
Task<ApiResult<LearningReviewResultDto>> ConfirmAsync(Guid attemptId, ConfirmAttemptRequest request, CancellationToken ct = default);
Task<ApiResult<LearningReviewResultDto>> ReopenAsync(Guid attemptId, CancellationToken ct = default);
Task<ApiResult<List<UserDirectoryEntry>>> GetUsersAsync(CancellationToken ct = default);
// ---- Kinder ----
Task<ApiResult<List<MyLearningSetDto>>> GetMineAsync(CancellationToken ct = default);
/// <summary>Startet einen Versuch (bei Prüfungen beginnt die Uhr) oder setzt den laufenden fort.</summary>
Task<ApiResult<LearningAttemptDto>> StartAsync(Guid setId, CancellationToken ct = default);
Task<ApiResult<LearningAttemptDto>> GetAttemptAsync(Guid attemptId, CancellationToken ct = default);
/// <summary>Speichert eine Antwort; bei 409 (abgegeben oder Zeit abgelaufen) enthält <see cref="ApiResult{T}.Problem"/> die Meldung.</summary>
Task<ApiResult<SaveAnswerResultDto>> SaveAnswerAsync(Guid attemptId, Guid itemId, SaveAnswerRequest request, CancellationToken ct = default);
Task<ApiResult<LearningAttemptDto>> SubmitAsync(Guid attemptId, CancellationToken ct = default);
}
public sealed class LearningApi(HttpClient http) : ILearningApi
{
private const string Base = "api/learning";
public Task<ApiResult<List<LearningSetDto>>> GetSetsAsync(CancellationToken ct = default) =>
Send<List<LearningSetDto>>(() => http.GetAsync($"{Base}/sets", ct), ct);
public Task<ApiResult<LearningSetDetailDto>> GetSetAsync(Guid id, CancellationToken ct = default) =>
Send<LearningSetDetailDto>(() => http.GetAsync($"{Base}/sets/{id}", ct), ct);
public Task<ApiResult<LearningSetDetailDto>> CreateSetAsync(CreateLearningSetRequest request, CancellationToken ct = default) =>
Send<LearningSetDetailDto>(() => http.PostAsJsonAsync($"{Base}/sets", request, ct), ct);
public Task<ApiResult<LearningSetDetailDto>> UpdateSetAsync(Guid id, UpdateLearningSetRequest request, CancellationToken ct = default) =>
Send<LearningSetDetailDto>(() => http.PutAsJsonAsync($"{Base}/sets/{id}", request, ct), ct, currentOnConflict: true);
public Task<ApiResult<bool>> DeleteSetAsync(Guid id, CancellationToken ct = default) =>
ApiCall.SendAsync(() => http.DeleteAsync($"{Base}/sets/{id}", ct), ct);
public Task<ApiResult<LearningSetDetailDto>> AssignAsync(Guid id, CancellationToken ct = default) =>
Send<LearningSetDetailDto>(() => http.PostAsync($"{Base}/sets/{id}/assign", null, ct), ct);
public Task<ApiResult<LearningSetDetailDto>> UnassignAsync(Guid id, CancellationToken ct = default) =>
Send<LearningSetDetailDto>(() => http.PostAsync($"{Base}/sets/{id}/unassign", null, ct), ct);
public Task<ApiResult<LearningSetDetailDto>> AddItemAsync(Guid setId, LearningItemInput request, CancellationToken ct = default) =>
Send<LearningSetDetailDto>(() => http.PostAsJsonAsync($"{Base}/sets/{setId}/items", request, ct), ct);
public Task<ApiResult<LearningSetDetailDto>> UpdateItemAsync(Guid setId, Guid itemId, UpdateLearningItemRequest request, CancellationToken ct = default) =>
Send<LearningSetDetailDto>(() => http.PutAsJsonAsync($"{Base}/sets/{setId}/items/{itemId}", request, ct), ct, currentOnConflict: true);
public Task<ApiResult<LearningSetDetailDto>> DeleteItemAsync(Guid setId, Guid itemId, CancellationToken ct = default) =>
Send<LearningSetDetailDto>(() => http.DeleteAsync($"{Base}/sets/{setId}/items/{itemId}", ct), ct);
public Task<ApiResult<LearningSetDetailDto>> ReorderItemsAsync(Guid setId, IReadOnlyList<Guid> itemIds, CancellationToken ct = default) =>
Send<LearningSetDetailDto>(() => http.PostAsJsonAsync($"{Base}/sets/{setId}/items/order", new ReorderLearningItemsRequest(itemIds), ct), ct);
public Task<ApiResult<List<LearningReviewQueueItemDto>>> GetReviewQueueAsync(CancellationToken ct = default) =>
Send<List<LearningReviewQueueItemDto>>(() => http.GetAsync($"{Base}/review", ct), ct);
public Task<ApiResult<LearningAttemptReviewDto>> GetReviewAttemptAsync(Guid attemptId, CancellationToken ct = default) =>
Send<LearningAttemptReviewDto>(() => http.GetAsync($"{Base}/attempts/{attemptId}", ct), ct);
public Task<ApiResult<LearningAttemptReviewDto>> ReviewAnswerAsync(Guid attemptId, Guid itemId, ReviewAnswerRequest request, CancellationToken ct = default) =>
Send<LearningAttemptReviewDto>(() => http.PutAsJsonAsync($"{Base}/attempts/{attemptId}/answers/{itemId}/review", request, ct), ct);
public Task<ApiResult<LearningReviewResultDto>> ConfirmAsync(Guid attemptId, ConfirmAttemptRequest request, CancellationToken ct = default) =>
Send<LearningReviewResultDto>(() => http.PostAsJsonAsync($"{Base}/attempts/{attemptId}/confirm", request, ct), ct);
public Task<ApiResult<LearningReviewResultDto>> ReopenAsync(Guid attemptId, CancellationToken ct = default) =>
Send<LearningReviewResultDto>(() => http.PostAsync($"{Base}/attempts/{attemptId}/reopen", null, ct), ct);
public Task<ApiResult<List<UserDirectoryEntry>>> GetUsersAsync(CancellationToken ct = default) =>
Send<List<UserDirectoryEntry>>(() => http.GetAsync("api/users", ct), ct);
public Task<ApiResult<List<MyLearningSetDto>>> GetMineAsync(CancellationToken ct = default) =>
Send<List<MyLearningSetDto>>(() => http.GetAsync($"{Base}/my", ct), ct);
public Task<ApiResult<LearningAttemptDto>> StartAsync(Guid setId, CancellationToken ct = default) =>
Send<LearningAttemptDto>(() => http.PostAsync($"{Base}/my/{setId}/attempts", null, ct), ct);
public Task<ApiResult<LearningAttemptDto>> GetAttemptAsync(Guid attemptId, CancellationToken ct = default) =>
Send<LearningAttemptDto>(() => http.GetAsync($"{Base}/my/attempts/{attemptId}", ct), ct);
public Task<ApiResult<SaveAnswerResultDto>> SaveAnswerAsync(Guid attemptId, Guid itemId, SaveAnswerRequest request, CancellationToken ct = default) =>
Send<SaveAnswerResultDto>(() => http.PutAsJsonAsync($"{Base}/my/attempts/{attemptId}/answers/{itemId}", request, ct), ct);
public Task<ApiResult<LearningAttemptDto>> SubmitAsync(Guid attemptId, CancellationToken ct = default) =>
Send<LearningAttemptDto>(() => http.PostAsync($"{Base}/my/attempts/{attemptId}/submit", null, ct), ct);
/// <summary>
/// 409 ist hier meist eine fachliche Meldung (Problem-Details). Nur wenn <paramref name="currentOnConflict"/> gesetzt ist und der Body ein
/// Blatt (<c>set</c>) enthält, steht darin der aktuelle Stand nach einer Versionskollision.
/// </summary>
private static async Task<ApiResult<T>> Send<T>(Func<Task<HttpResponseMessage>> send, CancellationToken ct, bool currentOnConflict = false)
{
try
{
using var response = await send();
if (response.StatusCode == HttpStatusCode.Conflict && currentOnConflict)
{
var text = await response.Content.ReadAsStringAsync(ct);
if (TryReadCurrent<T>(text) is { } current)
{
return new ApiResult<T>(current, new ApiProblem { Status = 409, Detail = "Das Blatt wurde inzwischen von jemand anderem geändert." });
}
return ApiResult<T>.Fail(ReadProblem(text, response.StatusCode));
}
if (!response.IsSuccessStatusCode)
{
return ApiResult<T>.Fail(await ApiClient.ReadProblemAsync(response, ct));
}
return response.StatusCode == HttpStatusCode.NoContent
? ApiResult<T>.Ok(default)
: ApiResult<T>.Ok(await response.Content.ReadFromJsonAsync<T>(ct));
}
catch (HttpRequestException)
{
return ApiResult<T>.Fail(ApiProblem.Offline);
}
}
private static T? TryReadCurrent<T>(string json)
{
try
{
using var document = JsonDocument.Parse(json);
return document.RootElement.ValueKind == JsonValueKind.Object && document.RootElement.TryGetProperty("set", out _)
? document.RootElement.Deserialize<T>(new JsonSerializerOptions(JsonSerializerDefaults.Web))
: default;
}
catch (JsonException)
{
return default;
}
}
private static ApiProblem ReadProblem(string json, HttpStatusCode status)
{
try
{
return JsonSerializer.Deserialize<ApiProblem>(json, new JsonSerializerOptions(JsonSerializerDefaults.Web)) ?? new ApiProblem { Status = (int)status };
}
catch (JsonException)
{
return new ApiProblem { Status = (int)status };
}
}
}
@@ -1,5 +1,6 @@
using FamilyPlanner.Shared.Calendar;
using FamilyPlanner.Shared.Chat;
using FamilyPlanner.Shared.Learning;
using FamilyPlanner.Shared.Meals;
using FamilyPlanner.Shared.Shopping;
using FamilyPlanner.Shared.Tasks;
@@ -43,6 +44,9 @@ public interface ILiveConnection
event Func<Task>? TaskPointsChanged;
/// <summary>Lernblatt oder Versuch geändert (ohne Inhalte; nur für die Zielperson und Eltern) – Daten neu laden.</summary>
event Func<LearningChangedDto, Task>? LearningChanged;
/// <summary>Termine/Kalender geändert (Kalender-Id, <see cref="Guid.Empty"/> = Kalenderliste/Rechte).</summary>
event Func<Guid, Task>? CalendarChanged;
@@ -87,6 +91,7 @@ public sealed class LiveConnection : ILiveConnection, IAsyncDisposable
_hub.On<TaskDto>(TaskEvents.TaskChanged, dto => Raise(TaskChanged, h => h(dto)));
_hub.On<Guid>(TaskEvents.TaskRemoved, id => Raise(TaskRemoved, h => h(id)));
_hub.On(TaskEvents.PointsChanged, () => Raise(TaskPointsChanged, h => h()));
_hub.On<LearningChangedDto>(LearningEvents.Changed, dto => Raise(LearningChanged, h => h(dto)));
_hub.On<Guid>(CalendarEvents.Changed, calendarId => Raise(CalendarChanged, h => h(calendarId)));
_hub.On<ChatMessageDto>(ChatEvents.MessageReceived, dto => Raise(ChatMessageReceived, h => h(dto)));
_hub.On<Guid, Guid>(ChatEvents.MessageRemoved, (channelId, messageId) => Raise(ChatMessageRemoved, h => h(channelId, messageId)));
@@ -135,6 +140,8 @@ public sealed class LiveConnection : ILiveConnection, IAsyncDisposable
public event Func<Task>? TaskPointsChanged;
public event Func<LearningChangedDto, Task>? LearningChanged;
public event Func<Guid, Task>? CalendarChanged;
public event Func<ChatMessageDto, Task>? ChatMessageReceived;
+328
View File
@@ -1471,3 +1471,331 @@ button.fp-meal-dish {
max-width: 92%;
}
}
/* Lernen (ADR-0021): Kinderansicht, Elternansicht, Editor, Prüfung */
.fp-learning-cards,
.fp-learning-list,
.fp-learning-queue,
.fp-learning-answers,
.fp-review-answers,
.fp-set-item-list,
.fp-set-attempts-list,
.fp-learning-dots {
list-style: none;
margin: 0;
padding: 0;
}
.fp-learning-card,
.fp-learning-list-item,
.fp-learning-queue-item {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 10px;
padding: 14px 16px;
border-radius: 16px;
background: var(--mud-palette-surface);
border: 2px solid var(--mud-palette-lines-default);
}
.fp-learning-list-item,
.fp-learning-queue-item {
border-width: 1px;
border-radius: 12px;
padding: 10px 14px;
}
.fp-learning-card.is-overdue {
border-color: var(--mud-palette-error);
}
.fp-learning-card-text {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.fp-learning-title {
font-size: 1.2rem;
font-weight: 600;
color: inherit;
text-decoration: none;
overflow-wrap: anywhere;
}
a.fp-learning-title:hover {
text-decoration: underline;
}
.fp-learning-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px 10px;
color: var(--mud-palette-text-secondary);
}
.fp-learning-overdue {
color: var(--mud-palette-error);
font-weight: 600;
}
.fp-learning-card-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
}
.fp-learning-card-actions .mud-button-root {
min-height: 52px;
min-width: 120px;
}
.fp-learning-list-item .fp-learning-card-actions .mud-button-root {
min-height: 0;
min-width: 0;
}
.fp-learning-result,
.fp-learning-points {
font-weight: 500;
}
/* Lösen */
.fp-learning-run-title {
overflow-wrap: anywhere;
}
.fp-learning-timer {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 12px;
border-radius: 999px;
border: 2px solid var(--mud-palette-lines-default);
font-size: 1.25rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.fp-learning-timer.is-urgent {
border-color: var(--mud-palette-error);
color: var(--mud-palette-error);
}
.fp-learning-progress {
display: flex;
flex-direction: column;
gap: 4px;
}
.fp-learning-progress-text {
color: var(--mud-palette-text-secondary);
font-size: .9rem;
}
.fp-learning-dots {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 12px;
}
.fp-learning-dot {
min-width: 44px;
min-height: 44px;
border-radius: 50%;
border: 2px solid var(--mud-palette-lines-default);
background: var(--mud-palette-surface);
color: inherit;
font: inherit;
font-weight: 600;
cursor: pointer;
}
.fp-learning-dot.is-answered {
background: var(--mud-palette-success-lighten);
border-color: var(--mud-palette-success);
}
.fp-learning-dot.is-current {
border-color: var(--mud-palette-primary);
box-shadow: 0 0 0 2px var(--mud-palette-primary);
}
.fp-learning-dot:focus-visible,
.fp-learning-choice:focus-visible,
.fp-item-correct:focus-visible {
outline: 3px solid var(--mud-palette-primary);
outline-offset: 2px;
}
.fp-learning-question {
margin: 0 0 12px;
font-size: 1.25rem;
line-height: 1.5;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.fp-learning-choices {
display: flex;
flex-direction: column;
gap: 10px;
}
.fp-learning-choice {
display: flex;
align-items: center;
gap: 12px;
min-height: 56px;
padding: 10px 16px;
border-radius: 14px;
border: 2px solid var(--mud-palette-lines-default);
background: var(--mud-palette-surface);
color: inherit;
font: inherit;
font-size: 1.1rem;
text-align: left;
cursor: pointer;
}
.fp-learning-choice.is-selected {
border-color: var(--mud-palette-primary);
background: var(--mud-palette-primary-hover);
}
.fp-learning-choice-mark {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 32px;
height: 32px;
border-radius: 50%;
border: 2px solid currentColor;
font-weight: 700;
}
.fp-learning-input input,
.fp-learning-input textarea {
font-size: 1.25rem !important;
}
.fp-learning-save-state {
min-height: 1.5rem;
margin-top: 4px;
color: var(--mud-palette-text-secondary);
font-size: .9rem;
}
.fp-learning-save-failed {
color: var(--mud-palette-error);
}
.fp-link-button {
background: none;
border: 0;
padding: 0;
color: var(--mud-palette-primary);
font: inherit;
text-decoration: underline;
cursor: pointer;
}
.fp-learning-nav .mud-button-root,
.fp-review-actions .mud-button-root {
min-height: 52px;
min-width: 112px;
}
/* Ergebnis */
.fp-learning-answer,
.fp-review-answer {
margin-bottom: 12px;
padding: 12px 16px;
border-radius: 12px;
border: 1px solid var(--mud-palette-lines-default);
background: var(--mud-palette-surface);
}
.fp-learning-answer p,
.fp-review-answer p {
margin: 4px 0;
}
.fp-learning-explanation {
color: var(--mud-palette-text-secondary);
}
/* Editor */
.fp-set-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 0;
border-bottom: 1px solid var(--mud-palette-lines-default);
}
.fp-set-item-text {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.fp-set-item-question {
font-weight: 600;
overflow-wrap: anywhere;
}
.fp-set-item-meta {
color: var(--mud-palette-text-secondary);
font-size: .875rem;
}
.fp-set-item-actions {
display: flex;
flex-wrap: wrap;
}
.fp-item-options {
border: 0;
margin: 0;
padding: 0;
}
.fp-item-correct {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 44px;
min-height: 44px;
border: 0;
border-radius: 50%;
background: none;
cursor: pointer;
}
.fp-set-attempts-list li {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 4px 0;
}
@media (max-width: 599.98px) {
.fp-learning-card,
.fp-learning-queue-item {
flex-direction: column;
align-items: stretch;
}
.fp-learning-card-actions .mud-button-root {
flex: 1;
}
}