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 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 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/before_quit_dialog.dart b/src/client/gui/lib/before_quit_dialog.dart index 2b1aa0de947..a99b8112724 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.dialogDoNotAskAgain), ], ), ], ), - 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/catalogue/catalogue.dart b/src/client/gui/lib/catalogue/catalogue.dart index 1299525aee2..554d657bf84 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,26 @@ 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 +185,16 @@ 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 4720bdd5a69..ffe217067aa 100644 --- a/src/client/gui/lib/catalogue/launch_form.dart +++ b/src/client/gui/lib/catalogue/launch_form.dart @@ -3,10 +3,10 @@ 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'; +import '../l10n/app_localizations.dart'; import '../notifications.dart'; import '../platform/platform.dart'; import '../providers.dart'; @@ -75,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); @@ -97,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, @@ -148,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, @@ -190,7 +191,7 @@ class _LaunchFormState extends ConsumerState { ); }); }), - child: const Text('Add mount'), + child: Text(l10n.mountsAddMount), ); final saveMountButton = TextButton( @@ -200,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( @@ -242,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), @@ -257,9 +258,10 @@ 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, @@ -272,15 +274,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), @@ -290,17 +292,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( @@ -416,28 +418,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/close_terminal_dialog.dart b/src/client/gui/lib/close_terminal_dialog.dart index 36f0fdcf6c7..6845592390c 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.dialogDoNotAskAgain), ], ), ], ), - 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/copyable_text.dart b/src/client/gui/lib/copyable_text.dart index b91a1a661a5..b1d7e5bf543 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,9 @@ 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/delete_instance_dialog.dart b/src/client/gui/lib/delete_instance_dialog.dart index cf0ea6f9218..54f840fe31d 100644 --- a/src/client/gui/lib/delete_instance_dialog.dart +++ b/src/client/gui/lib/delete_instance_dialog.dart @@ -1,30 +1,30 @@ import 'package:flutter/material.dart'; import 'confirmation_dialog.dart'; +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)!; 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.dialogDelete, onAction: () { onDelete(); Navigator.pop(context); }, - inactionText: 'Cancel', + inactionText: l10n.dialogCancel, onInaction: () => Navigator.pop(context), ); } diff --git a/src/client/gui/lib/help.dart b/src/client/gui/lib/help.dart index 6e603cc28b4..27a4240fa0d 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.helpLabel, 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 new file mode 100644 index 00000000000..374b609b674 --- /dev/null +++ b/src/client/gui/lib/l10n/app_en.arb @@ -0,0 +1,952 @@ +{ + "@@locale": "en", + "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" + } + } + }, + "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.", + "placeholders": { + "action": { + "type": "String" + } + } + }, + "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": { + "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" + } + } + }, + "dialogDoNotAskAgain": "Do not ask me again", + "@dialogDoNotAskAgain": { + "description": "Checkbox label to suppress a recurring confirmation dialog" + }, + "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" + }, + "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" + } + } + }, + "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 verb, e.g. Starting or Started" + }, + "object": { + "type": "String", + "description": "The instance name or count, e.g. primary or 3 instances" + } + } + }, + "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" + } + } + }, + "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'" + }, + "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" + }, + "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" + }, + "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" + }, + "helpLabel": "Help", + "@helpLabel": { + "description": "The word 'Help' used as a navigation label and page heading" + }, + "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": { + "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" + } + } + }, + "dialogDelete": "Delete", + "@dialogDelete": { + "description": "Generic Delete confirm button label used across delete confirmation dialogs" + }, + "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" + }, + "closeTerminalConfirm": "Close tab", + "@closeTerminalConfirm": { + "description": "Confirm button label on the close terminal tab dialog" + }, + "launchSuccessTitle": "{name} is up and running\n", + "@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}\n", + "@launchInProgress": { + "description": "Bold heading in the in-progress launch notification", + "placeholders": { + "name": { + "type": "String", + "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" + } + } + }, + "virtualizationBridgedNetworkNone": "None", + "@virtualizationBridgedNetworkNone": { + "description": "Dropdown option representing no bridged network selected" + }, + "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" + }, + "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" + }, + "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\n", + "@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" + } + } + }, + "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" + }, + "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" + }, + "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" + } + } + }, + "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" + }, + "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" + }, + "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", + "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" + }, + "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" + } +} diff --git a/src/client/gui/lib/main.dart b/src/client/gui/lib/main.dart index 08b77390418..4a4d53c0046 100644 --- a/src/client/gui/lib/main.dart +++ b/src/client/gui/lib/main.dart @@ -6,6 +6,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:window_manager/window_manager.dart'; import 'before_quit_dialog.dart'; +import 'l10n/app_localizations.dart'; import 'catalogue/catalogue.dart'; import 'daemon_unavailable.dart'; import 'help.dart'; @@ -16,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'; @@ -56,7 +58,12 @@ void main() async { runApp( UncontrolledProviderScope( container: providerContainer, - child: MaterialApp(theme: theme, home: const App()), + child: MaterialApp( + theme: theme, + home: const UpdateSystemNotificationListener(child: App()), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + ), ), ); } diff --git a/src/client/gui/lib/notifications/notification_entries.dart b/src/client/gui/lib/notifications/notification_entries.dart index 3065ff4c991..16878ec2d44 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).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,8 @@ 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).span.bold, message.span].spans), if (cancelable) ...[ const Divider(), Row( @@ -285,7 +288,7 @@ class LaunchingNotification extends ConsumerWidget { closeNotification(context); cancelCompleter.complete(); }, - child: Text('Cancel'), + child: Text(l10n.dialogCancel), ), const SizedBox(width: 20), ], 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'; 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..d4c1f80096e 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.settingsLabel, 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..85f3ccd3e9a 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,35 @@ 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.bridgeTitle, 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.bridgeFailedNetwork('$e')), ); }, ), diff --git a/src/client/gui/lib/sidebar.dart b/src/client/gui/lib/sidebar.dart index 1f1bba97f48..3ea7d361d97 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.catalogueLabel, 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.helpLabel, 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.settingsLabel, onPressed: () { ref.read(sidebarKeyNotifier).set(SettingsScreen.sidebarKey); }, 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) diff --git a/src/client/gui/lib/update_available.dart b/src/client/gui/lib/update_available.dart index a7b38bc77db..2ccc3aadb67 100644 --- a/src/client/gui/lib/update_available.dart +++ b/src/client/gui/lib/update_available.dart @@ -1,12 +1,13 @@ 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'; import 'notifications/notifications_provider.dart'; +import 'l10n/app_localizations.dart'; import 'platform/platform.dart'; import 'providers.dart'; @@ -29,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 @@ -65,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; @@ -72,6 +78,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 +88,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 +120,7 @@ class UpdateAvailableNotification extends StatelessWidget { @override Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; return SimpleNotification( barColor: _color, icon: SvgPicture.asset( @@ -124,7 +132,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 +142,8 @@ 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)), ), ], ), diff --git a/src/client/gui/lib/vm_action.dart b/src/client/gui/lib/vm_action.dart index 40497c875bc..aaad5d27d94 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,38 +14,10 @@ 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 get pastTense => switch (this) { - start => 'Started', - stop => 'Stopped', - suspend => 'Suspended', - restart => 'Restarted', - delete => 'Deleted', - recover => 'Recovered', - purge => 'Purged', - edit => 'Edited', - }; - - String get continuousTense => switch (this) { - start => 'Starting', - stop => 'Stopping', - suspend => 'Suspending', - restart => 'Restarting', - delete => 'Deleting', - recover => 'Recovering', - purge => 'Purging', - edit => 'Editing', - }; + 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}, @@ -72,12 +45,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 +63,7 @@ class VmActionButton extends StatelessWidget { ), ), ), - child: Text(action.name), + child: Text(action.label(l10n)), ); } } 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..57948c1a7bd 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,8 @@ 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..cdfcc7d03b8 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,19 @@ 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.vmActionNotification(action.continuousTense(l10n), name), + onSuccess: (_) => + l10n.vmActionNotification(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 +302,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 +322,7 @@ class _VmTerminalState extends ConsumerState { }, ), ContextMenuButtonItem( - label: 'Select All', + label: l10n.terminalContextSelectAll, onPressed: () { ContextMenuController.removeAny(); Actions.maybeInvoke( @@ -348,6 +354,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 +380,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 98057c4503e..b313d33fa3c 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,13 @@ class VmActionButtons extends ConsumerWidget { final notificationsNotifier = ref.read(notificationsProvider.notifier); notificationsNotifier.addOperation( function([name]), - loading: '${action.continuousTense} $name', - onSuccess: (_) => '${action.pastTense} $name', + loading: + l10n.vmActionNotification(action.continuousTense(l10n), name), + onSuccess: (_) => + l10n.vmActionNotification(action.pastTense(l10n), name), onError: (error) { - return 'Failed to ${action.name.toLowerCase()} $name: $error'; + return l10n.vmActionNotificationError( + action.name.toLowerCase(), name, '$error'); }, ); }; @@ -40,7 +45,6 @@ class VmActionButtons extends ConsumerWidget { context: context, barrierDismissible: false, builder: (_) => DeleteInstanceDialog( - multiple: false, onDelete: () => wrapInNotification(client.purge)(action), ), ); @@ -57,7 +61,7 @@ class VmActionButtons extends ConsumerWidget { ]; return PopupMenuButton( - tooltip: 'Show actions', + tooltip: l10n.vmActionsMenuTooltip, position: PopupMenuPosition.under, itemBuilder: (_) => actionButtons, child: Container( @@ -67,11 +71,12 @@ 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), ], ), ), @@ -98,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_bridge.dart b/src/client/gui/lib/vm_details/vm_details_bridge.dart index 118fdaafe08..ca29c51ad57 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.' + ? l10n.bridgeNoNetworks : validBridgedNetwork - ? "Once established, you won't be able to unset the connection." - : 'No valid bridged network is set.'; - + ? 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,10 @@ 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 +143,9 @@ 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..24891782089 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.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 15e5c9aa64c..64e10c7fe27 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,10 @@ 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 +119,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 +128,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 +146,29 @@ 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.span, '${mountPaths.sourcePath} ⭢ $target'.span.font('UbuntuMono'), - ' from ${widget.name}?'.span, + l10n.mountDeleteBodySuffix(widget.name).span, ].spans, ), - actionText: 'Delete', + actionText: l10n.dialogDelete, 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..cd4c2d82e1d 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,16 @@ 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 +80,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 +97,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 +109,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 +121,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 +134,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 +146,10 @@ 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, 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 diff --git a/src/client/gui/lib/vm_table/bulk_actions.dart b/src/client/gui/lib/vm_table/bulk_actions.dart index b253b793d90..43dcb2b0728 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,21 +24,25 @@ class BulkActionsBar extends ConsumerWidget { .values .toSet(); + final l10n = AppLocalizations.of(context)!; + Function(VmAction) wrapInNotification( Future Function(Iterable) function, ) { 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} $object', - onSuccess: (_) => '${action.pastTense} $object', + loading: l10n.bulkActionMessage(action.continuousTense(l10n), object), + onSuccess: (_) => + l10n.bulkActionMessage(action.pastTense(l10n), object), onError: (error) { - return 'Failed to ${action.name.toLowerCase()} $object: $error'; + return l10n.bulkActionError( + action.label(l10n).toLowerCase(), object, '$error'); }, ); }; @@ -52,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), ), ); 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/no_vms.dart b/src/client/gui/lib/vm_table/no_vms.dart index f362a317866..69d1922d3dd 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.catalogueLabel.span + .color(Colors.blue) + .link(ref, goToCatalogue), + l10n.noVmsMessageAfter.span, ].spans.size(16), ), ], 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), ), 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..b7a38682ee7 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)) 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/