From da0d3d0efabb261aa20be6f21d332ae99c181bee Mon Sep 17 00:00:00 2001 From: Scott Harder Date: Mon, 6 Apr 2026 17:20:31 -0500 Subject: [PATCH 01/25] [gui] GUI localizations infrastructure --- src/client/gui/l10n.yaml | 3 +++ src/client/gui/lib/main.dart | 10 +++++++++- src/client/gui/pubspec.lock | 5 +++++ src/client/gui/pubspec.yaml | 3 +++ 4 files changed, 20 insertions(+), 1 deletion(-) create mode 100644 src/client/gui/l10n.yaml diff --git a/src/client/gui/l10n.yaml b/src/client/gui/l10n.yaml new file mode 100644 index 00000000000..15338f2ddca --- /dev/null +++ b/src/client/gui/l10n.yaml @@ -0,0 +1,3 @@ +arb-dir: lib/l10n +template-arb-file: app_en.arb +output-localization-file: app_localizations.dart diff --git a/src/client/gui/lib/main.dart b/src/client/gui/lib/main.dart index 08b77390418..25d8fd5d4b8 100644 --- a/src/client/gui/lib/main.dart +++ b/src/client/gui/lib/main.dart @@ -5,7 +5,10 @@ import 'package:local_notifier/local_notifier.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:window_manager/window_manager.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; + import 'before_quit_dialog.dart'; +import 'l10n/app_localizations.dart'; import 'catalogue/catalogue.dart'; import 'daemon_unavailable.dart'; import 'help.dart'; @@ -56,7 +59,12 @@ void main() async { runApp( UncontrolledProviderScope( container: providerContainer, - child: MaterialApp(theme: theme, home: const App()), + child: MaterialApp( + theme: theme, + home: const App(), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + ), ), ); } diff --git a/src/client/gui/pubspec.lock b/src/client/gui/pubspec.lock index a1161f05422..1a89c4c02e9 100644 --- a/src/client/gui/pubspec.lock +++ b/src/client/gui/pubspec.lock @@ -295,6 +295,11 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + flutter_localizations: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" flutter_riverpod: dependency: "direct main" description: diff --git a/src/client/gui/pubspec.yaml b/src/client/gui/pubspec.yaml index d3a97a5e93b..f6768d0205b 100644 --- a/src/client/gui/pubspec.yaml +++ b/src/client/gui/pubspec.yaml @@ -6,6 +6,8 @@ environment: sdk: '>=3.0.3 <4.0.0' dependencies: + flutter_localizations: + sdk: flutter async: ^2.13.0 basics: ^0.10.0 built_collection: ^5.1.1 @@ -64,6 +66,7 @@ dev_dependencies: flutter_lints: ^6.0.0 flutter: + generate: true uses-material-design: true assets: - assets/ From b5d5e035e11e0f741bb834d7d8be37a8328185cf Mon Sep 17 00:00:00 2001 From: Scott Harder Date: Tue, 7 Apr 2026 09:52:54 -0500 Subject: [PATCH 02/25] [gui] Extract strings out of BeforeQuitDialog --- src/client/gui/lib/before_quit_dialog.dart | 20 ++++++--------- src/client/gui/lib/l10n/app_en.arb | 29 ++++++++++++++++++++++ 2 files changed, 36 insertions(+), 13 deletions(-) create mode 100644 src/client/gui/lib/l10n/app_en.arb diff --git a/src/client/gui/lib/before_quit_dialog.dart b/src/client/gui/lib/before_quit_dialog.dart index 2b1aa0de947..a8d9026a172 100644 --- a/src/client/gui/lib/before_quit_dialog.dart +++ b/src/client/gui/lib/before_quit_dialog.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'confirmation_dialog.dart'; +import 'l10n/app_localizations.dart'; class BeforeQuitDialog extends StatefulWidget { final int runningCount; @@ -23,22 +24,15 @@ class _BeforeQuitDialogState extends State { @override Widget build(BuildContext context) { - String getMessage() { - if (widget.runningCount == 1) { - return 'There is 1 running instance. Do you want to stop it?'; - } else { - return 'There are ${widget.runningCount} running instances. Do you want to stop them?'; - } - } - + final l10n = AppLocalizations.of(context)!; return ConfirmationDialog( - title: 'Stop running instances?', + title: l10n.beforeQuitTitle, body: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.only(left: 8), - child: Text(getMessage()), + child: Text(l10n.beforeQuitMessage(widget.runningCount)), ), const SizedBox(height: 24), Row( @@ -48,14 +42,14 @@ class _BeforeQuitDialogState extends State { onChanged: (value) => setState(() => remember = value!), ), const SizedBox(width: 8), - const Text('Do not ask me again'), + Text(l10n.beforeQuitDoNotAsk), ], ), ], ), - actionText: 'Stop instances', + actionText: l10n.beforeQuitStopAction, onAction: () => widget.onStop(remember), - inactionText: 'Leave instances running', + inactionText: l10n.beforeQuitKeepAction, onInaction: () => widget.onKeep(remember), ); } diff --git a/src/client/gui/lib/l10n/app_en.arb b/src/client/gui/lib/l10n/app_en.arb new file mode 100644 index 00000000000..c1d27a204b1 --- /dev/null +++ b/src/client/gui/lib/l10n/app_en.arb @@ -0,0 +1,29 @@ +{ + "@@locale": "en", + "beforeQuitTitle": "Stop running instances?", + "@beforeQuitTitle": { + "description": "Title of the dialog asking the user whether to stop running VMs on quit" + }, + "beforeQuitMessage": "{count, plural, =1{There is 1 running instance. Do you want to stop it?} other{There are {count} running instances. Do you want to stop them?}}", + "@beforeQuitMessage": { + "description": "Body of the dialog asking the user whether to stop running VMs on quit", + "placeholders": { + "count": { + "type": "int", + "description": "Number of running VM instances" + } + } + }, + "beforeQuitDoNotAsk": "Do not ask me again", + "@beforeQuitDoNotAsk": { + "description": "Checkbox label to suppress the quit dialog in future" + }, + "beforeQuitStopAction": "Stop instances", + "@beforeQuitStopAction": { + "description": "Confirm button label to stop running instances on quit" + }, + "beforeQuitKeepAction": "Leave instances running", + "@beforeQuitKeepAction": { + "description": "Cancel button label to leave instances running on quit" + } +} From e836c59d28931a55ce3b0f914743d6d6790382e9 Mon Sep 17 00:00:00 2001 From: Scott Harder Date: Tue, 7 Apr 2026 09:54:33 -0500 Subject: [PATCH 03/25] [gui] Ignore generated localization files --- src/client/gui/.gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/client/gui/.gitignore b/src/client/gui/.gitignore index 62d92cc21ca..db6c0ef7912 100644 --- a/src/client/gui/.gitignore +++ b/src/client/gui/.gitignore @@ -164,6 +164,9 @@ app.*.symbols .gclient_previous_custom_vars .gclient_previous_sync_commits +# Generated localization files +**/l10n/app_localizations*.dart + #### MULTIPASS SPECIFIC IGNORES ### lib/generated flutter*.log From 5453776825e0e0c78c2467bf0b61fe1eaf1d2c08 Mon Sep 17 00:00:00 2001 From: Scott Harder Date: Tue, 7 Apr 2026 09:54:52 -0500 Subject: [PATCH 04/25] [format] Add arb files to editor config --- .editorconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.editorconfig b/.editorconfig index 061f09bc9c4..c3458e91f31 100644 --- a/.editorconfig +++ b/.editorconfig @@ -23,7 +23,7 @@ insert_final_newline = true max_line_length = 80 trim_trailing_whitespace = true -[*.{json,json5,proto}] +[*.{json,json5,proto,arb}] indent_size = 4 indent_style = space insert_final_newline = true From 2cf9f1204af54a7eeabe08533f323eaff96c43d6 Mon Sep 17 00:00:00 2001 From: Scott Harder Date: Tue, 7 Apr 2026 10:54:34 -0500 Subject: [PATCH 05/25] [gui] Localize vm action labels --- src/client/gui/lib/l10n/app_en.arb | 96 +++++++++++++++++++ src/client/gui/lib/vm_action.dart | 62 ++++++------ src/client/gui/lib/vm_table/bulk_actions.dart | 9 +- 3 files changed, 134 insertions(+), 33 deletions(-) diff --git a/src/client/gui/lib/l10n/app_en.arb b/src/client/gui/lib/l10n/app_en.arb index c1d27a204b1..82eb922e98c 100644 --- a/src/client/gui/lib/l10n/app_en.arb +++ b/src/client/gui/lib/l10n/app_en.arb @@ -1,5 +1,101 @@ { "@@locale": "en", + "vmActionStartLabel": "Start", + "@vmActionStartLabel": { + "description": "Label for the Start action button" + }, + "vmActionStartPastTense": "Started", + "@vmActionStartPastTense": { + "description": "Notification text after a VM has been started" + }, + "vmActionStartContinuousTense": "Starting", + "@vmActionStartContinuousTense": { + "description": "Notification text while a VM is being started" + }, + "vmActionStopLabel": "Stop", + "@vmActionStopLabel": { + "description": "Label for the Stop action button" + }, + "vmActionStopPastTense": "Stopped", + "@vmActionStopPastTense": { + "description": "Notification text after a VM has been stopped" + }, + "vmActionStopContinuousTense": "Stopping", + "@vmActionStopContinuousTense": { + "description": "Notification text while a VM is being stopped" + }, + "vmActionSuspendLabel": "Suspend", + "@vmActionSuspendLabel": { + "description": "Label for the Suspend action button" + }, + "vmActionSuspendPastTense": "Suspended", + "@vmActionSuspendPastTense": { + "description": "Notification text after a VM has been suspended" + }, + "vmActionSuspendContinuousTense": "Suspending", + "@vmActionSuspendContinuousTense": { + "description": "Notification text while a VM is being suspended" + }, + "vmActionRestartLabel": "Restart", + "@vmActionRestartLabel": { + "description": "Label for the Restart action button" + }, + "vmActionRestartPastTense": "Restarted", + "@vmActionRestartPastTense": { + "description": "Notification text after a VM has been restarted" + }, + "vmActionRestartContinuousTense": "Restarting", + "@vmActionRestartContinuousTense": { + "description": "Notification text while a VM is being restarted" + }, + "vmActionDeleteLabel": "Delete", + "@vmActionDeleteLabel": { + "description": "Label for the Delete action button" + }, + "vmActionDeletePastTense": "Deleted", + "@vmActionDeletePastTense": { + "description": "Notification text after a VM has been deleted" + }, + "vmActionDeleteContinuousTense": "Deleting", + "@vmActionDeleteContinuousTense": { + "description": "Notification text while a VM is being deleted" + }, + "vmActionRecoverLabel": "Recover", + "@vmActionRecoverLabel": { + "description": "Label for the Recover action button" + }, + "vmActionRecoverPastTense": "Recovered", + "@vmActionRecoverPastTense": { + "description": "Notification text after a VM has been recovered" + }, + "vmActionRecoverContinuousTense": "Recovering", + "@vmActionRecoverContinuousTense": { + "description": "Notification text while a VM is being recovered" + }, + "vmActionPurgeLabel": "Purge", + "@vmActionPurgeLabel": { + "description": "Label for the Purge action button" + }, + "vmActionPurgePastTense": "Purged", + "@vmActionPurgePastTense": { + "description": "Notification text after a VM has been purged" + }, + "vmActionPurgeContinuousTense": "Purging", + "@vmActionPurgeContinuousTense": { + "description": "Notification text while a VM is being purged" + }, + "vmActionEditLabel": "Edit", + "@vmActionEditLabel": { + "description": "Label for the Edit action button" + }, + "vmActionEditPastTense": "Edited", + "@vmActionEditPastTense": { + "description": "Notification text after a VM has been edited" + }, + "vmActionEditContinuousTense": "Editing", + "@vmActionEditContinuousTense": { + "description": "Notification text while a VM is being edited" + }, "beforeQuitTitle": "Stop running instances?", "@beforeQuitTitle": { "description": "Title of the dialog asking the user whether to stop running VMs on quit" diff --git a/src/client/gui/lib/vm_action.dart b/src/client/gui/lib/vm_action.dart index 40497c875bc..40801d44fa0 100644 --- a/src/client/gui/lib/vm_action.dart +++ b/src/client/gui/lib/vm_action.dart @@ -2,6 +2,7 @@ import 'package:basics/basics.dart'; import 'package:flutter/material.dart'; import 'grpc_client.dart'; +import 'l10n/app_localizations.dart'; enum VmAction { start, @@ -13,37 +14,37 @@ enum VmAction { purge, edit; - String get name => switch (this) { - start => 'Start', - stop => 'Stop', - suspend => 'Suspend', - restart => 'Restart', - delete => 'Delete', - recover => 'Recover', - purge => 'Purge', - edit => 'Edit', + String label(AppLocalizations l10n) => switch (this) { + start => l10n.vmActionStartLabel, + stop => l10n.vmActionStopLabel, + suspend => l10n.vmActionSuspendLabel, + restart => l10n.vmActionRestartLabel, + delete => l10n.vmActionDeleteLabel, + recover => l10n.vmActionRecoverLabel, + purge => l10n.vmActionPurgeLabel, + edit => l10n.vmActionEditLabel, }; - String get pastTense => switch (this) { - start => 'Started', - stop => 'Stopped', - suspend => 'Suspended', - restart => 'Restarted', - delete => 'Deleted', - recover => 'Recovered', - purge => 'Purged', - edit => 'Edited', + String pastTense(AppLocalizations l10n) => switch (this) { + start => l10n.vmActionStartPastTense, + stop => l10n.vmActionStopPastTense, + suspend => l10n.vmActionSuspendPastTense, + restart => l10n.vmActionRestartPastTense, + delete => l10n.vmActionDeletePastTense, + recover => l10n.vmActionRecoverPastTense, + purge => l10n.vmActionPurgePastTense, + edit => l10n.vmActionEditPastTense, }; - String get continuousTense => switch (this) { - start => 'Starting', - stop => 'Stopping', - suspend => 'Suspending', - restart => 'Restarting', - delete => 'Deleting', - recover => 'Recovering', - purge => 'Purging', - edit => 'Editing', + String continuousTense(AppLocalizations l10n) => switch (this) { + start => l10n.vmActionStartContinuousTense, + stop => l10n.vmActionStopContinuousTense, + suspend => l10n.vmActionSuspendContinuousTense, + restart => l10n.vmActionRestartContinuousTense, + delete => l10n.vmActionDeleteContinuousTense, + recover => l10n.vmActionRecoverContinuousTense, + purge => l10n.vmActionPurgeContinuousTense, + edit => l10n.vmActionEditContinuousTense, }; Set get allowedStatuses => switch (this) { @@ -72,12 +73,13 @@ class VmActionButton extends StatelessWidget { @override Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; final enabled = action.allowedStatuses.containsAny(currentStatuses); final onPressed = enabled ? function : null; - return _buildButton(onPressed); + return _buildButton(onPressed, l10n); } - Widget _buildButton(VoidCallback? onPressed) { + Widget _buildButton(VoidCallback? onPressed, AppLocalizations l10n) { return OutlinedButton( onPressed: onPressed, style: ButtonStyle( @@ -89,7 +91,7 @@ class VmActionButton extends StatelessWidget { ), ), ), - child: Text(action.name), + child: Text(action.label(l10n)), ); } } diff --git a/src/client/gui/lib/vm_table/bulk_actions.dart b/src/client/gui/lib/vm_table/bulk_actions.dart index b253b793d90..3f7f4800695 100644 --- a/src/client/gui/lib/vm_table/bulk_actions.dart +++ b/src/client/gui/lib/vm_table/bulk_actions.dart @@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../delete_instance_dialog.dart'; import '../extensions.dart'; +import '../l10n/app_localizations.dart'; import '../notifications.dart'; import '../providers.dart'; import '../vm_action.dart'; @@ -23,6 +24,8 @@ class BulkActionsBar extends ConsumerWidget { .values .toSet(); + final l10n = AppLocalizations.of(context)!; + Function(VmAction) wrapInNotification( Future Function(Iterable) function, ) { @@ -34,10 +37,10 @@ class BulkActionsBar extends ConsumerWidget { final notificationsNotifier = ref.read(notificationsProvider.notifier); notificationsNotifier.addOperation( function(selectedVms), - loading: '${action.continuousTense} $object', - onSuccess: (_) => '${action.pastTense} $object', + loading: '${action.continuousTense(l10n)} $object', + onSuccess: (_) => '${action.pastTense(l10n)} $object', onError: (error) { - return 'Failed to ${action.name.toLowerCase()} $object: $error'; + return 'Failed to ${action.label(l10n).toLowerCase()} $object: $error'; }, ); }; From 64de2ac9925c670293334a96ac99e2b09343480b Mon Sep 17 00:00:00 2001 From: Scott Harder Date: Tue, 7 Apr 2026 10:59:47 -0500 Subject: [PATCH 06/25] [gui] Apply dart fixes --- src/client/gui/lib/catalogue/launch_form.dart | 1 - src/client/gui/lib/main.dart | 2 -- src/client/gui/lib/platform/windows.dart | 2 -- 3 files changed, 5 deletions(-) diff --git a/src/client/gui/lib/catalogue/launch_form.dart b/src/client/gui/lib/catalogue/launch_form.dart index 4720bdd5a69..203283a7a33 100644 --- a/src/client/gui/lib/catalogue/launch_form.dart +++ b/src/client/gui/lib/catalogue/launch_form.dart @@ -3,7 +3,6 @@ import 'dart:async'; import 'package:basics/basics.dart'; import 'package:flutter/material.dart' hide Switch, ImageInfo; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:protobuf/protobuf.dart'; import 'package:rxdart/rxdart.dart'; import '../ffi.dart'; diff --git a/src/client/gui/lib/main.dart b/src/client/gui/lib/main.dart index 25d8fd5d4b8..ad9e0314ff5 100644 --- a/src/client/gui/lib/main.dart +++ b/src/client/gui/lib/main.dart @@ -5,8 +5,6 @@ import 'package:local_notifier/local_notifier.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:window_manager/window_manager.dart'; -import 'package:flutter_localizations/flutter_localizations.dart'; - import 'before_quit_dialog.dart'; import 'l10n/app_localizations.dart'; import 'catalogue/catalogue.dart'; diff --git a/src/client/gui/lib/platform/windows.dart b/src/client/gui/lib/platform/windows.dart index 723527267dd..4335a2d9219 100644 --- a/src/client/gui/lib/platform/windows.dart +++ b/src/client/gui/lib/platform/windows.dart @@ -1,7 +1,5 @@ -import 'dart:ffi'; import 'dart:io'; -import 'package:ffi/ffi.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:win32/win32.dart'; From e57e6364b2fa0d710c31cde124c2d961283df4c3 Mon Sep 17 00:00:00 2001 From: Scott Harder Date: Tue, 7 Apr 2026 11:28:31 -0500 Subject: [PATCH 07/25] [gui] Extract strings from bulk actions --- src/client/gui/lib/l10n/app_en.arb | 56 +++++++++++++++++++ src/client/gui/lib/vm_table/bulk_actions.dart | 10 ++-- 2 files changed, 62 insertions(+), 4 deletions(-) diff --git a/src/client/gui/lib/l10n/app_en.arb b/src/client/gui/lib/l10n/app_en.arb index 82eb922e98c..f1b38a10440 100644 --- a/src/client/gui/lib/l10n/app_en.arb +++ b/src/client/gui/lib/l10n/app_en.arb @@ -121,5 +121,61 @@ "beforeQuitKeepAction": "Leave instances running", "@beforeQuitKeepAction": { "description": "Cancel button label to leave instances running on quit" + }, + "bulkActionInstanceCount": "{count, plural, =1{1 instance} other{{count} instances}}", + "@bulkActionInstanceCount": { + "description": "Refers to a number of VM instances in bulk action notifications", + "placeholders": { + "count": { + "type": "int", + "description": "Number of selected VM instances" + } + } + }, + "bulkActionLoading": "{verb} {object}", + "@bulkActionLoading": { + "description": "Notification shown while a bulk action is in progress", + "placeholders": { + "verb": { + "type": "String", + "description": "The action in continuous tense, e.g. Starting" + }, + "object": { + "type": "String", + "description": "The instance name or count, e.g. primary or 3 instances" + } + } + }, + "bulkActionSuccess": "{verb} {object}", + "@bulkActionSuccess": { + "description": "Notification shown after a bulk action succeeds", + "placeholders": { + "verb": { + "type": "String", + "description": "The action in past tense, e.g. Started" + }, + "object": { + "type": "String", + "description": "The instance name or count" + } + } + }, + "bulkActionError": "Failed to {verb} {object}: {error}", + "@bulkActionError": { + "description": "Notification shown when a bulk action fails", + "placeholders": { + "verb": { + "type": "String", + "description": "The action label in lowercase, e.g. start" + }, + "object": { + "type": "String", + "description": "The instance name or count" + }, + "error": { + "type": "String", + "description": "The error message" + } + } } } diff --git a/src/client/gui/lib/vm_table/bulk_actions.dart b/src/client/gui/lib/vm_table/bulk_actions.dart index 3f7f4800695..b240401da9e 100644 --- a/src/client/gui/lib/vm_table/bulk_actions.dart +++ b/src/client/gui/lib/vm_table/bulk_actions.dart @@ -32,15 +32,17 @@ class BulkActionsBar extends ConsumerWidget { return (action) { final object = selectedVms.length == 1 ? selectedVms.first - : '${selectedVms.length} instances'; + : l10n.bulkActionInstanceCount(selectedVms.length); final notificationsNotifier = ref.read(notificationsProvider.notifier); notificationsNotifier.addOperation( function(selectedVms), - loading: '${action.continuousTense(l10n)} $object', - onSuccess: (_) => '${action.pastTense(l10n)} $object', + loading: l10n.bulkActionLoading(action.continuousTense(l10n), object), + onSuccess: (_) => + l10n.bulkActionSuccess(action.pastTense(l10n), object), onError: (error) { - return 'Failed to ${action.label(l10n).toLowerCase()} $object: $error'; + return l10n.bulkActionError( + action.label(l10n).toLowerCase(), object, '$error'); }, ); }; From 2435a0d9ee79baca914e92199ca6bf1631207ada Mon Sep 17 00:00:00 2001 From: Scott Harder Date: Tue, 7 Apr 2026 11:36:49 -0500 Subject: [PATCH 08/25] [gui] Extract strings from `NoVms` --- src/client/gui/lib/l10n/app_en.arb | 16 ++++++++++++++++ src/client/gui/lib/vm_table/no_vms.dart | 14 +++++++++----- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/src/client/gui/lib/l10n/app_en.arb b/src/client/gui/lib/l10n/app_en.arb index f1b38a10440..5eaed4533ea 100644 --- a/src/client/gui/lib/l10n/app_en.arb +++ b/src/client/gui/lib/l10n/app_en.arb @@ -177,5 +177,21 @@ "description": "The error message" } } + }, + "noVmsTitle": "Zero Instances", + "@noVmsTitle": { + "description": "Heading shown when the user has no VM instances" + }, + "noVmsMessageBefore": "Return to the ", + "@noVmsMessageBefore": { + "description": "Text before the Catalogue link in the empty-state message. Forms the sentence: 'Return to the [Catalogue] to choose your instance or get started with the primary Ubuntu Image'" + }, + "noVmsMessageLink": "Catalogue", + "@noVmsMessageLink": { + "description": "Link label in the empty-state message. Forms the sentence: 'Return to the [Catalogue] to choose your instance or get started with the primary Ubuntu Image'" + }, + "noVmsMessageAfter": " to choose your instance or get started with the primary Ubuntu Image", + "@noVmsMessageAfter": { + "description": "Text after the Catalogue link in the empty-state message. Forms the sentence: 'Return to the [Catalogue] to choose your instance or get started with the primary Ubuntu Image'" } } diff --git a/src/client/gui/lib/vm_table/no_vms.dart b/src/client/gui/lib/vm_table/no_vms.dart index f362a317866..ac71935c698 100644 --- a/src/client/gui/lib/vm_table/no_vms.dart +++ b/src/client/gui/lib/vm_table/no_vms.dart @@ -4,6 +4,7 @@ import 'package:flutter_svg/flutter_svg.dart'; import '../catalogue/catalogue.dart'; import '../extensions.dart'; +import '../l10n/app_localizations.dart'; import '../sidebar.dart'; class NoVms extends ConsumerWidget { @@ -11,6 +12,8 @@ class NoVms extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final l10n = AppLocalizations.of(context)!; + final multipassLogo = SvgPicture.asset( 'assets/multipass.svg', width: 40, @@ -30,14 +33,15 @@ class NoVms extends ConsumerWidget { children: [ multipassLogo, const SizedBox(height: 22), - const Text('Zero Instances', style: TextStyle(fontSize: 21)), + Text(l10n.noVmsTitle, style: const TextStyle(fontSize: 21)), const SizedBox(height: 8), Text.rich( [ - 'Return to the '.span, - 'Catalogue'.span.color(Colors.blue).link(ref, goToCatalogue), - ' to choose your instance or get started with the primary Ubuntu Image' - .span, + l10n.noVmsMessageBefore.span, + l10n.noVmsMessageLink.span + .color(Colors.blue) + .link(ref, goToCatalogue), + l10n.noVmsMessageAfter.span, ].spans.size(16), ), ], From 69109b433073d7065aa9d9b9cea888bc54083572 Mon Sep 17 00:00:00 2001 From: Scott Harder Date: Tue, 7 Apr 2026 11:46:58 -0500 Subject: [PATCH 09/25] [gui] Extract strings from `SearchBox` --- src/client/gui/lib/l10n/app_en.arb | 4 ++++ src/client/gui/lib/vm_table/search_box.dart | 9 ++++++--- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/client/gui/lib/l10n/app_en.arb b/src/client/gui/lib/l10n/app_en.arb index 5eaed4533ea..630f295fa50 100644 --- a/src/client/gui/lib/l10n/app_en.arb +++ b/src/client/gui/lib/l10n/app_en.arb @@ -193,5 +193,9 @@ "noVmsMessageAfter": " to choose your instance or get started with the primary Ubuntu Image", "@noVmsMessageAfter": { "description": "Text after the Catalogue link in the empty-state message. Forms the sentence: 'Return to the [Catalogue] to choose your instance or get started with the primary Ubuntu Image'" + }, + "searchBoxHint": "Search instances...", + "@searchBoxHint": { + "description": "Placeholder text in the instance search box" } } diff --git a/src/client/gui/lib/vm_table/search_box.dart b/src/client/gui/lib/vm_table/search_box.dart index d8f1aa09d51..ee3986e3581 100644 --- a/src/client/gui/lib/vm_table/search_box.dart +++ b/src/client/gui/lib/vm_table/search_box.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../l10n/app_localizations.dart'; + class SearchNameNotifier extends Notifier { @override String build() { @@ -21,12 +23,13 @@ class SearchBox extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final l10n = AppLocalizations.of(context)!; return SizedBox( width: 220, child: TextField( - decoration: const InputDecoration( - hintText: 'Search instances...', - suffixIcon: Icon(Icons.search), + decoration: InputDecoration( + hintText: l10n.searchBoxHint, + suffixIcon: const Icon(Icons.search), ), onChanged: (name) => ref.read(searchNameProvider.notifier).set(name), ), From cffb0b66ef3ec4a116ec11f5cab7b56e3a66882b Mon Sep 17 00:00:00 2001 From: Scott Harder Date: Tue, 7 Apr 2026 12:10:45 -0500 Subject: [PATCH 10/25] [gui] Move string resolution to resource file --- src/client/gui/lib/l10n/app_en.arb | 117 ++++++----------------------- src/client/gui/lib/vm_action.dart | 36 +-------- 2 files changed, 28 insertions(+), 125 deletions(-) diff --git a/src/client/gui/lib/l10n/app_en.arb b/src/client/gui/lib/l10n/app_en.arb index 630f295fa50..be71af686a7 100644 --- a/src/client/gui/lib/l10n/app_en.arb +++ b/src/client/gui/lib/l10n/app_en.arb @@ -1,100 +1,31 @@ { "@@locale": "en", - "vmActionStartLabel": "Start", - "@vmActionStartLabel": { - "description": "Label for the Start action button" - }, - "vmActionStartPastTense": "Started", - "@vmActionStartPastTense": { - "description": "Notification text after a VM has been started" - }, - "vmActionStartContinuousTense": "Starting", - "@vmActionStartContinuousTense": { - "description": "Notification text while a VM is being started" - }, - "vmActionStopLabel": "Stop", - "@vmActionStopLabel": { - "description": "Label for the Stop action button" - }, - "vmActionStopPastTense": "Stopped", - "@vmActionStopPastTense": { - "description": "Notification text after a VM has been stopped" - }, - "vmActionStopContinuousTense": "Stopping", - "@vmActionStopContinuousTense": { - "description": "Notification text while a VM is being stopped" - }, - "vmActionSuspendLabel": "Suspend", - "@vmActionSuspendLabel": { - "description": "Label for the Suspend action button" - }, - "vmActionSuspendPastTense": "Suspended", - "@vmActionSuspendPastTense": { - "description": "Notification text after a VM has been suspended" - }, - "vmActionSuspendContinuousTense": "Suspending", - "@vmActionSuspendContinuousTense": { - "description": "Notification text while a VM is being suspended" - }, - "vmActionRestartLabel": "Restart", - "@vmActionRestartLabel": { - "description": "Label for the Restart action button" - }, - "vmActionRestartPastTense": "Restarted", - "@vmActionRestartPastTense": { - "description": "Notification text after a VM has been restarted" - }, - "vmActionRestartContinuousTense": "Restarting", - "@vmActionRestartContinuousTense": { - "description": "Notification text while a VM is being restarted" - }, - "vmActionDeleteLabel": "Delete", - "@vmActionDeleteLabel": { - "description": "Label for the Delete action button" - }, - "vmActionDeletePastTense": "Deleted", - "@vmActionDeletePastTense": { - "description": "Notification text after a VM has been deleted" - }, - "vmActionDeleteContinuousTense": "Deleting", - "@vmActionDeleteContinuousTense": { - "description": "Notification text while a VM is being deleted" - }, - "vmActionRecoverLabel": "Recover", - "@vmActionRecoverLabel": { - "description": "Label for the Recover action button" - }, - "vmActionRecoverPastTense": "Recovered", - "@vmActionRecoverPastTense": { - "description": "Notification text after a VM has been recovered" - }, - "vmActionRecoverContinuousTense": "Recovering", - "@vmActionRecoverContinuousTense": { - "description": "Notification text while a VM is being recovered" - }, - "vmActionPurgeLabel": "Purge", - "@vmActionPurgeLabel": { - "description": "Label for the Purge action button" - }, - "vmActionPurgePastTense": "Purged", - "@vmActionPurgePastTense": { - "description": "Notification text after a VM has been purged" - }, - "vmActionPurgeContinuousTense": "Purging", - "@vmActionPurgeContinuousTense": { - "description": "Notification text while a VM is being purged" - }, - "vmActionEditLabel": "Edit", - "@vmActionEditLabel": { - "description": "Label for the Edit action button" + "vmActionLabel": "{action, select, start{Start} stop{Stop} suspend{Suspend} restart{Restart} delete{Delete} recover{Recover} purge{Purge} edit{Edit} other{}}", + "@vmActionLabel": { + "description": "Button label for a VM action. The 'action' parameter is the lowercase enum name (start, stop, suspend, restart, delete, recover, purge, edit).", + "placeholders": { + "action": { + "type": "String" + } + } }, - "vmActionEditPastTense": "Edited", - "@vmActionEditPastTense": { - "description": "Notification text after a VM has been edited" + "vmActionPastTense": "{action, select, start{Started} stop{Stopped} suspend{Suspended} restart{Restarted} delete{Deleted} recover{Recovered} purge{Purged} edit{Edited} other{}}", + "@vmActionPastTense": { + "description": "Past-tense form of a VM action, used in success notifications. The 'action' parameter is the lowercase enum name.", + "placeholders": { + "action": { + "type": "String" + } + } }, - "vmActionEditContinuousTense": "Editing", - "@vmActionEditContinuousTense": { - "description": "Notification text while a VM is being edited" + "vmActionContinuousTense": "{action, select, start{Starting} stop{Stopping} suspend{Suspending} restart{Restarting} delete{Deleting} recover{Recovering} purge{Purging} edit{Editing} other{}}", + "@vmActionContinuousTense": { + "description": "Continuous-tense form of a VM action, used in progress notifications. The 'action' parameter is the lowercase enum name.", + "placeholders": { + "action": { + "type": "String" + } + } }, "beforeQuitTitle": "Stop running instances?", "@beforeQuitTitle": { diff --git a/src/client/gui/lib/vm_action.dart b/src/client/gui/lib/vm_action.dart index 40801d44fa0..aaad5d27d94 100644 --- a/src/client/gui/lib/vm_action.dart +++ b/src/client/gui/lib/vm_action.dart @@ -14,38 +14,10 @@ enum VmAction { purge, edit; - String label(AppLocalizations l10n) => switch (this) { - start => l10n.vmActionStartLabel, - stop => l10n.vmActionStopLabel, - suspend => l10n.vmActionSuspendLabel, - restart => l10n.vmActionRestartLabel, - delete => l10n.vmActionDeleteLabel, - recover => l10n.vmActionRecoverLabel, - purge => l10n.vmActionPurgeLabel, - edit => l10n.vmActionEditLabel, - }; - - String pastTense(AppLocalizations l10n) => switch (this) { - start => l10n.vmActionStartPastTense, - stop => l10n.vmActionStopPastTense, - suspend => l10n.vmActionSuspendPastTense, - restart => l10n.vmActionRestartPastTense, - delete => l10n.vmActionDeletePastTense, - recover => l10n.vmActionRecoverPastTense, - purge => l10n.vmActionPurgePastTense, - edit => l10n.vmActionEditPastTense, - }; - - String continuousTense(AppLocalizations l10n) => switch (this) { - start => l10n.vmActionStartContinuousTense, - stop => l10n.vmActionStopContinuousTense, - suspend => l10n.vmActionSuspendContinuousTense, - restart => l10n.vmActionRestartContinuousTense, - delete => l10n.vmActionDeleteContinuousTense, - recover => l10n.vmActionRecoverContinuousTense, - purge => l10n.vmActionPurgeContinuousTense, - edit => l10n.vmActionEditContinuousTense, - }; + String label(AppLocalizations l10n) => l10n.vmActionLabel(name); + String pastTense(AppLocalizations l10n) => l10n.vmActionPastTense(name); + String continuousTense(AppLocalizations l10n) => + l10n.vmActionContinuousTense(name); Set get allowedStatuses => switch (this) { start => const {Status.STOPPED, Status.SUSPENDED}, From 04f27ff56d8db52845e4947fe70565d9633ee5ff Mon Sep 17 00:00:00 2001 From: Scott Harder Date: Tue, 7 Apr 2026 12:21:34 -0500 Subject: [PATCH 11/25] [gui] Extract strings from `HelpScreen` --- src/client/gui/lib/help.dart | 13 ++++++++----- src/client/gui/lib/l10n/app_en.arb | 12 ++++++++++++ 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/src/client/gui/lib/help.dart b/src/client/gui/lib/help.dart index 6e603cc28b4..09e8d893e62 100644 --- a/src/client/gui/lib/help.dart +++ b/src/client/gui/lib/help.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:url_launcher/url_launcher.dart'; +import 'l10n/app_localizations.dart'; + class HelpScreen extends StatelessWidget { static const sidebarKey = 'help'; @@ -10,6 +12,7 @@ class HelpScreen extends StatelessWidget { @override Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; return Scaffold( body: Padding( padding: const EdgeInsets.symmetric(horizontal: 140).copyWith(top: 40), @@ -17,19 +20,19 @@ class HelpScreen extends StatelessWidget { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('Help', style: TextStyle(fontSize: 37)), + Text(l10n.helpTitle, style: const TextStyle(fontSize: 37)), const SizedBox(height: 32), - const SizedBox( + SizedBox( width: 530, child: Text( - 'View tutorials, how-to guides, and references in our extensive Multipass Documentation site.', - style: TextStyle(fontSize: 16), + l10n.helpBody, + style: const TextStyle(fontSize: 16), ), ), const SizedBox(height: 32), TextButton( onPressed: () => launchUrl(docsUrl), - child: const Text('View documentation'), + child: Text(l10n.helpViewDocs), ), ], ), diff --git a/src/client/gui/lib/l10n/app_en.arb b/src/client/gui/lib/l10n/app_en.arb index be71af686a7..8373375f66e 100644 --- a/src/client/gui/lib/l10n/app_en.arb +++ b/src/client/gui/lib/l10n/app_en.arb @@ -128,5 +128,17 @@ "searchBoxHint": "Search instances...", "@searchBoxHint": { "description": "Placeholder text in the instance search box" + }, + "helpTitle": "Help", + "@helpTitle": { + "description": "Title of the Help screen" + }, + "helpBody": "View tutorials, how-to guides, and references in our extensive Multipass Documentation site.", + "@helpBody": { + "description": "Introductory text on the Help screen" + }, + "helpViewDocs": "View documentation", + "@helpViewDocs": { + "description": "Button label that opens the Multipass documentation URL" } } From 5c56b6b20fb140d1e0dafe1718551e46e80cb51f Mon Sep 17 00:00:00 2001 From: Scott Harder Date: Tue, 7 Apr 2026 15:27:43 -0500 Subject: [PATCH 12/25] [gui] Extract strings from sidebar --- src/client/gui/lib/l10n/app_en.arb | 16 ++++++++++++++++ src/client/gui/lib/sidebar.dart | 15 ++++++++++----- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/src/client/gui/lib/l10n/app_en.arb b/src/client/gui/lib/l10n/app_en.arb index 8373375f66e..9f08b6cfa3c 100644 --- a/src/client/gui/lib/l10n/app_en.arb +++ b/src/client/gui/lib/l10n/app_en.arb @@ -140,5 +140,21 @@ "helpViewDocs": "View documentation", "@helpViewDocs": { "description": "Button label that opens the Multipass documentation URL" + }, + "sidebarCatalogue": "Catalogue", + "@sidebarCatalogue": { + "description": "Sidebar navigation label for the Catalogue screen" + }, + "sidebarInstances": "Instances", + "@sidebarInstances": { + "description": "Sidebar navigation label for the Instances screen" + }, + "sidebarHelp": "Help", + "@sidebarHelp": { + "description": "Sidebar navigation label for the Help screen" + }, + "sidebarSettings": "Settings", + "@sidebarSettings": { + "description": "Sidebar navigation label for the Settings screen" } } diff --git a/src/client/gui/lib/sidebar.dart b/src/client/gui/lib/sidebar.dart index 1f1bba97f48..7bdacdf9cc0 100644 --- a/src/client/gui/lib/sidebar.dart +++ b/src/client/gui/lib/sidebar.dart @@ -8,6 +8,7 @@ import 'package:flutter_svg/flutter_svg.dart'; import 'catalogue/catalogue.dart'; import 'extensions.dart'; import 'help.dart'; +import 'l10n/app_localizations.dart'; import 'providers.dart'; import 'settings/settings.dart'; import 'vm_details/terminal.dart'; @@ -101,6 +102,7 @@ class SideBar extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final l10n = AppLocalizations.of(context)!; final selectedSidebarKey = ref.watch(sidebarKeyProvider); final sidebarKeyNotifier = sidebarKeyProvider.notifier; final vmNames = ref.watch(vmNamesProvider); @@ -112,7 +114,7 @@ class SideBar extends ConsumerWidget { final catalogue = SidebarEntry( icon: SvgPicture.asset('assets/catalogue.svg'), selected: isSelected(CatalogueScreen.sidebarKey), - label: 'Catalogue', + label: l10n.sidebarCatalogue, onPressed: () { ref.read(sidebarKeyNotifier).set(CatalogueScreen.sidebarKey); }, @@ -122,7 +124,7 @@ class SideBar extends ConsumerWidget { icon: SvgPicture.asset('assets/instances.svg'), selected: isSelected(VmTableScreen.sidebarKey) || !expanded && selectedSidebarKey.startsWith('vm-'), - label: 'Instances', + label: l10n.sidebarInstances, badge: vmNames.length.toString(), onPressed: () { ref.read(sidebarKeyProvider.notifier).set(VmTableScreen.sidebarKey); @@ -132,7 +134,7 @@ class SideBar extends ConsumerWidget { final help = SidebarEntry( icon: SvgPicture.asset('assets/help.svg'), selected: isSelected(HelpScreen.sidebarKey), - label: 'Help', + label: l10n.sidebarHelp, onPressed: () { ref.read(sidebarKeyNotifier).set(HelpScreen.sidebarKey); }, @@ -141,7 +143,7 @@ class SideBar extends ConsumerWidget { final settings = SidebarEntry( icon: SvgPicture.asset('assets/settings.svg'), selected: isSelected(SettingsScreen.sidebarKey), - label: 'Settings', + label: l10n.sidebarSettings, onPressed: () { ref.read(sidebarKeyNotifier).set(SettingsScreen.sidebarKey); }, @@ -180,7 +182,10 @@ class SideBar extends ConsumerWidget { duration: SideBar.animationDuration, child: Text.rich( [ - 'Canonical\n'.span.size(12).color(Colors.white), + 'Canonical\n' + .span + .size(12) + .color(Colors.white), 'Multipass'.span.size(24).color(Colors.white), ].spans, ), From bcdf55095468f6a5ce9ef570b8445f96fdeca6c8 Mon Sep 17 00:00:00 2001 From: Scott Harder Date: Tue, 7 Apr 2026 15:30:12 -0500 Subject: [PATCH 13/25] [gui] Extract strings from `DeleteInstanceDialog` --- .../gui/lib/delete_instance_dialog.dart | 13 +++++----- src/client/gui/lib/l10n/app_en.arb | 26 +++++++++++++++++++ src/client/gui/lib/sidebar.dart | 5 +--- 3 files changed, 34 insertions(+), 10 deletions(-) diff --git a/src/client/gui/lib/delete_instance_dialog.dart b/src/client/gui/lib/delete_instance_dialog.dart index cf0ea6f9218..05574fd4d42 100644 --- a/src/client/gui/lib/delete_instance_dialog.dart +++ b/src/client/gui/lib/delete_instance_dialog.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'confirmation_dialog.dart'; +import 'l10n/app_localizations.dart'; class DeleteInstanceDialog extends StatelessWidget { final VoidCallback onDelete; @@ -14,17 +15,17 @@ class DeleteInstanceDialog extends StatelessWidget { @override Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final count = multiple ? 2 : 1; return ConfirmationDialog( - title: 'Delete instance${multiple ? 's' : ''}', - body: Text( - "You won't be able to recover ${multiple ? 'these instances' : 'this instance'}.", - ), - actionText: 'Delete', + title: l10n.deleteInstanceTitle(count), + body: Text(l10n.deleteInstanceBody(count)), + actionText: l10n.deleteInstanceConfirm, onAction: () { onDelete(); Navigator.pop(context); }, - inactionText: 'Cancel', + inactionText: l10n.dialogCancel, onInaction: () => Navigator.pop(context), ); } diff --git a/src/client/gui/lib/l10n/app_en.arb b/src/client/gui/lib/l10n/app_en.arb index 9f08b6cfa3c..177c2f92fc4 100644 --- a/src/client/gui/lib/l10n/app_en.arb +++ b/src/client/gui/lib/l10n/app_en.arb @@ -156,5 +156,31 @@ "sidebarSettings": "Settings", "@sidebarSettings": { "description": "Sidebar navigation label for the Settings screen" + }, + "deleteInstanceTitle": "{count, plural, =1{Delete instance} other{Delete instances}}", + "@deleteInstanceTitle": { + "description": "Title of the delete instance confirmation dialog", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "deleteInstanceBody": "{count, plural, =1{You won't be able to recover this instance.} other{You won't be able to recover these instances.}}", + "@deleteInstanceBody": { + "description": "Body text of the delete instance confirmation dialog", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "deleteInstanceConfirm": "Delete", + "@deleteInstanceConfirm": { + "description": "Confirm button label on the delete instance dialog" + }, + "dialogCancel": "Cancel", + "@dialogCancel": { + "description": "Generic cancel button label for dialogs" } } diff --git a/src/client/gui/lib/sidebar.dart b/src/client/gui/lib/sidebar.dart index 7bdacdf9cc0..1927fab3dd5 100644 --- a/src/client/gui/lib/sidebar.dart +++ b/src/client/gui/lib/sidebar.dart @@ -182,10 +182,7 @@ class SideBar extends ConsumerWidget { duration: SideBar.animationDuration, child: Text.rich( [ - 'Canonical\n' - .span - .size(12) - .color(Colors.white), + 'Canonical\n'.span.size(12).color(Colors.white), 'Multipass'.span.size(24).color(Colors.white), ].spans, ), From 4cbad636bb69d60927acde33c65fb44c24b223cb Mon Sep 17 00:00:00 2001 From: Scott Harder Date: Tue, 7 Apr 2026 15:36:03 -0500 Subject: [PATCH 14/25] [gui] Edit `DeleteInstanceDialog` usage --- src/client/gui/lib/delete_instance_dialog.dart | 5 ++--- src/client/gui/lib/vm_details/vm_action_buttons.dart | 1 - src/client/gui/lib/vm_table/bulk_actions.dart | 2 +- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/client/gui/lib/delete_instance_dialog.dart b/src/client/gui/lib/delete_instance_dialog.dart index 05574fd4d42..41a2d2320cc 100644 --- a/src/client/gui/lib/delete_instance_dialog.dart +++ b/src/client/gui/lib/delete_instance_dialog.dart @@ -5,18 +5,17 @@ import 'l10n/app_localizations.dart'; class DeleteInstanceDialog extends StatelessWidget { final VoidCallback onDelete; - final bool multiple; + final int count; const DeleteInstanceDialog({ super.key, required this.onDelete, - required this.multiple, + this.count = 1, }); @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; - final count = multiple ? 2 : 1; return ConfirmationDialog( title: l10n.deleteInstanceTitle(count), body: Text(l10n.deleteInstanceBody(count)), diff --git a/src/client/gui/lib/vm_details/vm_action_buttons.dart b/src/client/gui/lib/vm_details/vm_action_buttons.dart index 98057c4503e..1808dad0a19 100644 --- a/src/client/gui/lib/vm_details/vm_action_buttons.dart +++ b/src/client/gui/lib/vm_details/vm_action_buttons.dart @@ -40,7 +40,6 @@ class VmActionButtons extends ConsumerWidget { context: context, barrierDismissible: false, builder: (_) => DeleteInstanceDialog( - multiple: false, onDelete: () => wrapInNotification(client.purge)(action), ), ); diff --git a/src/client/gui/lib/vm_table/bulk_actions.dart b/src/client/gui/lib/vm_table/bulk_actions.dart index b240401da9e..7426fc634be 100644 --- a/src/client/gui/lib/vm_table/bulk_actions.dart +++ b/src/client/gui/lib/vm_table/bulk_actions.dart @@ -57,7 +57,7 @@ class BulkActionsBar extends ConsumerWidget { context: context, barrierDismissible: false, builder: (_) => DeleteInstanceDialog( - multiple: selectedVms.length > 1, + count: selectedVms.length, onDelete: () => wrapInNotification(client.purge)(action), ), ); From d32aa121e4cdce7a984a5087c51f6251e610e0e5 Mon Sep 17 00:00:00 2001 From: Scott Harder Date: Tue, 7 Apr 2026 15:46:05 -0500 Subject: [PATCH 15/25] [gui] Extract strings from `CloseTerminalDialog` --- src/client/gui/lib/close_terminal_dialog.dart | 14 +++++++------- src/client/gui/lib/l10n/app_en.arb | 16 ++++++++++++++++ 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/src/client/gui/lib/close_terminal_dialog.dart b/src/client/gui/lib/close_terminal_dialog.dart index 36f0fdcf6c7..9be15e7cedb 100644 --- a/src/client/gui/lib/close_terminal_dialog.dart +++ b/src/client/gui/lib/close_terminal_dialog.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'confirmation_dialog.dart'; +import 'l10n/app_localizations.dart'; class CloseTerminalDialog extends StatefulWidget { final Function() onYes; @@ -23,16 +24,15 @@ class _CloseTerminalDialogState extends State { @override Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; return ConfirmationDialog( - title: 'Close tab?', + title: l10n.closeTerminalTitle, body: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.only(left: 8), - child: const Text( - 'Are you sure you want to close this tab? Its current state will be lost.', - ), + child: Text(l10n.closeTerminalBody), ), const SizedBox(height: 24), Row( @@ -42,19 +42,19 @@ class _CloseTerminalDialogState extends State { onChanged: (value) => setState(() => doNotAsk = value!), ), const SizedBox(width: 8), - const Text('Do not ask me again'), + Text(l10n.closeTerminalDoNotAsk), ], ), ], ), - actionText: 'Close tab', + actionText: l10n.closeTerminalConfirm, onAction: () { widget.onDoNotAsk( doNotAsk, ); // Apply "do not ask" setting only when closing widget.onYes(); }, - inactionText: 'Cancel', + inactionText: l10n.dialogCancel, onInaction: () { widget.onNo(); // Don't apply "do not ask" setting when canceling }, diff --git a/src/client/gui/lib/l10n/app_en.arb b/src/client/gui/lib/l10n/app_en.arb index 177c2f92fc4..64d0939ab0b 100644 --- a/src/client/gui/lib/l10n/app_en.arb +++ b/src/client/gui/lib/l10n/app_en.arb @@ -182,5 +182,21 @@ "dialogCancel": "Cancel", "@dialogCancel": { "description": "Generic cancel button label for dialogs" + }, + "closeTerminalTitle": "Close tab?", + "@closeTerminalTitle": { + "description": "Title of the dialog asking the user to confirm closing a terminal tab" + }, + "closeTerminalBody": "Are you sure you want to close this tab? Its current state will be lost.", + "@closeTerminalBody": { + "description": "Body text of the close terminal tab confirmation dialog" + }, + "closeTerminalDoNotAsk": "Do not ask me again", + "@closeTerminalDoNotAsk": { + "description": "Checkbox label to suppress the close terminal dialog in future" + }, + "closeTerminalConfirm": "Close tab", + "@closeTerminalConfirm": { + "description": "Confirm button label on the close terminal tab dialog" } } From 693044cce4e81195094037b93a51a0512ff9dee8 Mon Sep 17 00:00:00 2001 From: Scott Harder Date: Tue, 7 Apr 2026 16:01:56 -0500 Subject: [PATCH 16/25] [gui] Remove strings from `Notifier` classes --- src/client/gui/lib/l10n/app_en.arb | 42 +++++++++++++++++++ .../notifications/notification_entries.dart | 19 +++++---- 2 files changed, 54 insertions(+), 7 deletions(-) diff --git a/src/client/gui/lib/l10n/app_en.arb b/src/client/gui/lib/l10n/app_en.arb index 64d0939ab0b..8deecbfd121 100644 --- a/src/client/gui/lib/l10n/app_en.arb +++ b/src/client/gui/lib/l10n/app_en.arb @@ -198,5 +198,47 @@ "closeTerminalConfirm": "Close tab", "@closeTerminalConfirm": { "description": "Confirm button label on the close terminal tab dialog" + }, + "launchSuccessTitle": "{name} is up and running", + "@launchSuccessTitle": { + "description": "Bold heading in the launch success notification", + "placeholders": { + "name": { + "type": "String", + "description": "The VM instance name" + } + } + }, + "launchSuccessBody": "You can start using it now", + "@launchSuccessBody": { + "description": "Subtitle in the launch success notification" + }, + "launchGoToInstance": "Go to instance", + "@launchGoToInstance": { + "description": "Button label in the launch success notification that navigates to the VM" + }, + "launchVerifyingImage": "Verifying image", + "@launchVerifyingImage": { + "description": "Progress message shown while verifying a VM image during launch" + }, + "launchDownloadingImage": "Downloading image {percent}%", + "@launchDownloadingImage": { + "description": "Progress message shown while downloading a VM image during launch", + "placeholders": { + "percent": { + "type": "String", + "description": "Download completion percentage" + } + } + }, + "launchInProgress": "Launching {name}", + "@launchInProgress": { + "description": "Bold heading in the in-progress launch notification", + "placeholders": { + "name": { + "type": "String", + "description": "The VM instance name" + } + } } } diff --git a/src/client/gui/lib/notifications/notification_entries.dart b/src/client/gui/lib/notifications/notification_entries.dart index 3065ff4c991..112a1bcd078 100644 --- a/src/client/gui/lib/notifications/notification_entries.dart +++ b/src/client/gui/lib/notifications/notification_entries.dart @@ -7,6 +7,7 @@ import 'package:grpc/grpc.dart' hide ConnectionState; import '../extensions.dart'; import '../grpc_client.dart'; +import '../l10n/app_localizations.dart'; import '../sidebar.dart'; import 'notifications_list.dart'; @@ -205,6 +206,7 @@ class LaunchingNotification extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final l10n = AppLocalizations.of(context)!; return StreamBuilder( stream: stream, builder: (_, snapshot) { @@ -221,8 +223,8 @@ class LaunchingNotification extends ConsumerWidget { children: [ Text.rich( [ - '$name is up and running\n'.span.bold, - 'You can start using it now'.span, + '${l10n.launchSuccessTitle(name)}\n'.span.bold, + l10n.launchSuccessBody.span, ].spans, ), Divider(), @@ -234,7 +236,7 @@ class LaunchingNotification extends ConsumerWidget { ref.read(sidebarKeyProvider.notifier).set('vm-$name'); closeNotification(context); }, - child: Text('Go to instance'), + child: Text(l10n.launchGoToInstance), ), ], ), @@ -249,11 +251,11 @@ class LaunchingNotification extends ConsumerWidget { case LaunchReply_CreateOneof.launchProgress: final progressType = l.launchProgress.type; if (progressType == LaunchProgress_ProgressTypes.VERIFY) { - return ('Verifying image', false); + return (l10n.launchVerifyingImage, false); } final downloadPercentage = l.launchProgress.percentComplete; - return ('Downloading image $downloadPercentage%', true); + return (l10n.launchDownloadingImage(downloadPercentage), true); case LaunchReply_CreateOneof.createMessage: return (l.createMessage, false); default: @@ -274,7 +276,10 @@ class LaunchingNotification extends ConsumerWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text.rich(['Launching $name\n'.span.bold, message.span].spans), + Text.rich([ + '${l10n.launchInProgress(name)}\n'.span.bold, + message.span + ].spans), if (cancelable) ...[ const Divider(), Row( @@ -285,7 +290,7 @@ class LaunchingNotification extends ConsumerWidget { closeNotification(context); cancelCompleter.complete(); }, - child: Text('Cancel'), + child: Text(l10n.dialogCancel), ), const SizedBox(width: 20), ], From a8729f4998ea2b7fafc87b3eb426e7c57479ccb7 Mon Sep 17 00:00:00 2001 From: Scott Harder Date: Tue, 7 Apr 2026 16:56:44 -0500 Subject: [PATCH 17/25] [gui] Extract strings from GUI settings classes --- src/client/gui/lib/l10n/app_en.arb | 153 ++++++++++++++++++ .../gui/lib/settings/about_section.dart | 16 +- .../gui/lib/settings/general_settings.dart | 26 +-- src/client/gui/lib/settings/hotkey.dart | 10 +- src/client/gui/lib/settings/settings.dart | 12 +- .../gui/lib/settings/usage_settings.dart | 46 ++++-- .../lib/settings/virtualization_settings.dart | 26 +-- 7 files changed, 235 insertions(+), 54 deletions(-) diff --git a/src/client/gui/lib/l10n/app_en.arb b/src/client/gui/lib/l10n/app_en.arb index 8deecbfd121..c8e1cb80c4d 100644 --- a/src/client/gui/lib/l10n/app_en.arb +++ b/src/client/gui/lib/l10n/app_en.arb @@ -240,5 +240,158 @@ "description": "The VM instance name" } } + }, + "aboutTitle": "About", + "@aboutTitle": { + "description": "Section heading on the About settings page" + }, + "aboutVersionLabel": "Multipass version", + "@aboutVersionLabel": { + "description": "Label for the Multipass client version field on the About page" + }, + "aboutDaemonVersionLabel": "Multipass daemon version", + "@aboutDaemonVersionLabel": { + "description": "Label for the Multipass daemon version field on the About page" + }, + "generalTitle": "General", + "@generalTitle": { + "description": "Section heading on the General settings page" + }, + "generalAutostartLabel": "Open the Multipass GUI on startup", + "@generalAutostartLabel": { + "description": "Toggle label for the autostart setting" + }, + "generalAutostartError": "Failed to set autostart: {error}", + "@generalAutostartError": { + "description": "Error notification when the autostart setting cannot be changed", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "generalOnCloseLabel": "When closing Multipass", + "@generalOnCloseLabel": { + "description": "Label for the dropdown that controls what happens when Multipass is closed" + }, + "generalOnCloseAsk": "Ask about running instances", + "@generalOnCloseAsk": { + "description": "Dropdown option: ask the user what to do with running instances on close" + }, + "generalOnCloseStop": "Stop running instances", + "@generalOnCloseStop": { + "description": "Dropdown option: automatically stop running instances on close" + }, + "generalOnCloseNothing": "Do not stop running instances", + "@generalOnCloseNothing": { + "description": "Dropdown option: leave running instances running on close" + }, + "usageTitle": "Usage", + "@usageTitle": { + "description": "Section heading on the Usage settings page" + }, + "usagePrivilegedMountsLabel": "Allow privileged mounts", + "@usagePrivilegedMountsLabel": { + "description": "Toggle label for the privileged mounts setting" + }, + "usagePrivilegedMountsError": "Failed to set privileged mounts: {error}", + "@usagePrivilegedMountsError": { + "description": "Error notification when the privileged mounts setting cannot be changed", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "usageAskTerminalCloseLabel": "Ask before closing terminal", + "@usageAskTerminalCloseLabel": { + "description": "Toggle label for the ask-before-closing-terminal setting" + }, + "usagePrimaryNameLabel": "Primary instance name", + "@usagePrimaryNameLabel": { + "description": "Label for the primary instance name field in Usage settings" + }, + "usagePrimaryNameErrorStartLetter": "Name must start with a letter", + "@usagePrimaryNameErrorStartLetter": { + "description": "Validation error when the primary instance name does not start with a letter" + }, + "usagePrimaryNameErrorTooShort": "Name must be at least 2 characters", + "@usagePrimaryNameErrorTooShort": { + "description": "Validation error when the primary instance name is too short" + }, + "usagePrimaryNameErrorEndChar": "Name must end in digit or letter", + "@usagePrimaryNameErrorEndChar": { + "description": "Validation error when the primary instance name ends with a hyphen" + }, + "usageHotkeyLabel": "Primary instance hotkey", + "@usageHotkeyLabel": { + "description": "Label for the primary instance hotkey field in Usage settings" + }, + "usagePassphraseLabel": "Authentication passphrase", + "@usagePassphraseLabel": { + "description": "Label for the authentication passphrase field in Usage settings" + }, + "usagePassphraseError": "Failed to set passphrase: {error}", + "@usagePassphraseError": { + "description": "Error notification when the passphrase cannot be changed", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "virtualizationTitle": "Virtualization", + "@virtualizationTitle": { + "description": "Section heading on the Virtualization settings page" + }, + "virtualizationDriverLabel": "Driver", + "@virtualizationDriverLabel": { + "description": "Label for the hypervisor driver dropdown" + }, + "virtualizationDriverError": "Failed to set driver: {error}", + "@virtualizationDriverError": { + "description": "Error notification when the driver setting cannot be changed", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "virtualizationBridgedNetworkLabel": "Bridged network", + "@virtualizationBridgedNetworkLabel": { + "description": "Label for the bridged network dropdown" + }, + "virtualizationBridgedNetworkNone": "None", + "@virtualizationBridgedNetworkNone": { + "description": "Dropdown option representing no bridged network selected" + }, + "virtualizationBridgedNetworkError": "Failed to set bridged network: {error}", + "@virtualizationBridgedNetworkError": { + "description": "Error notification when the bridged network setting cannot be changed", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "settingsTitle": "Settings", + "@settingsTitle": { + "description": "Page heading on the Settings screen" + }, + "hotkeyUnknownKey": "...", + "@hotkeyUnknownKey": { + "description": "Placeholder shown for the key portion of a hotkey when no key has been recorded yet" + }, + "hotkeyCtrl": "Ctrl", + "@hotkeyCtrl": { + "description": "Display name for the Control modifier key in a hotkey combination" + }, + "hotkeyShift": "Shift", + "@hotkeyShift": { + "description": "Display name for the Shift modifier key in a hotkey combination" + }, + "hotkeyInputPrompt": "Input...", + "@hotkeyInputPrompt": { + "description": "Placeholder shown in the hotkey recorder when it has focus and is waiting for input" } } diff --git a/src/client/gui/lib/settings/about_section.dart b/src/client/gui/lib/settings/about_section.dart index 5f15d853d90..6b78e71ecdd 100644 --- a/src/client/gui/lib/settings/about_section.dart +++ b/src/client/gui/lib/settings/about_section.dart @@ -2,25 +2,27 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../display_field.dart'; -import '../providers.dart'; import '../ffi.dart'; +import '../l10n/app_localizations.dart'; +import '../providers.dart'; class AboutSection extends ConsumerWidget { const AboutSection({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { + final l10n = AppLocalizations.of(context)!; final daemonVersion = ref.watch(daemonVersionProvider); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text( - 'About', - style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + Text( + l10n.aboutTitle, + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold), ), const SizedBox(height: 20), DisplayField( - label: 'Multipass version', + label: l10n.aboutVersionLabel, width: 260, text: multipassVersion, copyable: true, @@ -28,13 +30,13 @@ class AboutSection extends ConsumerWidget { if (multipassVersion != daemonVersion) const SizedBox(height: 20), if (multipassVersion != daemonVersion) DisplayField( - label: 'Multipass daemon version', + label: l10n.aboutDaemonVersionLabel, width: 260, text: daemonVersion, copyable: true, ), const SizedBox(height: 20), - DisplayField( + const DisplayField( label: 'Copyright © Canonical, Ltd.', width: 260, copyable: false, diff --git a/src/client/gui/lib/settings/general_settings.dart b/src/client/gui/lib/settings/general_settings.dart index da57f8f4aa2..1b5a5ae9125 100644 --- a/src/client/gui/lib/settings/general_settings.dart +++ b/src/client/gui/lib/settings/general_settings.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart' hide Switch; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../dropdown.dart'; +import '../l10n/app_localizations.dart'; import '../notifications.dart'; import '../providers.dart'; import '../switch.dart'; @@ -16,6 +17,7 @@ class GeneralSettings extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final l10n = AppLocalizations.of(context)!; final update = ref.watch(updateProvider); final autostart = ref.watch(autostartProvider).when( data: (data) => data, @@ -27,9 +29,9 @@ class GeneralSettings extends ConsumerWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text( - 'General', - style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + Text( + l10n.generalTitle, + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold), ), const SizedBox(height: 20), if (update.version.isNotBlank) ...[ @@ -37,28 +39,26 @@ class GeneralSettings extends ConsumerWidget { const SizedBox(height: 20), ], Switch( - label: 'Open the Multipass GUI on startup', + label: l10n.generalAutostartLabel, value: autostart, trailingSwitch: true, size: 30, onChanged: (value) { - ref - .read(autostartProvider.notifier) - .set(value) - .onError(ref.notifyError((e) => 'Failed to set autostart: $e')); + ref.read(autostartProvider.notifier).set(value).onError( + ref.notifyError((e) => l10n.generalAutostartError('$e'))); }, ), const SizedBox(height: 20), Dropdown( - label: 'When closing Multipass', + label: l10n.generalOnCloseLabel, width: 260, value: onAppClose ?? 'ask', onChanged: (value) => ref.read(onAppCloseProvider.notifier).set(value!), - items: const { - 'ask': 'Ask about running instances', - 'stop': 'Stop running instances', - 'nothing': 'Do not stop running instances', + items: { + 'ask': l10n.generalOnCloseAsk, + 'stop': l10n.generalOnCloseStop, + 'nothing': l10n.generalOnCloseNothing, }, ), ], diff --git a/src/client/gui/lib/settings/hotkey.dart b/src/client/gui/lib/settings/hotkey.dart index 25701146e46..d952ba8683c 100644 --- a/src/client/gui/lib/settings/hotkey.dart +++ b/src/client/gui/lib/settings/hotkey.dart @@ -6,6 +6,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../platform/platform.dart'; import '../providers.dart'; +import '../l10n/app_localizations.dart'; final hotkeySettingProvider = guiSettingProvider(hotkeyKey); @@ -164,17 +165,18 @@ class HotkeyRecorderState extends State { @override Widget build(BuildContext context) { - final keyLabel = key?.keyLabel ?? '...'; + final l10n = AppLocalizations.of(context)!; + final keyLabel = key?.keyLabel ?? l10n.hotkeyUnknownKey; final modifiers = [ - if (control) 'Ctrl', + if (control) l10n.hotkeyCtrl, if (alt) mpPlatform.altKey, - if (shift) 'Shift', + if (shift) l10n.hotkeyShift, if (meta) mpPlatform.metaKey, ].join('+'); final keyCombination = modifiers.isNotEmpty ? '$modifiers+$keyLabel' : hasFocus - ? 'Input...' + ? l10n.hotkeyInputPrompt : ''; return Focus( diff --git a/src/client/gui/lib/settings/settings.dart b/src/client/gui/lib/settings/settings.dart index a705d5bb1f0..714ec4c1c21 100644 --- a/src/client/gui/lib/settings/settings.dart +++ b/src/client/gui/lib/settings/settings.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; +import '../l10n/app_localizations.dart'; import 'general_settings.dart'; import 'usage_settings.dart'; import 'virtualization_settings.dart'; @@ -12,6 +13,7 @@ class SettingsScreen extends StatelessWidget { @override Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; const settings = Padding( padding: EdgeInsets.only(right: 15), child: Column( @@ -29,17 +31,17 @@ class SettingsScreen extends StatelessWidget { ), ); - return const Scaffold( + return Scaffold( body: Padding( - padding: EdgeInsets.symmetric(horizontal: 40, vertical: 40), + padding: const EdgeInsets.symmetric(horizontal: 40, vertical: 40), child: SizedBox( width: 800, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('Settings', style: TextStyle(fontSize: 37)), - SizedBox(height: 32), - Expanded(child: SingleChildScrollView(child: settings)), + Text(l10n.settingsTitle, style: const TextStyle(fontSize: 37)), + const SizedBox(height: 32), + const Expanded(child: SingleChildScrollView(child: settings)), ], ), ), diff --git a/src/client/gui/lib/settings/usage_settings.dart b/src/client/gui/lib/settings/usage_settings.dart index 6b29adfb9bb..7aa8d5d0699 100644 --- a/src/client/gui/lib/settings/usage_settings.dart +++ b/src/client/gui/lib/settings/usage_settings.dart @@ -9,6 +9,7 @@ import 'package:fpdart/fpdart.dart' hide State; import '../notifications/notifications_provider.dart'; import '../providers.dart'; +import '../l10n/app_localizations.dart'; import '../switch.dart'; import 'hotkey.dart'; @@ -22,6 +23,7 @@ class UsageSettings extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final l10n = AppLocalizations.of(context)!; final primaryName = ref.watch(primaryNameProvider); final hasPassphrase = ref.watch( passphraseProvider.select((value) { @@ -51,13 +53,14 @@ class UsageSettings extends ConsumerWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text( - 'Usage', - style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + Text( + l10n.usageTitle, + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold), ), const SizedBox(height: 20), PrimaryNameField( value: primaryName, + l10n: l10n, onSave: (value) { ref.read(primaryNameProvider.notifier).set(value); }, @@ -65,21 +68,23 @@ class UsageSettings extends ConsumerWidget { const SizedBox(height: 20), HotkeyField( value: hotkey, + l10n: l10n, onSave: (newHotkey) => ref.read(hotkeyProvider.notifier).set(newHotkey), ), const SizedBox(height: 20), PassphraseField( hasPassphrase: hasPassphrase, + l10n: l10n, onSave: (value) { ref.read(passphraseProvider.notifier).set(value).onError( - ref.notifyError((e) => 'Failed to set passphrase: $e'), + ref.notifyError((e) => l10n.usagePassphraseError('$e')), ); }, ), const SizedBox(height: 20), Switch( - label: 'Allow privileged mounts', + label: l10n.usagePrivilegedMountsLabel, value: privilegedMounts, trailingSwitch: true, size: 30, @@ -88,13 +93,13 @@ class UsageSettings extends ConsumerWidget { .read(privilegedMountsProvider.notifier) .set(value.toString()) .onError( - ref.notifyError((e) => 'Failed to set privileged mounts: $e'), + ref.notifyError((e) => l10n.usagePrivilegedMountsError('$e')), ); }, ), const SizedBox(height: 20), Switch( - label: 'Ask before closing terminal', + label: l10n.usageAskTerminalCloseLabel, value: askTerminalClose, trailingSwitch: true, size: 30, @@ -109,11 +114,13 @@ class UsageSettings extends ConsumerWidget { class PrimaryNameField extends StatefulWidget { final String value; + final AppLocalizations l10n; final ValueChanged onSave; const PrimaryNameField({ super.key, required this.value, + required this.l10n, required this.onSave, }); @@ -150,7 +157,7 @@ class _PrimaryNameFieldState extends State { @override Widget build(BuildContext context) { return SettingField( - label: 'Primary instance name', + label: widget.l10n.usagePrimaryNameLabel, onSave: () { if (formKey.currentState!.validate()) widget.onSave(controller.text); }, @@ -166,10 +173,14 @@ class _PrimaryNameFieldState extends State { value ??= ''; if (value.isEmpty) return null; if (RegExp(r'^[^A-Za-z]').hasMatch(value)) { - return 'Name must start with a letter'; + return widget.l10n.usagePrimaryNameErrorStartLetter; + } + if (value.length < 2) { + return widget.l10n.usagePrimaryNameErrorTooShort; + } + if (value.endsWith('-')) { + return widget.l10n.usagePrimaryNameErrorEndChar; } - if (value.length < 2) return 'Name must be at least 2 characters'; - if (value.endsWith('-')) return 'Name must end in digit or letter'; return null; }, inputFormatters: [ @@ -182,9 +193,14 @@ class _PrimaryNameFieldState extends State { class HotkeyField extends StatefulWidget { final SingleActivator? value; + final AppLocalizations l10n; final ValueChanged onSave; - const HotkeyField({super.key, required this.value, required this.onSave}); + const HotkeyField( + {super.key, + required this.value, + required this.l10n, + required this.onSave}); @override State createState() => _HotkeyFieldState(); @@ -216,7 +232,7 @@ class _HotkeyFieldState extends State { @override Widget build(BuildContext context) { return SettingField( - label: 'Primary instance hotkey', + label: widget.l10n.usageHotkeyLabel, onSave: () => widget.onSave(value), onDiscard: () => setState(() { recorderState.currentState?.set(widget.value); @@ -237,11 +253,13 @@ class _HotkeyFieldState extends State { class PassphraseField extends StatefulWidget { final bool hasPassphrase; + final AppLocalizations l10n; final ValueChanged onSave; const PassphraseField({ super.key, required this.hasPassphrase, + required this.l10n, required this.onSave, }); @@ -280,7 +298,7 @@ class _PassphraseFieldState extends State { @override Widget build(BuildContext context) { return SettingField( - label: 'Authentication passphrase', + label: widget.l10n.usagePassphraseLabel, onSave: () { widget.onSave(controller.text); controller.clear(); diff --git a/src/client/gui/lib/settings/virtualization_settings.dart b/src/client/gui/lib/settings/virtualization_settings.dart index 0d60244d25f..e8117a6db48 100644 --- a/src/client/gui/lib/settings/virtualization_settings.dart +++ b/src/client/gui/lib/settings/virtualization_settings.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../dropdown.dart'; +import '../l10n/app_localizations.dart'; import '../notifications/notifications_provider.dart'; import '../platform/platform.dart'; import '../providers.dart'; @@ -14,6 +15,7 @@ class VirtualizationSettings extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final l10n = AppLocalizations.of(context)!; final driver = ref.watch(driverProvider).when( data: (data) => data, loading: () => null, @@ -33,34 +35,36 @@ class VirtualizationSettings extends ConsumerWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text( - 'Virtualization', - style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + Text( + l10n.virtualizationTitle, + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold), ), const SizedBox(height: 20), Dropdown( - label: 'Driver', + label: l10n.virtualizationDriverLabel, width: 260, value: driver, items: {if (driver != null) driver: driver, ...mpPlatform.drivers}, onChanged: (value) { if (value == driver) return; - ref - .read(driverProvider.notifier) - .set(value as String) - .onError(ref.notifyError((e) => 'Failed to set driver: $e')); + ref.read(driverProvider.notifier).set(value as String).onError( + ref.notifyError((e) => l10n.virtualizationDriverError('$e'))); }, ), const SizedBox(height: 20), if (networks.isNotEmpty) Dropdown( - label: 'Bridged network', + label: l10n.virtualizationBridgedNetworkLabel, width: 260, value: networks.contains(bridgedNetwork) ? bridgedNetwork : '', - items: {'': 'None', ...Map.fromIterable(networks)}, + items: { + '': l10n.virtualizationBridgedNetworkNone, + ...Map.fromIterable(networks) + }, onChanged: (value) { ref.read(bridgedNetworkProvider.notifier).set(value!).onError( - ref.notifyError((e) => 'Failed to set bridged network: $e'), + ref.notifyError( + (e) => l10n.virtualizationBridgedNetworkError('$e')), ); }, ), From 4e6659f8f614c31d28d10b85eb0258df96a4c2a2 Mon Sep 17 00:00:00 2001 From: Scott Harder Date: Tue, 7 Apr 2026 18:07:39 -0500 Subject: [PATCH 18/25] [gui] Extrace strings from vm_details classes --- src/client/gui/lib/l10n/app_en.arb | 391 ++++++++++++++++++ .../gui/lib/vm_details/cpus_slider.dart | 14 +- .../gui/lib/vm_details/disk_slider.dart | 6 +- .../gui/lib/vm_details/ip_addresses.dart | 8 +- .../gui/lib/vm_details/memory_slider.dart | 4 +- .../gui/lib/vm_details/mount_points.dart | 42 +- src/client/gui/lib/vm_details/ram_slider.dart | 4 +- src/client/gui/lib/vm_details/terminal.dart | 18 +- .../gui/lib/vm_details/terminal_tabs.dart | 4 +- .../gui/lib/vm_details/vm_action_buttons.dart | 16 +- .../gui/lib/vm_details/vm_details_bridge.dart | 29 +- .../lib/vm_details/vm_details_general.dart | 25 +- .../gui/lib/vm_details/vm_details_mounts.dart | 38 +- .../lib/vm_details/vm_details_resources.dart | 32 +- 14 files changed, 525 insertions(+), 106 deletions(-) diff --git a/src/client/gui/lib/l10n/app_en.arb b/src/client/gui/lib/l10n/app_en.arb index c8e1cb80c4d..a7151e56d61 100644 --- a/src/client/gui/lib/l10n/app_en.arb +++ b/src/client/gui/lib/l10n/app_en.arb @@ -393,5 +393,396 @@ "hotkeyInputPrompt": "Input...", "@hotkeyInputPrompt": { "description": "Placeholder shown in the hotkey recorder when it has focus and is waiting for input" + }, + "dialogSave": "Save", + "@dialogSave": { + "description": "Generic Save button label used across multiple dialogs and edit forms" + }, + "dialogConfigure": "Configure", + "@dialogConfigure": { + "description": "Generic Configure button label used to enter edit mode" + }, + "vmStatCpuUsage": "CPU USAGE", + "@vmStatCpuUsage": { + "description": "Column header label for the CPU usage stat" + }, + "vmStatMemoryUsage": "MEMORY USAGE", + "@vmStatMemoryUsage": { + "description": "Column header label for the memory usage stat" + }, + "vmStatDiskUsage": "DISK USAGE", + "@vmStatDiskUsage": { + "description": "Column header label for the disk usage stat" + }, + "vmStatState": "STATE", + "@vmStatState": { + "description": "Column header label for the VM state stat" + }, + "vmStatImage": "IMAGE", + "@vmStatImage": { + "description": "Column header label for the image stat" + }, + "vmStatPrivateIp": "PRIVATE IP", + "@vmStatPrivateIp": { + "description": "Column header label for the private IP address stat" + }, + "vmStatPublicIp": "PUBLIC IP", + "@vmStatPublicIp": { + "description": "Column header label for the public IP address stat" + }, + "vmStatCreated": "CREATED", + "@vmStatCreated": { + "description": "Column header label for the creation timestamp stat" + }, + "vmStatUptime": "UPTIME", + "@vmStatUptime": { + "description": "Column header label for the uptime stat" + }, + "vmDetailsGeneralTitle": "General", + "@vmDetailsGeneralTitle": { + "description": "Section heading on the General VM details tab" + }, + "ipAddressesOtherTitle": "Other IP addresses", + "@ipAddressesOtherTitle": { + "description": "Tooltip and popup header for the secondary IP addresses list" + }, + "mountHostDirLabel": "HOST DIRECTORY", + "@mountHostDirLabel": { + "description": "Column header for the host (source) directory in a mount point row" + }, + "mountHostDirTooltip": "A directory on your local machine that will be shared with the instance", + "@mountHostDirTooltip": { + "description": "Tooltip explaining the host directory field" + }, + "mountGuestDirLabel": "GUEST DIRECTORY", + "@mountGuestDirLabel": { + "description": "Column header for the guest (target) directory in a mount point row" + }, + "mountGuestDirTooltip": "A destination inside the instance for the shared directory.\nIf the destination directory already exists, its contents will not be visible until unmounting.", + "@mountGuestDirTooltip": { + "description": "Tooltip explaining the guest directory field" + }, + "mountSourceEmpty": "Source cannot be empty", + "@mountSourceEmpty": { + "description": "Validation error when the mount source path is blank" + }, + "mountSelectButton": "Select", + "@mountSelectButton": { + "description": "Button label and file picker confirm label for selecting a host directory" + }, + "mountDuplicatePath": "This path is used by another mount", + "@mountDuplicatePath": { + "description": "Validation error when the mount target path conflicts with an existing mount" + }, + "mountsTitle": "Mounts", + "@mountsTitle": { + "description": "Section heading on the Mounts details tab" + }, + "mountsAddMount": "Add mount", + "@mountsAddMount": { + "description": "Button label to open the add-mount form" + }, + "mountDeleteTitle": "Delete mount", + "@mountDeleteTitle": { + "description": "Title of the confirmation dialog for removing a mount" + }, + "mountDeleteBodyPrefix": "Are you sure you want to remove the mount", + "@mountDeleteBodyPrefix": { + "description": "First part of the delete-mount confirmation body (followed by the mount path on a new line)" + }, + "mountDeleteBodySuffix": " from {instanceName}?", + "@mountDeleteBodySuffix": { + "description": "Last part of the delete-mount confirmation body", + "placeholders": { + "instanceName": { + "type": "String" + } + } + }, + "mountDeleteAction": "Delete", + "@mountDeleteAction": { + "description": "Confirm button label in the delete-mount dialog" + }, + "mountNotificationLoading": "Mounting {description}", + "@mountNotificationLoading": { + "description": "Loading notification while a mount operation is in progress", + "placeholders": { + "description": { + "type": "String" + } + } + }, + "mountNotificationSuccess": "Mounted {description}", + "@mountNotificationSuccess": { + "description": "Success notification after a mount operation completes", + "placeholders": { + "description": { + "type": "String" + } + } + }, + "mountNotificationError": "Failed to mount {description}: {error}", + "@mountNotificationError": { + "description": "Error notification when a mount operation fails", + "placeholders": { + "description": { + "type": "String" + }, + "error": { + "type": "String" + } + } + }, + "unmountNotificationLoading": "Unmounting ''{target}'' from {instanceName}", + "@unmountNotificationLoading": { + "description": "Loading notification while an unmount operation is in progress", + "placeholders": { + "target": { + "type": "String" + }, + "instanceName": { + "type": "String" + } + } + }, + "unmountNotificationSuccess": "Unmounted ''{target}'' from {instanceName}", + "@unmountNotificationSuccess": { + "description": "Success notification after an unmount operation completes", + "placeholders": { + "target": { + "type": "String" + }, + "instanceName": { + "type": "String" + } + } + }, + "unmountNotificationError": "Failed to unmount ''{target}'' from {instanceName}: {error}", + "@unmountNotificationError": { + "description": "Error notification when an unmount operation fails", + "placeholders": { + "target": { + "type": "String" + }, + "instanceName": { + "type": "String" + }, + "error": { + "type": "String" + } + } + }, + "bridgeTitle": "Bridged network", + "@bridgeTitle": { + "description": "Section heading on the Bridged Network details tab" + }, + "bridgeConnect": "Connect to bridged network.", + "@bridgeConnect": { + "description": "Checkbox label to enable bridged network connection" + }, + "bridgeNoNetworks": "No networks found.", + "@bridgeNoNetworks": { + "description": "Status message when no host networks are available for bridging" + }, + "bridgeNoValidNetwork": "No valid bridged network is set.", + "@bridgeNoValidNetwork": { + "description": "Status message when a bridged network is not configured" + }, + "bridgeEstablishedWarning": "Once established, you won't be able to unset the connection.", + "@bridgeEstablishedWarning": { + "description": "Warning shown when the bridged network checkbox is available" + }, + "bridgeStatusConnected": "Status: connected", + "@bridgeStatusConnected": { + "description": "Status line shown when the instance is connected to the bridged network" + }, + "bridgeStatusNotConnected": "Status: not connected", + "@bridgeStatusNotConnected": { + "description": "Status line shown when the instance is not connected to the bridged network" + }, + "bridgeFailedNetwork": "Failed to set bridged network: {error}", + "@bridgeFailedNetwork": { + "description": "Error notification when the bridged network setting cannot be changed", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "vmDetailsStopToConfigure": "Stop instance to configure", + "@vmDetailsStopToConfigure": { + "description": "Tooltip shown on the Configure button when the instance is running" + }, + "resourcesTitle": "Resources", + "@resourcesTitle": { + "description": "Section heading on the Resources details tab" + }, + "resourcesCpusDisplay": "CPUs {value}", + "@resourcesCpusDisplay": { + "description": "Read-only display of the CPU count", + "placeholders": { + "value": { + "type": "String" + } + } + }, + "resourcesMemoryDisplay": "Memory {value}", + "@resourcesMemoryDisplay": { + "description": "Read-only display of the memory size", + "placeholders": { + "value": { + "type": "String" + } + } + }, + "resourcesDiskDisplay": "Disk {value}", + "@resourcesDiskDisplay": { + "description": "Read-only display of the disk size", + "placeholders": { + "value": { + "type": "String" + } + } + }, + "resourcesSaveChanges": "Save changes", + "@resourcesSaveChanges": { + "description": "Save button label on the Resources edit form" + }, + "resourcesFailedCpus": "Failed to set CPUs: {error}", + "@resourcesFailedCpus": { + "description": "Error notification when the CPU count cannot be changed", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "resourcesFailedMemory": "Failed to set memory size: {error}", + "@resourcesFailedMemory": { + "description": "Error notification when the memory size cannot be changed", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "resourcesFailedDisk": "Failed to set disk size: {error}", + "@resourcesFailedDisk": { + "description": "Error notification when the disk size cannot be changed", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "cpusSliderLabel": "CPUs", + "@cpusSliderLabel": { + "description": "Label for the CPU count field in the sliders form" + }, + "cpusSliderOverProvisioning": "Over-provisioning of cores", + "@cpusSliderOverProvisioning": { + "description": "Warning shown when the requested CPU count exceeds the host CPU count" + }, + "memorySliderOverProvisioning": "Over-provisioning of {label}", + "@memorySliderOverProvisioning": { + "description": "Warning shown when the requested memory/disk size exceeds the host available amount", + "placeholders": { + "label": { + "type": "String" + } + } + }, + "ramSliderLabel": "Memory", + "@ramSliderLabel": { + "description": "Label for the RAM slider widget" + }, + "diskSliderLabel": "Disk", + "@diskSliderLabel": { + "description": "Label for the disk size slider widget" + }, + "diskSizeCannotDecrease": "Disk size cannot be decreased", + "@diskSizeCannotDecrease": { + "description": "Tooltip shown on the disk slider when the disk size cannot be reduced" + }, + "terminalSshFailed": "Failed to get SSH information: {error}", + "@terminalSshFailed": { + "description": "Error notification when SSH connection info cannot be retrieved", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "terminalContextCopy": "Copy", + "@terminalContextCopy": { + "description": "Context menu item to copy selected terminal text" + }, + "terminalContextPaste": "Paste", + "@terminalContextPaste": { + "description": "Context menu item to paste text into the terminal" + }, + "terminalContextSelectAll": "Select All", + "@terminalContextSelectAll": { + "description": "Context menu item to select all terminal text" + }, + "terminalOpenShell": "Open shell", + "@terminalOpenShell": { + "description": "Button label to open a new shell in the terminal view" + }, + "vmActionNotificationLoading": "{action} {instance}", + "@vmActionNotificationLoading": { + "description": "Loading notification while a VM action is in progress (e.g. 'Starting vm1')", + "placeholders": { + "action": { + "type": "String" + }, + "instance": { + "type": "String" + } + } + }, + "vmActionNotificationSuccess": "{action} {instance}", + "@vmActionNotificationSuccess": { + "description": "Success notification after a VM action completes (e.g. 'Started vm1')", + "placeholders": { + "action": { + "type": "String" + }, + "instance": { + "type": "String" + } + } + }, + "vmActionNotificationError": "Failed to {action} {instance}: {error}", + "@vmActionNotificationError": { + "description": "Error notification when a VM action fails", + "placeholders": { + "action": { + "type": "String" + }, + "instance": { + "type": "String" + }, + "error": { + "type": "String" + } + } + }, + "vmActionsMenuTooltip": "Show actions", + "@vmActionsMenuTooltip": { + "description": "Tooltip on the actions popup-menu button" + }, + "vmActionsMenuTitle": "Actions", + "@vmActionsMenuTitle": { + "description": "Title label displayed inside the VM actions popup menu" + }, + "terminalTabTitle": "Shell {id}", + "@terminalTabTitle": { + "description": "Title for a terminal tab, where id is the shell number", + "placeholders": { + "id": { + "type": "int" + } + } } } diff --git a/src/client/gui/lib/vm_details/cpus_slider.dart b/src/client/gui/lib/vm_details/cpus_slider.dart index d8408799c09..631003bf013 100644 --- a/src/client/gui/lib/vm_details/cpus_slider.dart +++ b/src/client/gui/lib/vm_details/cpus_slider.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../l10n/app_localizations.dart'; import '../providers.dart'; class CpusSlider extends ConsumerStatefulWidget { @@ -53,6 +54,7 @@ class _CpusSliderState extends ConsumerState { @override Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; final daemonInfo = ref.watch(daemonInfoProvider); final cores = daemonInfo.when( data: (data) => data.cpus, @@ -96,13 +98,13 @@ class _CpusSliderState extends ConsumerState { Row(children: [Text('$min'), Spacer(), Text('$max')]), if ((field.value ?? min) > cores) ...[ const SizedBox(height: 25), - const Row( + Row( children: [ - Icon(Icons.warning_rounded, color: Color(0xffCC7900)), - SizedBox(width: 5), + const Icon(Icons.warning_rounded, color: Color(0xffCC7900)), + const SizedBox(width: 5), Text( - 'Over-provisioning of cores', - style: TextStyle(fontSize: 16), + l10n.cpusSliderOverProvisioning, + style: const TextStyle(fontSize: 16), ), ], ), @@ -116,7 +118,7 @@ class _CpusSliderState extends ConsumerState { children: [ Row( children: [ - Text('CPUs', style: TextStyle(fontSize: 16)), + Text(l10n.cpusSliderLabel, style: const TextStyle(fontSize: 16)), const Spacer(), SizedBox(width: 65, child: textField), ], diff --git a/src/client/gui/lib/vm_details/disk_slider.dart b/src/client/gui/lib/vm_details/disk_slider.dart index ccdc56d0b83..f479d8f3ec7 100644 --- a/src/client/gui/lib/vm_details/disk_slider.dart +++ b/src/client/gui/lib/vm_details/disk_slider.dart @@ -3,6 +3,7 @@ import 'dart:math' as math; import 'package:flutter/material.dart' hide Tooltip; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../l10n/app_localizations.dart'; import '../providers.dart'; import '../tooltip.dart'; import 'mapping_slider.dart'; @@ -18,6 +19,7 @@ class DiskSlider extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final l10n = AppLocalizations.of(context)!; final daemonInfo = ref.watch(daemonInfoProvider); final disk = daemonInfo.when( data: (data) => data.availableSpace.toInt(), @@ -28,10 +30,10 @@ class DiskSlider extends ConsumerWidget { final enabled = min != max; return Tooltip( - message: 'Disk size cannot be decreased', + message: l10n.diskSizeCannotDecrease, visible: !enabled, child: MemorySlider( - label: 'Disk', + label: l10n.diskSliderLabel, enabled: enabled, initialValue: initialValue, min: min, diff --git a/src/client/gui/lib/vm_details/ip_addresses.dart b/src/client/gui/lib/vm_details/ip_addresses.dart index 64bd0fd234f..698805632a7 100644 --- a/src/client/gui/lib/vm_details/ip_addresses.dart +++ b/src/client/gui/lib/vm_details/ip_addresses.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart' hide Tooltip; import '../copyable_text.dart'; +import '../l10n/app_localizations.dart'; class IpAddresses extends StatelessWidget { final Iterable ips; @@ -9,6 +10,7 @@ class IpAddresses extends StatelessWidget { @override Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; final firstIp = ips.firstOrNull ?? '-'; final restIps = ips.skip(1).toList(); @@ -24,12 +26,12 @@ class IpAddresses extends StatelessWidget { child: PopupMenuButton( icon: const Icon(Icons.keyboard_arrow_down), position: PopupMenuPosition.under, - tooltip: 'Other IP addresses', + tooltip: l10n.ipAddressesOtherTitle, splashRadius: 10, itemBuilder: (_) => [ - const PopupMenuItem( + PopupMenuItem( enabled: false, - child: Text('Other IP addresses'), + child: Text(l10n.ipAddressesOtherTitle), ), ...restIps.map((ip) => PopupMenuItem(child: Text(ip))), ], diff --git a/src/client/gui/lib/vm_details/memory_slider.dart b/src/client/gui/lib/vm_details/memory_slider.dart index 177775053e4..5c0cb345512 100644 --- a/src/client/gui/lib/vm_details/memory_slider.dart +++ b/src/client/gui/lib/vm_details/memory_slider.dart @@ -4,6 +4,7 @@ import 'package:flutter/services.dart'; import '../dropdown.dart'; import '../extensions.dart'; import '../ffi.dart'; +import '../l10n/app_localizations.dart'; import 'mapping_slider.dart'; class MemorySlider extends StatefulWidget { @@ -70,6 +71,7 @@ class _MemorySliderState extends State { @override Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; final textField = TextField( controller: controller, enabled: widget.enabled, @@ -140,7 +142,7 @@ class _MemorySliderState extends State { const Icon(Icons.warning_rounded, color: Color(0xffCC7900)), const SizedBox(width: 5), Text( - 'Over-provisioning of ${widget.label.toLowerCase()}', + l10n.memorySliderOverProvisioning(widget.label.toLowerCase()), style: const TextStyle(fontSize: 16), ), ], diff --git a/src/client/gui/lib/vm_details/mount_points.dart b/src/client/gui/lib/vm_details/mount_points.dart index d96ce2c4d5d..fd143bc3db1 100644 --- a/src/client/gui/lib/vm_details/mount_points.dart +++ b/src/client/gui/lib/vm_details/mount_points.dart @@ -7,6 +7,7 @@ import 'package:flutter/material.dart' hide Tooltip; import 'package:flutter_svg/flutter_svg.dart'; import '../ffi.dart'; +import '../l10n/app_localizations.dart'; import '../platform/platform.dart'; import '../providers.dart'; import '../tooltip.dart'; @@ -65,34 +66,32 @@ class _EditableMountPointState extends State { @override Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; final headers = DefaultTextStyle.merge( style: const TextStyle(color: Colors.black), - child: const Row( + child: Row( children: [ Expanded( child: Row( children: [ - Text('HOST DIRECTORY'), - SizedBox(width: 8), + Text(l10n.mountHostDirLabel), + const SizedBox(width: 8), Tooltip( - message: - 'A directory on your local machine that will be shared with the instance', - child: Icon(Icons.info_outline, size: 20), + message: l10n.mountHostDirTooltip, + child: const Icon(Icons.info_outline, size: 20), ), ], ), ), - SizedBox(width: 24), + const SizedBox(width: 24), Expanded( child: Row( children: [ - Text('GUEST DIRECTORY'), - SizedBox(width: 8), + Text(l10n.mountGuestDirLabel), + const SizedBox(width: 8), Tooltip( - message: - 'A destination inside the instance for the shared directory.\n' - 'If the destination directory already exists, its contents will not be visible until unmounting.', - child: Icon(Icons.info_outline, size: 20), + message: l10n.mountGuestDirTooltip, + child: const Icon(Icons.info_outline, size: 20), ), ], ), @@ -104,7 +103,7 @@ class _EditableMountPointState extends State { final sourceField = ClippingTextField( controller: sourceController, validator: (value) { - return value.isNullOrBlank ? 'Source cannot be empty' : null; + return value.isNullOrBlank ? l10n.mountSourceEmpty : null; }, ); @@ -112,7 +111,7 @@ class _EditableMountPointState extends State { onPressed: () async { final chosenSource = sourceController.text; final source = await getDirectoryPath( - confirmButtonText: 'Select', + confirmButtonText: l10n.mountSelectButton, initialDirectory: await Directory(chosenSource).exists() ? chosenSource : mpPlatform.homeDirectory, @@ -120,7 +119,7 @@ class _EditableMountPointState extends State { if (source == null) return; sourceController.text = source; }, - child: const Text('Select'), + child: Text(l10n.mountSelectButton), ); final targetField = SpecInput( @@ -130,7 +129,7 @@ class _EditableMountPointState extends State { target ??= ''; target = target.isEmpty ? targetHint : target; return widget.existingTargets.contains(target) - ? 'This path is used by another mount' + ? l10n.mountDuplicatePath : null; }, ); @@ -183,15 +182,16 @@ class MountPointsView extends StatelessWidget { @override Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; final mounts = this.mounts.toList(); final headers = DefaultTextStyle.merge( style: const TextStyle(color: Colors.black), - child: const Row( + child: Row( children: [ - Expanded(child: Text('HOST DIRECTORY')), - SizedBox(width: 24), - Expanded(child: Text('GUEST DIRECTORY')), + Expanded(child: Text(l10n.mountHostDirLabel)), + const SizedBox(width: 24), + Expanded(child: Text(l10n.mountGuestDirLabel)), ], ), ); diff --git a/src/client/gui/lib/vm_details/ram_slider.dart b/src/client/gui/lib/vm_details/ram_slider.dart index f24750bce5a..084763cb484 100644 --- a/src/client/gui/lib/vm_details/ram_slider.dart +++ b/src/client/gui/lib/vm_details/ram_slider.dart @@ -3,6 +3,7 @@ import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../l10n/app_localizations.dart'; import '../providers.dart'; import 'mapping_slider.dart'; import 'memory_slider.dart'; @@ -17,6 +18,7 @@ class RamSlider extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final l10n = AppLocalizations.of(context)!; final daemonInfo = ref.watch(daemonInfoProvider); final ram = daemonInfo.when( data: (data) => data.memory.toInt(), @@ -26,7 +28,7 @@ class RamSlider extends ConsumerWidget { final max = math.max(initialValue ?? min, ram); return MemorySlider( - label: 'Memory', + label: l10n.ramSliderLabel, initialValue: initialValue, min: min, max: max, diff --git a/src/client/gui/lib/vm_details/terminal.dart b/src/client/gui/lib/vm_details/terminal.dart index 201fde74b2a..3bf8a29edeb 100644 --- a/src/client/gui/lib/vm_details/terminal.dart +++ b/src/client/gui/lib/vm_details/terminal.dart @@ -13,6 +13,7 @@ import 'package:synchronized/synchronized.dart'; import 'package:xterm/xterm.dart'; import '../logger.dart'; +import '../l10n/app_localizations.dart'; import '../notifications.dart'; import '../platform/platform.dart'; import '../providers.dart'; @@ -239,15 +240,16 @@ class _VmTerminalState extends ConsumerState { Future startVmIfNeeded(final bool vmRunning) async { if (vmRunning) return; + final l10n = AppLocalizations.of(context)!; final name = widget.name; final action = VmAction.start; final operation = ref.read(grpcClientProvider).start([name]); ref.read(notificationsProvider.notifier).addOperation( operation, - loading: '${action.continuousTense} $name', - onSuccess: (_) => '${action.pastTense} $name', + loading: l10n.vmActionNotificationLoading(action.continuousTense(l10n), name), + onSuccess: (_) => l10n.vmActionNotificationSuccess(action.pastTense(l10n), name), onError: (error) { - return 'Failed to ${action.name.toLowerCase()} $name: $error'; + return l10n.vmActionNotificationError(action.name.toLowerCase(), name, '$error'); }, ); await operation; @@ -297,16 +299,17 @@ class _VmTerminalState extends ConsumerState { ); void openContextMenu(Offset offset, BuildContext context) { + final l10n = AppLocalizations.of(context)!; final buttonItems = [ ContextMenuButtonItem( - label: 'Copy', + label: l10n.terminalContextCopy, onPressed: () { ContextMenuController.removeAny(); Actions.maybeInvoke(context, CopySelectionTextIntent.copy); }, ), ContextMenuButtonItem( - label: 'Paste', + label: l10n.terminalContextPaste, onPressed: () { ContextMenuController.removeAny(); Actions.maybeInvoke( @@ -316,7 +319,7 @@ class _VmTerminalState extends ConsumerState { }, ), ContextMenuButtonItem( - label: 'Select All', + label: l10n.terminalContextSelectAll, onPressed: () { ContextMenuController.removeAny(); Actions.maybeInvoke( @@ -348,6 +351,7 @@ class _VmTerminalState extends ConsumerState { @override Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; final terminal = ref.watch(terminalProvider(terminalIdentifier)); final vmStatus = ref.watch( vmInfoProvider(widget.name).select((info) { @@ -373,7 +377,7 @@ class _VmTerminalState extends ConsumerState { onPressed: canStartVm || vmRunning ? () => startVmIfNeeded(vmRunning).then((_) => openShell()) : null, - child: const Text('Open shell'), + child: Text(l10n.terminalOpenShell), ), const SizedBox(height: 32), ], diff --git a/src/client/gui/lib/vm_details/terminal_tabs.dart b/src/client/gui/lib/vm_details/terminal_tabs.dart index 4f44b76288e..e45f9a38916 100644 --- a/src/client/gui/lib/vm_details/terminal_tabs.dart +++ b/src/client/gui/lib/vm_details/terminal_tabs.dart @@ -7,6 +7,7 @@ import 'package:flutter_svg/flutter_svg.dart'; import 'package:fpdart/fpdart.dart'; import '../close_terminal_dialog.dart'; +import '../l10n/app_localizations.dart'; import '../providers.dart'; import 'terminal.dart'; @@ -159,6 +160,7 @@ class TerminalTabs extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final l10n = AppLocalizations.of(context)!; final provider = shellIdsProvider(name); final notifier = provider.notifier; final (:ids, :currentIndex) = ref.watch(provider); @@ -172,7 +174,7 @@ class TerminalTabs extends ConsumerWidget { key: ValueKey(shellId.id), index: index, child: Tab( - title: 'Shell ${shellId.id}', + title: l10n.terminalTabTitle(shellId.id), selected: index == currentIndex, os: os, onTap: () => ref.read(notifier).setCurrent(index), diff --git a/src/client/gui/lib/vm_details/vm_action_buttons.dart b/src/client/gui/lib/vm_details/vm_action_buttons.dart index 1808dad0a19..95dd9c9d8a1 100644 --- a/src/client/gui/lib/vm_details/vm_action_buttons.dart +++ b/src/client/gui/lib/vm_details/vm_action_buttons.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../delete_instance_dialog.dart'; +import '../l10n/app_localizations.dart'; import '../notifications.dart'; import '../providers.dart'; import '../vm_action.dart'; @@ -13,6 +14,7 @@ class VmActionButtons extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final l10n = AppLocalizations.of(context)!; final client = ref.watch(grpcClientProvider); Function(VmAction) wrapInNotification( @@ -22,10 +24,10 @@ class VmActionButtons extends ConsumerWidget { final notificationsNotifier = ref.read(notificationsProvider.notifier); notificationsNotifier.addOperation( function([name]), - loading: '${action.continuousTense} $name', - onSuccess: (_) => '${action.pastTense} $name', + loading: l10n.vmActionNotificationLoading(action.continuousTense(l10n), name), + onSuccess: (_) => l10n.vmActionNotificationSuccess(action.pastTense(l10n), name), onError: (error) { - return 'Failed to ${action.name.toLowerCase()} $name: $error'; + return l10n.vmActionNotificationError(action.name.toLowerCase(), name, '$error'); }, ); }; @@ -56,7 +58,7 @@ class VmActionButtons extends ConsumerWidget { ]; return PopupMenuButton( - tooltip: 'Show actions', + tooltip: l10n.vmActionsMenuTooltip, position: PopupMenuPosition.under, itemBuilder: (_) => actionButtons, child: Container( @@ -66,11 +68,11 @@ class VmActionButtons extends ConsumerWidget { decoration: BoxDecoration( border: Border.all(color: const Color(0xff333333)), ), - child: const Row( + child: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ - Text('Actions', style: TextStyle(fontWeight: FontWeight.bold)), - Icon(Icons.keyboard_arrow_down), + Text(l10n.vmActionsMenuTitle, style: const TextStyle(fontWeight: FontWeight.bold)), + const Icon(Icons.keyboard_arrow_down), ], ), ), diff --git a/src/client/gui/lib/vm_details/vm_details_bridge.dart b/src/client/gui/lib/vm_details/vm_details_bridge.dart index 118fdaafe08..bb77242bf06 100644 --- a/src/client/gui/lib/vm_details/vm_details_bridge.dart +++ b/src/client/gui/lib/vm_details/vm_details_bridge.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:fpdart/fpdart.dart'; import '../notifications.dart'; +import '../l10n/app_localizations.dart'; import '../providers.dart'; import '../tooltip.dart'; import 'vm_details.dart'; @@ -28,6 +29,7 @@ class _BridgedDetailsState extends ConsumerState { @override Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; final networks = ref.watch(networksProvider).when( data: (data) => data, loading: () => const {}, @@ -61,24 +63,23 @@ class _BridgedDetailsState extends ConsumerState { onSaved: (value) { if (value!) { ref.read(bridgedProvider.notifier).set(value.toString()).onError( - ref.notifyError((e) => 'Failed to set bridged network: $e'), + ref.notifyError((e) => l10n.bridgeFailedNetwork('$e')), ); } }, builder: (field) { final validBridgedNetwork = networks.contains(bridgedNetworkSetting); final message = networks.isEmpty - ? 'No networks found.' - : validBridgedNetwork - ? "Once established, you won't be able to unset the connection." - : 'No valid bridged network is set.'; - + ? l10n.bridgeNoNetworks + : validBridgedNetwork + ? l10n.bridgeEstablishedWarning + : l10n.bridgeNoValidNetwork; return CheckboxListTile( contentPadding: EdgeInsets.zero, controlAffinity: ListTileControlAffinity.leading, enabled: validBridgedNetwork, onChanged: field.didChange, - title: const Text('Connect to bridged network.'), + title: Text(l10n.bridgeConnect), value: field.value!, visualDensity: VisualDensity.standard, subtitle: Text(message), @@ -91,7 +92,7 @@ class _BridgedDetailsState extends ConsumerState { formKey.currentState?.save(); setState(() => editing = false); }, - child: const Text('Save'), + child: Text(l10n.dialogSave), ); void configure() { @@ -103,10 +104,10 @@ class _BridgedDetailsState extends ConsumerState { final configureButton = Tooltip( visible: !stopped, - message: 'Stop instance to configure', + message: l10n.vmDetailsStopToConfigure, child: OutlinedButton( onPressed: stopped ? configure : null, - child: const Text('Configure'), + child: Text(l10n.dialogConfigure), ), ); @@ -116,7 +117,7 @@ class _BridgedDetailsState extends ConsumerState { setState(() => editing = false); ref.read(activeEditPageProvider(widget.name).notifier).set(null); }, - child: const Text('Cancel'), + child: Text(l10n.dialogCancel), ); return Form( @@ -127,9 +128,9 @@ class _BridgedDetailsState extends ConsumerState { children: [ Row( children: [ - const SizedBox( + SizedBox( height: 50, - child: Text('Bridged network', style: TextStyle(fontSize: 24)), + child: Text(l10n.bridgeTitle, style: const TextStyle(fontSize: 24)), ), const Spacer(), if (editing) @@ -141,7 +142,7 @@ class _BridgedDetailsState extends ConsumerState { editing ? SizedBox(width: 300, child: bridgedCheckbox) : Text( - 'Status: ${bridged ?? false ? '' : 'not'} connected', + bridged ?? false ? l10n.bridgeStatusConnected : l10n.bridgeStatusNotConnected, style: const TextStyle(fontSize: 16), ), if (editing) diff --git a/src/client/gui/lib/vm_details/vm_details_general.dart b/src/client/gui/lib/vm_details/vm_details_general.dart index 657b60ee1a7..8493c3d6e3b 100644 --- a/src/client/gui/lib/vm_details/vm_details_general.dart +++ b/src/client/gui/lib/vm_details/vm_details_general.dart @@ -5,6 +5,7 @@ import 'package:intl/intl.dart'; import '../copyable_text.dart'; import '../extensions.dart'; +import '../l10n/app_localizations.dart'; import '../providers.dart'; import 'cpu_sparkline.dart'; import 'memory_usage.dart'; @@ -27,19 +28,20 @@ class VmDetailsHeader extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final l10n = AppLocalizations.of(context)!; final info = ref.watch(vmInfoProvider(name)); final cpu = VmStat( width: 120, height: 35, - label: 'CPU USAGE', + label: l10n.vmStatCpuUsage, child: CpuSparkline(info.name), ); final memory = VmStat( width: 110, height: 35, - label: 'MEMORY USAGE', + label: l10n.vmStatMemoryUsage, child: MemoryUsage( used: info.instanceInfo.memoryUsage, total: info.memoryTotal, @@ -49,7 +51,7 @@ class VmDetailsHeader extends ConsumerWidget { final disk = VmStat( width: 110, height: 35, - label: 'DISK USAGE', + label: l10n.vmStatDiskUsage, child: MemoryUsage( used: info.instanceInfo.diskUsage, total: info.diskTotal, @@ -151,41 +153,42 @@ class GeneralDetails extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final l10n = AppLocalizations.of(context)!; final info = ref.watch(vmInfoProvider(name)); final isLaunching = ref.watch(isLaunchingProvider(name)); final status = VmStat( width: 100, height: baseVmStatHeight, - label: 'STATE', + label: l10n.vmStatState, child: VmStatusIcon(info.instanceStatus.status, isLaunching: isLaunching), ); final image = VmStat( width: 150, height: baseVmStatHeight, - label: 'IMAGE', + label: l10n.vmStatImage, child: CopyableText(info.instanceInfo.currentRelease), ); final privateIp = VmStat( width: 150, height: baseVmStatHeight, - label: 'PRIVATE IP', + label: l10n.vmStatPrivateIp, child: CopyableText(info.instanceInfo.ipv4.firstOrNull ?? '-'), ); final publicIp = VmStat( width: 150, height: baseVmStatHeight, - label: 'PUBLIC IP', + label: l10n.vmStatPublicIp, child: CopyableText(info.instanceInfo.ipv4.skip(1).firstOrNull ?? '-'), ); final created = VmStat( width: 140, height: baseVmStatHeight, - label: 'CREATED', + label: l10n.vmStatCreated, child: CopyableText( info.instanceInfo.formattedCreationTime(isLaunching: isLaunching), ), @@ -194,7 +197,7 @@ class GeneralDetails extends ConsumerWidget { final uptime = VmStat( width: 300, height: baseVmStatHeight, - label: 'UPTIME', + label: l10n.vmStatUptime, child: Text(info.instanceInfo.uptime), ); @@ -202,9 +205,9 @@ class GeneralDetails extends ConsumerWidget { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - const SizedBox( + SizedBox( height: baseVmStatHeight, - child: Text('General', style: TextStyle(fontSize: 24)), + child: Text(l10n.vmDetailsGeneralTitle, style: const TextStyle(fontSize: 24)), ), Wrap( spacing: 50, diff --git a/src/client/gui/lib/vm_details/vm_details_mounts.dart b/src/client/gui/lib/vm_details/vm_details_mounts.dart index 15e5c9aa64c..2dd7c77dee3 100644 --- a/src/client/gui/lib/vm_details/vm_details_mounts.dart +++ b/src/client/gui/lib/vm_details/vm_details_mounts.dart @@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../confirmation_dialog.dart'; import '../extensions.dart'; +import '../l10n/app_localizations.dart'; import '../notifications/notifications_provider.dart'; import '../platform/platform.dart'; import '../providers.dart'; @@ -27,6 +28,7 @@ class _MountDetailsState extends ConsumerState { @override Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; final mounts = ref.watch( vmInfoProvider(widget.name).select((info) { return info.mountInfo.mountPaths.build(); @@ -52,7 +54,7 @@ class _MountDetailsState extends ConsumerState { if (!(formKey.currentState?.validate() ?? false)) return; formKey.currentState?.save(); }, - child: const Text('Save'), + child: Text(l10n.dialogSave), ); final configureButton = OutlinedButton( @@ -62,7 +64,7 @@ class _MountDetailsState extends ConsumerState { .read(activeEditPageProvider(widget.name).notifier) .set(ActiveEditPage.mounts); }, - child: const Text('Configure'), + child: Text(l10n.dialogConfigure), ); final cancelButton = OutlinedButton( @@ -70,7 +72,7 @@ class _MountDetailsState extends ConsumerState { setState(() => phase = MountDetailsPhase.idle); ref.read(activeEditPageProvider(widget.name).notifier).set(null); }, - child: const Text('Cancel'), + child: Text(l10n.dialogCancel), ); final addMountButton = OutlinedButton( @@ -80,7 +82,7 @@ class _MountDetailsState extends ConsumerState { .read(activeEditPageProvider(widget.name).notifier) .set(ActiveEditPage.mounts); }, - child: const Text('Add mount'), + child: Text(l10n.mountsAddMount), ); final topRightButton = phase == MountDetailsPhase.idle @@ -95,9 +97,9 @@ class _MountDetailsState extends ConsumerState { children: [ Row( children: [ - const SizedBox( + SizedBox( height: 50, - child: Text('Mounts', style: TextStyle(fontSize: 24)), + child: Text(l10n.mountsTitle, style: const TextStyle(fontSize: 24)), ), const Spacer(), topRightButton, @@ -116,6 +118,7 @@ class _MountDetailsState extends ConsumerState { } void doMount(MountRequest request) { + final l10n = AppLocalizations.of(context)!; final grpcClient = ref.read(grpcClientProvider); final notificationsNotifier = ref.read(notificationsProvider.notifier); final target = request.targetPaths.first.targetPath; @@ -124,15 +127,16 @@ class _MountDetailsState extends ConsumerState { request.targetPaths.first.instanceName = widget.name; notificationsNotifier.addOperation( grpcClient.mount(request), - loading: 'Mounting $description', - onSuccess: (_) => 'Mounted $description', - onError: (error) => 'Failed to mount $description: $error', + loading: l10n.mountNotificationLoading(description), + onSuccess: (_) => l10n.mountNotificationSuccess(description), + onError: (error) => l10n.mountNotificationError(description, '$error'), ); setState(() => phase = MountDetailsPhase.idle); ref.read(activeEditPageProvider(widget.name).notifier).set(null); } void doUnmount(MountPaths mountPaths) { + final l10n = AppLocalizations.of(context)!; final target = mountPaths.targetPath; final grpcClient = ref.read(grpcClientProvider); final notificationsNotifier = ref.read(notificationsProvider.notifier); @@ -141,27 +145,27 @@ class _MountDetailsState extends ConsumerState { context: context, barrierDismissible: false, builder: (context) => ConfirmationDialog( - title: 'Delete mount', + title: l10n.mountDeleteTitle, body: Text.rich( [ - 'Are you sure you want to remove the mount\n'.span, + '${l10n.mountDeleteBodyPrefix}\n'.span, '${mountPaths.sourcePath} ⭢ $target'.span.font('UbuntuMono'), - ' from ${widget.name}?'.span, + l10n.mountDeleteBodySuffix(widget.name).span, ].spans, ), - actionText: 'Delete', + actionText: l10n.mountDeleteAction, onAction: () { Navigator.pop(context); notificationsNotifier.addOperation( grpcClient.umount(widget.name, target), - loading: "Unmounting '$target' from ${widget.name}", - onSuccess: (_) => "Unmounted '$target' from ${widget.name}", + loading: l10n.unmountNotificationLoading(target, widget.name), + onSuccess: (_) => l10n.unmountNotificationSuccess(target, widget.name), onError: (error) { - return "Failed to unmount '$target' from ${widget.name}: $error"; + return l10n.unmountNotificationError(target, widget.name, '$error'); }, ); }, - inactionText: 'Cancel', + inactionText: l10n.dialogCancel, onInaction: () => Navigator.pop(context), ), ); diff --git a/src/client/gui/lib/vm_details/vm_details_resources.dart b/src/client/gui/lib/vm_details/vm_details_resources.dart index e6388f311fd..41045b99405 100644 --- a/src/client/gui/lib/vm_details/vm_details_resources.dart +++ b/src/client/gui/lib/vm_details/vm_details_resources.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../extensions.dart'; import '../ffi.dart'; +import '../l10n/app_localizations.dart'; import '../notifications.dart'; import '../providers.dart'; import '../tooltip.dart'; @@ -39,6 +40,7 @@ class _ResourcesDetailsState extends ConsumerState { @override Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; final cpus = ref.watch(cpusProvider).whenOrNull(data: int.tryParse); final ram = ref.watch(ramProvider).whenOrNull(data: memoryInBytes); final disk = ref.watch(diskProvider).whenOrNull(data: memoryInBytes); @@ -52,8 +54,8 @@ class _ResourcesDetailsState extends ConsumerState { final cpusResource = !editing ? Text( - 'CPUs ${cpus?.toString() ?? '…'}', - style: TextStyle(fontSize: 16), + l10n.resourcesCpusDisplay(cpus?.toString() ?? '…'), + style: const TextStyle(fontSize: 16), ) : CpusSlider( key: Key('cpus-$cpus'), @@ -61,15 +63,15 @@ class _ResourcesDetailsState extends ConsumerState { onSaved: (value) { if (value == null || value == cpus) return; ref.read(cpusProvider.notifier).set('$value').onError( - ref.notifyError((error) => 'Failed to set CPUs : $error'), + ref.notifyError((error) => l10n.resourcesFailedCpus('$error')), ); }, ); final ramResource = !editing ? Text( - 'Memory ${ram.map(humanReadableMemory) ?? '…'}', - style: TextStyle(fontSize: 16), + l10n.resourcesMemoryDisplay(ram.map(humanReadableMemory) ?? '…'), + style: const TextStyle(fontSize: 16), ) : RamSlider( key: Key('ram-$ram'), @@ -77,15 +79,15 @@ class _ResourcesDetailsState extends ConsumerState { onSaved: (value) { if (value == null || value == ram) return; ref.read(ramProvider.notifier).set('${value}B').onError( - ref.notifyError((e) => 'Failed to set memory size: $e'), + ref.notifyError((e) => l10n.resourcesFailedMemory('$e')), ); }, ); final diskResource = !editing ? Text( - 'Disk ${disk.map(humanReadableMemory) ?? '…'}', - style: TextStyle(fontSize: 16), + l10n.resourcesDiskDisplay(disk.map(humanReadableMemory) ?? '…'), + style: const TextStyle(fontSize: 16), ) : DiskSlider( key: Key('disk-$disk'), @@ -94,7 +96,7 @@ class _ResourcesDetailsState extends ConsumerState { onSaved: (value) { if (value == null || value == disk) return; ref.read(diskProvider.notifier).set('${value}B').onError( - ref.notifyError((e) => 'Failed to set disk size: $e'), + ref.notifyError((e) => l10n.resourcesFailedDisk('$e')), ); }, ); @@ -106,7 +108,7 @@ class _ResourcesDetailsState extends ConsumerState { setState(() => editing = false); ref.read(activeEditPageProvider(widget.name).notifier).set(null); }, - child: const Text('Save changes'), + child: Text(l10n.resourcesSaveChanges), ); void configure() { @@ -118,10 +120,10 @@ class _ResourcesDetailsState extends ConsumerState { final configureButton = Tooltip( visible: !stopped, - message: 'Stop instance to configure', + message: l10n.vmDetailsStopToConfigure, child: OutlinedButton( onPressed: stopped ? configure : null, - child: const Text('Configure'), + child: Text(l10n.dialogConfigure), ), ); @@ -131,7 +133,7 @@ class _ResourcesDetailsState extends ConsumerState { setState(() => editing = false); ref.read(activeEditPageProvider(widget.name).notifier).set(null); }, - child: const Text('Cancel'), + child: Text(l10n.dialogCancel), ); return Form( @@ -143,9 +145,9 @@ class _ResourcesDetailsState extends ConsumerState { children: [ Row( children: [ - const SizedBox( + SizedBox( height: 50, - child: Text('Resources', style: TextStyle(fontSize: 24)), + child: Text(l10n.resourcesTitle, style: const TextStyle(fontSize: 24)), ), const Spacer(), editing ? cancelButton : configureButton, From bb92b4dbfb37efb13c732d38976b548941bbc8a4 Mon Sep 17 00:00:00 2001 From: Scott Harder Date: Tue, 7 Apr 2026 18:16:05 -0500 Subject: [PATCH 19/25] [gui] Extract strings from `vm_table` directory --- src/client/gui/lib/l10n/app_en.arb | 24 +++++++++++++++++++ .../gui/lib/vm_table/header_selection.dart | 24 ++++++++++++++----- .../gui/lib/vm_table/vm_table_headers.dart | 17 +++++++++++++ src/client/gui/lib/vm_table/vms.dart | 18 +++++++------- 4 files changed, 69 insertions(+), 14 deletions(-) diff --git a/src/client/gui/lib/l10n/app_en.arb b/src/client/gui/lib/l10n/app_en.arb index a7151e56d61..ed07292fe8f 100644 --- a/src/client/gui/lib/l10n/app_en.arb +++ b/src/client/gui/lib/l10n/app_en.arb @@ -784,5 +784,29 @@ "type": "int" } } + }, + "vmTableColumnName": "NAME", + "@vmTableColumnName": { + "description": "Column header label for the instance name column in the VM table" + }, + "vmTableColumnsButton": "Columns", + "@vmTableColumnsButton": { + "description": "Label on the button that opens the column show/hide menu" + }, + "vmTableAllInstances": "All Instances", + "@vmTableAllInstances": { + "description": "Page heading above the VM table" + }, + "vmTableLaunch": "Launch", + "@vmTableLaunch": { + "description": "Button label that opens the catalogue to launch a new instance" + }, + "vmTableShowRunningOnly": "Show running instances only", + "@vmTableShowRunningOnly": { + "description": "Toggle label that filters the VM table to running instances" + }, + "vmTableTotal": "Total", + "@vmTableTotal": { + "description": "Label for the totals row at the bottom of the VM table" } } diff --git a/src/client/gui/lib/vm_table/header_selection.dart b/src/client/gui/lib/vm_table/header_selection.dart index e50c64aa974..fe59d3f0cd8 100644 --- a/src/client/gui/lib/vm_table/header_selection.dart +++ b/src/client/gui/lib/vm_table/header_selection.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/flutter_svg.dart'; +import '../l10n/app_localizations.dart'; import 'vm_table_headers.dart'; class EnabledHeadersNotifier extends Notifier> { @@ -23,8 +24,9 @@ final enabledHeadersProvider = class HeaderSelectionTile extends ConsumerWidget { final String name; + final String label; - const HeaderSelectionTile(this.name, {super.key}); + const HeaderSelectionTile(this.name, this.label, {super.key}); @override Widget build(BuildContext context, WidgetRef ref) { @@ -32,7 +34,7 @@ class HeaderSelectionTile extends ConsumerWidget { return CheckboxListTile( controlAffinity: ListTileControlAffinity.leading, - title: Text(name, style: const TextStyle(color: Colors.black)), + title: Text(label, style: const TextStyle(color: Colors.black)), value: enabledHeaders[name], onChanged: (isSelected) => ref .read(enabledHeadersProvider.notifier) @@ -46,13 +48,23 @@ class HeaderSelection extends StatelessWidget { @override Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final columnLabels = { + 'STATE': l10n.vmStatState, + 'CPU USAGE': l10n.vmStatCpuUsage, + 'MEMORY USAGE': l10n.vmStatMemoryUsage, + 'DISK USAGE': l10n.vmStatDiskUsage, + 'IMAGE': l10n.vmStatImage, + 'PRIVATE IP': l10n.vmStatPrivateIp, + 'PUBLIC IP': l10n.vmStatPublicIp, + }; return PopupMenuButton( position: PopupMenuPosition.under, itemBuilder: (_) => headers.skip(2).map((h) { return PopupMenuItem( padding: EdgeInsets.zero, enabled: false, - child: HeaderSelectionTile(h.name), + child: HeaderSelectionTile(h.name, columnLabels[h.name] ?? h.name), ); }).toList(), child: Container( @@ -70,9 +82,9 @@ class HeaderSelection extends StatelessWidget { BlendMode.srcIn, ), ), - const Text( - 'Columns', - style: TextStyle(fontWeight: FontWeight.bold), + Text( + l10n.vmTableColumnsButton, + style: const TextStyle(fontWeight: FontWeight.bold), ), ], ), diff --git a/src/client/gui/lib/vm_table/vm_table_headers.dart b/src/client/gui/lib/vm_table/vm_table_headers.dart index 79b8f58eb48..016cd3d2c9a 100644 --- a/src/client/gui/lib/vm_table/vm_table_headers.dart +++ b/src/client/gui/lib/vm_table/vm_table_headers.dart @@ -5,6 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../copyable_text.dart'; import '../extensions.dart'; +import '../l10n/app_localizations.dart'; import '../providers.dart'; import '../sidebar.dart'; import '../tooltip.dart'; @@ -16,6 +17,14 @@ import 'search_box.dart'; import 'table.dart'; import 'vms.dart'; +/// Returns a [childBuilder] for [TableHeader] that renders a localized column label. +Widget Function(String) _l10nHeader(String Function(AppLocalizations) label) { + return (_) => Builder( + builder: (context) => + TableHeader.defaultHeaderBuilder(label(AppLocalizations.of(context)!)), + ); +} + final headers = >[ TableHeader( name: 'checkbox', @@ -26,6 +35,7 @@ final headers = >[ ), TableHeader( name: 'NAME', + childBuilder: _l10nHeader((l10n) => l10n.vmTableColumnName), width: 115, minWidth: 70, sortKey: (info) => info.name, @@ -33,6 +43,7 @@ final headers = >[ ), TableHeader( name: 'STATE', + childBuilder: _l10nHeader((l10n) => l10n.vmStatState), width: 110, minWidth: 70, sortKey: (info) => info.instanceStatus.status.name, @@ -45,12 +56,14 @@ final headers = >[ ), TableHeader( name: 'CPU USAGE', + childBuilder: _l10nHeader((l10n) => l10n.vmStatCpuUsage), width: 130, minWidth: 100, cellBuilder: (info) => CpuSparkline(info.name), ), TableHeader( name: 'MEMORY USAGE', + childBuilder: _l10nHeader((l10n) => l10n.vmStatMemoryUsage), width: 140, minWidth: 130, cellBuilder: (info) => MemoryUsage( @@ -60,6 +73,7 @@ final headers = >[ ), TableHeader( name: 'DISK USAGE', + childBuilder: _l10nHeader((l10n) => l10n.vmStatDiskUsage), width: 130, minWidth: 100, cellBuilder: (info) => @@ -67,6 +81,7 @@ final headers = >[ ), TableHeader( name: 'IMAGE', + childBuilder: _l10nHeader((l10n) => l10n.vmStatImage), width: 140, minWidth: 70, cellBuilder: (info) { @@ -76,12 +91,14 @@ final headers = >[ ), TableHeader( name: 'PRIVATE IP', + childBuilder: _l10nHeader((l10n) => l10n.vmStatPrivateIp), width: 140, minWidth: 100, cellBuilder: (info) => IpAddresses(info.instanceInfo.ipv4.take(1)), ), TableHeader( name: 'PUBLIC IP', + childBuilder: _l10nHeader((l10n) => l10n.vmStatPublicIp), width: 140, minWidth: 100, cellBuilder: (info) => IpAddresses(info.instanceInfo.ipv4.skip(1)), diff --git a/src/client/gui/lib/vm_table/vms.dart b/src/client/gui/lib/vm_table/vms.dart index c63f0a25059..aec62d31d3e 100644 --- a/src/client/gui/lib/vm_table/vms.dart +++ b/src/client/gui/lib/vm_table/vms.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart' hide Table, Switch; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../catalogue/catalogue.dart'; +import '../l10n/app_localizations.dart'; import '../providers.dart'; import '../sidebar.dart'; import '../switch.dart'; @@ -69,21 +70,22 @@ class Vms extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final l10n = AppLocalizations.of(context)!; goToCatalogue() { ref.read(sidebarKeyProvider.notifier).set(CatalogueScreen.sidebarKey); } final heading = Row( children: [ - const Expanded( + Expanded( child: Text( - 'All Instances', - style: TextStyle(fontSize: 37, fontWeight: FontWeight.w300), + l10n.vmTableAllInstances, + style: const TextStyle(fontSize: 37, fontWeight: FontWeight.w300), maxLines: 1, overflow: TextOverflow.ellipsis, ), ), - TextButton(onPressed: goToCatalogue, child: const Text('Launch')), + TextButton(onPressed: goToCatalogue, child: Text(l10n.vmTableLaunch)), ], ); @@ -92,7 +94,7 @@ class Vms extends ConsumerWidget { final vmFilters = Row( children: [ Switch( - label: 'Show running instances only', + label: l10n.vmTableShowRunningOnly, value: runningOnly, onChanged: (v) => ref.read(runningOnlyProvider.notifier).set(v), ), @@ -123,9 +125,9 @@ class Vms extends ConsumerWidget { Container( margin: const EdgeInsets.all(10), alignment: Alignment.centerLeft, - child: const Text( - "Total", - style: TextStyle(fontWeight: FontWeight.bold), + child: Text( + l10n.vmTableTotal, + style: const TextStyle(fontWeight: FontWeight.bold), ), ), for (final name in enabledHeaderNames.whereValue((e) => e).keys.skip(2)) From 3344c69789df9af95ff5a671a8b1cf1161017575 Mon Sep 17 00:00:00 2001 From: Scott Harder Date: Tue, 7 Apr 2026 19:12:33 -0500 Subject: [PATCH 20/25] [gui] Extract some more localization strings --- src/client/gui/lib/copyable_text.dart | 3 +- src/client/gui/lib/daemon_unavailable.dart | 21 ++++++----- src/client/gui/lib/l10n/app_en.arb | 41 ++++++++++++++++++++++ src/client/gui/lib/update_available.dart | 13 ++++--- 4 files changed, 63 insertions(+), 15 deletions(-) diff --git a/src/client/gui/lib/copyable_text.dart b/src/client/gui/lib/copyable_text.dart index b91a1a661a5..bf02735847b 100644 --- a/src/client/gui/lib/copyable_text.dart +++ b/src/client/gui/lib/copyable_text.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart' hide Tooltip; import 'package:flutter/services.dart'; +import 'l10n/app_localizations.dart'; import 'tooltip.dart'; class CopyableText extends StatefulWidget { @@ -37,7 +38,7 @@ class _CopyableTextState extends State { setState(() => _copied = true); }, child: Tooltip( - message: _copied ? 'Copied' : 'Click to copy', + message: _copied ? AppLocalizations.of(context)!.copyableTextCopied : AppLocalizations.of(context)!.copyableTextClickToCopy, child: text, ), ), diff --git a/src/client/gui/lib/daemon_unavailable.dart b/src/client/gui/lib/daemon_unavailable.dart index 4ab2d89134a..15827f1c899 100644 --- a/src/client/gui/lib/daemon_unavailable.dart +++ b/src/client/gui/lib/daemon_unavailable.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart' hide Tooltip; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'ffi.dart'; +import 'l10n/app_localizations.dart'; import 'providers.dart'; import 'tooltip.dart'; import 'package:flutter/services.dart'; @@ -29,8 +30,9 @@ class _CopyErrorIconState extends State<_CopyErrorIcon> { @override Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; return Tooltip( - message: _copied ? 'Copied!' : 'Copy error message', + message: _copied ? l10n.daemonCopied : l10n.daemonCopyErrorTooltip, child: IconButton( icon: const Icon(Icons.copy, size: 20), onPressed: _copy, @@ -44,6 +46,7 @@ class DaemonUnavailable extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final l10n = AppLocalizations.of(context)!; final available = ref.watch(daemonAvailableProvider); final ffiAvailable = ref.watch(ffiAvailableProvider); @@ -77,9 +80,9 @@ class DaemonUnavailable extends ConsumerWidget { children: [ const Icon(Icons.error, color: Colors.red, size: 48), const SizedBox(height: 16), - const Text( - 'Fatal Error', - style: TextStyle( + Text( + l10n.daemonFatalError, + style: const TextStyle( fontSize: 20, fontWeight: FontWeight.bold, color: Colors.red, @@ -110,7 +113,7 @@ class DaemonUnavailable extends ConsumerWidget { children: [ TextButton( onPressed: () => exit(1), - child: const Text('Exit Application'), + child: Text(l10n.daemonExitButton), ), ], ), @@ -134,12 +137,12 @@ class DaemonUnavailable extends ConsumerWidget { BoxShadow(color: Colors.black54, blurRadius: 10, spreadRadius: 5), ], ), - child: const Row( + child: Row( mainAxisSize: MainAxisSize.min, children: [ - CircularProgressIndicator(color: Colors.orange), - SizedBox(width: 20), - Text('Waiting for daemon...'), + const CircularProgressIndicator(color: Colors.orange), + const SizedBox(width: 20), + Text(l10n.daemonWaiting), ], ), ); diff --git a/src/client/gui/lib/l10n/app_en.arb b/src/client/gui/lib/l10n/app_en.arb index ed07292fe8f..d8771cf1b7d 100644 --- a/src/client/gui/lib/l10n/app_en.arb +++ b/src/client/gui/lib/l10n/app_en.arb @@ -808,5 +808,46 @@ "vmTableTotal": "Total", "@vmTableTotal": { "description": "Label for the totals row at the bottom of the VM table" + }, + "copyableTextClickToCopy": "Click to copy", + "@copyableTextClickToCopy": { + "description": "Tooltip shown on a copyable text widget before the user clicks" + }, + "copyableTextCopied": "Copied", + "@copyableTextCopied": { + "description": "Tooltip shown on a copyable text widget after the user clicks" + }, + "daemonCopyErrorTooltip": "Copy error message", + "@daemonCopyErrorTooltip": { + "description": "Tooltip on the copy-icon button in the fatal error overlay (before copying)" + }, + "daemonCopied": "Copied!", + "@daemonCopied": { + "description": "Tooltip on the copy-icon button in the fatal error overlay (after copying)" + }, + "daemonFatalError": "Fatal Error", + "@daemonFatalError": { + "description": "Heading in the fatal FFI error overlay" + }, + "daemonExitButton": "Exit Application", + "@daemonExitButton": { + "description": "Button label to exit the app from the fatal error overlay" + }, + "daemonWaiting": "Waiting for daemon...", + "@daemonWaiting": { + "description": "Status message shown while waiting for the Multipass daemon to become available" + }, + "updateAvailableTitle": "Multipass {version} is available", + "@updateAvailableTitle": { + "description": "In-app banner and notification text announcing a new Multipass version", + "placeholders": { + "version": { + "type": "String" + } + } + }, + "updateAvailableUpgrade": "Upgrade now", + "@updateAvailableUpgrade": { + "description": "Button label to start the upgrade process" } } diff --git a/src/client/gui/lib/update_available.dart b/src/client/gui/lib/update_available.dart index a7b38bc77db..9ab99bba2f5 100644 --- a/src/client/gui/lib/update_available.dart +++ b/src/client/gui/lib/update_available.dart @@ -7,6 +7,7 @@ import 'package:url_launcher/url_launcher.dart'; import 'notifications/notification_entries.dart'; import 'notifications/notifications_provider.dart'; +import 'l10n/app_localizations.dart'; import 'platform/platform.dart'; import 'providers.dart'; @@ -72,6 +73,7 @@ class UpdateAvailable extends StatelessWidget { @override Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; final icon = Container( alignment: Alignment.center, color: _color, @@ -81,13 +83,13 @@ class UpdateAvailable extends StatelessWidget { ); final text = Text( - 'Multipass ${updateInfo.version} is available', + l10n.updateAvailableTitle(updateInfo.version), style: const TextStyle(fontSize: 16), ); - const button = TextButton( + final button = TextButton( onPressed: launchInstallUrl, - child: Text('Upgrade now'), + child: Text(l10n.updateAvailableUpgrade), ); return Container( @@ -113,6 +115,7 @@ class UpdateAvailableNotification extends StatelessWidget { @override Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; return SimpleNotification( barColor: _color, icon: SvgPicture.asset( @@ -124,7 +127,7 @@ class UpdateAvailableNotification extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Multipass ${updateInfo.version} is available', + l10n.updateAvailableTitle(updateInfo.version), style: const TextStyle(fontSize: 16), ), const SizedBox(height: 12), @@ -134,7 +137,7 @@ class UpdateAvailableNotification extends StatelessWidget { if (!context.mounted) return; closeNotification(context); }, - child: const Text('Upgrade now', style: TextStyle(fontSize: 14)), + child: Text(l10n.updateAvailableUpgrade, style: const TextStyle(fontSize: 14)), ), ], ), From 7e79fec0518f0163e19112aeb9010b0e7ee7d41b Mon Sep 17 00:00:00 2001 From: Scott Harder Date: Tue, 7 Apr 2026 19:33:22 -0500 Subject: [PATCH 21/25] [gui] Extract strings from catalogue classes --- src/client/gui/lib/catalogue/catalogue.dart | 19 ++-- src/client/gui/lib/catalogue/image_card.dart | 33 +++--- src/client/gui/lib/catalogue/launch_form.dart | 55 +++++----- src/client/gui/lib/l10n/app_en.arb | 102 ++++++++++++++++++ 4 files changed, 159 insertions(+), 50 deletions(-) diff --git a/src/client/gui/lib/catalogue/catalogue.dart b/src/client/gui/lib/catalogue/catalogue.dart index 1299525aee2..90e789c1903 100644 --- a/src/client/gui/lib/catalogue/catalogue.dart +++ b/src/client/gui/lib/catalogue/catalogue.dart @@ -7,6 +7,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:grpc/grpc.dart'; import 'package:intersperse/intersperse.dart'; +import '../l10n/app_localizations.dart'; import '../providers.dart'; import 'image_card.dart'; import 'launch_form.dart'; @@ -154,23 +155,25 @@ class CatalogueScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final l10n = AppLocalizations.of(context)!; final content = ref.watch(imagesProvider).when( skipLoadingOnRefresh: false, data: _buildCatalogue, error: (error, _) { - final errorMessage = error is GrpcError ? error.message : error; + final errorMessage = + error is GrpcError ? (error.message ?? error.toString()) : error.toString(); return Center( child: Column( children: [ const SizedBox(height: 32), Text( - 'Failed to retrieve images: $errorMessage', + l10n.catalogueLoadError(errorMessage), style: const TextStyle(fontSize: 16), ), const SizedBox(height: 16), TextButton( onPressed: () => ref.invalidate(imagesProvider), - child: const Text('Refresh'), + child: Text(l10n.catalogueRefresh), ), ], ), @@ -181,15 +184,15 @@ class CatalogueScreen extends ConsumerWidget { final welcomeText = Container( constraints: const BoxConstraints(maxWidth: 500), - child: const Column( + child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('Welcome to Multipass', style: TextStyle(fontSize: 37)), + Text(l10n.catalogueWelcomeTitle, style: const TextStyle(fontSize: 37)), Padding( - padding: EdgeInsets.symmetric(vertical: 8), + padding: const EdgeInsets.symmetric(vertical: 8), child: Text( - 'Get an instant VM in seconds. Multipass can launch and run virtual machines and configure them like a public cloud.', - style: TextStyle(fontSize: 16), + l10n.catalogueWelcomeBody, + style: const TextStyle(fontSize: 16), ), ), ], diff --git a/src/client/gui/lib/catalogue/image_card.dart b/src/client/gui/lib/catalogue/image_card.dart index 9e2e5414ef7..091ea3a46a8 100644 --- a/src/client/gui/lib/catalogue/image_card.dart +++ b/src/client/gui/lib/catalogue/image_card.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart' hide ImageInfo; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/flutter_svg.dart'; +import '../l10n/app_localizations.dart'; import '../providers.dart'; import 'catalogue.dart'; import 'launch_form.dart'; @@ -28,25 +29,24 @@ class ImageCard extends ConsumerWidget { }; } - String _getDisplayTitle(ImageInfo parentImage) { + String _getDisplayTitle(ImageInfo parentImage, AppLocalizations l10n) { return switch (parentImage.os.toLowerCase()) { 'ubuntu' when parentImage.aliases.any((a) => a.contains('core')) => - 'Ubuntu Core', - 'ubuntu' => 'Ubuntu Server', - 'debian' => 'Debian', - 'fedora' => 'Fedora', + l10n.imageCardTitleUbuntuCore, + 'ubuntu' => l10n.imageCardTitleUbuntuServer, + 'debian' => l10n.imageCardTitleDebian, + 'fedora' => l10n.imageCardTitleFedora, _ => parentImage.os, // Default case: return the OS name as-is }; } - String _getDescription(ImageInfo parentImage) { + String _getDescription(ImageInfo parentImage, AppLocalizations l10n) { return switch (parentImage.os.toLowerCase()) { 'ubuntu' when parentImage.aliases.any((a) => a.contains('core')) => - 'Ubuntu operating system optimised for IoT and Edge', - 'ubuntu' => - 'Ubuntu operating system designed as a backbone for the internet', - 'debian' => 'Debian official cloud image', - 'fedora' => 'Fedora Cloud Edition', + l10n.imageCardDescUbuntuCore, + 'ubuntu' => l10n.imageCardDescUbuntuServer, + 'debian' => l10n.imageCardDescDebian, + 'fedora' => l10n.imageCardDescFedora, _ => '', }; } @@ -59,6 +59,7 @@ class ImageCard extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final l10n = AppLocalizations.of(context)!; final selectedImage = ref.watch(selectedImageProvider(imageKey)) ?? parentImage; @@ -90,11 +91,11 @@ class ImageCard extends ConsumerWidget { _getParentImageLogo(parentImage.os), height: 24, fit: BoxFit.contain, - semanticsLabel: '${parentImage.os} logo', + semanticsLabel: l10n.imageCardLogoSemantics(parentImage.os), ), const SizedBox(width: 8), Text( - _getDisplayTitle(parentImage), + _getDisplayTitle(parentImage, l10n), style: const TextStyle( fontWeight: FontWeight.bold, fontSize: 24, @@ -103,7 +104,7 @@ class ImageCard extends ConsumerWidget { ], ), const SizedBox(height: 12), - Text(_getDescription(parentImage), + Text(_getDescription(parentImage, l10n), style: const TextStyle(fontWeight: FontWeight.w300)), const SizedBox(height: 16), const Spacer(), @@ -166,7 +167,7 @@ class ImageCard extends ConsumerWidget { initiateLaunchFlow(ref, launchRequest); }, - child: const Text('Launch'), + child: Text(l10n.vmTableLaunch), ), const SizedBox(width: 8), OutlinedButton( @@ -175,7 +176,7 @@ class ImageCard extends ConsumerWidget { selectedImage; Scaffold.of(context).openEndDrawer(); }, - child: const Text('Configure'), + child: Text(l10n.dialogConfigure), ), ]), ], diff --git a/src/client/gui/lib/catalogue/launch_form.dart b/src/client/gui/lib/catalogue/launch_form.dart index 203283a7a33..699afc50856 100644 --- a/src/client/gui/lib/catalogue/launch_form.dart +++ b/src/client/gui/lib/catalogue/launch_form.dart @@ -6,6 +6,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:rxdart/rxdart.dart'; import '../ffi.dart'; +import '../l10n/app_localizations.dart'; import '../notifications.dart'; import '../platform/platform.dart'; import '../providers.dart'; @@ -74,6 +75,7 @@ class _LaunchFormState extends ConsumerState { @override Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; final imageInfo = ref.watch(launchingImageProvider); final randomName = ref.watch(randomNameProvider); final vmNames = ref.watch(vmNamesProvider); @@ -96,11 +98,11 @@ class _LaunchFormState extends ConsumerState { ); final nameInput = SpecInput( - label: 'Name', + label: l10n.launchFormNameLabel, autofocus: true, - helper: 'Names cannot be changed once an instance is created', + helper: l10n.launchFormNameHelper, hint: randomName, - validator: nameValidator(vmNames, deletedVms), + validator: nameValidator(vmNames, deletedVms, l10n), onSaved: (value) => launchRequest.instanceName = value.isNullOrBlank ? randomName : value!, width: 360, @@ -147,10 +149,10 @@ class _LaunchFormState extends ConsumerState { }, builder: (field) { final message = networks.isEmpty - ? 'No networks found.' + ? l10n.bridgeNoNetworks : validBridgedNetwork - ? "Connect to the bridged network.\nOnce established, you won't be able to unset the connection." - : 'No valid bridged network is set.\nYou can set one in the Settings page.'; + ? l10n.launchFormBridgeConnect + : l10n.launchFormBridgeNoValidNetwork; return Switch( label: message, @@ -189,7 +191,7 @@ class _LaunchFormState extends ConsumerState { ); }); }), - child: const Text('Add mount'), + child: Text(l10n.mountsAddMount), ); final saveMountButton = TextButton( @@ -199,12 +201,12 @@ class _LaunchFormState extends ConsumerState { if (!mountFormState.validate()) return; mountFormState.save(); }, - child: const Text('Save'), + child: Text(l10n.dialogSave), ); final cancelMountButton = OutlinedButton( onPressed: () => setState(() => addingMount = false), - child: const Text('Cancel'), + child: Text(l10n.dialogCancel), ); final editableMountPoint = EditableMountPoint( @@ -241,13 +243,13 @@ class _LaunchFormState extends ConsumerState { children: [ Row( children: [ - const Text('Configure instance', style: TextStyle(fontSize: 24)), + Text(l10n.launchFormTitle, style: const TextStyle(fontSize: 24)), const Spacer(), closeButton, ], ), const SizedBox(height: 20), - const Text('Image', style: TextStyle(fontSize: 18)), + Text(l10n.launchFormImageLabel, style: const TextStyle(fontSize: 18)), const SizedBox(height: 4), chosenImageName, const SizedBox(height: 16), @@ -256,9 +258,9 @@ class _LaunchFormState extends ConsumerState { children: [nameInput, const Spacer()], ), const Divider(height: 60), - const SizedBox( + SizedBox( height: 50, - child: Text('Resources', style: TextStyle(fontSize: 24)), + child: Text(l10n.resourcesTitle, style: const TextStyle(fontSize: 24)), ), Row( crossAxisAlignment: CrossAxisAlignment.start, @@ -271,15 +273,15 @@ class _LaunchFormState extends ConsumerState { ], ), const Divider(height: 60), - const SizedBox( + SizedBox( height: 50, - child: Text('Bridged network', style: TextStyle(fontSize: 24)), + child: Text(l10n.bridgeTitle, style: const TextStyle(fontSize: 24)), ), bridgedSwitch, const Divider(height: 60), - const SizedBox( + SizedBox( height: 50, - child: Text('Mounts', style: TextStyle(fontSize: 24)), + child: Text(l10n.mountsTitle, style: const TextStyle(fontSize: 24)), ), mountPointsView, if (mountRequests.isNotEmpty) const SizedBox(height: 20), @@ -289,17 +291,17 @@ class _LaunchFormState extends ConsumerState { final launchButton = TextButton( onPressed: () => launch(imageInfo), - child: const Text('Launch'), + child: Text(l10n.vmTableLaunch), ); final launchAndConfigureNextButton = OutlinedButton( onPressed: () => launch(imageInfo, configureNext: true), - child: const Text('Launch & Configure next'), + child: Text(l10n.launchFormLaunchAndConfigureNext), ); final cancelButton = OutlinedButton( onPressed: () => Scaffold.of(context).closeEndDrawer(), - child: const Text('Cancel'), + child: Text(l10n.dialogCancel), ); return Stack( @@ -415,28 +417,29 @@ void initiateLaunchFlow( FormFieldValidator nameValidator( Iterable existingNames, Iterable deletedNames, + AppLocalizations l10n, ) { return (String? value) { if (value!.isEmpty) { return null; } if (value.length < 2) { - return 'Name must be at least 2 characters'; + return l10n.usagePrimaryNameErrorTooShort; } if (RegExp(r'[^A-Za-z0-9\-]').hasMatch(value)) { - return 'Name must contain only letters, numbers and dashes'; + return l10n.launchFormNameErrorInvalidChars; } if (RegExp(r'^[^A-Za-z]').hasMatch(value)) { - return 'Name must start with a letter'; + return l10n.usagePrimaryNameErrorStartLetter; } if (RegExp(r'[^A-Za-z0-9]$').hasMatch(value)) { - return 'Name must end in digit or letter'; + return l10n.usagePrimaryNameErrorEndChar; } if (existingNames.contains(value)) { - return 'Name is already in use'; + return l10n.launchFormNameErrorInUse; } if (deletedNames.contains(value)) { - return 'Name is already in use by a deleted instance'; + return l10n.launchFormNameErrorDeletedInUse; } return null; }; diff --git a/src/client/gui/lib/l10n/app_en.arb b/src/client/gui/lib/l10n/app_en.arb index d8771cf1b7d..f1cedaf38e1 100644 --- a/src/client/gui/lib/l10n/app_en.arb +++ b/src/client/gui/lib/l10n/app_en.arb @@ -849,5 +849,107 @@ "updateAvailableUpgrade": "Upgrade now", "@updateAvailableUpgrade": { "description": "Button label to start the upgrade process" + }, + "catalogueLoadError": "Failed to retrieve images: {error}", + "@catalogueLoadError": { + "description": "Error message shown when image retrieval fails", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "catalogueRefresh": "Refresh", + "@catalogueRefresh": { + "description": "Button label to refresh the image catalogue" + }, + "catalogueWelcomeTitle": "Welcome to Multipass", + "@catalogueWelcomeTitle": { + "description": "Welcome screen heading in the image catalogue" + }, + "catalogueWelcomeBody": "Get an instant VM in seconds. Multipass can launch and run virtual machines and configure them like a public cloud.", + "@catalogueWelcomeBody": { + "description": "Welcome screen body text in the image catalogue" + }, + "imageCardTitleUbuntuCore": "Ubuntu Core", + "@imageCardTitleUbuntuCore": { + "description": "Display title for Ubuntu Core image" + }, + "imageCardTitleUbuntuServer": "Ubuntu Server", + "@imageCardTitleUbuntuServer": { + "description": "Display title for Ubuntu Server image" + }, + "imageCardTitleDebian": "Debian", + "@imageCardTitleDebian": { + "description": "Display title for Debian image" + }, + "imageCardTitleFedora": "Fedora", + "@imageCardTitleFedora": { + "description": "Display title for Fedora image" + }, + "imageCardDescUbuntuCore": "Ubuntu operating system optimised for IoT and Edge", + "@imageCardDescUbuntuCore": { + "description": "Description for Ubuntu Core image" + }, + "imageCardDescUbuntuServer": "Ubuntu operating system designed as a backbone for the internet", + "@imageCardDescUbuntuServer": { + "description": "Description for Ubuntu Server image" + }, + "imageCardDescDebian": "Debian official cloud image", + "@imageCardDescDebian": { + "description": "Description for Debian image" + }, + "imageCardDescFedora": "Fedora Cloud Edition", + "@imageCardDescFedora": { + "description": "Description for Fedora image" + }, + "imageCardLogoSemantics": "{os} logo", + "@imageCardLogoSemantics": { + "description": "Accessibility label for an OS logo image", + "placeholders": { + "os": { + "type": "String" + } + } + }, + "launchFormTitle": "Configure instance", + "@launchFormTitle": { + "description": "Title of the launch/configure instance form" + }, + "launchFormImageLabel": "Image", + "@launchFormImageLabel": { + "description": "Section label for the image selection in the launch form" + }, + "launchFormNameLabel": "Name", + "@launchFormNameLabel": { + "description": "Label for the instance name input in the launch form" + }, + "launchFormNameHelper": "Names cannot be changed once an instance is created", + "@launchFormNameHelper": { + "description": "Helper text for the instance name input in the launch form" + }, + "launchFormBridgeConnect": "Connect to the bridged network.\nOnce established, you won't be able to unset the connection.", + "@launchFormBridgeConnect": { + "description": "Label on the bridged network switch when a valid bridged network is configured" + }, + "launchFormBridgeNoValidNetwork": "No valid bridged network is set.\nYou can set one in the Settings page.", + "@launchFormBridgeNoValidNetwork": { + "description": "Message on the bridged network switch when no valid bridged network is configured" + }, + "launchFormLaunchAndConfigureNext": "Launch & Configure next", + "@launchFormLaunchAndConfigureNext": { + "description": "Button label to launch and then configure the next instance" + }, + "launchFormNameErrorInvalidChars": "Name must contain only letters, numbers and dashes", + "@launchFormNameErrorInvalidChars": { + "description": "Validation error when instance name contains invalid characters" + }, + "launchFormNameErrorInUse": "Name is already in use", + "@launchFormNameErrorInUse": { + "description": "Validation error when instance name is already taken" + }, + "launchFormNameErrorDeletedInUse": "Name is already in use by a deleted instance", + "@launchFormNameErrorDeletedInUse": { + "description": "Validation error when instance name belongs to a deleted instance" } } From 8af3cda647f6e7a4ef47cc536e8165b81eb13c3d Mon Sep 17 00:00:00 2001 From: Scott Harder Date: Tue, 7 Apr 2026 21:20:14 -0500 Subject: [PATCH 22/25] [gui] Consolidate identical strings --- src/client/gui/lib/before_quit_dialog.dart | 2 +- src/client/gui/lib/catalogue/catalogue.dart | 8 +- src/client/gui/lib/catalogue/launch_form.dart | 3 +- src/client/gui/lib/close_terminal_dialog.dart | 2 +- src/client/gui/lib/copyable_text.dart | 4 +- .../gui/lib/delete_instance_dialog.dart | 2 +- src/client/gui/lib/help.dart | 2 +- src/client/gui/lib/l10n/app_en.arb | 107 ++++-------------- src/client/gui/lib/settings/settings.dart | 2 +- .../lib/settings/virtualization_settings.dart | 5 +- src/client/gui/lib/sidebar.dart | 6 +- src/client/gui/lib/update_available.dart | 3 +- .../gui/lib/vm_details/memory_slider.dart | 3 +- src/client/gui/lib/vm_details/terminal.dart | 9 +- .../gui/lib/vm_details/vm_action_buttons.dart | 12 +- .../gui/lib/vm_details/vm_details_bridge.dart | 17 +-- .../lib/vm_details/vm_details_general.dart | 2 +- .../gui/lib/vm_details/vm_details_mounts.dart | 11 +- .../lib/vm_details/vm_details_resources.dart | 6 +- src/client/gui/lib/vm_table/bulk_actions.dart | 4 +- src/client/gui/lib/vm_table/no_vms.dart | 2 +- .../gui/lib/vm_table/vm_table_headers.dart | 6 +- 22 files changed, 88 insertions(+), 130 deletions(-) diff --git a/src/client/gui/lib/before_quit_dialog.dart b/src/client/gui/lib/before_quit_dialog.dart index a8d9026a172..a99b8112724 100644 --- a/src/client/gui/lib/before_quit_dialog.dart +++ b/src/client/gui/lib/before_quit_dialog.dart @@ -42,7 +42,7 @@ class _BeforeQuitDialogState extends State { onChanged: (value) => setState(() => remember = value!), ), const SizedBox(width: 8), - Text(l10n.beforeQuitDoNotAsk), + Text(l10n.dialogDoNotAskAgain), ], ), ], diff --git a/src/client/gui/lib/catalogue/catalogue.dart b/src/client/gui/lib/catalogue/catalogue.dart index 90e789c1903..554d657bf84 100644 --- a/src/client/gui/lib/catalogue/catalogue.dart +++ b/src/client/gui/lib/catalogue/catalogue.dart @@ -160,8 +160,9 @@ class CatalogueScreen extends ConsumerWidget { skipLoadingOnRefresh: false, data: _buildCatalogue, error: (error, _) { - final errorMessage = - error is GrpcError ? (error.message ?? error.toString()) : error.toString(); + final errorMessage = error is GrpcError + ? (error.message ?? error.toString()) + : error.toString(); return Center( child: Column( children: [ @@ -187,7 +188,8 @@ class CatalogueScreen extends ConsumerWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(l10n.catalogueWelcomeTitle, style: const TextStyle(fontSize: 37)), + Text(l10n.catalogueWelcomeTitle, + style: const TextStyle(fontSize: 37)), Padding( padding: const EdgeInsets.symmetric(vertical: 8), child: Text( diff --git a/src/client/gui/lib/catalogue/launch_form.dart b/src/client/gui/lib/catalogue/launch_form.dart index 699afc50856..ffe217067aa 100644 --- a/src/client/gui/lib/catalogue/launch_form.dart +++ b/src/client/gui/lib/catalogue/launch_form.dart @@ -260,7 +260,8 @@ class _LaunchFormState extends ConsumerState { const Divider(height: 60), SizedBox( height: 50, - child: Text(l10n.resourcesTitle, style: const TextStyle(fontSize: 24)), + child: + Text(l10n.resourcesTitle, style: const TextStyle(fontSize: 24)), ), Row( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/src/client/gui/lib/close_terminal_dialog.dart b/src/client/gui/lib/close_terminal_dialog.dart index 9be15e7cedb..6845592390c 100644 --- a/src/client/gui/lib/close_terminal_dialog.dart +++ b/src/client/gui/lib/close_terminal_dialog.dart @@ -42,7 +42,7 @@ class _CloseTerminalDialogState extends State { onChanged: (value) => setState(() => doNotAsk = value!), ), const SizedBox(width: 8), - Text(l10n.closeTerminalDoNotAsk), + Text(l10n.dialogDoNotAskAgain), ], ), ], diff --git a/src/client/gui/lib/copyable_text.dart b/src/client/gui/lib/copyable_text.dart index bf02735847b..b1d7e5bf543 100644 --- a/src/client/gui/lib/copyable_text.dart +++ b/src/client/gui/lib/copyable_text.dart @@ -38,7 +38,9 @@ class _CopyableTextState extends State { setState(() => _copied = true); }, child: Tooltip( - message: _copied ? AppLocalizations.of(context)!.copyableTextCopied : AppLocalizations.of(context)!.copyableTextClickToCopy, + message: _copied + ? AppLocalizations.of(context)!.copyableTextCopied + : AppLocalizations.of(context)!.copyableTextClickToCopy, child: text, ), ), diff --git a/src/client/gui/lib/delete_instance_dialog.dart b/src/client/gui/lib/delete_instance_dialog.dart index 41a2d2320cc..54f840fe31d 100644 --- a/src/client/gui/lib/delete_instance_dialog.dart +++ b/src/client/gui/lib/delete_instance_dialog.dart @@ -19,7 +19,7 @@ class DeleteInstanceDialog extends StatelessWidget { return ConfirmationDialog( title: l10n.deleteInstanceTitle(count), body: Text(l10n.deleteInstanceBody(count)), - actionText: l10n.deleteInstanceConfirm, + actionText: l10n.dialogDelete, onAction: () { onDelete(); Navigator.pop(context); diff --git a/src/client/gui/lib/help.dart b/src/client/gui/lib/help.dart index 09e8d893e62..27a4240fa0d 100644 --- a/src/client/gui/lib/help.dart +++ b/src/client/gui/lib/help.dart @@ -20,7 +20,7 @@ class HelpScreen extends StatelessWidget { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(l10n.helpTitle, style: const TextStyle(fontSize: 37)), + Text(l10n.helpLabel, style: const TextStyle(fontSize: 37)), const SizedBox(height: 32), SizedBox( width: 530, diff --git a/src/client/gui/lib/l10n/app_en.arb b/src/client/gui/lib/l10n/app_en.arb index f1cedaf38e1..83d501ff304 100644 --- a/src/client/gui/lib/l10n/app_en.arb +++ b/src/client/gui/lib/l10n/app_en.arb @@ -41,9 +41,9 @@ } } }, - "beforeQuitDoNotAsk": "Do not ask me again", - "@beforeQuitDoNotAsk": { - "description": "Checkbox label to suppress the quit dialog in future" + "dialogDoNotAskAgain": "Do not ask me again", + "@dialogDoNotAskAgain": { + "description": "Checkbox label to suppress a recurring confirmation dialog" }, "beforeQuitStopAction": "Stop instances", "@beforeQuitStopAction": { @@ -63,13 +63,13 @@ } } }, - "bulkActionLoading": "{verb} {object}", - "@bulkActionLoading": { - "description": "Notification shown while a bulk action is in progress", + "bulkActionMessage": "{verb} {object}", + "@bulkActionMessage": { + "description": "Notification for a bulk action; verb is in continuous tense (e.g. Starting) or past tense (e.g. Started) depending on context", "placeholders": { "verb": { "type": "String", - "description": "The action in continuous tense, e.g. Starting" + "description": "The action verb, e.g. Starting or Started" }, "object": { "type": "String", @@ -77,20 +77,6 @@ } } }, - "bulkActionSuccess": "{verb} {object}", - "@bulkActionSuccess": { - "description": "Notification shown after a bulk action succeeds", - "placeholders": { - "verb": { - "type": "String", - "description": "The action in past tense, e.g. Started" - }, - "object": { - "type": "String", - "description": "The instance name or count" - } - } - }, "bulkActionError": "Failed to {verb} {object}: {error}", "@bulkActionError": { "description": "Notification shown when a bulk action fails", @@ -117,10 +103,6 @@ "@noVmsMessageBefore": { "description": "Text before the Catalogue link in the empty-state message. Forms the sentence: 'Return to the [Catalogue] to choose your instance or get started with the primary Ubuntu Image'" }, - "noVmsMessageLink": "Catalogue", - "@noVmsMessageLink": { - "description": "Link label in the empty-state message. Forms the sentence: 'Return to the [Catalogue] to choose your instance or get started with the primary Ubuntu Image'" - }, "noVmsMessageAfter": " to choose your instance or get started with the primary Ubuntu Image", "@noVmsMessageAfter": { "description": "Text after the Catalogue link in the empty-state message. Forms the sentence: 'Return to the [Catalogue] to choose your instance or get started with the primary Ubuntu Image'" @@ -129,10 +111,6 @@ "@searchBoxHint": { "description": "Placeholder text in the instance search box" }, - "helpTitle": "Help", - "@helpTitle": { - "description": "Title of the Help screen" - }, "helpBody": "View tutorials, how-to guides, and references in our extensive Multipass Documentation site.", "@helpBody": { "description": "Introductory text on the Help screen" @@ -141,21 +119,21 @@ "@helpViewDocs": { "description": "Button label that opens the Multipass documentation URL" }, - "sidebarCatalogue": "Catalogue", - "@sidebarCatalogue": { - "description": "Sidebar navigation label for the Catalogue screen" + "catalogueLabel": "Catalogue", + "@catalogueLabel": { + "description": "The word 'Catalogue' used as a navigation label, page heading, and link text" }, "sidebarInstances": "Instances", "@sidebarInstances": { "description": "Sidebar navigation label for the Instances screen" }, - "sidebarHelp": "Help", - "@sidebarHelp": { - "description": "Sidebar navigation label for the Help screen" + "helpLabel": "Help", + "@helpLabel": { + "description": "The word 'Help' used as a navigation label and page heading" }, - "sidebarSettings": "Settings", - "@sidebarSettings": { - "description": "Sidebar navigation label for the Settings screen" + "settingsLabel": "Settings", + "@settingsLabel": { + "description": "The word 'Settings' used as a navigation label and page heading" }, "deleteInstanceTitle": "{count, plural, =1{Delete instance} other{Delete instances}}", "@deleteInstanceTitle": { @@ -175,9 +153,9 @@ } } }, - "deleteInstanceConfirm": "Delete", - "@deleteInstanceConfirm": { - "description": "Confirm button label on the delete instance dialog" + "dialogDelete": "Delete", + "@dialogDelete": { + "description": "Generic Delete confirm button label used across delete confirmation dialogs" }, "dialogCancel": "Cancel", "@dialogCancel": { @@ -191,10 +169,6 @@ "@closeTerminalBody": { "description": "Body text of the close terminal tab confirmation dialog" }, - "closeTerminalDoNotAsk": "Do not ask me again", - "@closeTerminalDoNotAsk": { - "description": "Checkbox label to suppress the close terminal dialog in future" - }, "closeTerminalConfirm": "Close tab", "@closeTerminalConfirm": { "description": "Confirm button label on the close terminal tab dialog" @@ -357,27 +331,10 @@ } } }, - "virtualizationBridgedNetworkLabel": "Bridged network", - "@virtualizationBridgedNetworkLabel": { - "description": "Label for the bridged network dropdown" - }, "virtualizationBridgedNetworkNone": "None", "@virtualizationBridgedNetworkNone": { "description": "Dropdown option representing no bridged network selected" }, - "virtualizationBridgedNetworkError": "Failed to set bridged network: {error}", - "@virtualizationBridgedNetworkError": { - "description": "Error notification when the bridged network setting cannot be changed", - "placeholders": { - "error": { - "type": "String" - } - } - }, - "settingsTitle": "Settings", - "@settingsTitle": { - "description": "Page heading on the Settings screen" - }, "hotkeyUnknownKey": "...", "@hotkeyUnknownKey": { "description": "Placeholder shown for the key portion of a hotkey when no key has been recorded yet" @@ -438,10 +395,6 @@ "@vmStatUptime": { "description": "Column header label for the uptime stat" }, - "vmDetailsGeneralTitle": "General", - "@vmDetailsGeneralTitle": { - "description": "Section heading on the General VM details tab" - }, "ipAddressesOtherTitle": "Other IP addresses", "@ipAddressesOtherTitle": { "description": "Tooltip and popup header for the secondary IP addresses list" @@ -499,10 +452,6 @@ } } }, - "mountDeleteAction": "Delete", - "@mountDeleteAction": { - "description": "Confirm button label in the delete-mount dialog" - }, "mountNotificationLoading": "Mounting {description}", "@mountNotificationLoading": { "description": "Loading notification while a mount operation is in progress", @@ -729,21 +678,9 @@ "@terminalOpenShell": { "description": "Button label to open a new shell in the terminal view" }, - "vmActionNotificationLoading": "{action} {instance}", - "@vmActionNotificationLoading": { - "description": "Loading notification while a VM action is in progress (e.g. 'Starting vm1')", - "placeholders": { - "action": { - "type": "String" - }, - "instance": { - "type": "String" - } - } - }, - "vmActionNotificationSuccess": "{action} {instance}", - "@vmActionNotificationSuccess": { - "description": "Success notification after a VM action completes (e.g. 'Started vm1')", + "vmActionNotification": "{action} {instance}", + "@vmActionNotification": { + "description": "Notification for a VM action; action is the verb in continuous tense (e.g. 'Starting vm1') or past tense (e.g. 'Started vm1') depending on context", "placeholders": { "action": { "type": "String" diff --git a/src/client/gui/lib/settings/settings.dart b/src/client/gui/lib/settings/settings.dart index 714ec4c1c21..d4c1f80096e 100644 --- a/src/client/gui/lib/settings/settings.dart +++ b/src/client/gui/lib/settings/settings.dart @@ -39,7 +39,7 @@ class SettingsScreen extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(l10n.settingsTitle, style: const TextStyle(fontSize: 37)), + Text(l10n.settingsLabel, style: const TextStyle(fontSize: 37)), const SizedBox(height: 32), const Expanded(child: SingleChildScrollView(child: settings)), ], diff --git a/src/client/gui/lib/settings/virtualization_settings.dart b/src/client/gui/lib/settings/virtualization_settings.dart index e8117a6db48..85f3ccd3e9a 100644 --- a/src/client/gui/lib/settings/virtualization_settings.dart +++ b/src/client/gui/lib/settings/virtualization_settings.dart @@ -54,7 +54,7 @@ class VirtualizationSettings extends ConsumerWidget { const SizedBox(height: 20), if (networks.isNotEmpty) Dropdown( - label: l10n.virtualizationBridgedNetworkLabel, + label: l10n.bridgeTitle, width: 260, value: networks.contains(bridgedNetwork) ? bridgedNetwork : '', items: { @@ -63,8 +63,7 @@ class VirtualizationSettings extends ConsumerWidget { }, onChanged: (value) { ref.read(bridgedNetworkProvider.notifier).set(value!).onError( - ref.notifyError( - (e) => l10n.virtualizationBridgedNetworkError('$e')), + ref.notifyError((e) => l10n.bridgeFailedNetwork('$e')), ); }, ), diff --git a/src/client/gui/lib/sidebar.dart b/src/client/gui/lib/sidebar.dart index 1927fab3dd5..3ea7d361d97 100644 --- a/src/client/gui/lib/sidebar.dart +++ b/src/client/gui/lib/sidebar.dart @@ -114,7 +114,7 @@ class SideBar extends ConsumerWidget { final catalogue = SidebarEntry( icon: SvgPicture.asset('assets/catalogue.svg'), selected: isSelected(CatalogueScreen.sidebarKey), - label: l10n.sidebarCatalogue, + label: l10n.catalogueLabel, onPressed: () { ref.read(sidebarKeyNotifier).set(CatalogueScreen.sidebarKey); }, @@ -134,7 +134,7 @@ class SideBar extends ConsumerWidget { final help = SidebarEntry( icon: SvgPicture.asset('assets/help.svg'), selected: isSelected(HelpScreen.sidebarKey), - label: l10n.sidebarHelp, + label: l10n.helpLabel, onPressed: () { ref.read(sidebarKeyNotifier).set(HelpScreen.sidebarKey); }, @@ -143,7 +143,7 @@ class SideBar extends ConsumerWidget { final settings = SidebarEntry( icon: SvgPicture.asset('assets/settings.svg'), selected: isSelected(SettingsScreen.sidebarKey), - label: l10n.sidebarSettings, + label: l10n.settingsLabel, onPressed: () { ref.read(sidebarKeyNotifier).set(SettingsScreen.sidebarKey); }, diff --git a/src/client/gui/lib/update_available.dart b/src/client/gui/lib/update_available.dart index 9ab99bba2f5..5a1766b2b16 100644 --- a/src/client/gui/lib/update_available.dart +++ b/src/client/gui/lib/update_available.dart @@ -137,7 +137,8 @@ class UpdateAvailableNotification extends StatelessWidget { if (!context.mounted) return; closeNotification(context); }, - child: Text(l10n.updateAvailableUpgrade, style: const TextStyle(fontSize: 14)), + child: Text(l10n.updateAvailableUpgrade, + style: const TextStyle(fontSize: 14)), ), ], ), diff --git a/src/client/gui/lib/vm_details/memory_slider.dart b/src/client/gui/lib/vm_details/memory_slider.dart index 5c0cb345512..57948c1a7bd 100644 --- a/src/client/gui/lib/vm_details/memory_slider.dart +++ b/src/client/gui/lib/vm_details/memory_slider.dart @@ -142,7 +142,8 @@ class _MemorySliderState extends State { const Icon(Icons.warning_rounded, color: Color(0xffCC7900)), const SizedBox(width: 5), Text( - l10n.memorySliderOverProvisioning(widget.label.toLowerCase()), + l10n.memorySliderOverProvisioning( + widget.label.toLowerCase()), style: const TextStyle(fontSize: 16), ), ], diff --git a/src/client/gui/lib/vm_details/terminal.dart b/src/client/gui/lib/vm_details/terminal.dart index 3bf8a29edeb..cdfcc7d03b8 100644 --- a/src/client/gui/lib/vm_details/terminal.dart +++ b/src/client/gui/lib/vm_details/terminal.dart @@ -246,10 +246,13 @@ class _VmTerminalState extends ConsumerState { final operation = ref.read(grpcClientProvider).start([name]); ref.read(notificationsProvider.notifier).addOperation( operation, - loading: l10n.vmActionNotificationLoading(action.continuousTense(l10n), name), - onSuccess: (_) => l10n.vmActionNotificationSuccess(action.pastTense(l10n), name), + loading: + l10n.vmActionNotification(action.continuousTense(l10n), name), + onSuccess: (_) => + l10n.vmActionNotification(action.pastTense(l10n), name), onError: (error) { - return l10n.vmActionNotificationError(action.name.toLowerCase(), name, '$error'); + return l10n.vmActionNotificationError( + action.name.toLowerCase(), name, '$error'); }, ); await operation; diff --git a/src/client/gui/lib/vm_details/vm_action_buttons.dart b/src/client/gui/lib/vm_details/vm_action_buttons.dart index 95dd9c9d8a1..2debf68fc50 100644 --- a/src/client/gui/lib/vm_details/vm_action_buttons.dart +++ b/src/client/gui/lib/vm_details/vm_action_buttons.dart @@ -24,10 +24,13 @@ class VmActionButtons extends ConsumerWidget { final notificationsNotifier = ref.read(notificationsProvider.notifier); notificationsNotifier.addOperation( function([name]), - loading: l10n.vmActionNotificationLoading(action.continuousTense(l10n), name), - onSuccess: (_) => l10n.vmActionNotificationSuccess(action.pastTense(l10n), name), + loading: + l10n.vmActionNotification(action.continuousTense(l10n), name), + onSuccess: (_) => + l10n.vmActionNotification(action.pastTense(l10n), name), onError: (error) { - return l10n.vmActionNotificationError(action.name.toLowerCase(), name, '$error'); + return l10n.vmActionNotificationError( + action.name.toLowerCase(), name, '$error'); }, ); }; @@ -71,7 +74,8 @@ class VmActionButtons extends ConsumerWidget { child: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ - Text(l10n.vmActionsMenuTitle, style: const TextStyle(fontWeight: FontWeight.bold)), + Text(l10n.vmActionsMenuTitle, + style: const TextStyle(fontWeight: FontWeight.bold)), const Icon(Icons.keyboard_arrow_down), ], ), diff --git a/src/client/gui/lib/vm_details/vm_details_bridge.dart b/src/client/gui/lib/vm_details/vm_details_bridge.dart index bb77242bf06..ca29c51ad57 100644 --- a/src/client/gui/lib/vm_details/vm_details_bridge.dart +++ b/src/client/gui/lib/vm_details/vm_details_bridge.dart @@ -70,16 +70,16 @@ class _BridgedDetailsState extends ConsumerState { builder: (field) { final validBridgedNetwork = networks.contains(bridgedNetworkSetting); final message = networks.isEmpty - ? l10n.bridgeNoNetworks - : validBridgedNetwork - ? l10n.bridgeEstablishedWarning - : l10n.bridgeNoValidNetwork; + ? l10n.bridgeNoNetworks + : validBridgedNetwork + ? l10n.bridgeEstablishedWarning + : l10n.bridgeNoValidNetwork; return CheckboxListTile( contentPadding: EdgeInsets.zero, controlAffinity: ListTileControlAffinity.leading, enabled: validBridgedNetwork, onChanged: field.didChange, - title: Text(l10n.bridgeConnect), + title: Text(l10n.bridgeConnect), value: field.value!, visualDensity: VisualDensity.standard, subtitle: Text(message), @@ -130,7 +130,8 @@ class _BridgedDetailsState extends ConsumerState { children: [ SizedBox( height: 50, - child: Text(l10n.bridgeTitle, style: const TextStyle(fontSize: 24)), + child: Text(l10n.bridgeTitle, + style: const TextStyle(fontSize: 24)), ), const Spacer(), if (editing) @@ -142,7 +143,9 @@ class _BridgedDetailsState extends ConsumerState { editing ? SizedBox(width: 300, child: bridgedCheckbox) : Text( - bridged ?? false ? l10n.bridgeStatusConnected : l10n.bridgeStatusNotConnected, + bridged ?? false + ? l10n.bridgeStatusConnected + : l10n.bridgeStatusNotConnected, style: const TextStyle(fontSize: 16), ), if (editing) diff --git a/src/client/gui/lib/vm_details/vm_details_general.dart b/src/client/gui/lib/vm_details/vm_details_general.dart index 8493c3d6e3b..24891782089 100644 --- a/src/client/gui/lib/vm_details/vm_details_general.dart +++ b/src/client/gui/lib/vm_details/vm_details_general.dart @@ -207,7 +207,7 @@ class GeneralDetails extends ConsumerWidget { children: [ SizedBox( height: baseVmStatHeight, - child: Text(l10n.vmDetailsGeneralTitle, style: const TextStyle(fontSize: 24)), + child: Text(l10n.generalTitle, style: const TextStyle(fontSize: 24)), ), Wrap( spacing: 50, diff --git a/src/client/gui/lib/vm_details/vm_details_mounts.dart b/src/client/gui/lib/vm_details/vm_details_mounts.dart index 2dd7c77dee3..0cd62ae4bde 100644 --- a/src/client/gui/lib/vm_details/vm_details_mounts.dart +++ b/src/client/gui/lib/vm_details/vm_details_mounts.dart @@ -99,7 +99,8 @@ class _MountDetailsState extends ConsumerState { children: [ SizedBox( height: 50, - child: Text(l10n.mountsTitle, style: const TextStyle(fontSize: 24)), + child: Text(l10n.mountsTitle, + style: const TextStyle(fontSize: 24)), ), const Spacer(), topRightButton, @@ -153,15 +154,17 @@ class _MountDetailsState extends ConsumerState { l10n.mountDeleteBodySuffix(widget.name).span, ].spans, ), - actionText: l10n.mountDeleteAction, + actionText: l10n.dialogDelete, onAction: () { Navigator.pop(context); notificationsNotifier.addOperation( grpcClient.umount(widget.name, target), loading: l10n.unmountNotificationLoading(target, widget.name), - onSuccess: (_) => l10n.unmountNotificationSuccess(target, widget.name), + onSuccess: (_) => + l10n.unmountNotificationSuccess(target, widget.name), onError: (error) { - return l10n.unmountNotificationError(target, widget.name, '$error'); + return l10n.unmountNotificationError( + target, widget.name, '$error'); }, ); }, diff --git a/src/client/gui/lib/vm_details/vm_details_resources.dart b/src/client/gui/lib/vm_details/vm_details_resources.dart index 41045b99405..cd4c2d82e1d 100644 --- a/src/client/gui/lib/vm_details/vm_details_resources.dart +++ b/src/client/gui/lib/vm_details/vm_details_resources.dart @@ -63,7 +63,8 @@ class _ResourcesDetailsState extends ConsumerState { onSaved: (value) { if (value == null || value == cpus) return; ref.read(cpusProvider.notifier).set('$value').onError( - ref.notifyError((error) => l10n.resourcesFailedCpus('$error')), + ref.notifyError( + (error) => l10n.resourcesFailedCpus('$error')), ); }, ); @@ -147,7 +148,8 @@ class _ResourcesDetailsState extends ConsumerState { children: [ SizedBox( height: 50, - child: Text(l10n.resourcesTitle, style: const TextStyle(fontSize: 24)), + child: Text(l10n.resourcesTitle, + style: const TextStyle(fontSize: 24)), ), const Spacer(), editing ? cancelButton : configureButton, diff --git a/src/client/gui/lib/vm_table/bulk_actions.dart b/src/client/gui/lib/vm_table/bulk_actions.dart index 7426fc634be..43dcb2b0728 100644 --- a/src/client/gui/lib/vm_table/bulk_actions.dart +++ b/src/client/gui/lib/vm_table/bulk_actions.dart @@ -37,9 +37,9 @@ class BulkActionsBar extends ConsumerWidget { final notificationsNotifier = ref.read(notificationsProvider.notifier); notificationsNotifier.addOperation( function(selectedVms), - loading: l10n.bulkActionLoading(action.continuousTense(l10n), object), + loading: l10n.bulkActionMessage(action.continuousTense(l10n), object), onSuccess: (_) => - l10n.bulkActionSuccess(action.pastTense(l10n), object), + l10n.bulkActionMessage(action.pastTense(l10n), object), onError: (error) { return l10n.bulkActionError( action.label(l10n).toLowerCase(), object, '$error'); diff --git a/src/client/gui/lib/vm_table/no_vms.dart b/src/client/gui/lib/vm_table/no_vms.dart index ac71935c698..69d1922d3dd 100644 --- a/src/client/gui/lib/vm_table/no_vms.dart +++ b/src/client/gui/lib/vm_table/no_vms.dart @@ -38,7 +38,7 @@ class NoVms extends ConsumerWidget { Text.rich( [ l10n.noVmsMessageBefore.span, - l10n.noVmsMessageLink.span + l10n.catalogueLabel.span .color(Colors.blue) .link(ref, goToCatalogue), l10n.noVmsMessageAfter.span, diff --git a/src/client/gui/lib/vm_table/vm_table_headers.dart b/src/client/gui/lib/vm_table/vm_table_headers.dart index 016cd3d2c9a..b7a38682ee7 100644 --- a/src/client/gui/lib/vm_table/vm_table_headers.dart +++ b/src/client/gui/lib/vm_table/vm_table_headers.dart @@ -20,9 +20,9 @@ import 'vms.dart'; /// Returns a [childBuilder] for [TableHeader] that renders a localized column label. Widget Function(String) _l10nHeader(String Function(AppLocalizations) label) { return (_) => Builder( - builder: (context) => - TableHeader.defaultHeaderBuilder(label(AppLocalizations.of(context)!)), - ); + builder: (context) => TableHeader.defaultHeaderBuilder( + label(AppLocalizations.of(context)!)), + ); } final headers = >[ From 9b472597f8d6b4be922bb11ee559ed4a5ab132dd Mon Sep 17 00:00:00 2001 From: Scott Harder Date: Wed, 15 Apr 2026 22:02:55 -0500 Subject: [PATCH 23/25] [gui] Extract strings from `tray_menu` --- src/client/gui/lib/l10n/app_en.arb | 40 +++++++++++++++++++++++++++++- src/client/gui/lib/tray_menu.dart | 31 +++++++++++++++-------- 2 files changed, 60 insertions(+), 11 deletions(-) diff --git a/src/client/gui/lib/l10n/app_en.arb b/src/client/gui/lib/l10n/app_en.arb index 83d501ff304..261a96c8e34 100644 --- a/src/client/gui/lib/l10n/app_en.arb +++ b/src/client/gui/lib/l10n/app_en.arb @@ -888,5 +888,43 @@ "launchFormNameErrorDeletedInUse": "Name is already in use by a deleted instance", "@launchFormNameErrorDeletedInUse": { "description": "Validation error when instance name belongs to a deleted instance" + }, + "trayToggleWindow": "Toggle window", + "@trayToggleWindow": { + "description": "Tray menu item label to show or hide the main window" + }, + "trayMultipassVersion": "multipass version: {version}", + "@trayMultipassVersion": { + "description": "Tray menu item showing the Multipass client version", + "placeholders": { + "version": { + "type": "String" + } + } + }, + "trayCopyright": "Copyright (C) Canonical, Ltd.", + "@trayCopyright": { + "description": "Tray menu copyright notice" + }, + "trayMultipassdVersion": "multipassd version: {version}", + "@trayMultipassdVersion": { + "description": "Tray menu item showing the Multipass daemon version", + "placeholders": { + "version": { + "type": "String" + } + } + }, + "trayQuit": "Quit", + "@trayQuit": { + "description": "Tray menu item label to quit the application" + }, + "trayErrorInstanceData": "Failed retrieving instance data", + "@trayErrorInstanceData": { + "description": "Tray menu error message shown when instance data cannot be retrieved" + }, + "trayOpenInMultipass": "Open in Multipass", + "@trayOpenInMultipass": { + "description": "Tray menu item label to open the instance in the Multipass window" } -} +} \ No newline at end of file diff --git a/src/client/gui/lib/tray_menu.dart b/src/client/gui/lib/tray_menu.dart index 50ba7b016a3..4dbba8d6909 100644 --- a/src/client/gui/lib/tray_menu.dart +++ b/src/client/gui/lib/tray_menu.dart @@ -1,8 +1,10 @@ import 'dart:async'; import 'dart:io'; +import 'dart:ui'; import 'package:basics/basics.dart'; import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:multipass_gui/vm_details/terminal.dart'; @@ -12,6 +14,7 @@ import 'package:tray_menu/tray_menu.dart'; import 'package:window_manager/window_manager.dart'; import 'ffi.dart'; +import 'l10n/app_localizations.dart'; import 'platform/platform.dart'; import 'providers.dart'; import 'sidebar.dart'; @@ -26,6 +29,14 @@ extension WindowManagerExtensions on WindowManager { } } +AppLocalizations _l10n() { + try { + return lookupAppLocalizations(PlatformDispatcher.instance.locale); + } on FlutterError catch (_) { + return lookupAppLocalizations(const Locale('en')); + } +} + Future _iconFilePath() async { final dataDir = await getApplicationSupportDirectory(); final iconName = mpPlatform.trayIconFile; @@ -44,7 +55,7 @@ Future setupTrayMenu(ProviderContainer providerContainer) async { if (mpPlatform.showToggleWindow) { await TrayMenu.instance.addLabel( 'toggle-window', - label: 'Toggle window', + label: _l10n().trayToggleWindow, callback: (_, __) async => await windowManager.isVisible() ? windowManager.hide() : windowManager.showAndRestore(), @@ -54,16 +65,16 @@ Future setupTrayMenu(ProviderContainer providerContainer) async { await TrayMenu.instance.addSeparator(_separatorAboutKey); final aboutSubmenu = await TrayMenu.instance.addSubmenu( 'about', - label: 'About', + label: _l10n().aboutTitle, ); await aboutSubmenu.addLabel( 'multipass-version', - label: 'multipass version: $multipassVersion', + label: _l10n().trayMultipassVersion(multipassVersion), enabled: false, ); await aboutSubmenu.addLabel( 'copyright', - label: 'Copyright (C) Canonical, Ltd.', + label: _l10n().trayCopyright, enabled: false, ); providerContainer.listen( @@ -73,7 +84,7 @@ Future setupTrayMenu(ProviderContainer providerContainer) async { if (next == multipassVersion) return; await aboutSubmenu.addLabel( 'multipassd-version', - label: 'multipassd version: $next', + label: _l10n().trayMultipassdVersion(next), enabled: false, before: 'copyright', ); @@ -82,7 +93,7 @@ Future setupTrayMenu(ProviderContainer providerContainer) async { ); await TrayMenu.instance.addLabel( 'quit', - label: 'Quit', + label: _l10n().trayQuit, callback: (_, __) => windowManager.close(), ); @@ -115,7 +126,7 @@ Future _setTrayMenuError() async { } await TrayMenu.instance.remove(_separatorVmsKey); - const errorMessage = 'Failed retrieving instance data'; + final errorMessage = _l10n().trayErrorInstanceData; final errorLabel = TrayMenu.instance.get(_errorKey); if (errorLabel != null) { await errorLabel.setLabel(errorMessage); @@ -173,20 +184,20 @@ Future _updateTrayMenu( ); await submenu.addLabel( 'start', - label: 'Start', + label: _l10n().vmActionLabel('start'), enabled: startEnabled, callback: (_, __) => grpcClient.start([name]), ); await submenu.addLabel( 'stop', - label: 'Stop', + label: _l10n().vmActionLabel('stop'), enabled: stopEnabled, callback: (_, __) => grpcClient.stop([name]), ); await submenu.addSeparator('separator'); await submenu.addLabel( 'open', - label: 'Open in Multipass', + label: _l10n().trayOpenInMultipass, callback: (_, __) { providerContainer .read(vmScreenLocationProvider(name).notifier) From bb7b18f503eddff3ecf60eaaf465eb7a7f0d4a3b Mon Sep 17 00:00:00 2001 From: Scott Harder Date: Wed, 15 Apr 2026 22:06:31 -0500 Subject: [PATCH 24/25] [gui] Extract strings from `vm_details` --- src/client/gui/lib/l10n/app_en.arb | 15 ++++++++++++--- .../lib/notifications/notification_entries.dart | 8 +++----- .../gui/lib/vm_details/vm_action_buttons.dart | 2 +- .../gui/lib/vm_details/vm_details_mounts.dart | 2 +- src/client/gui/lib/vm_details/vm_status_icon.dart | 8 +++++--- 5 files changed, 22 insertions(+), 13 deletions(-) diff --git a/src/client/gui/lib/l10n/app_en.arb b/src/client/gui/lib/l10n/app_en.arb index 261a96c8e34..d436252d7d0 100644 --- a/src/client/gui/lib/l10n/app_en.arb +++ b/src/client/gui/lib/l10n/app_en.arb @@ -9,6 +9,15 @@ } } }, + "vmStatusLabel": "{status, select, running{Running} stopped{Stopped} suspended{Suspended} restarting{Restarting} starting{Starting} suspending{Suspending} deleted{Deleted} delayed_shutdown{Delayed shutdown} launching{Launching} other{}}", + "@vmStatusLabel": { + "description": "Display label for a VM status. The 'status' parameter is the lowercase protobuf enum name (running, stopped, suspended, restarting, starting, suspending, deleted, delayed_shutdown) or 'launching'.", + "placeholders": { + "status": { + "type": "String" + } + } + }, "vmActionPastTense": "{action, select, start{Started} stop{Stopped} suspend{Suspended} restart{Restarted} delete{Deleted} recover{Recovered} purge{Purged} edit{Edited} other{}}", "@vmActionPastTense": { "description": "Past-tense form of a VM action, used in success notifications. The 'action' parameter is the lowercase enum name.", @@ -173,7 +182,7 @@ "@closeTerminalConfirm": { "description": "Confirm button label on the close terminal tab dialog" }, - "launchSuccessTitle": "{name} is up and running", + "launchSuccessTitle": "{name} is up and running\n", "@launchSuccessTitle": { "description": "Bold heading in the launch success notification", "placeholders": { @@ -205,7 +214,7 @@ } } }, - "launchInProgress": "Launching {name}", + "launchInProgress": "Launching {name}\n", "@launchInProgress": { "description": "Bold heading in the in-progress launch notification", "placeholders": { @@ -439,7 +448,7 @@ "@mountDeleteTitle": { "description": "Title of the confirmation dialog for removing a mount" }, - "mountDeleteBodyPrefix": "Are you sure you want to remove the mount", + "mountDeleteBodyPrefix": "Are you sure you want to remove the mount\n", "@mountDeleteBodyPrefix": { "description": "First part of the delete-mount confirmation body (followed by the mount path on a new line)" }, diff --git a/src/client/gui/lib/notifications/notification_entries.dart b/src/client/gui/lib/notifications/notification_entries.dart index 112a1bcd078..16878ec2d44 100644 --- a/src/client/gui/lib/notifications/notification_entries.dart +++ b/src/client/gui/lib/notifications/notification_entries.dart @@ -223,7 +223,7 @@ class LaunchingNotification extends ConsumerWidget { children: [ Text.rich( [ - '${l10n.launchSuccessTitle(name)}\n'.span.bold, + l10n.launchSuccessTitle(name).span.bold, l10n.launchSuccessBody.span, ].spans, ), @@ -276,10 +276,8 @@ class LaunchingNotification extends ConsumerWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text.rich([ - '${l10n.launchInProgress(name)}\n'.span.bold, - message.span - ].spans), + Text.rich( + [l10n.launchInProgress(name).span.bold, message.span].spans), if (cancelable) ...[ const Divider(), Row( diff --git a/src/client/gui/lib/vm_details/vm_action_buttons.dart b/src/client/gui/lib/vm_details/vm_action_buttons.dart index 2debf68fc50..b313d33fa3c 100644 --- a/src/client/gui/lib/vm_details/vm_action_buttons.dart +++ b/src/client/gui/lib/vm_details/vm_action_buttons.dart @@ -103,7 +103,7 @@ class ActionTile extends ConsumerWidget { enabled: enabled, contentPadding: const EdgeInsets.symmetric(horizontal: 16), title: Text( - action.name, + action.label(AppLocalizations.of(context)!), style: enabled ? const TextStyle(color: Colors.black) : null, ), onTap: () { diff --git a/src/client/gui/lib/vm_details/vm_details_mounts.dart b/src/client/gui/lib/vm_details/vm_details_mounts.dart index 0cd62ae4bde..64e10c7fe27 100644 --- a/src/client/gui/lib/vm_details/vm_details_mounts.dart +++ b/src/client/gui/lib/vm_details/vm_details_mounts.dart @@ -149,7 +149,7 @@ class _MountDetailsState extends ConsumerState { title: l10n.mountDeleteTitle, body: Text.rich( [ - '${l10n.mountDeleteBodyPrefix}\n'.span, + l10n.mountDeleteBodyPrefix.span, '${mountPaths.sourcePath} ⭢ $target'.span.font('UbuntuMono'), l10n.mountDeleteBodySuffix(widget.name).span, ].spans, diff --git a/src/client/gui/lib/vm_details/vm_status_icon.dart b/src/client/gui/lib/vm_details/vm_status_icon.dart index 63dbb9d4237..353ced667a9 100644 --- a/src/client/gui/lib/vm_details/vm_status_icon.dart +++ b/src/client/gui/lib/vm_details/vm_status_icon.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart' hide Tooltip; import '../extensions.dart'; import '../grpc_client.dart'; +import '../l10n/app_localizations.dart'; import '../tooltip.dart'; const unknownIcon = Icon(Icons.help, color: Color(0xff757575), size: 15); @@ -28,9 +29,10 @@ class VmStatusIcon extends StatelessWidget { @override Widget build(BuildContext context) { - final statusName = !isLaunching - ? status.name.toLowerCase().replaceAll('_', ' ') - : 'launching'; + final l10n = AppLocalizations.of(context)!; + final statusName = l10n.vmStatusLabel( + !isLaunching ? status.name.toLowerCase() : 'launching', + ); final icon = !isLaunching ? icons[status] ?? unknownIcon From 40c77fa13debd584d0bea9985b58baccd16c4f98 Mon Sep 17 00:00:00 2001 From: Scott Harder Date: Thu, 16 Apr 2026 08:21:22 -0500 Subject: [PATCH 25/25] [gui] Localize system update notification strings --- src/client/gui/lib/l10n/app_en.arb | 15 +++++++- src/client/gui/lib/main.dart | 3 +- src/client/gui/lib/update_available.dart | 45 +++++++++++++----------- 3 files changed, 41 insertions(+), 22 deletions(-) diff --git a/src/client/gui/lib/l10n/app_en.arb b/src/client/gui/lib/l10n/app_en.arb index d436252d7d0..374b609b674 100644 --- a/src/client/gui/lib/l10n/app_en.arb +++ b/src/client/gui/lib/l10n/app_en.arb @@ -796,6 +796,19 @@ "@updateAvailableUpgrade": { "description": "Button label to start the upgrade process" }, + "localNotificationUpdateTitle": "Multipass Update Available", + "@localNotificationUpdateTitle": { + "description": "Title of the system notification announcing a new Multipass version" + }, + "localNotificationUpdateBody": "Version {version} is available. Click to upgrade now.", + "@localNotificationUpdateBody": { + "description": "Body text of the system notification announcing a new Multipass version", + "placeholders": { + "version": { + "type": "String" + } + } + }, "catalogueLoadError": "Failed to retrieve images: {error}", "@catalogueLoadError": { "description": "Error message shown when image retrieval fails", @@ -936,4 +949,4 @@ "@trayOpenInMultipass": { "description": "Tray menu item label to open the instance in the Multipass window" } -} \ No newline at end of file +} diff --git a/src/client/gui/lib/main.dart b/src/client/gui/lib/main.dart index ad9e0314ff5..4a4d53c0046 100644 --- a/src/client/gui/lib/main.dart +++ b/src/client/gui/lib/main.dart @@ -17,6 +17,7 @@ import 'settings/hotkey.dart'; import 'settings/settings.dart'; import 'sidebar.dart'; import 'tray_menu.dart'; +import 'update_available.dart'; import 'vm_details/mapping_slider.dart'; import 'vm_details/vm_details.dart'; import 'vm_table/vm_table_screen.dart'; @@ -59,7 +60,7 @@ void main() async { container: providerContainer, child: MaterialApp( theme: theme, - home: const App(), + home: const UpdateSystemNotificationListener(child: App()), localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, ), diff --git a/src/client/gui/lib/update_available.dart b/src/client/gui/lib/update_available.dart index 5a1766b2b16..2ccc3aadb67 100644 --- a/src/client/gui/lib/update_available.dart +++ b/src/client/gui/lib/update_available.dart @@ -1,8 +1,8 @@ import 'package:basics/basics.dart'; import 'package:flutter/material.dart'; -import 'package:local_notifier/local_notifier.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; +import 'package:local_notifier/local_notifier.dart'; import 'package:url_launcher/url_launcher.dart'; import 'notifications/notification_entries.dart'; @@ -30,25 +30,6 @@ class UpdateNotifier extends Notifier { // Update the state state = updateInfo; - - // Create and show a local notification - _showLocalNotification(updateInfo); - } - - void _showLocalNotification(UpdateInfo updateInfo) { - if (!mpPlatform.showLocalUpdateNotifications) return; - - final notification = LocalNotification( - title: 'Multipass Update Available', - body: 'Version ${updateInfo.version} is available. Click to upgrade now.', - ); - - notification.onClick = () async { - await launchInstallUrl(); - await notification.close(); - }; - - notification.show(); } @override @@ -66,6 +47,30 @@ final installUrl = Uri.parse('https://canonical.com/multipass/install'); Future launchInstallUrl() => launchUrl(installUrl); +class UpdateSystemNotificationListener extends ConsumerWidget { + const UpdateSystemNotificationListener({required this.child, super.key}); + + final Widget child; + + @override + Widget build(BuildContext context, WidgetRef ref) { + ref.listen(updateProvider, (_, updateInfo) { + if (!mpPlatform.showLocalUpdateNotifications) return; + final l10n = AppLocalizations.of(context)!; + final notification = LocalNotification( + title: l10n.localNotificationUpdateTitle, + body: l10n.localNotificationUpdateBody(updateInfo.version), + ); + notification.onClick = () async { + await launchInstallUrl(); + await notification.close(); + }; + notification.show(); + }); + return child; + } +} + class UpdateAvailable extends StatelessWidget { final UpdateInfo updateInfo;