From 94557da63476a4e612a13b5646170ab2bccc4787 Mon Sep 17 00:00:00 2001 From: Fuggschen Date: Mon, 20 Apr 2026 12:38:15 +0200 Subject: [PATCH 1/7] feat: add multilingual UI localization support - Initialize and expose the localization service at startup so the UI can bind localization resources before settings load. - Add Avalonia markup extension for localized text binding that uses an observable wrapper to refresh UI on language changes. - Add a value converter that resolves localization keys to localized strings with optional format arguments and fallback handling. - Add strings.csv as an embedded resource - Add language setting property to application settings - Add multilingual UI localization strings for DV Mod Manager. - Add localization service interface for key-based translation lookup, live language switching, and English fallback - Add localization service that loads embedded CSV translations, supports language fallback, and notifies UI on language changes. - Use a full resource identifier for the cached source in version cache entries - Add localization support to the main window view model and replace hardcoded status messages and UI labels with localized strings. - Add localization support for mod item status labels and refresh them on language change - Add language selection support to settings with available locale options and persist the preferred language - Localize UI text and tooltips by replacing hardcoded strings with binding expressions and add a string localization converter resource. - Enable localization for mod detail panel text and button labels by converting hardcoded strings to localized bindings. - Localize ModListView UI labels, tooltips, search placeholder, and update badge text via localization converter. - Localize the profile dialog text and button labels by replacing hardcoded strings with resource-bound localization keys - Localize settings dialog labels, placeholders, tooltips, and buttons while adding a language selection combo box. --- DVModManager/App.axaml.cs | 11 + DVModManager/Converters/LocalizeExtension.cs | 73 +++++ DVModManager/Converters/StringKeyConverter.cs | 40 +++ DVModManager/DVModManager.csproj | 4 + DVModManager/Models/AppSettings.cs | 3 + DVModManager/Resources/strings.csv | 153 ++++++++++ DVModManager/Services/ILocalizationService.cs | 48 ++++ DVModManager/Services/LocalizationService.cs | 271 ++++++++++++++++++ DVModManager/Services/VersionCacheService.cs | 2 +- .../ViewModels/MainWindowViewModel.cs | 81 ++++-- DVModManager/ViewModels/ModItemViewModel.cs | 39 ++- DVModManager/ViewModels/SettingsViewModel.cs | 23 ++ DVModManager/Views/MainWindow.axaml | 47 +-- DVModManager/Views/ModDetailPanel.axaml | 19 +- DVModManager/Views/ModListView.axaml | 16 +- DVModManager/Views/ProfileDialog.axaml | 16 +- DVModManager/Views/SettingsDialog.axaml | 63 ++-- 17 files changed, 805 insertions(+), 104 deletions(-) create mode 100644 DVModManager/Converters/LocalizeExtension.cs create mode 100644 DVModManager/Converters/StringKeyConverter.cs create mode 100644 DVModManager/Resources/strings.csv create mode 100644 DVModManager/Services/ILocalizationService.cs create mode 100644 DVModManager/Services/LocalizationService.cs diff --git a/DVModManager/App.axaml.cs b/DVModManager/App.axaml.cs index b9cf2ca..90e5f13 100644 --- a/DVModManager/App.axaml.cs +++ b/DVModManager/App.axaml.cs @@ -24,6 +24,14 @@ public override void OnFrameworkInitializationCompleted() ConfigureServices(services); Services = services.BuildServiceProvider(); + // Initialize localization service with default language (English). + // If settings are loaded later, the language will be updated via MainWindowViewModel. + // For now, we just ensure the service is ready before any UI is created. + var localizationService = Services.GetRequiredService(); + // Expose as an Application-level resource so XAML can bind to it via {StaticResource Loc} + Resources["Loc"] = localizationService; + // Language will be applied after settings load in MainWindowViewModel.InitializeAsync() + // Catch unhandled exceptions on any thread and surface them in the status bar / console var logDir = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), @@ -106,6 +114,9 @@ private static void ConfigureServices(IServiceCollection services) // HTTP services.AddHttpClient(); + // Localization (must be registered early, before UI/ViewModels) + services.AddSingleton(); + // Core infrastructure services.AddSingleton(); services.AddSingleton(); diff --git a/DVModManager/Converters/LocalizeExtension.cs b/DVModManager/Converters/LocalizeExtension.cs new file mode 100644 index 0000000..2040c3a --- /dev/null +++ b/DVModManager/Converters/LocalizeExtension.cs @@ -0,0 +1,73 @@ +using System.ComponentModel; +using Avalonia.Data; +using Avalonia.Markup.Xaml; +using Avalonia.Markup.Xaml.MarkupExtensions; +using DVModManager.Services; +using Microsoft.Extensions.DependencyInjection; + +namespace DVModManager.Converters; + +/// +/// Markup extension for localizing text. +/// Usage in XAML: Text="{conv:Localize toolbar.save_profile}" +/// +public class LocalizeExtension : MarkupExtension +{ + public string Key { get; set; } = ""; + + public LocalizeExtension() { } + + public LocalizeExtension(string key) + { + Key = key; + } + + public override object ProvideValue(IServiceProvider serviceProvider) + { + if (string.IsNullOrWhiteSpace(Key)) + return string.Empty; + + var source = App.Services.GetRequiredService(); + + // Wrap each key in a dedicated observable so PropertyChanged("Value") fires + // on language change — Avalonia's ReflectionBindingExtension reliably handles + // named-property change notifications but NOT "Item[]" (WPF/Silverlight convention). + var localizedValue = new LocalizedValue(source, Key); + + var binding = new ReflectionBindingExtension(nameof(LocalizedValue.Value)) + { + Source = localizedValue, + Mode = BindingMode.OneWay, + FallbackValue = Key, + }; + + return binding.ProvideValue(serviceProvider); + } +} + +/// +/// Single-key observable wrapper over . +/// Raises PropertyChanged("Value") whenever the active language changes, +/// ensuring bound UI elements update immediately without relying on "Item[]". +/// +internal sealed class LocalizedValue : INotifyPropertyChanged +{ + private readonly ILocalizationService _service; + private readonly string _key; + + public string Value => _service[_key]; + + public event PropertyChangedEventHandler? PropertyChanged; + + public LocalizedValue(ILocalizationService service, string key) + { + _service = service; + _key = key; + service.LanguageChanged += OnLanguageChanged; + } + + private void OnLanguageChanged(object? sender, EventArgs e) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Value))); + } +} diff --git a/DVModManager/Converters/StringKeyConverter.cs b/DVModManager/Converters/StringKeyConverter.cs new file mode 100644 index 0000000..95c65fe --- /dev/null +++ b/DVModManager/Converters/StringKeyConverter.cs @@ -0,0 +1,40 @@ +using Avalonia.Data.Converters; +using DVModManager.Services; + +namespace DVModManager.Converters; + +/// +/// Value converter that translates a localization key (from ViewModel) to a localized string. +/// Binding: Text="{Binding Some ObservableProperty, Converter={StaticResource LocalizeConverter}}" +/// where the property returns a localization key like "status.activated". +/// +public class StringKeyConverter : IValueConverter +{ + public object? Convert(object? value, Type targetType, object? parameter, System.Globalization.CultureInfo culture) + { + if (value is not string key || string.IsNullOrEmpty(key)) + return ""; + + try + { + var localizationService = App.Services.GetService(typeof(ILocalizationService)) as ILocalizationService; + if (localizationService == null) + return key; // Fallback + + // If parameter contains format args, apply them + if (parameter is object[] args) + return localizationService.GetString(key, args); + + return localizationService.GetString(key); + } + catch + { + return key; + } + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, System.Globalization.CultureInfo culture) + { + throw new NotImplementedException(); + } +} diff --git a/DVModManager/DVModManager.csproj b/DVModManager/DVModManager.csproj index 49a2a06..47fc79f 100644 --- a/DVModManager/DVModManager.csproj +++ b/DVModManager/DVModManager.csproj @@ -34,4 +34,8 @@ + + + + diff --git a/DVModManager/Models/AppSettings.cs b/DVModManager/Models/AppSettings.cs index 6bd6e63..4169a07 100644 --- a/DVModManager/Models/AppSettings.cs +++ b/DVModManager/Models/AppSettings.cs @@ -15,6 +15,9 @@ public class AppSettings /// "Dark" or "Light" public string ThemeVariant { get; set; } = "Dark"; + /// Language code: "en", "de", "fr", etc. + public string Language { get; set; } = "en"; + public bool AutoCheckUpdatesOnStartup { get; set; } = true; public bool BackupBeforeChanges { get; set; } = true; diff --git a/DVModManager/Resources/strings.csv b/DVModManager/Resources/strings.csv new file mode 100644 index 0000000..4d13884 --- /dev/null +++ b/DVModManager/Resources/strings.csv @@ -0,0 +1,153 @@ +key,en,de,fr,zh +lang.en,English,Englisch,Anglais, +lang.de,German,Deutsch,Allemand, +lang.fr,French,Français,Français, +lang.zh,Chinese,Chinesisch,Chinois, +window.title,DV Mod Manager,DV Mod Manager,Gestionnaire de mods DV, +toolbar.profile.label,Profile:,Profil:,Profil :, +toolbar.save_profile,Save,Speichern,Enregistrer, +toolbar.save_profile.tooltip,Save current mod selection as the active profile,Aktuelle Modauswahl als aktives Profil speichern,Enregistrer la sélection actuelle des mods comme profil actif, +toolbar.manage_profiles,Manage Profiles,Profile verwalten,Gérer les profils, +toolbar.manage_profiles.tooltip,"Load, import, export or delete profiles","Profile laden, importieren, exportieren oder löschen","Charger, importer, exporter ou supprimer des profils", +toolbar.refresh,⟳ Refresh,⟳ Aktualisieren,⟳ Actualiser, +toolbar.refresh.tooltip,Rescan the Mods folder,Mods-Ordner neu scannen,Rescanner le dossier Mods, +toolbar.unload_all,⏊ Unload All,⏊ Alle entladen,⏊ Décharger tout, +toolbar.unload_all.tooltip,Deactivate all currently active mods,Alle derzeit aktiven Mods deaktivieren,Désactiver tous les mods actuellement actifs, +toolbar.check_updates,↑ Check Updates,↑ Updates prüfen,↑ Vérifier les mises à jour, +toolbar.check_updates.tooltip,Check all mods for available updates,Alle Mods auf verfügbare Updates prüfen,Vérifier les mises à jour disponibles pour tous les mods, +toolbar.update_all,↑↑ Update All,↑↑ Alle aktualisieren,↑↑ Mettre à jour tout, +toolbar.update_all.tooltip,Download and install all available updates,Alle verfügbaren Updates herunterladen und installieren,Télécharger et installer toutes les mises à jour disponibles, +toolbar.settings,⚙,⚙,⚙, +toolbar.settings.tooltip,Open settings,Einstellungen öffnen,Ouvrir les paramètres, +panel.available,Available Mods,Verfügbare Mods,Mods disponibles, +panel.active,Active Mods,Aktive Mods,Mods actifs, +panel.details,Details,Details,Détails, +activate.button,▶,▶,▶, +activate.button.tooltip,Activate selected mod,Ausgewählten Mod aktivieren,Activer le mod sélectionné, +deactivate.button,◀,◀,◀, +deactivate.button.tooltip,Deactivate selected mod,Ausgewählten Mod deaktivieren,Désactiver le mod sélectionné, +install.button,⊕,⊕,⊕, +install.button.tooltip,Install mod from archive,Mod aus Archiv installieren,Installer un mod à partir d'une archive, +statusbar.game_running,Game Running,Spiel läuft,Jeu en cours d'exécution, +statusbar.game_not_running,Game Not Running,Spiel läuft nicht,Jeu non en cours d'exécution, +statusbar.ready,Ready.,Bereit.,Prêt., +settings.title,Settings,Einstellungen,Paramètres, +settings.section.game_path,Derail Valley Installation,Derail Valley-Installation,Installation de Derail Valley, +settings.game_path.placeholder,Path to Derail Valley folder...,Pfad zum Derail Valley-Ordner...,Chemin d'accès au dossier Derail Valley..., +settings.game_path.browse,Browse,Durchsuchen,Parcourir, +settings.game_path.auto,Auto,Automatisch,Automatique, +settings.game_path.auto.tooltip,Auto-detect via Steam,Per Steam automatisch erkennen,Détection automatique via Steam, +settings.section.storage,Storage Folder,Speicherordner,Dossier de stockage, +settings.storage.description,"Where version archives, profiles and logs are kept.","Hier werden Versionsarchive, Profile und Protokolle gespeichert.","Où les archives de version, les profils et les journaux sont conservés.", +settings.storage.browse,Browse,Durchsuchen,Parcourir, +settings.section.nexus,Nexus Mods API Key,Nexus Mods API-Schlüssel,Clé API Nexus Mods, +settings.nexus.description,Required for Nexus Mods update checks. Leave blank to skip.,Erforderlich für Nexus Mods-Update-Überprüfungen. Leer lassen zum Überspringen.,Requis pour les vérifications de mise à jour de Nexus Mods. Laisser en blanc pour ignorer., +settings.nexus.placeholder,Your Nexus Mods API key...,Ihr Nexus Mods API-Schlüssel...,Votre clé API Nexus Mods..., +settings.section.github,GitHub Token (optional),GitHub-Token (optional),Jeton GitHub (facultatif), +settings.github.description,Increases the GitHub API rate limit from 60 to 5000 requests/hour.,Erhöht das GitHub API-Ratelimit von 60 auf 5000 Anfragen pro Stunde.,Augmente la limite de débit de l'API GitHub de 60 à 5000 requêtes par heure., +settings.github.placeholder,ghp_...,ghp_...,ghp_..., +settings.section.general,General,Allgemein,Général, +settings.auto_check_updates,Check for updates on startup,Bei Start auf Updates prüfen,Vérifier les mises à jour au démarrage, +settings.backup_before_changes,Backup mods before bulk changes,Mods vor Massenänderungen sichern,Sauvegarder les mods avant les modifications en bloc, +settings.backup_folder_button,Open Folder,Ordner öffnen,Ouvrir le dossier, +settings.max_cache_size,Max version cache size (GB),Max. Versionscachegröße (GB),Taille maximale du cache de version (Go), +settings.language,Language,Sprache,Langue, +settings.language.english,English,Englisch,Anglais, +settings.button.cancel,Cancel,Abbrechen,Annuler, +settings.button.save,Save,Speichern,Enregistrer, +dialog.button.ok,OK,OK,OK, +dialog.button.yes,Yes,Ja,Oui, +dialog.button.no,No,Nein,Non, +dialog.uninstall_title,Uninstall Mod,Mod deinstallieren,Désinstaller le mod, +dialog.uninstall_message,"Remove ''{0}''? The current version will be archived for rollback, then deleted.","„{0}"" entfernen? Die aktuelle Version wird zur Wiederherstellung archiviert und dann gelöscht.","Supprimer « {0} » ? La version actuelle sera archivée pour restauration, puis supprimée.", +dialog.update_all_title,Update All,Alle aktualisieren,Mettre à jour tout, +dialog.update_all_message,Update {0} mod(s)? Current versions will be archived.,{0} Mod(s) aktualisieren? Aktuelle Versionen werden archiviert.,Mettre à jour {0} mod(s) ? Les versions actuelles seront archivées., +dialog.unload_all_title,Unload All Mods,Alle Mods entfernen,Décharger tous les mods, +dialog.unload_all_message,Deactivate all {0} active mod(s)?,Alle {0} aktiven Mod(s) deaktivieren?,Désactiver tous les {0} mod(s) actif(s) ?, +dialog.download_missing_title,Download Missing Mods,Fehlende Mods herunterladen,Télécharger les mods manquants, +dialog.invalid_path_title,Invalid Path,Ungültiger Pfad,Chemin invalide, +dialog.invalid_path_message,The selected folder does not appear to be a Derail Valley installation.,Der ausgewählte Ordner scheint keine Derail Valley-Installation zu sein.,Le dossier sélectionné ne semble pas être une installation de Derail Valley., +dialog.game_not_found_title,Not Found,Nicht gefunden,Non trouvé, +dialog.game_not_found_message,Could not auto-detect Derail Valley. Please select the folder manually.,Derail Valley konnte nicht automatisch erkannt werden. Wählen Sie den Ordner manuell aus.,Impossible de détecter automatiquement Derail Valley. Veuillez sélectionner le dossier manuellement., +modstate.active,Active,Aktiv,Actif, +modstate.inactive,Inactive,Inaktiv,Inactif, +modstate.update_available,Update available → {0},Update verfügbar → {0},Mise à jour disponible → {0}, +modstate.missing_dependency,Missing dependency,Fehlende Abhängigkeit,Dépendance manquante, +modstate.no_metadata,No metadata,Keine Metadaten,Pas de métadonnées, +modstate.missing,Missing — not found on disk,Fehlend – nicht auf Datenträger gefunden,Manquant — non trouvé sur le disque, +status.detected_game,Detected game at: {0},Spiel erkannt unter: {0},Jeu détecté à : {0}, +status.game_not_found,Game not found. Please set the game path in Settings.,Spiel nicht gefunden. Bitte legen Sie den Spielpfad in den Einstellungen fest.,Jeu non trouvé. Veuillez définir le chemin du jeu dans les paramètres., +status.startup_error,Startup error: {0},Startfehler: {0},Erreur de démarrage : {0}, +status.no_mods_found,No mods found in {0},Keine Mods gefunden in {0},Aucun mod trouvé dans {0}, +status.mods_found,"Found {0} mod(s) — {1} active, {2} inactive.","{0} Mod(s) gefunden – {1} aktiv, {2} inaktiv.","{0} mod(s) trouvé(s) — {1} actif, {2} inactif.", +status.game_path_not_set,Game path not set. Open Settings to configure.,Spielpfad nicht gesetzt. Öffnen Sie Einstellungen zum Konfigurieren.,Chemin du jeu non défini. Ouvrez Paramètres pour configurer., +status.mods_folder_not_found,Mods folder not found: {0},Mods-Ordner nicht gefunden: {0},Dossier Mods non trouvé : {0}, +status.scan_error,Scan error: {0},Scan-Fehler: {0},Erreur de numérisation : {0}, +status.no_active_mods,No active mods.,Keine aktiven Mods.,Aucun mod actif., +status.missing_dependencies,Missing dependencies for {0}: {1},Fehlende Abhängigkeiten für {0}: {1},Dépendances manquantes pour {0} : {1}, +status.activated,Activated: {0},Aktiviert: {0},Activé : {0}, +status.activation_failed,Failed to activate: {0},Aktivierung fehlgeschlagen: {0},Impossible d'activer : {0}, +status.activated_count,Activated {0}/{1} mod(s) in group ''{2}''.,"{0}/{1} Mod(s) in Gruppe „{2}"" aktiviert.",{0}/{1} mod(s) activé(s) dans le groupe « {2} »., +status.activated_with_missing_deps,Activated {0}/{1} — missing deps: {1},Aktiviert {0}/{1} – fehlende Abhängigkeiten: {1},Activé {0}/{1} — dépendances manquantes : {1}, +status.deactivated,Deactivated: {0},Deaktiviert: {0},Désactivé : {0}, +status.deactivation_failed,Failed to deactivate: {0},Deaktivierung fehlgeschlagen: {0},Impossible de désactiver : {0}, +status.deactivated_count,Deactivated {0}/{1} mod(s) in group ''{2}''.,"{0}/{1} Mod(s) in Gruppe „{2}"" deaktiviert.",{0}/{1} mod(s) désactivé(s) dans le groupe « {2} »., +status.install_success,Installed: {0},Installiert: {0},Installé : {0}, +status.install_failed,"Install failed. Ensure the archive contains Info.json.","Installation fehlgeschlagen. Stellen Sie sicher, dass das Archiv Info.json enthält.","Échec de l'installation. Assurez-vous que l'archive contient Info.json.", +status.uninstalled,Uninstalled: {0},Deinstalliert: {0},Désinstallé : {0}, +status.checking_updates,Checking for updates...,Auf Updates wird überprüft...,Vérification des mises à jour…, +status.updates_available,{0} update(s) available.,{0} Update(s) verfügbar.,{0} mise(s) à jour disponible(s)., +status.all_up_to_date,All mods are up to date.,Alle Mods sind aktuell.,Tous les mods sont à jour., +status.update_opened,Opened mod page for {0} v{1},Modseite für {0} v{1} geöffnet,Page de mod ouverte pour {0} v{1}, +status.updating,Updating {0} to v{1}...,,Mise à jour de {0} vers v{1}…, +status.updated,Updated {0} to v{1},{0} auf v{1} aktualisiert,{0} mis à jour vers v{1}, +status.update_failed,Update failed for {0},Update fehlgeschlagen für {0},Échec de la mise à jour de {0}, +status.rolling_back,Rolling back {0} to v{1}...,Rollback von {0} zu v{1}…,Restauration de {0} à v{1}…, +status.rolled_back,Rolled back {0} to v{1},Rollback von {0} zu v{1} durchgeführt,{0} restauré à v{1}, +status.rollback_failed,Rollback failed for {0},Rollback fehlgeschlagen für {0},Échec du rollback pour {0}, +status.profile_saved,Profile ''{0}'' saved.,"Profil „{0}"" gespeichert.",Profil « {0} » enregistré., +status.profile_applied,Applied profile ''{0}''.,"Profil „{0}"" angewendet.",Profil « {0} » appliqué., +status.profile_already_applied,Profile is already applied.,Profil ist bereits angewendet.,Le profil est déjà appliqué., +status.no_downloadable_updates,No downloadable updates available.,Keine herunterladbaren Updates verfügbar.,Aucune mise à jour téléchargeable disponible., +status.downloading_mods,Downloading {0} mod(s)...,{0} Mod(s) werden heruntergeladen…,Téléchargement de {0} mod(s)…, +status.download_complete,Downloaded {0} mod(s). Nexus mods ({1}) opened in browser — install manually then re-apply.,{0} Mod(s) heruntergeladen. Nexus-Mods ({1}) im Browser geöffnet – manuell installieren und dann erneut anwenden.,{0} mod(s) téléchargé(s). Mods Nexus ({1}) ouverts dans le navigateur — installer manuellement puis appliquer à nouveau., +status.activating_mod,Activating {0}...,Aktiviere {0}…,Activation de {0}…, +status.deactivating_mod,Deactivating {0}...,Deaktiviere {0}…,Désactivation de {0}…, +status.activating_group,Activating {0} mod(s)...,Aktiviere {0} Mod(s)…,Activation de {0} mod(s)…, +status.deactivating_group,Deactivating {0} mod(s)...,Deaktiviere {0} Mod(s)…,Désactivation de {0} mod(s)…, +busy.installing_mod,Installing mod...,Mod wird installiert…,Installation du mod…, +busy.creating_backup,Creating backup...,Erstelle Backup…,Création d'une sauvegarde…, +busy.resolving_mod,Resolving {0}...,Löse {0} auf…,Résolution de {0}…, +busy.downloading_mod,Downloading {0}...,Lade {0} herunter…,Téléchargement de {0}…, +busy.download_progress,Downloading {0} {1:P0}...,Lade {0} {1:P0} herunter…,Téléchargement de {0} {1:P0}…, +busy.downloading_multiple,Downloading {0} ({1}/{2})...,Lade {0} herunter ({1}/{2})…,Téléchargement de {0} ({1}/{2})…, +busy.updating_mod,Updating {0}...,Aktualisiere {0}…,Mise à jour de {0}…, +busy.update_progress,Updating {0}… {1:P0},Aktualisiere {0}… {1:P0},Mise à jour de {0}… {1:P0}, +busy.update_multiple,Updating {0}… {1:P0} ({2}/{3})...,Aktualisiere {0}… {1:P0} ({2}/{3})…,Mise à jour de {0}… {1:P0} ({2}/{3})…, +menu.operations,Operations,Operationen,Opérations, +menu.version_history,Version History,Versionsverlauf,Historique des versions, +filter.search_mods,Search mods...,Mods durchsuchen...,Rechercher des mods..., +ui.select_mod_details,Select a mod to see details,Wählen Sie ein Mod aus um Details zu sehen,Sélectionnez un mod pour voir les détails, +button.add_group,+ Group,+ Gruppe,+ Groupe, +button.rename_group,Rename group,Gruppe umbenennen,Renommer le groupe, +button.delete_group,Delete group,Gruppe löschen,Supprimer le groupe, +badge.update,UPDATE,AKTUALISIERUNG,MISE À JOUR, +link.homepage,🌐 HomePage,🌐 Startseite,🌐 Page d'accueil, +link.repository,📦 Repository,📦 Repository,📦 Référentiel, +mod.missing_dependencies,Missing Dependencies,Fehlende Abhängigkeiten,Dépendances manquantes, +button.uninstall,✕ Uninstall,✕ Deinstallieren,✕ Désinstaller, +button.update,↑ Update,↑ Aktualisieren,↑ Mettre à jour, +menu.version_history,Version History,Versionsverlauf,Historique des versions, +button.rollback,↩ Rollback to Selected Version,↩ Zu ausgewählter Version zurücksetzen,↩ Revenir à la version sélectionnée, +profile.dialog_title,Manage Profiles,Profile verwalten,Gérer les profils, +profile.saved_profiles,Saved Profiles,Gespeicherte Profile,Profils enregistrés, +profile.help_text,Use the main toolbar to save the current mod selection as a new profile.,Verwenden Sie die Hauptsymbolleiste um die aktuelle Mod-Auswahl als neues Profil zu speichern.,Utilisez la barre d'outils principale pour enregistrer la sélection actuelle des mods comme nouveau profil., +profile.button_apply,✓ Apply,✓ Anwenden,✓ Appliquer, +profile.button_export,Export,Exportieren,Exporter, +profile.button_import,Import,Importieren,Importer, +profile.button_delete,✕ Delete,✕ Löschen,✕ Supprimer, +button.close,Close,Schließen,Fermer, +modversion.source.cached,Cached,Zwischengespeichert,Mis en cache, +modversion.source.github,GitHub,GitHub,GitHub, +modversion.source.nexus,Nexus,Nexus,Nexus, +modversion.source.manual,Manual,Manuell,Manuel, diff --git a/DVModManager/Services/ILocalizationService.cs b/DVModManager/Services/ILocalizationService.cs new file mode 100644 index 0000000..ba2e00b --- /dev/null +++ b/DVModManager/Services/ILocalizationService.cs @@ -0,0 +1,48 @@ +namespace DVModManager.Services; + +/// +/// Provides localization/translation services with key-based lookup, live language switching, +/// and fallback to English for missing translations. +/// +public interface ILocalizationService +{ + /// Gets the currently active language code (e.g., "en", "de", "fr"). + string CurrentLanguage { get; } + + /// Gets a list of supported language codes. + IReadOnlyList SupportedLanguages { get; } + + /// + /// Gets the localized string for the given key in the current language, + /// falling back to English if unavailable, or returning the key itself if not found anywhere. + /// + /// The translation key (e.g., "btn.save", "status.game_detected"). + /// The translated string, English fallback, or the key name if missing entirely. + string GetString(string key); + + /// + /// Indexer shorthand for XAML binding paths like [toolbar.save_profile]. + /// + string this[string key] { get; } + + /// + /// Gets the localized string with format arguments applied (like string.Format). + /// Falls back to English if unavailable, or returns the key if not found. + /// + /// The translation key. + /// Format arguments. + /// The formatted translated string, English fallback, or key if missing. + string GetString(string key, params object?[] args); + + /// + /// Switches the active language and notifies all subscribers. + /// Language must be in SupportedLanguages or falls back to "en". + /// + /// The language code to switch to. + void SetLanguage(string languageCode); + + /// + /// Raised when the active language changes, allowing UI elements to refresh. + /// + event EventHandler? LanguageChanged; +} diff --git a/DVModManager/Services/LocalizationService.cs b/DVModManager/Services/LocalizationService.cs new file mode 100644 index 0000000..b749e8c --- /dev/null +++ b/DVModManager/Services/LocalizationService.cs @@ -0,0 +1,271 @@ +using System.Globalization; +using System.ComponentModel; + +namespace DVModManager.Services; + +/// +/// Loads translations from an embedded CSV resource (key,en,de,fr,... format) +/// and provides runtime key lookup with fallback to English. +/// Implements INotifyPropertyChanged to notify UI when language changes. +/// +public class LocalizationService : ILocalizationService, INotifyPropertyChanged +{ + private readonly Dictionary> _translations = []; + private readonly List _languages = []; + private string _currentLanguage = "en"; + + public string CurrentLanguage + { + get => _currentLanguage; + private set + { + if (_currentLanguage != value) + { + _currentLanguage = value; + OnPropertyChanged(nameof(CurrentLanguage)); + } + } + } + + public IReadOnlyList SupportedLanguages => _languages.AsReadOnly(); + + public event EventHandler? LanguageChanged; + public event PropertyChangedEventHandler? PropertyChanged; + + public LocalizationService() + { + LoadTranslationsFromEmbeddedCsv(); + } + + /// + /// Loads translations from the embedded CSV resource "DVModManager.Resources.strings.csv". + /// Expected format: key,en,de,fr,... + /// Handles BOM, empty cells, and missing language columns gracefully. + /// + private void LoadTranslationsFromEmbeddedCsv() + { + _translations.Clear(); + _languages.Clear(); + _languages.Add("en"); // English always supported as fallback + + try + { + var assembly = typeof(LocalizationService).Assembly; + const string expectedResourceName = "DVModManager.Resources.strings.csv"; + var resourceName = expectedResourceName; + + // Resolve resource name defensively in case namespace/publish settings change. + // This avoids silent failures where the exact expected name cannot be found. + var stream = assembly.GetManifestResourceStream(resourceName); + if (stream == null) + { + resourceName = assembly.GetManifestResourceNames() + .FirstOrDefault(n => n.EndsWith("Resources.strings.csv", StringComparison.OrdinalIgnoreCase) + || n.EndsWith("strings.csv", StringComparison.OrdinalIgnoreCase)) + ?? expectedResourceName; + stream = assembly.GetManifestResourceStream(resourceName); + } + + using (stream) + { + if (stream == null) + { + var names = string.Join(", ", assembly.GetManifestResourceNames()); + Console.Error.WriteLine($"Warning: Embedded localization CSV not found. Expected '{expectedResourceName}'. Available resources: {names}"); + return; + } + + using (var reader = new StreamReader(stream)) + { + // Parse header row to identify language columns + var headerLine = reader.ReadLine(); + if (headerLine == null) return; + + // Be defensive about BOM and zero-width marks without dropping regular characters. + headerLine = headerLine.TrimStart('\uFEFF'); + + var headers = ParseCsvLine(headerLine).Select(h => h.Trim()).ToArray(); + if (headers.Length == 0) + { + Console.Error.WriteLine("CSV header is empty. Localization unavailable."); + return; + } + + var keyHeader = headers[0] + .Trim('\uFEFF', '\0', ' ', '\t', '\r', '\n'); + if (!keyHeader.Equals("key", StringComparison.OrdinalIgnoreCase)) + { + Console.Error.WriteLine($"Warning: CSV header first column is '{headers[0]}' instead of 'key'. Proceeding anyway."); + } + + // Extract language codes from header (skip "key" column) + var languages = headers.Skip(1).ToList(); + _languages.Clear(); + _languages.Add("en"); // Always available + foreach (var lang in languages) + { + if (!string.IsNullOrWhiteSpace(lang) && lang != "en" && !_languages.Contains(lang)) + _languages.Add(lang); + } + + // Parse data rows + string? line; + while ((line = reader.ReadLine()) != null) + { + if (string.IsNullOrWhiteSpace(line)) continue; + + // Be defensive about stray BOM on row starts. + line = line.TrimStart('\uFEFF'); + + var parts = ParseCsvLine(line); + if (parts.Length < 1) continue; + + var key = parts[0].Trim(); + if (string.IsNullOrWhiteSpace(key)) continue; + + // Initialize entry for this key + if (!_translations.ContainsKey(key)) + _translations[key] = []; + + // Populate each language column (index 0 is key, so language i is at parts[i+1]) + for (int i = 0; i < languages.Count && i + 1 < parts.Length; i++) + { + var langCode = languages[i].Trim(); + var value = parts[i + 1].Trim(); + + if (!string.IsNullOrWhiteSpace(langCode) && !string.IsNullOrWhiteSpace(value)) + { + _translations[key][langCode] = UnescapeCsvValue(value); + } + } + } + + Console.Error.WriteLine($"Localization loaded from '{resourceName}'. Languages: {string.Join(", ", _languages)}. Keys: {_translations.Count}."); + } + } + } + catch (Exception ex) + { + Console.Error.WriteLine($"Error loading embedded localization CSV: {ex.Message}"); + } + } + + /// + /// Parses one CSV row and supports quoted cells with commas and escaped quotes. + /// + private static string[] ParseCsvLine(string line) + { + var result = new List(); + var current = new System.Text.StringBuilder(); + bool inQuotes = false; + + for (int i = 0; i < line.Length; i++) + { + var ch = line[i]; + if (ch == '"') + { + if (inQuotes && i + 1 < line.Length && line[i + 1] == '"') + { + // Escaped quote inside a quoted field. + current.Append('"'); + i++; + } + else + { + inQuotes = !inQuotes; + } + continue; + } + + if (ch == ',' && !inQuotes) + { + result.Add(current.ToString()); + current.Clear(); + continue; + } + + current.Append(ch); + } + + result.Add(current.ToString()); + return result.ToArray(); + } + + /// + /// Unescapes CSV-quoted values (handles quoted newlines, commas, escaped quotes, etc.). + /// For now, assumes values are not quoted unless we detect quotes during parsing. + /// + private static string UnescapeCsvValue(string value) + { + // Basic unescaping: if value was quoted, remove outer quotes and unescape inner quotes + if (value.StartsWith("\"") && value.EndsWith("\"")) + { + value = value[1..^1]; + value = value.Replace("\"\"", "\""); + } + return value; + } + + public string GetString(string key) + { + if (string.IsNullOrEmpty(key)) return ""; + + // Try current language + if (_translations.TryGetValue(key, out var langDict)) + { + if (langDict.TryGetValue(_currentLanguage, out var translation)) + return translation; + + // Fall back to English if current language not found + if (langDict.TryGetValue("en", out var englishTranslation)) + return englishTranslation; + } + + // Return key name as last resort + return key; + } + + /// + /// Indexer for XAML binding: {Binding [key], Source={StaticResource Loc}} + /// When language changes, "Item[]" PropertyChanged fires to refresh all indexer bindings. + /// + public string this[string key] => GetString(key); + + public string GetString(string key, params object?[] args) + { + var template = GetString(key); + try + { + return string.Format(CultureInfo.CurrentCulture, template, args); + } + catch + { + // If format fails, return template as-is + return template; + } + } + + public void SetLanguage(string languageCode) + { + if (string.IsNullOrWhiteSpace(languageCode)) + languageCode = "en"; + + // Normalize to supported language or fall back to English + if (!_languages.Contains(languageCode)) + languageCode = "en"; + + if (languageCode == _currentLanguage) + return; // No change + + CurrentLanguage = languageCode; // Use property setter to trigger PropertyChanged + // Notify all indexer bindings ([key]) that every key's value has changed + // "Item[]" is the standard PropertyChanged name for indexers (defined in Binding.IndexerName in System.Windows.Data) + OnPropertyChanged("Item[]"); + LanguageChanged?.Invoke(this, EventArgs.Empty); + } + + protected void OnPropertyChanged(string propertyName) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } +} diff --git a/DVModManager/Services/VersionCacheService.cs b/DVModManager/Services/VersionCacheService.cs index b671eaa..5a31c32 100644 --- a/DVModManager/Services/VersionCacheService.cs +++ b/DVModManager/Services/VersionCacheService.cs @@ -32,7 +32,7 @@ public async Task ArchiveCurrentVersionAsync(ModInfo mod, string storagePath) ModId = mod.Id, Version = mod.Version, ArchivePath = archivePath, - Source = "cached", + Source = "modversion.source.cached", ArchivedAt = DateTime.UtcNow, ArchiveSizeBytes = new FileInfo(archivePath).Length }); diff --git a/DVModManager/ViewModels/MainWindowViewModel.cs b/DVModManager/ViewModels/MainWindowViewModel.cs index c278e34..f6fd5b7 100644 --- a/DVModManager/ViewModels/MainWindowViewModel.cs +++ b/DVModManager/ViewModels/MainWindowViewModel.cs @@ -20,6 +20,7 @@ public partial class MainWindowViewModel : ViewModelBase private readonly IProfileService _profileService; private readonly IUpdateService _updateService; private readonly IDialogService _dialogService; + private readonly ILocalizationService _localization; // ── Observable state ────────────────────────────────────────────────────── [ObservableProperty] private ObservableCollection _availableMods = []; @@ -32,6 +33,9 @@ public partial class MainWindowViewModel : ViewModelBase [ObservableProperty] private string _busyMessage = ""; [ObservableProperty] private string _selectedProfileName = "Default"; [ObservableProperty] private ObservableCollection _profileNames = []; + [ObservableProperty] private string _windowTitle = "DV Mod Manager"; + [ObservableProperty] private string _panelAvailableHeader = "Available Mods"; + [ObservableProperty] private string _panelActiveHeader = "Active Mods"; // ── Version history for selected mod ────────────────────────────────────── [ObservableProperty] private IReadOnlyList _selectedModVersionHistory = []; @@ -62,7 +66,8 @@ public MainWindowViewModel( IVersionCacheService versionCache, IProfileService profileService, IUpdateService updateService, - IDialogService dialogService) + IDialogService dialogService, + ILocalizationService localization) { _settings = settings; _gameDetection = gameDetection; @@ -72,11 +77,14 @@ public MainWindowViewModel( _profileService = profileService; _updateService = updateService; _dialogService = dialogService; + _localization = localization; _gameDetection.GameRunningChanged += OnGameRunningChanged; _modDiscovery.ModsChanged += OnModsChangedExternally; + _localization.LanguageChanged += (_, _) => UpdateLocalizedStrings(); IsGameRunning = _gameDetection.IsGameRunning(); + UpdateLocalizedStrings(); } partial void OnAvailableFilterChanged(string value) => ApplyGroupedFilters(); @@ -277,19 +285,25 @@ public async Task InitializeAsync() try { await _settings.LoadAsync(); + + // Apply language from persisted settings + if (!string.IsNullOrEmpty(_settings.Settings.Language)) + _localization.SetLanguage(_settings.Settings.Language); + SelectedProfileName = _settings.Settings.ActiveProfileName; + UpdateLocalizedStrings(); if (string.IsNullOrEmpty(_settings.Settings.GamePath)) { _settings.Settings.GamePath = _gameDetection.DetectGamePath(); if (_settings.Settings.GamePath != null) { - StatusMessage = $"Detected game at: {_settings.Settings.GamePath}"; + StatusMessage = _localization.GetString("status.detected_game", _settings.Settings.GamePath); await _settings.SaveAsync(); } else { - StatusMessage = "Game not found. Please set the game path in Settings."; + StatusMessage = _localization.GetString("status.game_path_not_set"); await PromptGamePathAsync(); return; } @@ -305,7 +319,7 @@ public async Task InitializeAsync() } catch (Exception ex) { - StatusMessage = $"Startup error: {ex.Message}"; + StatusMessage = _localization.GetString("status.startup_error", ex.Message); } } @@ -370,11 +384,11 @@ private async Task ActivateSelectedModAsync() { mod.SyncFromModel(); MoveToActive(mod); - StatusMessage = $"Activated: {displayName}"; + StatusMessage = _localization.GetString("status.activated", displayName); } else { - StatusMessage = $"Failed to activate: {displayName}"; + StatusMessage = _localization.GetString("status.activation_failed", displayName); } } @@ -398,7 +412,7 @@ private async Task DeactivateSelectedModAsync() if (success) { target.SyncFromModel(); MoveToInactive(target); deactivated++; } } ClearBusy(); - StatusMessage = $"Deactivated {deactivated}/{targets.Count} mod(s) in group '{group.Name}'."; + StatusMessage = _localization.GetString("status.deactivated_count", deactivated, targets.Count, group.Name); return; } @@ -413,11 +427,11 @@ private async Task DeactivateSelectedModAsync() { SelectedMod.SyncFromModel(); MoveToInactive(SelectedMod); - StatusMessage = $"Deactivated: {dn}"; + StatusMessage = _localization.GetString("status.deactivated", dn); } else { - StatusMessage = $"Failed to deactivate: {dn}"; + StatusMessage = _localization.GetString("status.deactivation_failed", dn); } } @@ -435,11 +449,11 @@ private async Task InstallModFromFileAsync() if (mod != null) { await RefreshModsAsync(); - StatusMessage = $"Installed: {mod.EffectiveDisplayName}"; + StatusMessage = _localization.GetString("status.install_success", mod.EffectiveDisplayName); } else { - StatusMessage = "Install failed. Ensure the archive contains Info.json."; + StatusMessage = _localization.GetString("status.install_failed"); } } @@ -463,7 +477,7 @@ private async Task UninstallSelectedModAsync() if (success) { await RefreshModsAsync(); - StatusMessage = $"Uninstalled: {displayName}"; + StatusMessage = _localization.GetString("status.uninstalled", displayName); } } @@ -486,8 +500,8 @@ private async Task CheckUpdatesAsync() } StatusMessage = updates.Count > 0 - ? $"{updates.Count} update(s) available." - : "All mods are up to date."; + ? _localization.GetString("status.updates_available", updates.Count) + : _localization.GetString("status.all_up_to_date"); } [RelayCommand(CanExecute = nameof(CanModify))] @@ -505,13 +519,13 @@ private async Task UpdateSelectedModAsync() var url = update.ChangelogUrl ?? update.DownloadUrl; if (!string.IsNullOrEmpty(url)) Helpers.PlatformHelper.Open(url); - StatusMessage = $"Opened mod page for {displayName} v{update.LatestVersion}"; + StatusMessage = _localization.GetString("status.update_opened", displayName, update.LatestVersion); return; } SetBusy($"Updating {displayName} to v{update.LatestVersion}..."); var progress = new Progress(p => - BusyMessage = $"Updating {displayName}… {p:P0}"); + BusyMessage = _localization.GetString("busy.update_progress", displayName, p)); var success = await _modInstall.UpdateModAsync( SelectedMod.ModInfo, update, _settings.Settings.GamePath, _settings.Settings.StoragePath, progress); ClearBusy(); @@ -519,11 +533,11 @@ private async Task UpdateSelectedModAsync() if (success) { await RefreshModsAsync(); - StatusMessage = $"Updated {displayName} to v{update.LatestVersion}"; + StatusMessage = _localization.GetString("status.updated", displayName, update.LatestVersion); } else { - StatusMessage = $"Update failed for {displayName}"; + StatusMessage = _localization.GetString("status.update_failed", displayName); } } @@ -538,7 +552,7 @@ private async Task UpdateAllModsAsync() if (modsWithUpdates.Count == 0) { - StatusMessage = "No downloadable updates available."; + StatusMessage = _localization.GetString("status.no_downloadable_updates"); return; } @@ -618,7 +632,7 @@ private async Task SaveCurrentProfileAsync() _settings.Settings.ActiveProfileName = SelectedProfileName; await _settings.SaveAsync(); await RefreshProfileListAsync(); - StatusMessage = $"Profile '{SelectedProfileName}' saved."; + StatusMessage = _localization.GetString("status.profile_saved", SelectedProfileName); } [RelayCommand(CanExecute = nameof(CanModify))] @@ -626,7 +640,7 @@ private async Task DeactivateAllModsAsync() { if (_settings.Settings.GamePath == null) return; var active = ActiveMods.ToList(); - if (active.Count == 0) { StatusMessage = "No active mods."; return; } + if (active.Count == 0) { StatusMessage = _localization.GetString("status.no_active_mods"); return; } var confirmed = await _dialogService.ConfirmAsync("Unload All Mods", $"Deactivate all {active.Count} active mod(s)?"); @@ -661,7 +675,7 @@ private async Task ApplyProfileAsync(string profileName) if (!hasWork) { - StatusMessage = "Profile is already applied."; + StatusMessage = _localization.GetString("status.profile_already_applied"); return; } @@ -740,7 +754,7 @@ private async Task ApplyProfileAsync(string profileName) _settings.Settings.ActiveProfileName = profileName; SelectedProfileName = profileName; await _settings.SaveAsync(); - StatusMessage = $"Applied profile '{profileName}'."; + StatusMessage = _localization.GetString("status.profile_applied", profileName); return; } @@ -779,7 +793,7 @@ await _modInstall.RollbackToVersionAsync( SelectedProfileName = profileName; await _settings.SaveAsync(); await RefreshModsAsync(); - StatusMessage = $"Applied profile '{profileName}'."; + StatusMessage = _localization.GetString("status.profile_applied", profileName); } // ── Refresh ─────────────────────────────────────────────────────────────── @@ -837,8 +851,8 @@ await Dispatcher.UIThread.InvokeAsync(() => var active = mods.Count(m => m.IsActive); var inactive = mods.Count(m => !m.IsActive); StatusMessage = mods.Count == 0 - ? $"No mods found in {modsDir}" - : $"Found {mods.Count} mod(s) — {active} active, {inactive} inactive."; + ? _localization.GetString("status.no_mods_found", modsDir) + : _localization.GetString("status.mods_found", mods.Count, active, inactive); }); } @@ -878,8 +892,16 @@ private async Task OpenSettingsAsync() if (vm.Saved) { var previousGamePath = _settings.Settings.GamePath; + var previousLanguage = _settings.Settings.Language; vm.Apply(_settings.Settings); // copy UI values → AppSettings await _settings.SaveAsync(); + + // Apply language change if it changed + if (_settings.Settings.Language != previousLanguage) + { + _localization.SetLanguage(_settings.Settings.Language); + } + await RefreshModsAsync(); // Restart file watchers if the game path changed @@ -941,6 +963,13 @@ private void OnModsChangedExternally(object? sender, EventArgs e) Dispatcher.UIThread.Post(async () => await RefreshModsAsync()); } + private void UpdateLocalizedStrings() + { + WindowTitle = _localization.GetString("window.title"); + PanelAvailableHeader = _localization.GetString("panel.available"); + PanelActiveHeader = _localization.GetString("panel.active"); + } + private void MoveToActive(ModItemViewModel vm) { AvailableMods.Remove(vm); diff --git a/DVModManager/ViewModels/ModItemViewModel.cs b/DVModManager/ViewModels/ModItemViewModel.cs index 8609343..914e255 100644 --- a/DVModManager/ViewModels/ModItemViewModel.cs +++ b/DVModManager/ViewModels/ModItemViewModel.cs @@ -1,17 +1,32 @@ using Avalonia.Media; using CommunityToolkit.Mvvm.ComponentModel; using DVModManager.Models; +using DVModManager.Services; +using Microsoft.Extensions.DependencyInjection; namespace DVModManager.ViewModels; public partial class ModItemViewModel : ViewModelBase { private readonly ModInfo _modInfo; + private readonly ILocalizationService? _localization; /// Normal constructor from a scanned ModInfo. public ModItemViewModel(ModInfo modInfo) { _modInfo = modInfo; + + try + { + _localization = App.Services.GetService(typeof(ILocalizationService)) as ILocalizationService; + if (_localization != null) + _localization.LanguageChanged += (_, _) => OnPropertyChanged(nameof(StatusLabel)); + } + catch + { + _localization = null; + } + SyncFromModel(); } @@ -45,14 +60,26 @@ public static ModItemViewModel CreateMissing(string modId) => public ModInfo ModInfo => _modInfo; + private string LocalizeStatus(string key, string fallback, params object?[] args) + { + if (_localization == null) + return args.Length == 0 ? fallback : string.Format(fallback, args); + + var localized = _localization.GetString(key, args); + if (localized == key) + return args.Length == 0 ? fallback : string.Format(fallback, args); + + return localized; + } + public string StatusLabel => State switch { - ModState.UpdateAvailable => $"Update available \u2192 {UpdateVersion}", - ModState.MissingDependency => "Missing dependency", - ModState.NoMetadata => "No metadata", - ModState.Missing => "Missing \u2014 not found on disk", - ModState.Active => "Active", - _ => "Inactive" + ModState.UpdateAvailable => LocalizeStatus("modstate.update_available", "Update available → {0}", UpdateVersion), + ModState.MissingDependency => LocalizeStatus("modstate.missing_dependency", "Missing dependency"), + ModState.NoMetadata => LocalizeStatus("modstate.no_metadata", "No metadata"), + ModState.Missing => LocalizeStatus("modstate.missing", "Missing — not found on disk"), + ModState.Active => LocalizeStatus("modstate.active", "Active"), + _ => LocalizeStatus("modstate.inactive", "Inactive") }; public IBrush StatusBrush => State switch diff --git a/DVModManager/ViewModels/SettingsViewModel.cs b/DVModManager/ViewModels/SettingsViewModel.cs index 6aeefd8..e196405 100644 --- a/DVModManager/ViewModels/SettingsViewModel.cs +++ b/DVModManager/ViewModels/SettingsViewModel.cs @@ -9,6 +9,7 @@ public partial class SettingsViewModel : ViewModelBase { private readonly IDialogService _dialogService; private readonly IGameDetectionService _gameDetection; + private readonly ILocalizationService? _localization; public bool Saved { get; private set; } @@ -20,11 +21,31 @@ public partial class SettingsViewModel : ViewModelBase [ObservableProperty] private bool _backupBeforeChanges = true; [ObservableProperty] private int _maxCacheGb = 5; [ObservableProperty] private string _themeVariant = "Dark"; + [ObservableProperty] private string _language = "en"; + + // Available language options for the UI + public List AvailableLanguages { get; } = ["en", "de", "fr", "zh"]; + + // Display names for languages (for the dropdown) + public Dictionary LanguageDisplayNames => new() + { + { "en", "English" }, + { "de", "Deutsch" }, + { "fr", "Français" }, + { "zh", "中文" } + }; public SettingsViewModel(IDialogService dialogService, IGameDetectionService gameDetection) { _dialogService = dialogService; _gameDetection = gameDetection; + + // Try to get localization service if available + try + { + _localization = App.Services.GetService(typeof(ILocalizationService)) as ILocalizationService; + } + catch { } } public void Load(AppSettings settings) @@ -37,6 +58,7 @@ public void Load(AppSettings settings) BackupBeforeChanges = settings.BackupBeforeChanges; MaxCacheGb = (int)(settings.MaxCacheSizeBytes / (1024 * 1024 * 1024)); ThemeVariant = settings.ThemeVariant; + Language = settings.Language; Saved = false; } @@ -50,6 +72,7 @@ public void Apply(AppSettings settings) settings.BackupBeforeChanges = BackupBeforeChanges; settings.MaxCacheSizeBytes = (long)MaxCacheGb * 1024 * 1024 * 1024; settings.ThemeVariant = ThemeVariant; + settings.Language = Language; } [RelayCommand] diff --git a/DVModManager/Views/MainWindow.axaml b/DVModManager/Views/MainWindow.axaml index 3baa350..3e4c19d 100644 --- a/DVModManager/Views/MainWindow.axaml +++ b/DVModManager/Views/MainWindow.axaml @@ -5,7 +5,7 @@ xmlns:conv="using:DVModManager.Converters" x:Class="DVModManager.Views.MainWindow" x:DataType="vm:MainWindowViewModel" - Title="DV Mod Manager" + Title="{Binding WindowTitle}" Width="1280" Height="800" MinWidth="900" MinHeight="600" WindowStartupLocation="CenterScreen" @@ -15,6 +15,7 @@ + @@ -32,7 +33,7 @@ Orientation="Horizontal" Spacing="6"> - @@ -44,16 +45,16 @@ Background="#313244" BorderBrush="#45475a" /> - + VerticalAlignment="Center" /> ✏ + ToolTip.Tip="{conv:Localize button.rename_group}">✏ + ToolTip.Tip="{conv:Localize button.delete_group}">🗑 @@ -196,7 +196,7 @@ Padding="4,1" VerticalAlignment="Center" IsVisible="{Binding HasUpdate}"> - diff --git a/DVModManager/Views/ProfileDialog.axaml b/DVModManager/Views/ProfileDialog.axaml index 07d6003..cdf76c4 100644 --- a/DVModManager/Views/ProfileDialog.axaml +++ b/DVModManager/Views/ProfileDialog.axaml @@ -5,7 +5,7 @@ xmlns:conv="using:DVModManager.Converters" x:Class="DVModManager.Views.ProfileDialog" x:DataType="vm:ProfileViewModel" - Title="Manage Profiles" + Title="{conv:Localize profile.dialog_title}" Width="500" Height="480" WindowStartupLocation="CenterOwner" CanResize="False" @@ -23,7 +23,7 @@ - @@ -72,7 +72,7 @@ Spacing="8" Margin="0,30,0,0"> -