Add LocalizationService for API-based string loading

Introduces a LocalizationService that loads all localized strings from the API and exposes them via an indexer, emulating IStringLocalizer behavior. The service supports language switching, formatting, and notifies consumers on language changes. Supported languages are provided as a static list for UI use. Designed for scoped DI in Blazor components.
This commit is contained in:
2026-05-13 22:44:22 +02:00
parent dfcf197deb
commit dab573d6d7

View File

@@ -0,0 +1,119 @@
using EnvelopeGenerator.ReceiverUI.Web.Client.Api;
namespace EnvelopeGenerator.ReceiverUI.Web.Client.Services;
/// <summary>
/// Pulls all localized strings from the API once and exposes them
/// via an indexer mimicking <c>IStringLocalizer&lt;Resource&gt;["Key"]</c>
/// in the legacy MVC Web project.
///
/// Missing keys fall back to the key itself (matching the previous behavior
/// where <c>_localizer["X"].Value</c> returned "X" if not found).
///
/// Components consume this service as a scoped dependency and may call
/// <see cref="EnsureLoadedAsync"/> in <c>OnInitializedAsync</c>.
/// </summary>
public class LocalizationService
{
private readonly ReceiverApiClient _api;
private readonly ILogger<LocalizationService> _logger;
private Dictionary<string, string> _strings = new();
private Task? _loadTask;
private readonly object _gate = new();
public LocalizationService(ReceiverApiClient api, ILogger<LocalizationService> logger)
{
_api = api;
_logger = logger;
}
/// <summary>
/// Languages exposed in the footer's language switcher. Kept as a
/// small in-memory list so the receiver UI does not need an extra
/// API roundtrip just to populate a dropdown. Mirrors the cultures
/// configured server-side (de-DE, en-US, tr-TR).
/// </summary>
public static readonly IReadOnlyList<(string Code, string Native, string Flag)> SupportedLanguages =
new[]
{
("de", "Deutsch", "fi-de"),
("en", "English", "fi-gb"),
("tr", "Türkçe", "fi-tr"),
};
/// <summary>Currently active language code (best effort, set after a switch).</summary>
public string? CurrentLanguage { get; private set; }
/// <summary>Fires whenever the language changes and strings are reloaded.</summary>
public event Action? Changed;
/// <summary>
/// Get a localized string by key. Returns the key itself if not found
/// (compatible with the legacy <c>_localizer["..."].Value</c> behavior).
/// </summary>
public string this[string key]
{
get => _strings.TryGetValue(key, out var v) ? v : key;
}
/// <summary>
/// Format a localized template with positional arguments (e.g. "{0}", "{1}").
/// </summary>
public string Format(string key, params object?[] args)
{
var template = this[key];
try
{
return string.Format(template, args);
}
catch (FormatException)
{
return template;
}
}
public IReadOnlyDictionary<string, string> All => _strings;
/// <summary>
/// Loads localization strings from the API. Safe to call multiple times;
/// concurrent callers share the same in-flight request.
/// </summary>
public Task EnsureLoadedAsync(CancellationToken ct = default)
{
lock (_gate)
{
return _loadTask ??= LoadCoreAsync(ct);
}
}
/// <summary>Forces a reload (e.g. after a language change).</summary>
public Task ReloadAsync(CancellationToken ct = default)
{
lock (_gate)
{
_loadTask = LoadCoreAsync(ct);
return _loadTask;
}
}
private async Task LoadCoreAsync(CancellationToken ct)
{
// Refresh the current language alongside the strings so the
// footer dropdown reflects the cookie value picked up by the API.
var langTask = _api.GetLanguageAsync(ct);
var dict = await _api.GetLocalizationStringsAsync(ct);
if (dict is not null)
_strings = new Dictionary<string, string>(dict, StringComparer.OrdinalIgnoreCase);
else
_logger.LogWarning("Localization strings could not be loaded; falling back to keys.");
CurrentLanguage = await langTask ?? CurrentLanguage;
Changed?.Invoke();
}
public async Task ChangeLanguageAsync(string language, CancellationToken ct = default)
{
await _api.SetLanguageAsync(language, ct);
CurrentLanguage = language;
await ReloadAsync(ct);
}
}