diff --git a/.gitignore b/.gitignore index cf89c5e40..e45a981bb 100644 --- a/.gitignore +++ b/.gitignore @@ -61,3 +61,6 @@ windows/.ffmpeg-cache/ # Local investigation/benchmark evidence (may contain user strategy renders) /artifacts/ + +# Built from lib/l10n/*.arb by flutter gen-l10n (pubspec `generate: true`). +lib/l10n/generated/ diff --git a/l10n.yaml b/l10n.yaml new file mode 100644 index 000000000..b5b782b4c --- /dev/null +++ b/l10n.yaml @@ -0,0 +1,9 @@ +# Every word the app shows lives in lib/l10n: app_en.arb is the source, and +# each other app_.arb holds the same keys translated. Flutter turns +# them into AppLocalizations (lib/l10n/generated, never committed). +arb-dir: lib/l10n +template-arb-file: app_en.arb +output-dir: lib/l10n/generated +output-localization-file: app_localizations.dart +output-class: AppLocalizations +nullable-getter: false diff --git a/lib/collab/cloud_sync_error_message.dart b/lib/collab/cloud_sync_error_message.dart index 246bfc32e..ce07e1432 100644 --- a/lib/collab/cloud_sync_error_message.dart +++ b/lib/collab/cloud_sync_error_message.dart @@ -1,4 +1,3 @@ -import 'package:flutter/foundation.dart' show kIsWeb; import 'package:icarus/collab/collab_models.dart'; import 'package:icarus/collab/generated/convex_error_codes.dart'; import 'package:icarus/collab/page_changes.dart'; @@ -68,6 +67,15 @@ String incompletePageCopyMessage(String pageName) => const _incompletePageCopyMark = "didn't finish saving on this computer"; +/// The page name in [reason], when it starts as an +/// [incompletePageCopyMessage] does; null otherwise. +String? incompletePageCopyPageName(String reason) { + const start = 'The copy of '; + final end = reason.lastIndexOf(' $_incompletePageCopyMark'); + if (!reason.startsWith(start) || end < start.length) return null; + return reason.substring(start.length, end); +} + /// Whether [reason] is an [incompletePageCopyMessage]. bool isIncompletePageCopyReason(String? reason) => reason != null && reason.contains(_incompletePageCopyMark); @@ -108,131 +116,5 @@ bool isClientUpgradeRequiredReason(String? reason) => const _keptRefusal = 'ConvexFunctionException($clientUpgradeRequiredCode, Client upgrade required)'; -/// What the user is told when the server needs a newer Icarus, with the way -/// out their platform has. Desktop says what it can only once it knows -/// whether an update is there to install. -String clientUpgradeRequiredMessage({ - bool isWeb = kIsWeb, - ClientUpdateAvailability update = ClientUpdateAvailability.available, -}) { - if (isWeb) return 'Icarus was updated. Reload to keep syncing.'; - return switch (update) { - ClientUpdateAvailability.available => - 'Icarus was updated. Install the update to keep syncing.', - ClientUpdateAvailability.checking => - 'Icarus was updated. Looking for the update this app needs to keep ' - 'syncing.', - ClientUpdateAvailability.unavailable => - "Icarus was updated, but the update isn't available for this app yet. " - 'Your saved work stays on this device and syncs once the update is ' - 'installed.', - }; -} - /// Whether the update a refused desktop build needs can be installed yet. enum ClientUpdateAvailability { available, checking, unavailable } - -/// What the user is told about a page delete held for them (see -/// heldPageDelete). A page this device could not check is never said to -/// have changed: nothing says it did. -String heldPageDeleteMessage(({String page, bool checked}) held) => held.checked - ? '${held.page} $pageChangedSinceDeleteReason. Review before deleting.' - : "Icarus couldn't check ${held.page} for a teammate's changes. " - 'Review it before deleting.'; - -String friendlyCloudSyncError(String raw) { - if (isClientUpgradeRequiredReason(raw)) { - return clientUpgradeRequiredMessage(); - } - if (heldPageDelete(raw) case final held?) { - return heldPageDeleteMessage(held); - } - final lower = raw.toLowerCase(); - if (lower.contains(_incompletePageCopyMark)) return raw; - if (lower.contains('strategy was deleted')) { - return 'This strategy was deleted, so its unsent changes cannot be ' - 'saved. Discard them from the library.'; - } - if (lower.contains('unreadable saved work')) { - return 'A saved cloud change could not be read. It remains on this ' - 'device; keep this strategy open and recover the outbox before ' - 'continuing.'; - } - if (lower.contains('could not be verified in the durable outbox') || - lower.contains('could not be saved to the durable outbox')) { - return unverifiedCloudWorkMessage; - } - if (lower.contains(editAccessRemovedMessage.toLowerCase())) { - return editAccessRemovedMessage; - } - if (lower.contains('forbidden')) { - return 'This account does not have permission to save these changes. ' - 'They remain on this device. Ask the owner for edit access, then ' - 'retry.'; - } - if (lower.contains('retry paused')) { - return 'A saved cloud change is paused after repeated failures. Retry ' - 'when the connection and account are healthy.'; - } - if (lower.contains(pageTooLargeToCopyMessage.toLowerCase())) { - return 'A new page was too large for the cloud to copy, so it was not ' - 'added. Keep mine tries again; Use cloud drops it.'; - } - if (lower.contains('too large for cloud sync')) { - return 'A saved change is too large for cloud sync. It remains saved on ' - 'this device. Reduce it, then choose Keep mine to retry, or Use ' - 'cloud to drop it.'; - } - if (lower.contains(lineupOverlapMessage.toLowerCase())) { - return 'This lineup shares a spot with lineups saved separately, so it ' - 'was not saved to the cloud. It remains on this device. Keep mine ' - 'sends it again, and is refused while the spot is still shared; Use ' - 'cloud removes it here.'; - } - if (lower.contains(lineupPageMismatchMessage.toLowerCase())) { - return 'This lineup clashes with one on another page, so it was not ' - 'saved to the cloud. It remains on this device; Use cloud removes ' - 'it here.'; - } - if (lower.contains(teammateDeletedCannotRestoreMessage.toLowerCase())) { - return 'A teammate deleted what this change edits, and this change ' - 'cannot bring it back. Use cloud to let it go.'; - } - if (lower.contains(teammateDeletedMessage.toLowerCase())) { - return 'A teammate deleted what you changed. Keep mine brings it back ' - 'as you have it; Use cloud lets it go.'; - } - if (lower.contains(retiredLineupOpMessage.toLowerCase())) { - return 'A lineup change saved by an older version of Icarus cannot be ' - 'sent to the cloud, so it is not in this strategy. Redo it, then ' - 'choose Use cloud to clear this.'; - } - if (lower.contains(undrawableWorkMessage.toLowerCase())) { - return 'A saved change no longer fits this page as the cloud has it now, ' - 'so it was not sent. It remains on this device. Keep mine replaces ' - "the cloud's version with yours, whole; Use cloud drops yours."; - } - if (lower.contains(pageDeletedMessage.toLowerCase())) { - return 'The page these changes were on was deleted, so they were not ' - 'saved. They remain on this device; Use cloud drops them.'; - } - if (lower.contains('needs attention')) { - return 'Another edit reached the cloud first. Your version remains ' - 'saved on this device.'; - } - if (lower.contains('cannot be retried automatically')) { - return 'The server cannot match this retained edit to a current cloud ' - 'revision. It remains saved on this device.'; - } - if (lower.contains('auth')) { - return 'Your cloud session needs to be refreshed — retry, or sign in ' - 'again from the library.'; - } - if (lower.contains('offline') || lower.contains('connection')) { - return 'The cloud could not be reached.'; - } - if (lower.contains('setup is not ready')) { - return 'Cloud sync is still starting up.'; - } - return "Some changes haven't reached the cloud yet."; -} diff --git a/lib/config/platform_policy.dart b/lib/config/platform_policy.dart index 171db25c5..bb6221b16 100644 --- a/lib/config/platform_policy.dart +++ b/lib/config/platform_policy.dart @@ -1,21 +1,29 @@ import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/l10n/l10n.dart'; /// A feature one platform may not offer. [label] names it in user-facing /// copy, so it reads as the subject of a sentence. enum PlatformFeature { - exportFiles('Export'), - importFiles('Import'), - screenshot('Screenshot'), - videoExport('Video export'), - fileDrop('Drag and drop'), - addImages('Adding images'), - addLineups('Adding lineups'), - replays('Replays'); + exportFiles, + importFiles, + screenshot, + videoExport, + fileDrop, + addImages, + addLineups, + replays; - const PlatformFeature(this.label); - - final String label; + String label(AppLocalizations l10n) => switch (this) { + exportFiles => l10n.serviceMsgFeatureExport, + importFiles => l10n.serviceMsgFeatureImport, + screenshot => l10n.serviceMsgFeatureScreenshot, + videoExport => l10n.serviceMsgFeatureVideoExport, + fileDrop => l10n.serviceMsgFeatureFileDrop, + addImages => l10n.serviceMsgFeatureAddImages, + addLineups => l10n.serviceMsgFeatureAddLineups, + replays => l10n.serviceMsgFeatureReplays, + }; } /// What this build of Icarus offers. The web beta is one value of this @@ -77,12 +85,12 @@ class PlatformPolicy { !desktopOnly.contains(feature) && !comingToWebBeta.contains(feature); /// Why [feature] is not here, in one short sentence, or null when it is. - String? unavailableMessage(PlatformFeature feature) { + String? unavailableMessage(AppLocalizations l10n, PlatformFeature feature) { if (desktopOnly.contains(feature)) { - return '${feature.label} is desktop-only for now.'; + return l10n.serviceMsgFeatureDesktopOnly(feature.label(l10n)); } if (comingToWebBeta.contains(feature)) { - return '${feature.label} is coming to the web beta.'; + return l10n.serviceMsgFeatureComingToWeb(feature.label(l10n)); } return null; } diff --git a/lib/const/line_provider.dart b/lib/const/line_provider.dart index 6bc8b2c17..d551257d9 100644 --- a/lib/const/line_provider.dart +++ b/lib/const/line_provider.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'dart:ui'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/collab/strategy_capabilities_provider.dart'; import 'package:hive_ce_flutter/adapters.dart'; import 'package:icarus/const/agents.dart'; @@ -1029,16 +1030,19 @@ class LineUpProvider extends Notifier { if (placement == null) return; if (placement.pinnedOriginId != null) { Settings.showToast( - message: "The origin is pinned. Drag an ability instead.", + message: ref.read(l10nProvider).lineupOriginPinned, backgroundColor: Settings.tacticalVioletTheme.destructive, ); return; } final pinnedType = placement.pinnedAgentType; if (pinnedType != null && agent.type != pinnedType) { + final l10n = ref.read(l10nProvider); Settings.showToast( - message: "Drag ${AgentData.agents[pinnedType]?.name ?? 'the agent'} " - "for this landing spot.", + message: switch (AgentData.agents[pinnedType]?.name) { + final name? => l10n.lineupDragPinnedAgent(l10n.gameName(name)), + null => l10n.lineupDragPinnedAgentUnknown, + }, backgroundColor: Settings.tacticalVioletTheme.destructive, ); return; @@ -1059,7 +1063,7 @@ class LineUpProvider extends Notifier { if (placement == null) return; if (placement.pinnedLandingId != null) { Settings.showToast( - message: "The landing spot is pinned. Drag an agent instead.", + message: ref.read(l10nProvider).lineupLandingPinned, backgroundColor: Settings.tacticalVioletTheme.destructive, ); return; @@ -1067,14 +1071,14 @@ class LineUpProvider extends Notifier { final agentType = placement.lockedAgentType; if (agentType == null) { Settings.showToast( - message: "Please select an agent first.", + message: ref.read(l10nProvider).lineupSelectAgentFirst, backgroundColor: Settings.tacticalVioletTheme.destructive, ); return; } if (ability.data.type != agentType) { Settings.showToast( - message: "Ability does not match the selected agent.", + message: ref.read(l10nProvider).lineupAbilityAgentMismatch, backgroundColor: Settings.tacticalVioletTheme.destructive, ); return; @@ -1791,8 +1795,7 @@ class LineUpProvider extends Notifier { void _toastDroppedEdit() { Settings.showToast( - message: 'The page changed, so the placement edit closed without ' - 'saving.', + message: ref.read(l10nProvider).lineupPlacementEditClosed, backgroundColor: Settings.tacticalVioletTheme.destructive, ); } diff --git a/lib/const/shortcut_info.dart b/lib/const/shortcut_info.dart index 744b43a77..84c5f6d16 100644 --- a/lib/const/shortcut_info.dart +++ b/lib/const/shortcut_info.dart @@ -1,6 +1,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:icarus/l10n/l10n.dart'; enum IcarusShortcutAction { undo, @@ -175,19 +176,43 @@ class IcarusKeyBinding { class IcarusShortcutDefinition { const IcarusShortcutDefinition({ required this.action, - required this.title, required this.defaultBinding, required this.intent, this.searchAliases = const [], }); final IcarusShortcutAction action; - final String title; final IcarusKeyBinding defaultBinding; final Intent intent; final List searchAliases; String get id => action.name; + + String title(AppLocalizations l10n) => switch (action) { + IcarusShortcutAction.undo => l10n.shortcutUndo, + IcarusShortcutAction.redo => l10n.shortcutRedo, + IcarusShortcutAction.draw => l10n.shortcutDraw, + IcarusShortcutAction.erase => l10n.shortcutEraser, + IcarusShortcutAction.addText => l10n.shortcutAddText, + IcarusShortcutAction.navigation => l10n.shortcutNavigation, + IcarusShortcutAction.toggleAgentFilter => + l10n.shortcutToggleAgentFilter, + IcarusShortcutAction.forwardPage => l10n.shortcutNextPage, + IcarusShortcutAction.backwardPage => l10n.shortcutPreviousPage, + IcarusShortcutAction.addPage => l10n.shortcutAddPage, + IcarusShortcutAction.addLineup => l10n.shortcutAddLineup, + IcarusShortcutAction.switchSide => l10n.shortcutSwitchSide, + IcarusShortcutAction.switchSideThisPage => + l10n.shortcutSwitchSideThisPage, + IcarusShortcutAction.openDeleteMenu => l10n.shortcutOpenDeleteMenu, + IcarusShortcutAction.selectAll => l10n.shortcutSelectAll, + IcarusShortcutAction.rotateClockwise => l10n.shortcutRotateClockwise, + IcarusShortcutAction.rotateCounterclockwise => + l10n.shortcutRotateCounterclockwise, + IcarusShortcutAction.saveStrategy => l10n.shortcutSaveStrategy, + IcarusShortcutAction.pasteImage => l10n.shortcutPaste, + IcarusShortcutAction.openInAppDebug => l10n.shortcutOpenDebugLog, + }; } class ShortcutInfo { @@ -196,7 +221,6 @@ class ShortcutInfo { static const reservedShortcuts = [ IcarusShortcutDefinition( action: IcarusShortcutAction.undo, - title: 'Undo', defaultBinding: IcarusKeyBinding( trigger: LogicalKeyboardKey.keyZ, primary: true, @@ -205,7 +229,6 @@ class ShortcutInfo { ), IcarusShortcutDefinition( action: IcarusShortcutAction.redo, - title: 'Redo', defaultBinding: IcarusKeyBinding( trigger: LogicalKeyboardKey.keyZ, primary: true, @@ -222,75 +245,64 @@ class ShortcutInfo { static const editableShortcuts = [ IcarusShortcutDefinition( action: IcarusShortcutAction.draw, - title: 'Draw', defaultBinding: IcarusKeyBinding(trigger: LogicalKeyboardKey.keyQ), intent: ToggleDrawingIntent(), ), IcarusShortcutDefinition( action: IcarusShortcutAction.erase, - title: 'Eraser', defaultBinding: IcarusKeyBinding(trigger: LogicalKeyboardKey.keyW), intent: ToggleErasingIntent(), searchAliases: ['erase'], ), IcarusShortcutDefinition( action: IcarusShortcutAction.addText, - title: 'Add Text', defaultBinding: IcarusKeyBinding(trigger: LogicalKeyboardKey.keyT), intent: AddedTextIntent(), searchAliases: ['text'], ), IcarusShortcutDefinition( action: IcarusShortcutAction.navigation, - title: 'Navigation', defaultBinding: IcarusKeyBinding(trigger: LogicalKeyboardKey.keyS), intent: NavigationActionIntent(), searchAliases: ['select', 'pointer'], ), IcarusShortcutDefinition( action: IcarusShortcutAction.toggleAgentFilter, - title: 'Toggle Agent Filter', defaultBinding: IcarusKeyBinding(trigger: LogicalKeyboardKey.keyR), intent: ToggleAgentFilterIntent(), searchAliases: ['agents', 'filter'], ), IcarusShortcutDefinition( action: IcarusShortcutAction.forwardPage, - title: 'Next Page', defaultBinding: IcarusKeyBinding(trigger: LogicalKeyboardKey.keyD), intent: ForwardPageIntent(), searchAliases: ['forward'], ), IcarusShortcutDefinition( action: IcarusShortcutAction.backwardPage, - title: 'Previous Page', defaultBinding: IcarusKeyBinding(trigger: LogicalKeyboardKey.keyA), intent: BackwardPageIntent(), searchAliases: ['backward', 'back'], ), IcarusShortcutDefinition( action: IcarusShortcutAction.addPage, - title: 'Add Page', defaultBinding: IcarusKeyBinding(trigger: LogicalKeyboardKey.keyC), intent: AddPageIntent(), ), IcarusShortcutDefinition( action: IcarusShortcutAction.addLineup, - title: 'Add Lineup', defaultBinding: IcarusKeyBinding(trigger: LogicalKeyboardKey.keyG), intent: ToggleLineupIntent(), searchAliases: ['lineup'], ), IcarusShortcutDefinition( action: IcarusShortcutAction.switchSide, - title: 'Switch Side', defaultBinding: IcarusKeyBinding(trigger: LogicalKeyboardKey.keyF), intent: SwitchSideIntent(), searchAliases: ['attack', 'defense', 'defend', 'flip', 'side'], ), IcarusShortcutDefinition( action: IcarusShortcutAction.switchSideThisPage, - title: 'Switch Side (This Page)', defaultBinding: IcarusKeyBinding( trigger: LogicalKeyboardKey.keyF, shift: true, @@ -300,7 +312,6 @@ class ShortcutInfo { ), IcarusShortcutDefinition( action: IcarusShortcutAction.openDeleteMenu, - title: 'Open Delete Menu', defaultBinding: IcarusKeyBinding(trigger: openDeleteMenuKey), intent: ContextualDeleteIntent(), searchAliases: ['delete'], @@ -309,7 +320,6 @@ class ShortcutInfo { // fixed Delete, Backspace and Escape. IcarusShortcutDefinition( action: IcarusShortcutAction.selectAll, - title: 'Select All', defaultBinding: IcarusKeyBinding( trigger: LogicalKeyboardKey.keyA, primary: true, @@ -319,14 +329,12 @@ class ShortcutInfo { ), IcarusShortcutDefinition( action: IcarusShortcutAction.rotateClockwise, - title: 'Rotate Clockwise', defaultBinding: IcarusKeyBinding(trigger: LogicalKeyboardKey.keyX), intent: RotateHoveredIntent(clockwise: true), searchAliases: ['rotate', 'turn', 'clockwise'], ), IcarusShortcutDefinition( action: IcarusShortcutAction.rotateCounterclockwise, - title: 'Rotate Counterclockwise', defaultBinding: IcarusKeyBinding( trigger: LogicalKeyboardKey.keyX, shift: true, @@ -336,7 +344,6 @@ class ShortcutInfo { ), IcarusShortcutDefinition( action: IcarusShortcutAction.saveStrategy, - title: 'Save Strategy', defaultBinding: IcarusKeyBinding( trigger: LogicalKeyboardKey.keyS, primary: true, @@ -346,7 +353,6 @@ class ShortcutInfo { ), IcarusShortcutDefinition( action: IcarusShortcutAction.pasteImage, - title: 'Paste', defaultBinding: IcarusKeyBinding( trigger: LogicalKeyboardKey.keyV, primary: true, @@ -356,7 +362,6 @@ class ShortcutInfo { ), IcarusShortcutDefinition( action: IcarusShortcutAction.openInAppDebug, - title: 'Open Debug Log', defaultBinding: IcarusKeyBinding(trigger: LogicalKeyboardKey.f12), intent: OpenInAppDebugIntent(), searchAliases: ['debug', 'log'], @@ -472,12 +477,13 @@ class ShortcutInfo { static bool matchesSearch( IcarusShortcutDefinition definition, String query, - Map customBindings, { + Map customBindings, + AppLocalizations l10n, { TargetPlatform? platform, }) { final normalizedQuery = normalizeSearch(query); if (normalizedQuery.isEmpty) return true; - final title = normalizeSearch(definition.title); + final title = normalizeSearch(definition.title(l10n)); final aliases = definition.searchAliases.map(normalizeSearch).join(' '); final binding = effectiveBindingFor(definition.id, customBindings); return title.contains(normalizedQuery) || diff --git a/lib/const/update_checker.dart b/lib/const/update_checker.dart index 573bc476c..ba985f123 100644 --- a/lib/const/update_checker.dart +++ b/lib/const/update_checker.dart @@ -6,6 +6,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:http/http.dart' as http; import 'package:icarus/const/settings.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:url_launcher/url_launcher.dart' show launchUrl; @@ -225,27 +226,28 @@ class UpdateChecker { } static void showUpdateDialog(BuildContext context, UpdateCheckResult result) { + final l10n = context.l10n; final String remoteVersion = result.remoteVersion ?? Settings.versionName; final String titleText = (result.updateTitle != null && result.updateTitle!.trim().isNotEmpty) ? result.updateTitle! - : 'Version $remoteVersion is available'; + : l10n.serviceMsgStoreUpdateVersionAvailable(remoteVersion); final String releaseNotes = - result.releaseNotes ?? - 'An update is available in the Microsoft Store.'; + result.releaseNotes ?? l10n.serviceMsgStoreUpdateDefaultNotes; final List features = result.features ?? const []; showDialog( context: context, builder: (BuildContext context) => ShadDialog.alert( - title: const Text('Update Available'), + title: Text(l10n.serviceMsgStoreUpdateTitle), description: Text.rich( TextSpan( text: '$titleText\n\n', children: [ - const TextSpan(text: 'Release Notes:\n'), + TextSpan(text: '${l10n.serviceMsgStoreUpdateReleaseNotes}\n'), TextSpan(text: '$releaseNotes\n'), - if (features.isNotEmpty) const TextSpan(text: '\nWhat is new:\n'), + if (features.isNotEmpty) + TextSpan(text: '\n${l10n.serviceMsgStoreUpdateWhatsNew}\n'), if (features.isNotEmpty) TextSpan( text: features.map((feature) => '- $feature').join('\n'), @@ -258,7 +260,7 @@ class UpdateChecker { onPressed: () { Navigator.of(context).pop(); }, - child: const Text('Later'), + child: Text(l10n.serviceMsgStoreUpdateLater), ), TextButton( onPressed: () async { @@ -270,7 +272,7 @@ class UpdateChecker { Navigator.of(context).pop(); }, - child: const Text('Update Now'), + child: Text(l10n.serviceMsgStoreUpdateNow), ), ], ), diff --git a/lib/hive/hive_adapters.g.dart b/lib/hive/hive_adapters.g.dart index ae553d339..f8e313e25 100644 --- a/lib/hive/hive_adapters.g.dart +++ b/lib/hive/hive_adapters.g.dart @@ -1130,13 +1130,14 @@ class AppPreferencesAdapter extends TypeAdapter { fields[26] == null ? false : fields[26] as bool, includeVideoCaptions: fields[27] == null ? false : fields[27] as bool, canvasSelectionHintSeen: fields[23] == null ? false : fields[23] as bool, + languageCode: fields[28] as String?, ); } @override void write(BinaryWriter writer, AppPreferences obj) { writer - ..writeByte(27) + ..writeByte(28) ..writeByte(0) ..write(obj.defaultThemeProfileIdForNewStrategies) ..writeByte(1) @@ -1190,7 +1191,9 @@ class AppPreferencesAdapter extends TypeAdapter { ..writeByte(26) ..write(obj.includeScreenshotCaptions) ..writeByte(27) - ..write(obj.includeVideoCaptions); + ..write(obj.includeVideoCaptions) + ..writeByte(28) + ..write(obj.languageCode); } @override diff --git a/lib/hive/hive_adapters.g.yaml b/lib/hive/hive_adapters.g.yaml index a601fcb4e..f890f762e 100644 --- a/lib/hive/hive_adapters.g.yaml +++ b/lib/hive/hive_adapters.g.yaml @@ -371,7 +371,7 @@ types: index: 4 AppPreferences: typeId: 28 - nextIndex: 28 + nextIndex: 29 fields: defaultThemeProfileIdForNewStrategies: index: 0 @@ -427,6 +427,8 @@ types: index: 26 includeVideoCaptions: index: 27 + languageCode: + index: 28 PlacedViewConeAgent: typeId: 29 nextIndex: 12 diff --git a/lib/interactive_map.dart b/lib/interactive_map.dart index d36e34d50..319f1e52f 100644 --- a/lib/interactive_map.dart +++ b/lib/interactive_map.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/widgets/asset_svg.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/widgets/draggable_widgets/utilities/svg_height_view_cone.dart'; import 'package:icarus/providers/svg_height_runtime_provider.dart'; import 'package:icarus/widgets/editor_operation_scope.dart'; @@ -343,7 +344,7 @@ class _InteractiveMapState extends ConsumerState child: AssetSvg( assetName, colorMapper: mapColorMapper, - semanticsLabel: 'Map', + semanticsLabel: context.l10n.strategyViewMapSemanticLabel, fit: BoxFit.contain, ), ), @@ -408,7 +409,7 @@ class _InteractiveMapState extends ConsumerState flipY: !isAttack, child: AssetSvg( barrierAssetName, - semanticsLabel: 'Barrier', + semanticsLabel: context.l10n.strategyViewBarrierSemanticLabel, fit: BoxFit.contain, ), ), @@ -423,7 +424,7 @@ class _InteractiveMapState extends ConsumerState height: height, child: AssetSvg( calloutsAssetName, - semanticsLabel: 'Callouts', + semanticsLabel: context.l10n.strategyViewCalloutsSemanticLabel, fit: BoxFit.contain, ), ), @@ -438,7 +439,7 @@ class _InteractiveMapState extends ConsumerState flipY: !isAttack, child: AssetSvg( ultOrbsAssetName, - semanticsLabel: 'Ult Orbs', + semanticsLabel: context.l10n.strategyViewUltOrbsSemanticLabel, fit: BoxFit.contain, ), ), diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb new file mode 100644 index 000000000..abb2e58f6 --- /dev/null +++ b/lib/l10n/app_en.arb @@ -0,0 +1,5986 @@ +{ + "@@locale": "en", + "commonCancel": "Cancel", + "commonRetry": "Retry", + "commonTryAgain": "Try again", + "commonDelete": "Delete", + "commonRename": "Rename", + "commonEdit": "Edit", + "commonClear": "Clear", + "commonClose": "Close", + "commonSave": "Save", + "commonDone": "Done", + "commonApply": "Apply", + "commonDiscard": "Discard", + "commonExport": "Export", + "commonShare": "Share", + "commonLeave": "Leave", + "commonLogIn": "Log In", + "commonSignOut": "Sign Out", + "commonReconnect": "Reconnect", + "commonSentenceSpace": " ", + "@commonSentenceSpace": { + "description": "Goes between two whole sentences shown one after the other. A single space in English; empty in languages that don't separate sentences with a space." + }, + "confirmDialogConfirm": "Confirm", + "@confirmDialogConfirm": { + "description": "Default confirm button on a confirmation dialog." + }, + "deleteFolderTitle": "Delete '{name}'?", + "@deleteFolderTitle": { + "description": "Title of the dialog confirming a folder delete.", + "placeholders": { + "name": { + "type": "String", + "example": "Ascent defaults" + } + } + }, + "deleteFolderDescription": "This also removes every strategy and subfolder inside it.", + "@deleteFolderDescription": { + "description": "Body of the folder delete confirmation." + }, + "deleteFolderFailed": "Couldn't delete this folder. Try again.", + "@deleteFolderFailed": { + "description": "Error shown in the folder delete dialog when the delete fails." + }, + "deleteFolderDeleting": "Deleting...", + "@deleteFolderDeleting": { + "description": "Delete button label while the folder is being deleted." + }, + "deletePageTitle": "Delete '{name}'?", + "@deletePageTitle": { + "description": "Title of the dialog confirming a page delete.", + "placeholders": { + "name": { + "type": "String", + "example": "A execute" + } + } + }, + "deletePageRestorableFor": "You can restore it from Recently deleted for {days} days.", + "@deletePageRestorableFor": { + "description": "Last sentence of the page delete confirmation for a cloud strategy. 'Recently deleted' is the name of the dialog that lists deleted pages.", + "placeholders": { + "days": { + "type": "int", + "example": "30" + } + } + }, + "deletePageCannotUndo": "This action cannot be undone.", + "@deletePageCannotUndo": { + "description": "Last sentence of the page delete confirmation for a strategy kept on this device." + }, + "deletePageConfirm": "Are you sure you want to delete this page? {afterwards}", + "@deletePageConfirm": { + "description": "Page delete confirmation when nobody else is on the page.", + "placeholders": { + "afterwards": { + "type": "String", + "example": "This action cannot be undone." + } + } + }, + "deletePagePeerOne": "{name} is on this page right now. Delete it anyway? {afterwards}", + "@deletePagePeerOne": { + "description": "Page delete confirmation when one teammate is on the page. The name is drawn in bold.", + "placeholders": { + "name": { + "type": "String", + "example": "Alex" + }, + "afterwards": { + "type": "String", + "example": "This action cannot be undone." + } + } + }, + "deletePagePeersTwo": "{first} and {second} are on this page right now. Delete it anyway? {afterwards}", + "@deletePagePeersTwo": { + "description": "Page delete confirmation when two teammates are on the page. The names are drawn in bold.", + "placeholders": { + "first": { + "type": "String", + "example": "Alex" + }, + "second": { + "type": "String", + "example": "Sam" + }, + "afterwards": { + "type": "String", + "example": "This action cannot be undone." + } + } + }, + "deletePagePeersMany": "{first}, {second} and {others, plural, =1{1 other} other{{others} others}} are on this page right now. Delete it anyway? {afterwards}", + "@deletePagePeersMany": { + "description": "Page delete confirmation when three or more teammates are on the page. The two names are drawn in bold; others counts the rest.", + "placeholders": { + "first": { + "type": "String", + "example": "Alex" + }, + "second": { + "type": "String", + "example": "Sam" + }, + "others": { + "type": "int", + "example": "2" + }, + "afterwards": { + "type": "String", + "example": "This action cannot be undone." + } + } + }, + "deletePageDeleteAnyway": "Delete anyway", + "@deletePageDeleteAnyway": { + "description": "Confirm button on the page delete dialog when teammates are on the page." + }, + "deletedPageTitle": "This page was deleted", + "@deletedPageTitle": { + "description": "Title of the dialog shown when a teammate deletes the page the user is editing." + }, + "deletedPageDescription": "“{name}” was deleted while you were editing it. Restore it to keep your latest changes, or discard them.", + "@deletedPageDescription": { + "description": "Body of the deleted page dialog.", + "placeholders": { + "name": { + "type": "String", + "example": "A execute" + } + } + }, + "deletedPageGone": "“{name}” can no longer be restored, so your latest changes to it could not be saved.", + "@deletedPageGone": { + "description": "Body of the deleted page dialog once the page can no longer be restored.", + "placeholders": { + "name": { + "type": "String", + "example": "A execute" + } + } + }, + "deletedPageRestoreFailed": "Could not confirm the page was restored. Check your connection and try again, or discard your changes.", + "@deletedPageRestoreFailed": { + "description": "Error in the deleted page dialog when restoring could not be confirmed." + }, + "deletedPageLeaveFailed": "Could not leave this page yet: changes to it are still being sent, or another page could not load. Try again in a moment.", + "@deletedPageLeaveFailed": { + "description": "Error in the deleted page dialog when discarding could not leave the page." + }, + "deletedPageDiscardChanges": "Discard changes", + "@deletedPageDiscardChanges": { + "description": "Button in the deleted page dialog." + }, + "deletedPageRestore": "Restore page", + "@deletedPageRestore": { + "description": "Button in the deleted page dialog." + }, + "deletedPageRestoring": "Restoring…", + "@deletedPageRestoring": { + "description": "Restore page button label while the restore runs." + }, + "recentlyDeletedTitle": "Recently deleted", + "@recentlyDeletedTitle": { + "description": "Title of the dialog listing a cloud strategy's deleted pages." + }, + "recentlyDeletedDescription": "Deleted pages stay here for {days} days, then they are gone for good.", + "@recentlyDeletedDescription": { + "description": "Subtitle of the Recently deleted dialog.", + "placeholders": { + "days": { + "type": "int", + "example": "30" + } + } + }, + "recentlyDeletedLoadFailed": "Could not load deleted pages. Check your connection and try again.", + "recentlyDeletedLoading": "Loading…", + "@recentlyDeletedLoading": { + "description": "Shown while the deleted pages load." + }, + "recentlyDeletedEmpty": "No deleted pages.", + "@recentlyDeletedEmpty": { + "description": "Empty state of the Recently deleted dialog." + }, + "recentlyDeletedGone": "It can no longer be restored.", + "@recentlyDeletedGone": { + "description": "Error under a deleted page whose restore time ran out." + }, + "recentlyDeletedRestoreFailed": "Could not confirm the restore. Check your connection and try again.", + "recentlyDeletedRestore": "Restore", + "@recentlyDeletedRestore": { + "description": "Button on a deleted page row that brings the page back." + }, + "recentlyDeletedRestoring": "Restoring…", + "@recentlyDeletedRestoring": { + "description": "Restore button label while the restore runs." + }, + "recentlyDeletedRowDetail": "{deleted} · {timeLeft}", + "@recentlyDeletedRowDetail": { + "description": "Detail line under a deleted page's name.", + "placeholders": { + "deleted": { + "type": "String", + "example": "Deleted by Sam, 2 days ago" + }, + "timeLeft": { + "type": "String", + "example": "28 days left" + } + } + }, + "recentlyDeletedByYou": "Deleted by you, {when}", + "@recentlyDeletedByYou": { + "description": "Who deleted a page and when, when it was the user.", + "placeholders": { + "when": { + "type": "String", + "example": "5 minutes ago" + } + } + }, + "recentlyDeletedBy": "Deleted by {name}, {when}", + "@recentlyDeletedBy": { + "description": "Who deleted a page and when.", + "placeholders": { + "name": { + "type": "String", + "example": "Sam" + }, + "when": { + "type": "String", + "example": "2 days ago" + } + } + }, + "recentlyDeletedWhen": "Deleted {when}", + "@recentlyDeletedWhen": { + "description": "When a page was deleted, when who deleted it is unknown.", + "placeholders": { + "when": { + "type": "String", + "example": "2 days ago" + } + } + }, + "recentlyDeletedJustNow": "just now", + "@recentlyDeletedJustNow": { + "description": "Fills 'when' in 'Deleted by Sam, just now'. Lowercase in English because it sits mid-sentence." + }, + "recentlyDeletedMinutesAgo": "{count, plural, =1{1 minute} other{{count} minutes}} ago", + "@recentlyDeletedMinutesAgo": { + "description": "Fills 'when' in 'Deleted by Sam, 5 minutes ago'.", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "recentlyDeletedHoursAgo": "{count, plural, =1{1 hour} other{{count} hours}} ago", + "@recentlyDeletedHoursAgo": { + "description": "Fills 'when' in 'Deleted by Sam, 3 hours ago'.", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "recentlyDeletedDaysAgo": "{count, plural, =1{1 day} other{{count} days}} ago", + "@recentlyDeletedDaysAgo": { + "description": "Fills 'when' in 'Deleted by Sam, 2 days ago'.", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "recentlyDeletedDaysLeft": "{count, plural, =1{1 day left} other{{count} days left}}", + "@recentlyDeletedDaysLeft": { + "description": "How long a deleted page can still be restored.", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "recentlyDeletedLessThanADayLeft": "Less than a day left", + "@recentlyDeletedLessThanADayLeft": { + "description": "How long a deleted page can still be restored, under a day." + }, + "releaseNotesTitle": "What's new", + "@releaseNotesTitle": { + "description": "Title of the dialog listing every version's patch notes." + }, + "releaseNotesShowWelcomeTour": "Show the welcome tour", + "@releaseNotesShowWelcomeTour": { + "description": "Link at the top of the What's new dialog that opens the welcome tour." + }, + "releaseNotesInstalled": "Installed", + "@releaseNotesInstalled": { + "description": "Badge beside the version the user has installed. Space is tight." + }, + "releaseNotesAvailable": "Available", + "@releaseNotesAvailable": { + "description": "Badge beside a version newer than the installed one. Space is tight." + }, + "releaseNotesLoadFailed": "Couldn't load patch notes. Check your connection and try again.", + "webBetaTitle": "Icarus web beta", + "@webBetaTitle": { + "description": "Title of the dialog explaining the web beta." + }, + "webBetaDescription": "Strategies you make here are saved to your Icarus account and open in the desktop app too.", + "webBetaDownloadForWindows": "Download for Windows", + "@webBetaDownloadForWindows": { + "description": "Button that downloads the desktop app installer." + }, + "webBetaDesktopOnly": "Desktop-only for now", + "@webBetaDesktopOnly": { + "description": "Heading above the list of features that need the desktop app." + }, + "webBetaComing": "Coming to the web beta", + "@webBetaComing": { + "description": "Heading above the list of features not yet in the web beta." + }, + "webViewTitle": "WebView", + "@webViewTitle": { + "description": "Title of the dialog shown when Microsoft WebView2 is missing. WebView is a product name." + }, + "webViewNotInstalled": "WebView is not installed on your system. Please install it to use youtube videos.", + "webViewInstall": "Install", + "@webViewInstall": { + "description": "Button that opens the WebView2 download page." + }, + "authInvalidEmail": "Enter a valid email address.", + "@authInvalidEmail": { + "description": "Validation error in the sign-in dialog." + }, + "authPasswordTooShort": "Password must be at least 6 characters.", + "@authPasswordTooShort": { + "description": "Validation error in the sign-in and set password dialogs." + }, + "authPasswordsDoNotMatch": "Passwords do not match.", + "@authPasswordsDoNotMatch": { + "description": "Validation error when the confirm password differs." + }, + "authDiscordLoginFailed": "Failed to complete login. Please try again.", + "@authDiscordLoginFailed": { + "description": "Error when the Discord sign-in comes back from the browser and fails." + }, + "authSignInTitle": "Sign in", + "@authSignInTitle": { + "description": "Title of the sign-in dialog." + }, + "authSignUpTitle": "Create account", + "@authSignUpTitle": { + "description": "Title of the dialog when it is on the create-account form." + }, + "authResetPasswordTitle": "Reset password", + "@authResetPasswordTitle": { + "description": "Title of the dialog when it is on the password reset form." + }, + "authSignInDescription": "Sign in with email and password.", + "authSignUpDescription": "Use email and password to create an account.", + "authResetPasswordDescription": "Enter your account's email and we'll send a link to set a new password. It works for accounts made with Discord too.", + "authEmailHint": "Email", + "@authEmailHint": { + "description": "Placeholder of the email field." + }, + "authPasswordHint": "Password", + "@authPasswordHint": { + "description": "Placeholder of the password field." + }, + "authConfirmPasswordHint": "Confirm password", + "@authConfirmPasswordHint": { + "description": "Placeholder of the confirm password field when creating an account." + }, + "authForgotPassword": "Forgot password?", + "@authForgotPassword": { + "description": "Link under the password field that opens the reset form." + }, + "authSwitchToSignUp": "Create an account", + "@authSwitchToSignUp": { + "description": "Button on the sign-in form that switches to the create-account form." + }, + "authSwitchToSignIn": "Sign in instead", + "@authSwitchToSignIn": { + "description": "Button on the create-account form that switches to the sign-in form." + }, + "authBackToSignIn": "Back to sign in", + "@authBackToSignIn": { + "description": "Button on the reset form that goes back to sign in." + }, + "authContinueWithDiscord": "Continue with Discord", + "@authContinueWithDiscord": { + "description": "Button that signs in through Discord in the browser." + }, + "authSignInButton": "Sign in", + "@authSignInButton": { + "description": "Submit button on the sign-in form." + }, + "authSigningIn": "Signing in…", + "@authSigningIn": { + "description": "Submit button while signing in." + }, + "authSignUpButton": "Create account", + "@authSignUpButton": { + "description": "Submit button on the create-account form." + }, + "authCreating": "Creating…", + "@authCreating": { + "description": "Submit button while the account is being created." + }, + "authSendResetLink": "Send reset link", + "@authSendResetLink": { + "description": "Submit button on the password reset form." + }, + "authSending": "Sending…", + "@authSending": { + "description": "Submit button while the reset email is sent." + }, + "authDiscordPending": "Finish signing in with Discord in your browser — this dialog will close automatically.", + "@authDiscordPending": { + "description": "Banner shown while the Discord sign-in is open in the browser." + }, + "setPasswordTitle": "Set a new password", + "@setPasswordTitle": { + "description": "Title of the dialog shown after a password reset link signs the user in." + }, + "setPasswordDescription": "Choose the password you will sign in with.", + "setPasswordDescriptionSignedIn": "You're signed in as {email}. Choose the password you will sign in with.", + "@setPasswordDescriptionSignedIn": { + "description": "Set password dialog description when the account's email is known.", + "placeholders": { + "email": { + "type": "String", + "example": "sam@example.com" + } + } + }, + "setPasswordNewHint": "New password", + "@setPasswordNewHint": { + "description": "Placeholder of the new password field." + }, + "setPasswordConfirmHint": "Confirm new password", + "@setPasswordConfirmHint": { + "description": "Placeholder of the confirm new password field." + }, + "setPasswordNotNow": "Not now", + "@setPasswordNotNow": { + "description": "Button that closes the set password dialog without changing the password." + }, + "setPasswordSave": "Save password", + "@setPasswordSave": { + "description": "Button that saves the new password." + }, + "setPasswordSaving": "Saving…", + "@setPasswordSaving": { + "description": "Save password button while saving." + }, + "setPasswordSaved": "Password saved. Sign in with your email and this password from now on.", + "@setPasswordSaved": { + "description": "Toast after the new password is saved." + }, + "createStrategyPickMap": "Pick a map", + "@createStrategyPickMap": { + "description": "Title of the new strategy dialog, which is a grid of maps." + }, + "createStrategyOutOfRotation": "Out of rotation", + "@createStrategyOutOfRotation": { + "description": "Toggle in the new strategy dialog that shows maps not in the current Valorant map pool." + }, + "deleteStrategyTitle": "Delete {name}", + "@deleteStrategyTitle": { + "description": "Title of the strategy delete confirmation. The name is drawn in bold.", + "placeholders": { + "name": { + "type": "String", + "example": "Ascent retake" + } + } + }, + "deleteStrategyConfirm": "Are you sure you want to delete {name}? This action cannot be undone.", + "@deleteStrategyConfirm": { + "description": "Body of the strategy delete confirmation. The name is drawn in bold.", + "placeholders": { + "name": { + "type": "String", + "example": "Ascent retake" + } + } + }, + "deleteStrategyFailed": "Couldn't delete this strategy. Try again.", + "@deleteStrategyFailed": { + "description": "Error in the strategy delete dialog when the delete fails." + }, + "deleteStrategyDeleting": "Deleting...", + "@deleteStrategyDeleting": { + "description": "Delete button label while the strategy is being deleted." + }, + "renameStrategyTitle": "Rename Strategy", + "@renameStrategyTitle": { + "description": "Title of the rename strategy dialog." + }, + "renameStrategyEmptyName": "Strategy name cannot be empty.", + "@renameStrategyEmptyName": { + "description": "Toast when the user tries to rename a strategy to nothing." + }, + "lineupMediaName": "Name", + "@lineupMediaName": { + "description": "Label above the lineup name field." + }, + "lineupMediaNameHint": "Optional, for telling this apart from other lineups here", + "@lineupMediaNameHint": { + "description": "Placeholder of the lineup name field." + }, + "lineupMediaYoutubeLink": "Youtube link", + "@lineupMediaYoutubeLink": { + "description": "Label above the lineup's YouTube link field." + }, + "lineupMediaYoutubeLinkHint": "Paste YouTube link here...", + "@lineupMediaYoutubeLinkHint": { + "description": "Placeholder of the lineup's YouTube link field." + }, + "lineupMediaImages": "Images", + "@lineupMediaImages": { + "description": "Label above the lineup's image grid." + }, + "lineupMediaNotes": "Notes", + "@lineupMediaNotes": { + "description": "Label above the lineup notes field." + }, + "lineupMediaNotesHint": "Add any notes here...", + "@lineupMediaNotesHint": { + "description": "Placeholder of the lineup notes field." + }, + "lineupMediaAddImages": "Click here to add images", + "@lineupMediaAddImages": { + "description": "Empty state of the lineup's image grid; clicking it opens a file picker." + }, + "lineupMediaPaste": "Paste", + "@lineupMediaPaste": { + "description": "Label on the small tile that pastes an image from the clipboard. Space is tight." + }, + "lineupMediaPasteFromClipboard": "Paste from clipboard", + "@lineupMediaPasteFromClipboard": { + "description": "Button that pastes an image from the clipboard into the lineup." + }, + "createLineupNoClipboardImage": "No image found in clipboard", + "@createLineupNoClipboardImage": { + "description": "Toast when pasting an image into a lineup finds none." + }, + "createLineupClipboardTypeUnsupported": "Clipboard image type not supported", + "@createLineupClipboardTypeUnsupported": { + "description": "Toast when the pasted image is in a format Icarus cannot use." + }, + "createLineupLostEditAccess": "You can no longer edit this strategy, so this lineup was not saved.", + "@createLineupLostEditAccess": { + "description": "Toast when the user's edit access to a shared strategy ends while the lineup dialog is open." + }, + "createLineupQueueImagesFailed": "Could not queue these images for cloud sync. They remain on this device. Try again before closing.", + "@createLineupQueueImagesFailed": { + "description": "Toast when a lineup's images could not be queued for upload." + }, + "createLineupCommitPending": "The lineup is kept in the editor, but its cloud work is still pending. Use Save before leaving.", + "@createLineupCommitPending": { + "description": "Toast when a lineup was saved locally but its cloud upload is not finished. 'Save' is the name of the strategy save button." + }, + "createLineupCreateTitle": "Create Lineup", + "@createLineupCreateTitle": { + "description": "Title of the lineup dialog when creating a lineup." + }, + "createLineupEditTitle": "Edit Lineup", + "@createLineupEditTitle": { + "description": "Title of the lineup dialog when editing a lineup." + }, + "createLineupEditPlacement": "Edit placement", + "@createLineupEditPlacement": { + "description": "Button that saves the lineup and lets the user move where it is placed on the map." + }, + "lineupPanelUnnamed": "Lineup {number}", + "@lineupPanelUnnamed": { + "description": "Name shown for a lineup the user did not name, by its position in the list.", + "placeholders": { + "number": { + "type": "int", + "example": "2" + } + } + }, + "lineupPanelRenameTitle": "Rename lineup", + "@lineupPanelRenameTitle": { + "description": "Title of the rename lineup dialog." + }, + "lineupPanelRenameLostEditAccess": "You can no longer edit this strategy, so the name was not saved.", + "@lineupPanelRenameLostEditAccess": { + "description": "Toast when edit access ends while renaming a lineup." + }, + "lineupPanelOriginFallback": "Origin", + "@lineupPanelOriginFallback": { + "description": "Title of the lineup panel for a throw spot whose agent is unknown. Origin means the spot a lineup is thrown from." + }, + "lineupPanelLandHere": "{count, plural, other{{count} lineups land here}}", + "@lineupPanelLandHere": { + "description": "Subtitle of the lineup panel opened from a landing spot.", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "lineupPanelFromHere": "{count, plural, other{{count} lineups from here}}", + "@lineupPanelFromHere": { + "description": "Subtitle of the lineup panel opened from the spot lineups are thrown from.", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "lineupPanelEditMedia": "Edit media", + "@lineupPanelEditMedia": { + "description": "Context menu item on a lineup that opens its video, images and notes." + }, + "lineupPanelDeleteLineup": "Delete lineup", + "@lineupPanelDeleteLineup": { + "description": "Context menu item that deletes a lineup." + }, + "mapThemeEditorPreviewLabel": "Map theme preview", + "@mapThemeEditorPreviewLabel": { + "description": "Screen reader label of the map preview in the map theme editor." + }, + "mapThemeEditorNewProfileTitle": "New profile", + "@mapThemeEditorNewProfileTitle": { + "description": "Title of the map theme editor when creating a theme profile (a saved set of map colours)." + }, + "mapThemeEditorEditProfileTitle": "Edit profile", + "@mapThemeEditorEditProfileTitle": { + "description": "Title of the map theme editor when editing a theme profile." + }, + "mapThemeEditorCustomizeTitle": "Customize map theme", + "@mapThemeEditorCustomizeTitle": { + "description": "Title of the map theme editor when changing the colours of the open strategy only." + }, + "mapThemeEditorCustomizeDescription": "Only this strategy is affected.", + "mapThemeEditorProfileNameHint": "Profile name", + "@mapThemeEditorProfileNameHint": { + "description": "Placeholder of the theme profile name field." + }, + "mapThemeEditorBase": "Base", + "@mapThemeEditorBase": { + "description": "Tab picking the map's base colour. Space is tight." + }, + "mapThemeEditorDetail": "Detail", + "@mapThemeEditorDetail": { + "description": "Tab picking the map's detail colour. Space is tight." + }, + "mapThemeEditorHighlight": "Highlight", + "@mapThemeEditorHighlight": { + "description": "Tab picking the map's highlight colour. Space is tight." + }, + "mapThemeEditorSaveProfile": "Save profile", + "@mapThemeEditorSaveProfile": { + "description": "Button that saves a new theme profile." + }, + "mapThemeEditorSaveChanges": "Save changes", + "@mapThemeEditorSaveChanges": { + "description": "Button that saves edits to a theme profile." + }, + "mapThemeEditorCreateFailed": "Profile limit reached or invalid name.", + "@mapThemeEditorCreateFailed": { + "description": "Toast when a new theme profile could not be created." + }, + "mapThemeEditorSavedAndApplied": "Profile saved and applied.", + "@mapThemeEditorSavedAndApplied": { + "description": "Toast after saving a new theme profile and applying it to the open strategy." + }, + "mapThemeEditorSaved": "Profile saved.", + "@mapThemeEditorSaved": { + "description": "Toast after saving a new theme profile." + }, + "mapThemeEditorEnterName": "Enter a profile name.", + "@mapThemeEditorEnterName": { + "description": "Toast when saving a theme profile with an empty name." + }, + "mapThemeEditorUpdateFailed": "Couldn't update this profile.", + "@mapThemeEditorUpdateFailed": { + "description": "Toast when a theme profile's colours could not be saved." + }, + "mapThemeEditorUpdated": "Profile updated.", + "@mapThemeEditorUpdated": { + "description": "Toast after saving edits to a theme profile." + }, + "mapThemeEditorRenameFailed": "Colors saved, but the name couldn't be changed.", + "@mapThemeEditorRenameFailed": { + "description": "Toast when a theme profile's colours saved but its rename failed." + }, + "uploadImageUnsupportedFormat": "Unsupported image format", + "@uploadImageUnsupportedFormat": { + "description": "Toast when a dropped file is not an image Icarus can use." + }, + "uploadImageTitle": "Upload image", + "@uploadImageTitle": { + "description": "Title of the dialog that adds an image to the map." + }, + "uploadImageDescriptionDrop": "Drop an image here or click to choose a file.", + "uploadImageDescriptionClick": "Click to choose a file.", + "@uploadImageDescriptionClick": { + "description": "Upload image dialog description where dropping files is not available." + }, + "uploadImageUseImage": "Use image", + "@uploadImageUseImage": { + "description": "Button that places the chosen image." + }, + "uploadImageDragTip": "Tip: You can also drag & drop an image from your desktop.", + "uploadImageTagColor": "Tag color", + "@uploadImageTagColor": { + "description": "Label above the colour swatches for the image's tag." + }, + "uploadImageDropToUpload": "Drop to upload", + "@uploadImageDropToUpload": { + "description": "Shown in the drop area while a file is dragged over it." + }, + "uploadImageDropOrClick": "Drop or click to upload", + "@uploadImageDropOrClick": { + "description": "Headline of the empty drop area." + }, + "uploadImageCheckingClipboard": "Checking clipboard…", + "@uploadImageCheckingClipboard": { + "description": "Shown while Icarus looks for an image on the clipboard." + }, + "uploadImageSelectedImage": "Selected image", + "@uploadImageSelectedImage": { + "description": "Shown in place of the file name when the chosen image has none." + }, + "screenshotDialogDefaultFileName": "new image", + "@screenshotDialogDefaultFileName": { + "description": "File name offered when saving a screenshot of a strategy that has no name. Used as a file name, so avoid characters like / : ? *." + }, + "screenshotDialogChooseFolder": "Choose a folder for the screenshots", + "@screenshotDialogChooseFolder": { + "description": "Title of the system folder picker when saving several screenshots." + }, + "screenshotDialogFolderPickerFailed": "Couldn't open the folder picker. Please try again.", + "@screenshotDialogFolderPickerFailed": { + "description": "Error toast when the system folder picker fails to open." + }, + "screenshotDialogSavedToFolder": "Saved {count} screenshots to {folder}.", + "@screenshotDialogSavedToFolder": { + "description": "Toast after several screenshots were written to a folder. Always two or more.", + "placeholders": { + "count": { + "type": "int", + "example": "4" + }, + "folder": { + "type": "String", + "example": "Screenshots" + } + } + }, + "screenshotDialogCancelled": "Screenshots cancelled.", + "@screenshotDialogCancelled": { + "description": "Toast after the user stopped a screenshot save." + }, + "screenshotDialogExportFailedMany": "Could not export the screenshots. Please try again.", + "@screenshotDialogExportFailedMany": { + "description": "Error toast when saving several screenshots fails." + }, + "screenshotDialogExportFailedOne": "Could not export the screenshot. Please try again.", + "@screenshotDialogExportFailedOne": { + "description": "Error toast when saving one screenshot fails." + }, + "screenshotDialogSavingManyTitle": "Saving screenshots", + "@screenshotDialogSavingManyTitle": { + "description": "Screenshot dialog title while several pages are being saved." + }, + "screenshotDialogSavingOneTitle": "Saving screenshot", + "@screenshotDialogSavingOneTitle": { + "description": "Screenshot dialog title while one page is being saved." + }, + "screenshotDialogSaveOneTitle": "Save screenshot", + "@screenshotDialogSaveOneTitle": { + "description": "Screenshot dialog title for a strategy with one page." + }, + "screenshotDialogSaveManyTitle": "Save screenshots", + "@screenshotDialogSaveManyTitle": { + "description": "Screenshot dialog title when the user picks pages to save." + }, + "screenshotDialogTransparentBackground": "Transparent background", + "@screenshotDialogTransparentBackground": { + "description": "Checkbox that saves screenshots without the map background." + }, + "screenshotDialogIncludeCaptions": "Include captions", + "@screenshotDialogIncludeCaptions": { + "description": "Checkbox that draws captions into the screenshots. A caption is a note kept on a placed item." + }, + "screenshotDialogIncludeCaptionsDescription": "Numbers on the map, notes in the side margins.", + "@screenshotDialogIncludeCaptionsDescription": { + "description": "Explains how captions appear in a screenshot: a number by each item on the map, its note in the margin." + }, + "screenshotDialogSaveManyButton": "Save {count} screenshots", + "@screenshotDialogSaveManyButton": { + "description": "Save button when two or more pages are ticked.", + "placeholders": { + "count": { + "type": "int", + "example": "3" + } + } + }, + "screenshotDialogSaveOneButton": "Save screenshot", + "@screenshotDialogSaveOneButton": { + "description": "Save button when one page is ticked." + }, + "screenshotDialogStop": "Stop", + "@screenshotDialogStop": { + "description": "Button that stops a screenshot save in progress." + }, + "screenshotDialogStoppingButton": "Stopping…", + "@screenshotDialogStoppingButton": { + "description": "Stop button after it was pressed." + }, + "screenshotDialogClearAll": "Clear all", + "@screenshotDialogClearAll": { + "description": "Button that unticks every page." + }, + "screenshotDialogSelectAll": "Select all", + "@screenshotDialogSelectAll": { + "description": "Button that ticks every page." + }, + "screenshotDialogCaptured": "Captured", + "@screenshotDialogCaptured": { + "description": "Status chip on a page tile once its screenshot is taken. Space is tight." + }, + "screenshotDialogStoppingStatus": "Stopping", + "@screenshotDialogStoppingStatus": { + "description": "Status chip on the page being captured after the user pressed Stop. Space is tight." + }, + "screenshotDialogWaitingForPictures": "Waiting for pictures", + "@screenshotDialogWaitingForPictures": { + "description": "Status chip while the page waits for its images to download. Space is tight." + }, + "screenshotDialogCapturing": "Capturing", + "@screenshotDialogCapturing": { + "description": "Status chip on the page being captured now. Space is tight." + }, + "exportVideoIncludeCaptionsDescription": "Numbers on the map, notes in the side margins.", + "@exportVideoIncludeCaptionsDescription": { + "description": "Explains how captions appear in an exported video: a number by each item on the map, its note in the margin." + }, + "exportVideoCaptionsTooSlow": "Page {page} needs about {seconds} s to read. Each step holds for {step} s.", + "@exportVideoCaptionsTooSlow": { + "description": "Hint under Include captions when one page's captions take longer to read than a step is shown. A step is one page in the video.", + "placeholders": { + "page": { + "type": "int", + "example": "3" + }, + "seconds": { + "type": "int", + "example": "8" + }, + "step": { + "type": "int", + "example": "5" + } + } + }, + "exportVideoDefaultFileName": "new video", + "@exportVideoDefaultFileName": { + "description": "File name offered when exporting a video of a strategy that has no name. Used as a file name, so avoid characters like / : ? *." + }, + "exportVideoBrowserUnsupported": "This browser cannot export video. Try the latest Chrome or Edge, or the desktop app.", + "@exportVideoBrowserUnsupported": { + "description": "Toast on the web when the browser has no video encoder." + }, + "exportVideoEncoderNotFound": "Video encoder not found. Reinstalling Icarus should fix this.", + "@exportVideoEncoderNotFound": { + "description": "Toast on desktop when the bundled video encoder is missing." + }, + "exportVideoSavingChanges": "Saving your changes", + "@exportVideoSavingChanges": { + "description": "Progress label while unsaved work is saved before the export starts." + }, + "exportVideoPreparing": "Preparing", + "@exportVideoPreparing": { + "description": "Progress label before the first frame is drawn." + }, + "exportVideoExported": "Video exported.", + "@exportVideoExported": { + "description": "Toast after a video export finishes." + }, + "exportVideoCancelled": "Video export cancelled.", + "@exportVideoCancelled": { + "description": "Toast after the user cancels a video export." + }, + "exportVideoFailed": "Video export failed: {error}", + "@exportVideoFailed": { + "description": "Toast when a video export fails. The error is technical text, usually English.", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "exportVideoSeconds": "{seconds}s", + "@exportVideoSeconds": { + "description": "A short length of time in seconds, as on the step duration readout and the video length estimate.", + "placeholders": { + "seconds": { + "type": "int", + "example": "5" + } + } + }, + "exportVideoMinutesSeconds": "{minutes}m {seconds}s", + "@exportVideoMinutesSeconds": { + "description": "A video length of a minute or more. Seconds are always two digits.", + "placeholders": { + "minutes": { + "type": "int", + "example": "1" + }, + "seconds": { + "type": "String", + "example": "05" + } + } + }, + "exportVideoExportingTitle": "Exporting video", + "@exportVideoExportingTitle": { + "description": "Title of the video export dialog while the export runs." + }, + "exportVideoCancelExport": "Cancel export", + "@exportVideoCancelExport": { + "description": "Button that stops a running video export." + }, + "exportVideoKeepTabOpen": "Keep this tab open until the video downloads.", + "@exportVideoKeepTabOpen": { + "description": "Note on the web while a video export runs." + }, + "exportVideoTitle": "Export video", + "@exportVideoTitle": { + "description": "Title of the video export dialog." + }, + "exportVideoDescription": "Plays the selected pages in order. Each step holds for the step duration, then transitions into the next.", + "@exportVideoDescription": { + "description": "Video export dialog description. A step is one page as shown in the video." + }, + "exportVideoStepDuration": "Step duration", + "@exportVideoStepDuration": { + "description": "Section label for how long each page is held in the video." + }, + "exportVideoStepDurationDescription": "How long each step holds on screen before its transition plays.", + "exportVideoTransparentBackground": "Transparent background", + "@exportVideoTransparentBackground": { + "description": "Checkbox that exports the video without the map background." + }, + "exportVideoTransparentBackgroundDescription": "Leave out the background and dots. Saves a lossless .mov that keeps the transparency, for video editors.", + "exportVideoIncludeCaptions": "Include captions", + "@exportVideoIncludeCaptions": { + "description": "Checkbox that draws captions into the video. A caption is a note kept on a placed item." + }, + "exportVideoQuality": "Quality", + "@exportVideoQuality": { + "description": "Section label above the video quality presets." + }, + "exportVideoTransparentQualityNote": "A transparent video is lossless at 30 fps, so these don't apply.", + "@exportVideoTransparentQualityNote": { + "description": "Shown under the dimmed quality presets when a transparent background is on." + }, + "exportVideoPages": "Pages", + "@exportVideoPages": { + "description": "Section label above the list of pages to include." + }, + "exportVideoPagesSelected": "{selected} of {total}", + "@exportVideoPagesSelected": { + "description": "How many pages are ticked for the video.", + "placeholders": { + "selected": { + "type": "int", + "example": "3" + }, + "total": { + "type": "int", + "example": "5" + } + } + }, + "exportVideoClearAll": "Clear all", + "@exportVideoClearAll": { + "description": "Button that unticks every page." + }, + "exportVideoSelectAll": "Select all", + "@exportVideoSelectAll": { + "description": "Button that ticks every page." + }, + "exportVideoQualityPotato": "Potato", + "@exportVideoQualityPotato": { + "description": "Name of the smallest video quality preset; a joke name for low quality. Space is tight." + }, + "exportVideoQualitySocial": "Social", + "@exportVideoQualitySocial": { + "description": "Name of the video quality preset sized for social media. Space is tight." + }, + "exportVideoQualityMax": "Max", + "@exportVideoQualityMax": { + "description": "Name of the highest video quality preset. Space is tight." + }, + "exportVideoQualityPotatoCaption": "~10 MB", + "@exportVideoQualityPotatoCaption": { + "description": "Approximate file size of the Potato preset." + }, + "exportVideoQualitySocialCaption": "~20 MB", + "@exportVideoQualitySocialCaption": { + "description": "Approximate file size of the Social preset." + }, + "exportVideoQualityMaxCaption": "60 fps, no size cap", + "@exportVideoQualityMaxCaption": { + "description": "What sets the Max preset apart: 60 frames per second and no file size limit." + }, + "exportVideoSelectAPage": "Select at least one page to export.", + "@exportVideoSelectAPage": { + "description": "Summary line when no page is ticked." + }, + "exportVideoSummaryLength": "~{duration} video", + "@exportVideoSummaryLength": { + "description": "First part of the export summary line: the estimated video length. Followed by exportVideoSummaryFormat in a lighter colour.", + "placeholders": { + "duration": { + "type": "String", + "example": "1m 05s" + } + } + }, + "exportVideoSummaryFormat": " · {height}p · {format}", + "@exportVideoSummaryFormat": { + "description": "Second part of the export summary line, after the length: resolution and file type. Keep the leading space and dots.", + "placeholders": { + "height": { + "type": "int", + "example": "1080" + }, + "format": { + "type": "String", + "example": "MP4" + } + } + }, + "shareLinksTitle": "Share \"{name}\"", + "@shareLinksTitle": { + "description": "Title of the share dialog. The name is the strategy or folder name.", + "placeholders": { + "name": { + "type": "String", + "example": "Ascent retake" + } + } + }, + "shareLinksJustNow": "just now", + "@shareLinksJustNow": { + "description": "Fills 'when' in 'Created just now' / 'Joined just now'. Lowercase because it sits mid-sentence." + }, + "shareLinksMinutesAgo": "{count}m ago", + "@shareLinksMinutesAgo": { + "description": "Short relative time, minutes. Fills 'when' in 'Created 5m ago'.", + "placeholders": { + "count": { + "type": "int", + "example": "5" + } + } + }, + "shareLinksHoursAgo": "{count}h ago", + "@shareLinksHoursAgo": { + "description": "Short relative time, hours.", + "placeholders": { + "count": { + "type": "int", + "example": "3" + } + } + }, + "shareLinksDaysAgo": "{count}d ago", + "@shareLinksDaysAgo": { + "description": "Short relative time, days.", + "placeholders": { + "count": { + "type": "int", + "example": "2" + } + } + }, + "shareLinksTeammateFallback": "Teammate", + "@shareLinksTeammateFallback": { + "description": "Name shown for a person with access who has no display name." + }, + "shareLinksLoadMoreFailed": "Couldn't load more people.", + "@shareLinksLoadMoreFailed": { + "description": "Toast when the next page of people with access fails to load." + }, + "shareLinksLinkCopied": "Share link copied to clipboard.", + "@shareLinksLinkCopied": { + "description": "Toast after a share link is copied." + }, + "shareLinksCodeCopied": "Share code copied to clipboard.", + "@shareLinksCodeCopied": { + "description": "Toast after a share code is copied." + }, + "shareLinksCreateFailed": "Failed to create share link.", + "@shareLinksCreateFailed": { + "description": "Toast when a share link could not be created." + }, + "shareLinksLoadFailed": "Couldn't load the share links.", + "@shareLinksLoadFailed": { + "description": "Shown in the share dialog when it cannot load." + }, + "shareLinksPeopleWithAccess": "People with access", + "@shareLinksPeopleWithAccess": { + "description": "Section heading in the share dialog." + }, + "shareLinksLoadPeopleFailed": "Couldn't load who else has access.", + "shareLinksLoadingMore": "Loading…", + "@shareLinksLoadingMore": { + "description": "Show more button while more people load." + }, + "shareLinksShowMore": "Show more", + "@shareLinksShowMore": { + "description": "Button that loads more people with access." + }, + "shareLinksFolderAccess": "People with access to the folder \"{folder}\" can open this too.", + "@shareLinksFolderAccess": { + "description": "Line in the share dialog for a strategy inside a shared folder.", + "placeholders": { + "folder": { + "type": "String", + "example": "Team strats" + } + } + }, + "shareLinksJoined": "Joined {when}", + "@shareLinksJoined": { + "description": "Under a person's name: when they got access.", + "placeholders": { + "when": { + "type": "String", + "example": "2d ago" + } + } + }, + "shareLinksJoinedThroughEditLink": "Joined through an edit link · {when}", + "@shareLinksJoinedThroughEditLink": { + "description": "Under a person's name: they got access through a link that lets them edit.", + "placeholders": { + "when": { + "type": "String", + "example": "2d ago" + } + } + }, + "shareLinksJoinedThroughViewLink": "Joined through a view link · {when}", + "@shareLinksJoinedThroughViewLink": { + "description": "Under a person's name: they got access through a view-only link.", + "placeholders": { + "when": { + "type": "String", + "example": "2d ago" + } + } + }, + "shareLinksJoinedThroughLink": "Joined through a link · {when}", + "@shareLinksJoinedThroughLink": { + "description": "Under a person's name: they got access through a link that no longer shows.", + "placeholders": { + "when": { + "type": "String", + "example": "2d ago" + } + } + }, + "shareLinksNewLink": "New link", + "@shareLinksNewLink": { + "description": "Section heading above the controls that create a share link." + }, + "shareLinksCanEdit": "Can edit", + "@shareLinksCanEdit": { + "description": "Access level that lets a person change the strategy. Used for links and for people." + }, + "shareLinksViewOnly": "View only", + "@shareLinksViewOnly": { + "description": "Kind of new share link that only lets people look." + }, + "shareLinksViewOnlyLinkDescription": "Anyone with the link can open every page", + "shareLinksCanEditLinkDescription": "Anyone with the link can change pages, drawings, and media", + "shareLinksCreating": "Creating…", + "@shareLinksCreating": { + "description": "Create & copy button while the link is created." + }, + "shareLinksCreateAndCopy": "Create & copy", + "@shareLinksCreateAndCopy": { + "description": "Button that creates a share link and copies it." + }, + "shareLinksActiveLinks": "Active links", + "@shareLinksActiveLinks": { + "description": "Section heading above the list of share links." + }, + "shareLinksNoLinks": "No links yet. Create one above to share it.", + "@shareLinksNoLinks": { + "description": "Empty state of the share links list." + }, + "shareLinksYouSuffix": " (you)", + "@shareLinksYouSuffix": { + "description": "Drawn after the user's own name in the people list, in a lighter colour. Keep the leading space if your language uses one." + }, + "shareLinksOwner": "Owner", + "@shareLinksOwner": { + "description": "Shown beside the owner in the people list." + }, + "shareLinksCanView": "Can view", + "@shareLinksCanView": { + "description": "Access level of a person who can only look." + }, + "shareLinksCanViewMemberDescription": "Opens every page, changes nothing", + "shareLinksCanEditMemberDescription": "Changes pages, drawings, and media", + "shareLinksRemoveMember": "Remove {name}", + "@shareLinksRemoveMember": { + "description": "Tooltip and screen reader label of the button that removes a person's access.", + "placeholders": { + "name": { + "type": "String", + "example": "Sam" + } + } + }, + "shareLinksNameSeparator": ", ", + "@shareLinksNameSeparator": { + "description": "Goes between names in a list of people." + }, + "shareLinksJoinedNamesAndOthers": "{names} and others", + "@shareLinksJoinedNamesAndOthers": { + "description": "List of some people who joined through a link, when there are more than listed.", + "placeholders": { + "names": { + "type": "String", + "example": "Sam, Alex" + } + } + }, + "shareLinksEditBadge": "Edit", + "@shareLinksEditBadge": { + "description": "Badge on a share link that lets people edit. Space is tight." + }, + "shareLinksViewBadge": "View", + "@shareLinksViewBadge": { + "description": "Badge on a view-only share link. Space is tight." + }, + "shareLinksCreated": "Created {when}", + "@shareLinksCreated": { + "description": "Under a share link: when it was made.", + "placeholders": { + "when": { + "type": "String", + "example": "2d ago" + } + } + }, + "shareLinksCreatedRemoving": "Created {when} · Removing people…", + "@shareLinksCreatedRemoving": { + "description": "Under a disabled share link while the people who joined through it are being removed.", + "placeholders": { + "when": { + "type": "String", + "example": "2d ago" + } + } + }, + "shareLinksCreatedJoined": "Created {when} · {count} joined", + "@shareLinksCreatedJoined": { + "description": "Under a share link: when it was made and how many people joined through it.", + "placeholders": { + "when": { + "type": "String", + "example": "2d ago" + }, + "count": { + "type": "int", + "example": "3" + } + } + }, + "shareLinksCreatedJoinedMore": "Created {when} · {count}+ joined", + "@shareLinksCreatedJoinedMore": { + "description": "Like shareLinksCreatedJoined, when at least that many people joined.", + "placeholders": { + "when": { + "type": "String", + "example": "2d ago" + }, + "count": { + "type": "int", + "example": "50" + } + } + }, + "shareLinksCopyLinkTooltip": "Copy link", + "@shareLinksCopyLinkTooltip": { + "description": "Tooltip of the button that copies a share link." + }, + "shareLinksCopyCodeTooltip": "Copy code", + "@shareLinksCopyCodeTooltip": { + "description": "Tooltip of the button that copies a share code." + }, + "addSharedItemEmpty": "Paste a share link or code first.", + "@addSharedItemEmpty": { + "description": "Error in the add shared item dialog when the field is empty." + }, + "addSharedItemInvalid": "That doesn't look like an Icarus share link or code.", + "@addSharedItemInvalid": { + "description": "Error in the add shared item dialog when the text is not a link or code." + }, + "addSharedItemFailed": "Couldn't add that item. The link may be revoked, or you may need to sign in.", + "@addSharedItemFailed": { + "description": "Error when a share link or code could not be added." + }, + "addSharedItemTitle": "Add Shared Item", + "@addSharedItemTitle": { + "description": "Title of the dialog that adds a strategy or folder from a share link." + }, + "addSharedItemDescription": "Paste an Icarus share link or enter a share code to add it to Shared with Me.", + "@addSharedItemDescription": { + "description": "Add shared item dialog description. 'Shared with Me' names the library's shared section." + }, + "addSharedItemAdd": "Add", + "@addSharedItemAdd": { + "description": "Button that adds the shared item." + }, + "addSharedItemAdding": "Adding…", + "@addSharedItemAdding": { + "description": "Add button while adding." + }, + "addSharedItemPlaceholder": "{origin}/share/… or ICR-XXXX-XXXX-XXXX-XXXX", + "@addSharedItemPlaceholder": { + "description": "Placeholder of the share link field: an example link and the share code format. Only 'or' needs translating.", + "placeholders": { + "origin": { + "type": "String", + "example": "https://icarus-strats.xyz" + } + } + }, + "leaveSharedTitle": "Leave \"{name}\"?", + "@leaveSharedTitle": { + "description": "Title of the dialog confirming the user leaves a strategy or folder shared with them.", + "placeholders": { + "name": { + "type": "String", + "example": "Team strats" + } + } + }, + "leaveSharedDescription": "You lose access, and it leaves your Shared tab. You can only get it back through a link from its owner.", + "leaveSharedFailed": "Couldn't leave it. Check your connection and try again.", + "@leaveSharedFailed": { + "description": "Toast when leaving a shared item fails." + }, + "welcomeTourKicker": "New in this update", + "@welcomeTourKicker": { + "description": "Small heading above each welcome tour step." + }, + "welcomeTourLibraryTitle": "Your library is still here", + "@welcomeTourLibraryTitle": { + "description": "Title of the welcome tour step that reassures the user their local strategies are safe." + }, + "welcomeTourLibraryBody": "Everything you made before this update is saved on this computer, exactly as it was. Nothing moves unless you move it.", + "welcomeTourCloudTitle": "Sign in to sync and share", + "@welcomeTourCloudTitle": { + "description": "Title of the welcome tour step about accounts." + }, + "welcomeTourCloudBody": "Strategies you make while signed in are saved to your account, so they open on any computer and you can share them with your team. To bring an older one along, right-click it and choose **Move to cloud**.", + "@welcomeTourCloudBody": { + "description": "Welcome tour step about accounts. Text between ** and ** is drawn brighter: it is the name of a menu item, so translate it the same as that menu item and keep the ** marks." + }, + "welcomeTourReplaysTitle": "Turn your matches into strategies", + "@welcomeTourReplaysTitle": { + "description": "Title of the welcome tour step about replays (VALORANT match recordings)." + }, + "welcomeTourReplaysBody": "The **Replays** tab lists matches you downloaded in VALORANT. Pause on any moment and press **Capture** to save it as a strategy page.", + "@welcomeTourReplaysBody": { + "description": "Welcome tour step about replays. Text between ** and ** is drawn brighter: it names a tab and a button on screen, so translate them the same as those and keep the ** marks. This copy states what the release promises about replays and is the most likely to change." + }, + "welcomeTourBack": "Back", + "@welcomeTourBack": { + "description": "Button that goes to the previous welcome tour step." + }, + "welcomeTourNext": "Next", + "@welcomeTourNext": { + "description": "Button that goes to the next welcome tour step." + }, + "welcomeTourNotNow": "Not now", + "@welcomeTourNotNow": { + "description": "Button that skips signing in and goes to the next step." + }, + "welcomeTourSignIn": "Sign in", + "@welcomeTourSignIn": { + "description": "Button that opens the sign-in dialog." + }, + "welcomeTourOpenReplays": "Open Replays", + "@welcomeTourOpenReplays": { + "description": "Button that closes the tour and opens the Replays tab." + }, + "welcomeTourStepLabel": "Step {step} of {count}", + "@welcomeTourStepLabel": { + "description": "Screen reader label of a step dot in the welcome tour.", + "placeholders": { + "step": { + "type": "int", + "example": "1" + }, + "count": { + "type": "int", + "example": "3" + } + } + }, + "welcomeTourPictureASiteExecute": "A site execute", + "@welcomeTourPictureASiteExecute": { + "description": "Example strategy name drawn in a welcome tour picture. 'A site' is a bomb site in VALORANT; an execute is a coordinated push onto it. Space is tight." + }, + "welcomeTourPictureBRetake": "B retake vs Viper", + "@welcomeTourPictureBRetake": { + "description": "Example strategy name drawn in a welcome tour picture. Viper is an agent name; keep it as is. Space is tight." + }, + "welcomeTourPicturePistolDefaults": "Pistol defaults", + "@welcomeTourPicturePistolDefaults": { + "description": "Example strategy name drawn in a welcome tour picture: default setups for the pistol round. Space is tight." + }, + "welcomeTourPictureRetakeDrills": "Retake drills", + "@welcomeTourPictureRetakeDrills": { + "description": "Example strategy name drawn in a welcome tour picture. Space is tight." + }, + "welcomeTourPictureOnThisDevice": "On this device", + "@welcomeTourPictureOnThisDevice": { + "description": "Badge under a strategy in a welcome tour picture; should match the library's badge for strategies stored locally. Space is tight." + }, + "welcomeTourPictureMoving": "Moving…", + "@welcomeTourPictureMoving": { + "description": "Badge under a strategy being moved to the cloud, in a welcome tour picture. Space is tight." + }, + "welcomeTourPictureInYourAccount": "In your account", + "@welcomeTourPictureInYourAccount": { + "description": "Badge under a cloud strategy in a welcome tour picture. Space is tight." + }, + "welcomeTourPictureReplayDate": "Tue, Oct 13", + "@welcomeTourPictureReplayDate": { + "description": "Example match date drawn in the welcome tour's replay picture. Write any short weekday and date in your language's usual format." + }, + "welcomeTourPictureMoveToCloud": "Move to cloud", + "@welcomeTourPictureMoveToCloud": { + "description": "Menu item drawn in a welcome tour picture; should match the library's real right-click menu item and the **Move to cloud** text in the tour." + }, + "welcomeTourPictureCapture": "Capture", + "@welcomeTourPictureCapture": { + "description": "Button drawn in the welcome tour's replay picture; should match the replay viewer's Capture button." + }, + "createLineupPasteFailed": "Could not add a pasted image: {error}", + "@createLineupPasteFailed": { + "description": "Error toast when a pasted image could not be added to a lineup. The error is technical text, usually English.", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "syncButtonConflictToast": "Another edit reached the cloud first. Your version is still on this device and needs attention.", + "@syncButtonConflictToast": { + "description": "Toast when the server rejected an edit because another edit landed first." + }, + "syncButtonUseCloudFailed": "Could not load the cloud version. Your saved version was not changed.", + "@syncButtonUseCloudFailed": { + "description": "Error in the sync popover after pressing Use cloud failed." + }, + "syncButtonKeepBothUnchanged": "Could not load the cloud version. Nothing was changed.", + "@syncButtonKeepBothUnchanged": { + "description": "Error in the sync popover after pressing Keep both, when nothing changed." + }, + "syncButtonKeepBothCopiesOnly": "Your version was added as a copy, but the conflict is still open.", + "@syncButtonKeepBothCopiesOnly": { + "description": "Error in the sync popover after Keep both added a copy but could not close the conflict." + }, + "syncButtonKeepBothFailed": "Could not keep both versions. Your version is still saved on this device.", + "@syncButtonKeepBothFailed": { + "description": "Error in the sync popover after Keep both failed." + }, + "syncButtonViewOnlyTooltip": "View only · {status}", + "@syncButtonViewOnlyTooltip": { + "description": "Tooltip of the cloud save button for a user who can only view the strategy. {status} is the sync status tooltip text.", + "placeholders": { + "status": { + "type": "String" + } + } + }, + "syncButtonOfflineCopyTooltip": "{count, plural, =0{Offline. Showing this computer's copy; changes stay on this device} =1{Offline. Showing this computer's copy · 1 change saved on this computer} other{Offline. Showing this computer's copy · {count} changes saved on this computer}}", + "@syncButtonOfflineCopyTooltip": { + "description": "Tooltip of the cloud save button while offline and showing the copy saved on this computer, with no known copy time. {count} is how many changes wait to be sent. The =0 branch has different wording: nothing waits, so changes made now stay on this device.", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "syncButtonOfflineCopyFromTooltip": "{count, plural, =0{Offline. Showing this computer's copy from {when}; changes stay on this device} =1{Offline. Showing this computer's copy from {when} · 1 change saved on this computer} other{Offline. Showing this computer's copy from {when} · {count} changes saved on this computer}}", + "@syncButtonOfflineCopyFromTooltip": { + "description": "Tooltip of the cloud save button while offline and showing the copy saved on this computer. {when} is a time, or a date and time, already formatted. {count} is how many changes wait to be sent. The =0 branch has different wording: nothing waits, so changes made now stay on this device.", + "placeholders": { + "when": { + "type": "String" + }, + "count": { + "type": "int" + } + } + }, + "syncButtonCopyDateTime": "{date}, {time}", + "@syncButtonCopyDateTime": { + "description": "A short month-and-day date followed by a time, both already formatted for the language. Used inside the offline copy tooltip.", + "placeholders": { + "date": { + "type": "String" + }, + "time": { + "type": "String" + } + } + }, + "syncButtonSyncedTooltip": "Synced", + "@syncButtonSyncedTooltip": { + "description": "Tooltip of the cloud save button when everything is on the server." + }, + "syncButtonSyncedAtTooltip": "Synced at {time}", + "@syncButtonSyncedAtTooltip": { + "description": "Tooltip of the cloud save button when everything is on the server. {time} is a 24-hour HH:mm time.", + "placeholders": { + "time": { + "type": "String" + } + } + }, + "syncButtonEditNotSynced": "Edit not synced yet", + "@syncButtonEditNotSynced": { + "description": "Tooltip and popover title while an edit is still in progress and has not been sent." + }, + "syncButtonSyncingTooltip": "Syncing…", + "@syncButtonSyncingTooltip": { + "description": "Tooltip of the cloud save button while changes are being sent." + }, + "syncButtonNeedsAttention": "Sync needs attention", + "@syncButtonNeedsAttention": { + "description": "Tooltip and popover title when sync needs the user to act." + }, + "syncButtonDeleteAnyway": "Delete anyway", + "@syncButtonDeleteAnyway": { + "description": "Button in the sync popover: send the held page delete even though a teammate changed the page." + }, + "syncButtonKeepHeldPages": "{count, plural, =1{Keep the page} other{Keep the pages}}", + "@syncButtonKeepHeldPages": { + "description": "Button in the sync popover: cancel the held page delete(s) and keep the page(s).", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "syncButtonUseCloud": "Use cloud", + "@syncButtonUseCloud": { + "description": "Small button in the sync popover: replace the user's version with the cloud's. Space is tight." + }, + "syncButtonKeepBoth": "Keep both", + "@syncButtonKeepBoth": { + "description": "Small button in the sync popover: keep the cloud's version and add the user's beside it as a copy. Space is tight." + }, + "syncButtonKeepMine": "Keep mine", + "@syncButtonKeepMine": { + "description": "Small button in the sync popover: send the user's version over the cloud's. Space is tight." + }, + "syncButtonRetrySync": "Retry sync", + "@syncButtonRetrySync": { + "description": "Small button in the sync popover: send the waiting changes again." + }, + "syncButtonLastSyncedAt": "Last synced at {time}", + "@syncButtonLastSyncedAt": { + "description": "Small line at the bottom of the sync popover. {time} is a 24-hour HH:mm time.", + "placeholders": { + "time": { + "type": "String" + } + } + }, + "syncButtonSyncedTitle": "All changes synced", + "@syncButtonSyncedTitle": { + "description": "Sync popover title when everything is on the server." + }, + "syncButtonSyncingTitle": "Syncing changes", + "@syncButtonSyncingTitle": { + "description": "Sync popover title while changes are being sent." + }, + "syncButtonOfflineTitle": "Working offline", + "@syncButtonOfflineTitle": { + "description": "Sync popover title while offline." + }, + "syncButtonSyncedExplanation": "Your strategy is safely stored in the cloud.", + "syncButtonEditingExplanation": "Finish editing or switch pages to send this change to the cloud.", + "syncButtonSyncingOtherExplanation": "Saved changes from your cloud library are being sent in the background.", + "@syncButtonSyncingOtherExplanation": { + "description": "Sync popover text while changes from other strategies are being sent." + }, + "syncButtonSyncingExplanation": "Your edits are being sent to the cloud. You can keep working — this happens in the background.", + "syncButtonOfflineExplanation": "Changes are kept on this device and will sync automatically when your connection returns.", + "syncButtonOtherStrategyAlsoNeedsAttention": "Saved work in another strategy also needs attention. Open it from the Cloud library to review the reason.", + "@syncButtonOtherStrategyAlsoNeedsAttention": { + "description": "Sync popover sentence added after this strategy's own problem, when another strategy has a problem too." + }, + "syncButtonOtherStrategyNeedsAttention": "Saved work in another strategy needs attention. Open it from the Cloud library to review the reason.", + "@syncButtonOtherStrategyNeedsAttention": { + "description": "Sync popover text when only another strategy has a problem, not this one." + }, + "syncButtonLineupsNoLongerFit": "A teammate changed these lineups while you were working on them, so your change was not sent. The page shows your version, still saved on this device.", + "@syncButtonLineupsNoLongerFit": { + "description": "Sync popover text above a list of lineup changes, when the user's lineup change no longer fits the cloud's page." + }, + "syncButtonLineupsNoLongerFitChoices": "Keep mine saves these lineups exactly as shown, replacing all of their changes, even to lineups you did not edit. Use cloud replaces yours, and Keep both adds yours beside theirs as a copy.", + "@syncButtonLineupsNoLongerFitChoices": { + "description": "Explains the three buttons Keep mine, Use cloud and Keep both; use the same words as those buttons." + }, + "syncButtonLineupConflict": "A teammate changed these lineups while you were working on them. Your version remains saved on this device.", + "@syncButtonLineupConflict": { + "description": "Sync popover text above a list of conflicting lineup changes." + }, + "syncButtonLineupConflictChoices": "Keep mine replaces their changes, Use cloud replaces yours, and Keep both adds yours beside theirs as a copy.", + "@syncButtonLineupConflictChoices": { + "description": "Explains the three buttons Keep mine, Use cloud and Keep both; use the same words as those buttons." + }, + "syncButtonRejected": "Another edit reached the cloud first. Your version remains saved on this device.", + "@syncButtonRejected": { + "description": "Sync popover text when the server rejected the user's edit." + }, + "syncButtonRejectedChoice": "{count, plural, =1{Choose which version to keep for this conflicting change.} other{Your choice applies to all {count} conflicting changes.}}", + "@syncButtonRejectedChoice": { + "description": "Sync popover sentence after syncButtonRejected. {count} is the number of rejected changes.", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "syncButtonOtherChangesNotSaved": "Other changes here were not saved either. They remain on this device.", + "syncButtonChoiceAppliesToAll": "Your choice applies to all {count} changes that need attention.", + "@syncButtonChoiceAppliesToAll": { + "description": "Sync popover sentence; {count} is always more than one.", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "syncButtonImagesFailed": "{count, plural, =1{One image failed to upload.} other{{count} images failed to upload.}}", + "@syncButtonImagesFailed": { + "description": "Sync popover sentence.", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "syncButtonChangesNotSent": "Some changes haven't reached the cloud yet.", + "syncButtonRetryHint": "Retry to send them now.", + "@syncButtonRetryHint": { + "description": "Sync popover sentence pointing at the Retry sync button." + }, + "syncButtonLineupDiffTitle": "How your version differs from the cloud", + "@syncButtonLineupDiffTitle": { + "description": "Heading above a list of lineup changes in the sync popover." + }, + "syncButtonYourChangesTitle": "Your changes", + "@syncButtonYourChangesTitle": { + "description": "Heading above the user's lineup changes in the sync popover." + }, + "syncButtonCloudChangesTitle": "Cloud changes", + "@syncButtonCloudChangesTitle": { + "description": "Heading above a teammate's lineup changes in the sync popover." + }, + "syncButtonNoLineupChanges": "No lineup changes", + "@syncButtonNoLineupChanges": { + "description": "Shown under a lineup change heading when the list is empty." + }, + "syncButtonMoreLineups": "{count, plural, =1{and 1 more lineup} other{and {count} more lineups}}", + "@syncButtonMoreLineups": { + "description": "Last line of a shortened list of lineup changes.", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "outboxBannerUpgradeTitle": "Cloud work is waiting for a newer Icarus", + "@outboxBannerUpgradeTitle": { + "description": "Title of the library's floating cloud-work card when the server refuses this app version." + }, + "outboxBannerUpgradeDetail": "{count, plural, =1{1 saved change is kept on this device. {message}} other{{count} saved changes are kept on this device. {message}}}", + "@outboxBannerUpgradeDetail": { + "description": "Detail of the cloud-work card when the server refuses this app version. {message} is a following sentence explaining how to update.", + "placeholders": { + "count": { + "type": "int" + }, + "message": { + "type": "String" + } + } + }, + "outboxBannerAttentionTitle": "Cloud work needs attention", + "@outboxBannerAttentionTitle": { + "description": "Title of the library's floating cloud-work card." + }, + "outboxBannerSyncingTitle": "Syncing cloud work", + "@outboxBannerSyncingTitle": { + "description": "Title of the library's floating cloud-work card." + }, + "outboxBannerOfflineTitle": "Working offline", + "@outboxBannerOfflineTitle": { + "description": "Title of the library's floating cloud-work card while offline." + }, + "outboxBannerUnreadableDetail": "Icarus cannot read some saved cloud work on this device. Stay signed in and review it.", + "outboxBannerReconnectDetail": "Reconnect this account before Icarus can send its saved work.", + "outboxBannerDeletedStrategyDetail": "{count, plural, =1{1 unsent change to a strategy that was deleted cannot be saved. Discard it to clear this.} other{{count} unsent changes to a strategy that was deleted cannot be saved. Discard them to clear this.}}", + "@outboxBannerDeletedStrategyDetail": { + "description": "Detail of the cloud-work card when changes wait for a strategy the server no longer has.", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "outboxBannerAttentionDetail": "{count, plural, =1{1 saved change needs} other{{count} saved changes need}} review across {strategyCount, plural, =1{1 strategy} other{{strategyCount} strategies}}.", + "@outboxBannerAttentionDetail": { + "description": "Detail of the cloud-work card. {count} is the number of saved changes, {strategyCount} the number of strategies they belong to.", + "placeholders": { + "count": { + "type": "int" + }, + "strategyCount": { + "type": "int" + } + } + }, + "outboxBannerSendingDetail": "{count, plural, =1{1 saved change is} other{{count} saved changes are}} being sent from {strategyCount, plural, =1{1 strategy} other{{strategyCount} strategies}}.", + "@outboxBannerSendingDetail": { + "description": "Detail of the cloud-work card while sending. {count} is the number of saved changes, {strategyCount} the number of strategies they belong to.", + "placeholders": { + "count": { + "type": "int" + }, + "strategyCount": { + "type": "int" + } + } + }, + "outboxBannerOfflineDetail": "{count, plural, =1{1 saved change is waiting on this device and will resume when the connection returns.} other{{count} saved changes are waiting on this device and will resume when the connection returns.}}", + "@outboxBannerOfflineDetail": { + "description": "Detail of the cloud-work card while offline.", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "outboxBannerRetryCaptures": "{count, plural, =1{Retry replay capture} other{Retry replay captures}}", + "@outboxBannerRetryCaptures": { + "description": "Small button on the cloud-work card: send failed replay captures again.", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "outboxBannerDiscardCaptures": "{count, plural, =1{Discard replay capture} other{Discard {count} replay captures}}", + "@outboxBannerDiscardCaptures": { + "description": "Small button on the cloud-work card: throw away failed replay captures.", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "outboxBannerDiscardChangesTo": "Discard changes to {strategyName}", + "@outboxBannerDiscardChangesTo": { + "description": "Small button on the cloud-work card for a strategy deleted on the server. {strategyName} is the user's name for the strategy.", + "placeholders": { + "strategyName": { + "type": "String" + } + } + }, + "outboxBannerDiscardDeletedStrategyChanges": "Discard changes to the deleted strategy", + "@outboxBannerDiscardDeletedStrategyChanges": { + "description": "Small button on the cloud-work card for a deleted strategy whose name is unknown." + }, + "outboxBannerCaptureDiscardedToast": "Discarded. It may still appear in your library.", + "@outboxBannerCaptureDiscardedToast": { + "description": "Toast after discarding a replay capture that may already have reached the server." + }, + "outboxBannerUnnamedStrategy": "A cloud strategy", + "@outboxBannerUnnamedStrategy": { + "description": "Stands in for a strategy's name when it is unknown; used as {strategyName} in the outboxBannerReview… buttons." + }, + "outboxBannerReviewSync": "{strategyName}: review sync", + "@outboxBannerReviewSync": { + "description": "Small button on the cloud-work card that opens a strategy whose sync needs attention.", + "placeholders": { + "strategyName": { + "type": "String" + } + } + }, + "outboxBannerImagesFailed": "{strategyName}: {count, plural, =1{1 image failed} other{{count} images failed}}", + "@outboxBannerImagesFailed": { + "description": "Small button on the cloud-work card that opens a strategy whose image uploads failed.", + "placeholders": { + "strategyName": { + "type": "String" + }, + "count": { + "type": "int" + } + } + }, + "outboxBannerReview": "{strategyName}: review", + "@outboxBannerReview": { + "description": "Small button on the cloud-work card that opens a strategy that needs attention.", + "placeholders": { + "strategyName": { + "type": "String" + } + } + }, + "searchFieldHint": "Search", + "@searchFieldHint": { + "description": "Placeholder in the expanding search field." + }, + "libraryTabMyLibrary": "My Library", + "@libraryTabMyLibrary": { + "description": "Library tab: the user's own strategies and folders. Space is tight on phones." + }, + "libraryTabReplays": "Replays", + "@libraryTabReplays": { + "description": "Library tab: Valorant match recordings." + }, + "libraryTabShared": "Shared", + "@libraryTabShared": { + "description": "Library tab: what teammates shared with the user. Space is tight on phones." + }, + "libraryTabSharedSemantics": "Shared library", + "@libraryTabSharedSemantics": { + "description": "Screen reader label of the Shared library tab." + }, + "libraryTabCommunity": "Community", + "@libraryTabCommunity": { + "description": "Library tab: the public space. Space is tight on phones." + }, + "libraryTabCommunitySemantics": "Community library", + "@libraryTabCommunitySemantics": { + "description": "Screen reader label of the Community library tab." + }, + "libraryAddByLinkOrCode": "Add by Link or Code", + "@libraryAddByLinkOrCode": { + "description": "Button: add something a teammate shared, by pasting its share link or code." + }, + "libraryNewStrategy": "New Strategy", + "@libraryNewStrategy": { + "description": "Button and menu item in the library's title strip that starts a new strategy." + }, + "libraryNewFolder": "New Folder", + "@libraryNewFolder": { + "description": "Menu item in the library's New menu." + }, + "libraryNewMenuSemantics": "New", + "@libraryNewMenuSemantics": { + "description": "Screen reader label of the phone's + button that opens the New menu." + }, + "libraryAddReplay": "Add Replay", + "@libraryAddReplay": { + "description": "Button on the Replays tab: pick a Valorant replay file to add." + }, + "librarySortBy": "Sort by", + "@librarySortBy": { + "description": "Heading of the library's sort menu." + }, + "librarySortTooltip": "Sort", + "@librarySortTooltip": { + "description": "Tooltip of the library's sort button." + }, + "libraryImportIca": "Import .ica", + "@libraryImportIca": { + "description": "Menu item: import a strategy file. .ica is the file extension; keep it." + }, + "libraryImportBackup": "Import Backup", + "@libraryImportBackup": { + "description": "Menu item: import a whole library backup." + }, + "libraryExportLibrary": "Export Library", + "@libraryExportLibrary": { + "description": "Menu item: save a backup of the whole library." + }, + "libraryLogInSemantics": "Log in to Icarus", + "@libraryLogInSemantics": { + "description": "Screen reader label of the Log In button in the library's title strip." + }, + "libraryAccountSemantics": "Account for {displayName}", + "@libraryAccountSemantics": { + "description": "Screen reader label of the account avatar button. {displayName} is the user's name.", + "placeholders": { + "displayName": { + "type": "String" + } + } + }, + "folderEmptyTitle": "No strategies in this folder", + "@folderEmptyTitle": { + "description": "Empty state title of an empty folder in the library." + }, + "folderEmptyLocalSubtitle": "Create a new strategy or drop strategies, folders, or .zip archives", + "@folderEmptyLocalSubtitle": { + "description": "Empty state text of an empty folder on this computer. .zip is a file extension; keep it." + }, + "folderCreateStrategy": "Create Strategy", + "@folderCreateStrategy": { + "description": "Button in a library empty state that starts a new strategy." + }, + "folderLibraryEmptyTitle": "Your library is empty", + "@folderLibraryEmptyTitle": { + "description": "Empty state title of My Library with nothing in it." + }, + "folderLibraryEmptyCloudSubtitle": "Strategies you make are saved to your account.", + "@folderLibraryEmptyCloudSubtitle": { + "description": "Empty state text of My Library when signed in to the cloud." + }, + "folderLibraryEmptyLocalSubtitle": "Create a new strategy or drop strategies, folders, or .zip archives here.", + "@folderLibraryEmptyLocalSubtitle": { + "description": "Empty state text of My Library on this computer. .zip is a file extension; keep it." + }, + "folderSharedEmptyTitle": "Nothing shared with you yet", + "@folderSharedEmptyTitle": { + "description": "Empty state title of the Shared tab." + }, + "folderSharedEmptySubtitle": "Add a share link or code from a teammate to keep it here.", + "@folderSharedEmptySubtitle": { + "description": "Empty state text of the Shared tab." + }, + "folderSharedFolderEmptySubtitle": "Strategies shared into this folder will show up here.", + "@folderSharedFolderEmptySubtitle": { + "description": "Empty state text of an empty folder inside the Shared tab." + }, + "folderEmptyCloudSubtitle": "Create a new strategy to fill it.", + "@folderEmptyCloudSubtitle": { + "description": "Empty state text of an empty cloud folder." + }, + "folderNoMatchesTitle": "No matches", + "@folderNoMatchesTitle": { + "description": "Library empty state title when a search found nothing." + }, + "folderNoMatchesSubtitle": "Try a different search", + "@folderNoMatchesSubtitle": { + "description": "Library empty state text when a search found nothing." + }, + "folderCloudUnavailableTitle": "Cloud unavailable", + "@folderCloudUnavailableTitle": { + "description": "Title shown in the library when the cloud cannot be reached." + }, + "folderCloudUnavailableRetrySubtitle": "Retry to reach your online strategies, or go back to your library to keep working.", + "folderCloudUnavailableSignInSubtitle": "Sign in again to reach your online strategies, or go back to your library to keep working.", + "folderBackToMyLibrary": "Back to My Library", + "@folderBackToMyLibrary": { + "description": "Button: go to the My Library tab. Use the same name as the My Library tab." + }, + "folderCloudLoadFailedTitle": "Couldn't load your cloud library", + "folderCheckConnectionSubtitle": "Check your connection and try again.", + "folderSharedNotKeptTitle": "Shared isn't on this computer yet", + "@folderSharedNotKeptTitle": { + "description": "Title while offline when the Shared tab was never loaded on this computer. Shared is the tab's name." + }, + "folderNotKeptTitle": "This folder isn't on this computer yet", + "@folderNotKeptTitle": { + "description": "Title while offline when a cloud folder was never loaded on this computer." + }, + "folderNotKeptSubtitle": "Icarus keeps what you open while online. This loads when you're back online.", + "folderCloudNeedsUpgradeTitle": "Your cloud library needs a newer Icarus", + "@folderCloudNeedsUpgradeTitle": { + "description": "Title when the server refuses this app version." + }, + "folderCloudUnreachableTitle": "Couldn't reach your cloud library", + "folderSignInTitle": "Log in to use the web beta", + "@folderSignInTitle": { + "description": "Title of My Library on the web when signed out." + }, + "folderSignInSubtitle": "Strategies you make here are saved to your Icarus account.", + "folderCommunityTitle": "Community strats are coming soon", + "@folderCommunityTitle": { + "description": "Title of the Community tab placeholder. 'strats' is gamer slang for strategies." + }, + "folderCommunitySubtitle": "This space is reserved for public lineups, team executes, and discoverable strategy packs.", + "@folderCommunitySubtitle": { + "description": "Text of the Community tab placeholder. An 'execute' is a planned team attack on a site." + }, + "folderCloudLoadingBanner": "Loading your cloud library. Showing what's on this computer for now.", + "@folderCloudLoadingBanner": { + "description": "Banner above My Library while the cloud library loads." + }, + "folderCloudOfflineBanner": "Offline. Showing your cloud library as of {when}. Changes sync when you're back online.", + "@folderCloudOfflineBanner": { + "description": "Banner above the library while offline. {when} is a time, or a date and time, already formatted.", + "placeholders": { + "when": { + "type": "String" + } + } + }, + "folderCopyDateTime": "{date}, {time}", + "@folderCopyDateTime": { + "description": "A short month-and-day date followed by a time, both already formatted for the language. Used inside the offline library banner.", + "placeholders": { + "date": { + "type": "String" + }, + "time": { + "type": "String" + } + } + }, + "folderCloudErrorBanner": "Couldn't load your cloud library. Showing what's on this computer.", + "@folderCloudErrorBanner": { + "description": "Banner above My Library when the cloud library failed to load." + }, + "colorPickerTitle": "Select color", + "@colorPickerTitle": { + "description": "Default title of the color picker dialog." + }, + "colorPickerRed": "Red", + "@colorPickerRed": { + "description": "Placeholder of the red channel number field (0-255) in the color picker. The field is narrow." + }, + "colorPickerGreen": "Green", + "@colorPickerGreen": { + "description": "Placeholder of the green channel number field (0-255) in the color picker. The field is narrow." + }, + "colorPickerBlue": "Blue", + "@colorPickerBlue": { + "description": "Placeholder of the blue channel number field (0-255) in the color picker. The field is narrow." + }, + "colorPickerAlpha": "Alpha", + "@colorPickerAlpha": { + "description": "Placeholder of the opacity number field in the color picker. The field is narrow." + }, + "colorPickerHue": "Hue", + "@colorPickerHue": { + "description": "Placeholder of the hue number field (0-360) in the color picker. The field is narrow." + }, + "colorPickerSaturation": "Sat", + "@colorPickerSaturation": { + "description": "Abbreviation of saturation: placeholder of a narrow number field (0-100) in the color picker." + }, + "colorPickerLightness": "Lum", + "@colorPickerLightness": { + "description": "Abbreviation of lightness (HSL): placeholder of a narrow number field (0-100) in the color picker." + }, + "colorPickerValue": "Val", + "@colorPickerValue": { + "description": "Abbreviation of value/brightness (HSV): placeholder of a narrow number field (0-100) in the color picker." + }, + "folderEditSaveFailed": "Couldn't save this folder. Try again.", + "@folderEditSaveFailed": { + "description": "Error inside the folder dialog when saving failed." + }, + "folderEditTitleEdit": "Edit Folder", + "@folderEditTitleEdit": { + "description": "Title of the folder dialog when changing an existing folder." + }, + "folderEditTitleAdd": "Add Folder", + "@folderEditTitleAdd": { + "description": "Title of the folder dialog when creating a folder." + }, + "folderEditNameHint": "Folder Name", + "@folderEditNameHint": { + "description": "Placeholder of the folder name field." + }, + "folderEditSaving": "Saving...", + "@folderEditSaving": { + "description": "Folder dialog's Done button while the save is in flight." + }, + "folderEditCustomColorTitle": "Pick a custom color", + "@folderEditCustomColorTitle": { + "description": "Title of the dialog for picking a folder's custom color." + }, + "folderEditIconFilterAll": "All", + "@folderEditIconFilterAll": { + "description": "Tab in the folder dialog's icon picker: show every icon." + }, + "folderEditIconFilterSymbols": "Symbols", + "@folderEditIconFilterSymbols": { + "description": "Tab in the folder dialog's icon picker: symbol icons." + }, + "folderEditIconFilterRoles": "Roles", + "@folderEditIconFilterRoles": { + "description": "Tab in the folder dialog's icon picker: Valorant agent role icons." + }, + "folderEditIconFilterModes": "Modes", + "@folderEditIconFilterModes": { + "description": "Tab in the folder dialog's icon picker: game mode icons." + }, + "folderCardMovingToCloud": "Moving to cloud", + "@folderCardMovingToCloud": { + "description": "Small footer line on a folder card while its contents move to the cloud." + }, + "folderCardEmpty": "Empty", + "@folderCardEmpty": { + "description": "Small footer line on a folder card with no strategies." + }, + "folderCardStrategyCount": "{count, plural, =1{1 strategy} other{{count} strategies}}", + "@folderCardStrategyCount": { + "description": "Small footer line on a folder card.", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "folderCardLocalOnlyTooltip": "Saved only on this computer", + "@folderCardLocalOnlyTooltip": { + "description": "Tooltip of the monitor icon on a folder that is not in the cloud." + }, + "folderCardPin": "Pin", + "@folderCardPin": { + "description": "Folder menu item: keep this folder at the top of the library." + }, + "folderCardUnpin": "Unpin", + "@folderCardUnpin": { + "description": "Folder menu item: stop keeping this folder at the top." + }, + "folderCardMoveToCloud": "Move to cloud", + "@folderCardMoveToCloud": { + "description": "Folder menu item: move this folder from this computer into the cloud library." + }, + "folderCardExportFailed": "Failed to export folder.", + "@folderCardExportFailed": { + "description": "Error toast when exporting a folder failed." + }, + "icaDropAllNewerVersion": "These files were created in a newer version of Icarus. Please update the app and try again.", + "@icaDropAllNewerVersion": { + "description": "Import result when every file came from a newer Icarus." + }, + "icaDropSomeNewerVersion": "{count, plural, =1{1 was created in a newer version of Icarus. Please update the app and try again.} other{{count} were created in a newer version of Icarus. Please update the app and try again.}}", + "@icaDropSomeNewerVersion": { + "description": "Sentence added after an import summary; {count} is how many of the files came from a newer Icarus.", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "icaDropImportedThemeProfiles": "{count, plural, =1{Imported 1 theme profile.} other{Imported {count} theme profiles.}}", + "@icaDropImportedThemeProfiles": { + "description": "Sentence in an import summary.", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "icaDropRestoredLibrarySettings": "Restored library settings.", + "@icaDropRestoredLibrarySettings": { + "description": "Sentence in an import summary after a library backup restored settings." + }, + "icaDropNothingImported": "No compatible strategies or folders were imported.", + "@icaDropNothingImported": { + "description": "Import summary when nothing could be imported." + }, + "icaDropSkippedFiles": "{count, plural, =1{Skipped 1 file.} other{Skipped {count} files.}}", + "@icaDropSkippedFiles": { + "description": "Last sentence of an import summary: files that could not be imported.", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "icaDropImportedStrategiesIntoFolders": "Imported {strategyCount, plural, =1{1 strategy} other{{strategyCount} strategies}} into {folderCount, plural, =1{1 folder} other{{folderCount} folders}}.", + "@icaDropImportedStrategiesIntoFolders": { + "description": "Import summary.", + "placeholders": { + "strategyCount": { + "type": "int" + }, + "folderCount": { + "type": "int" + } + } + }, + "icaDropImportedStrategies": "{count, plural, =1{Imported 1 strategy.} other{Imported {count} strategies.}}", + "@icaDropImportedStrategies": { + "description": "Import summary.", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "icaDropImportedFolders": "{count, plural, =1{Imported 1 folder.} other{Imported {count} folders.}}", + "@icaDropImportedFolders": { + "description": "Import summary.", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "icaDropImportFailed": "Failed to import dropped items.", + "@icaDropImportFailed": { + "description": "Error toast when files dropped on the library could not be imported." + }, + "icaDropOverlayLabel": "Import strategies, folders, backups, or replays", + "@icaDropOverlayLabel": { + "description": "Large label over the library while files are dragged onto it." + }, + "libraryImportIcaFailed": "Failed to import strategy file.", + "@libraryImportIcaFailed": { + "description": "Error toast when importing a strategy file failed." + }, + "libraryImportBackupFailed": "Failed to import backup archive.", + "@libraryImportBackupFailed": { + "description": "Error toast when importing a library backup failed." + }, + "libraryExportFailed": "Failed to export library.", + "@libraryExportFailed": { + "description": "Error toast when exporting the library failed." + }, + "libraryCreateCloudStrategyFailed": "Couldn't create cloud strategy right now. Please try logging in again.", + "@libraryCreateCloudStrategyFailed": { + "description": "Error toast when a new cloud strategy could not be created." + }, + "libraryCreateStrategyFailed": "Couldn't create strategy right now.", + "@libraryCreateStrategyFailed": { + "description": "Error toast when a new strategy could not be created." + }, + "libraryCreateFolderMenu": "Create folder", + "@libraryCreateFolderMenu": { + "description": "Right-click menu item on the library background." + }, + "libraryCreateStrategyMenu": "Create strategy", + "@libraryCreateStrategyMenu": { + "description": "Right-click menu item on the library background." + }, + "desktopUpdateRequiredBadge": "Required", + "@desktopUpdateRequiredBadge": { + "description": "Small badge on the desktop update dialog when the update is mandatory." + }, + "desktopUpdateRestartToUpdate": "Restart to update", + "@desktopUpdateRestartToUpdate": { + "description": "Desktop update dialog button once the update is downloaded." + }, + "desktopUpdateDownload": "Download Update", + "@desktopUpdateDownload": { + "description": "Desktop update dialog button that starts the download." + }, + "desktopUpdateDownloadFailed": "Failed to download the desktop update. Please try again.", + "@desktopUpdateDownloadFailed": { + "description": "Error toast." + }, + "desktopUpdateRestartRequiredTitle": "Restart Required", + "@desktopUpdateRestartRequiredTitle": { + "description": "Title of the confirm dialog before restarting to install an update." + }, + "desktopUpdateRestartWarning": "A restart is required to complete the update installation.\nAny unsaved changes will be lost. Would you like to restart now?", + "@desktopUpdateRestartWarning": { + "description": "Body of the confirm dialog before restarting to install an update. Keep the line break." + }, + "desktopUpdateRestartConfirm": "Restart", + "@desktopUpdateRestartConfirm": { + "description": "Confirm button: restart now to install the update." + }, + "desktopUpdateRestartCancel": "Not now", + "@desktopUpdateRestartCancel": { + "description": "Cancel button: do not restart yet." + }, + "desktopUpdateApplyFailed": "Failed to apply the downloaded desktop update. Please try downloading it again.", + "@desktopUpdateApplyFailed": { + "description": "Error toast." + }, + "desktopUpdateDownloading": "Downloading… {percent}%", + "@desktopUpdateDownloading": { + "description": "Label over the download progress bar. {percent} is 0-100.", + "placeholders": { + "percent": { + "type": "int" + } + } + }, + "captionCardAgentName": "Agent", + "@captionCardAgentName": { + "description": "Header of a caption card on a placed agent whose agent is unknown." + }, + "captionCardTextName": "Text", + "@captionCardTextName": { + "description": "Header of a caption card on a placed text box." + }, + "captionCardImageName": "Image", + "@captionCardImageName": { + "description": "Header of a caption card on a placed image." + }, + "captionCardVisionConeName": "Vision cone", + "@captionCardVisionConeName": { + "description": "Header of a caption card on a placed vision cone (what an agent can see)." + }, + "captionCardShapeName": "Shape", + "@captionCardShapeName": { + "description": "Header of a caption card on a placed shape." + }, + "captionCardItemGone": "That item is gone, so your note wasn't saved. It's on your clipboard.", + "@captionCardItemGone": { + "description": "Toast when a teammate deleted the item while the user was writing its caption." + }, + "captionCardHint": "Write a caption", + "@captionCardHint": { + "description": "Placeholder of the caption text field." + }, + "captionCardSaveShortcut": "{shortcut} to save", + "@captionCardSaveShortcut": { + "description": "Small hint in the caption card header. {shortcut} is a key combination such as Ctrl+Enter or ⌘Enter; space is tight.", + "placeholders": { + "shortcut": { + "type": "String" + } + } + }, + "lineupControlPlaceBoth": "Place an agent and ability to continue", + "@lineupControlPlaceBoth": { + "description": "Tooltip of the disabled Next button while placing a lineup." + }, + "lineupControlPlaceAgent": "Place an agent to continue", + "@lineupControlPlaceAgent": { + "description": "Tooltip of the disabled Next button while placing a lineup." + }, + "lineupControlPlaceAbility": "Place an ability to continue", + "@lineupControlPlaceAbility": { + "description": "Tooltip of the disabled Next button while placing a lineup." + }, + "lineupControlFinalize": "Finalize lineup details", + "@lineupControlFinalize": { + "description": "Tooltip of the Next button once a lineup's agent and ability are placed." + }, + "lineupControlNext": "Next", + "@lineupControlNext": { + "description": "Button: go on to the lineup details dialog." + }, + "canvasPasteNoImage": "No image found in clipboard", + "@canvasPasteNoImage": { + "description": "Error toast when pasting onto the map with nothing usable on the clipboard." + }, + "canvasPasteFailed": "Couldn't paste what you copied.", + "@canvasPasteFailed": { + "description": "Error toast when pasting onto the map failed." + }, + "canvasPasteAddingPicture": "Adding picture…", + "@canvasPasteAddingPicture": { + "description": "Toast while a pasted picture is being prepared." + }, + "canvasPasteUnsupportedImage": "Clipboard image type not supported", + "@canvasPasteUnsupportedImage": { + "description": "Error toast when the pasted picture's format cannot be used." + }, + "canvasPasteOtherMap": "What you copied is from another map.", + "@canvasPasteOtherMap": { + "description": "Error toast when pasting items copied on a different map." + }, + "canvasPasteFromMap": "What you copied is from {mapName}. Paste it into a strategy on {mapName}.", + "@canvasPasteFromMap": { + "description": "Error toast when pasting items copied on a different map. {mapName} is a Valorant map name.", + "placeholders": { + "mapName": { + "type": "String" + } + } + }, + "canvasPastePicturesMissing": "A picture in what you copied couldn't be read when you copied it, so nothing was pasted. Check your connection and copy it again.", + "canvasPastePictureTooLarge": "A picture in what you copied is over 15 MB, more than a cloud strategy takes, so nothing was pasted.", + "canvasPastePageChanged": "The page changed before the paste finished, so nothing was pasted.", + "editorToolbarViewOnlySignIn": "View only · Sign in to add it to your library", + "@editorToolbarViewOnlySignIn": { + "description": "Tooltip of the editor toolbar button on a strategy opened from a share link while signed out." + }, + "editorToolbarViewOnlyAddToLibrary": "View only · Add to my library", + "@editorToolbarViewOnlyAddToLibrary": { + "description": "Tooltip of the editor toolbar button that adds a strategy opened from a share link to the user's library." + }, + "editorToolbarExportIca": "Export .ica", + "@editorToolbarExportIca": { + "description": "Tooltip of the editor toolbar button that exports the strategy file. .ica is the file extension; keep it." + }, + "editorToolbarExportVideo": "Export video", + "@editorToolbarExportVideo": { + "description": "Tooltip of the editor toolbar button." + }, + "editorToolbarScreenshot": "Screenshot", + "@editorToolbarScreenshot": { + "description": "Tooltip of the editor toolbar button." + }, + "editorToolbarSettings": "Settings", + "@editorToolbarSettings": { + "description": "Tooltip of the editor toolbar button." + }, + "clientUpgradeUpdate": "Update", + "@clientUpgradeUpdate": { + "description": "Button shown when the server refuses this app version: install the waiting update." + }, + "clientUpgradeReload": "Reload", + "@clientUpgradeReload": { + "description": "Button on the web when the server refuses this app version: reload the page." + }, + "clientUpgradeCheckAgain": "Check again", + "@clientUpgradeCheckAgain": { + "description": "Button when the server refuses this app version and no update is known yet." + }, + "mapArtworkLoadFailedTitle": "Map didn't load", + "@mapArtworkLoadFailedTitle": { + "description": "Shown over the map area when its artwork failed to load." + }, + "mapArtworkLoadFailedRetrying": "Trying again. Your strategy isn't affected.", + "imageDropOverlayLabel": "Import image file (.png, .jpg, .webp, .gif, .bmp)", + "@imageDropOverlayLabel": { + "description": "Large label over the editor while an image file is dragged onto it. Keep the file extensions." + }, + "canvasSelectionHint": "Dragging on the map now selects. Hold Space and drag to move the map.", + "@canvasSelectionHint": { + "description": "One-time toast. Space is the keyboard key." + }, + "globalShortcutsFileSaved": "File saved", + "@globalShortcutsFileSaved": { + "description": "Toast after saving with the keyboard shortcut." + }, + "globalShortcutsSaveFailed": "Save failed", + "@globalShortcutsSaveFailed": { + "description": "Toast after saving with the keyboard shortcut failed." + }, + "libraryBreadcrumbSharedWithMe": "Shared with Me", + "@libraryBreadcrumbSharedWithMe": { + "description": "First item of the folder path breadcrumb inside the Shared tab." + }, + "lineupMediaNoImages": "No images available", + "@lineupMediaNoImages": { + "description": "Shown in a lineup's media viewer when it has no images or video." + }, + "lineupEditorsOneOnLineup": "{name} is editing this lineup right now.", + "@lineupEditorsOneOnLineup": { + "description": "Notice in a lineup dialog. {name} is a teammate's display name.", + "placeholders": { + "name": { + "type": "String" + } + } + }, + "lineupEditorsOneOnLineups": "{name} is editing these lineups right now.", + "@lineupEditorsOneOnLineups": { + "description": "Notice in a dialog listing several lineups. {name} is a teammate's display name.", + "placeholders": { + "name": { + "type": "String" + } + } + }, + "lineupEditorsTwoOnLineup": "{first} and {second} are editing this lineup right now.", + "@lineupEditorsTwoOnLineup": { + "description": "Notice in a lineup dialog. {first} and {second} are teammates' display names.", + "placeholders": { + "first": { + "type": "String" + }, + "second": { + "type": "String" + } + } + }, + "lineupEditorsTwoOnLineups": "{first} and {second} are editing these lineups right now.", + "@lineupEditorsTwoOnLineups": { + "description": "Notice in a dialog listing several lineups. {first} and {second} are teammates' display names.", + "placeholders": { + "first": { + "type": "String" + }, + "second": { + "type": "String" + } + } + }, + "lineupEditorsManyOnLineup": "{name} and {count, plural, =1{1 other is} other{{count} others are}} editing this lineup right now.", + "@lineupEditorsManyOnLineup": { + "description": "Notice in a lineup dialog. {name} is a teammate's display name; {count} is how many more teammates are editing (always 2 or more).", + "placeholders": { + "name": { + "type": "String" + }, + "count": { + "type": "int" + } + } + }, + "lineupEditorsManyOnLineups": "{name} and {count, plural, =1{1 other is} other{{count} others are}} editing these lineups right now.", + "@lineupEditorsManyOnLineups": { + "description": "Notice in a dialog listing several lineups. {name} is a teammate's display name; {count} is how many more teammates are editing (always 2 or more).", + "placeholders": { + "name": { + "type": "String" + }, + "count": { + "type": "int" + } + } + }, + "lineupEditStatusOne": "Drag {agentName} or the ability to its new spot", + "@lineupEditStatusOne": { + "description": "Status pill while moving a lineup. {agentName} is a Valorant agent's name.", + "placeholders": { + "agentName": { + "type": "String" + } + } + }, + "lineupEditStatusOneUnknownAgent": "Drag the agent or the ability to its new spot", + "@lineupEditStatusOneUnknownAgent": { + "description": "Status pill while moving a lineup whose agent is unknown." + }, + "lineupEditStatusSharedSpot": "Drag {agentName} or an ability. These {count} lineups share a spot, so they move together", + "@lineupEditStatusSharedSpot": { + "description": "Status pill while moving lineups that share a spot. {agentName} is a Valorant agent's name; {count} is 2 or more.", + "placeholders": { + "agentName": { + "type": "String" + }, + "count": { + "type": "int" + } + } + }, + "lineupEditStatusSharedSpotUnknownAgent": "Drag the agent or an ability. These {count} lineups share a spot, so they move together", + "@lineupEditStatusSharedSpotUnknownAgent": { + "description": "Status pill while moving lineups that share a spot, agent unknown. {count} is 2 or more.", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "lineupPlacementDragAbility": "Drag the ability to where it lands", + "@lineupPlacementDragAbility": { + "description": "Status pill while placing a lineup." + }, + "lineupPlacementDragAnAgent": "Drag an agent to where you throw from", + "@lineupPlacementDragAnAgent": { + "description": "Status pill while placing a lineup." + }, + "lineupPlacementDragAgent": "Drag {agentName} to where you throw from", + "@lineupPlacementDragAgent": { + "description": "Status pill while placing a lineup. {agentName} is a Valorant agent's name.", + "placeholders": { + "agentName": { + "type": "String" + } + } + }, + "lineupPlacementDragUnknownAgent": "Drag the agent to where you throw from", + "@lineupPlacementDragUnknownAgent": { + "description": "Status pill while placing a lineup whose agent is unknown." + }, + "settingsTitle": "Settings", + "@settingsTitle": { + "description": "Heading at the top of the settings dialog's navigation rail." + }, + "settingsNavCurrentStrategy": "Current strategy", + "@settingsNavCurrentStrategy": { + "description": "Small group header in the settings navigation rail, above the settings that apply only to the open strategy. The rail is 208 px wide." + }, + "settingsNavAppWide": "App-wide", + "@settingsNavAppWide": { + "description": "Small group header in the settings navigation rail, above the settings that apply to the whole app." + }, + "settingsNavDefaults": "Defaults", + "@settingsNavDefaults": { + "description": "Settings navigation item that jumps to the defaults for new strategies." + }, + "settingsNavAutosave": "Autosave", + "@settingsNavAutosave": { + "description": "Settings navigation item that jumps to the workspace behavior section (autosave)." + }, + "settingsNavMapLayers": "Map layers", + "@settingsNavMapLayers": { + "description": "Settings navigation item that jumps to the map visibility section." + }, + "settingsObjectStyling": "Object styling", + "@settingsObjectStyling": { + "description": "Settings section (and its navigation item) for marker sizes and colors in the open strategy." + }, + "settingsAccount": "Account", + "@settingsAccount": { + "description": "Settings section (and its navigation item) for the signed-in account." + }, + "settingsPrivacy": "Privacy", + "@settingsPrivacy": { + "description": "Settings section (and its navigation item) for analytics." + }, + "settingsKeybinds": "Keybinds", + "@settingsKeybinds": { + "description": "Settings section (and its navigation item) listing keyboard shortcuts." + }, + "settingsAgentMarkersTitle": "Agent markers", + "@settingsAgentMarkersTitle": { + "description": "Slider title: size of agent markers in the open strategy." + }, + "settingsAgentMarkersDescription": "Resize placed agents and view tools for this strategy.", + "settingsAbilityMarkersTitle": "Ability markers", + "@settingsAbilityMarkersTitle": { + "description": "Slider title: size of ability markers in the open strategy." + }, + "settingsAbilityMarkersDescription": "Resize utility icons and placement helpers for this strategy.", + "settingsNeutralTeamColorsTitle": "Neutral team marker colors", + "@settingsNeutralTeamColorsTitle": { + "description": "Checkbox title in the open strategy's settings." + }, + "settingsNeutralTeamColorsDescription": "Render ally and enemy marker accents as matching-brightness greys.", + "settingsMarkerSizesDiffer": "Marker sizes differ across pages. Apply these sizes everywhere?", + "@settingsMarkerSizesDiffer": { + "description": "Banner under the marker size sliders when the strategy's pages use different sizes." + }, + "settingsApplyMarkerSizesToAllPages": "Apply to all pages", + "@settingsApplyMarkerSizesToAllPages": { + "description": "Button under the marker sizes banner." + }, + "settingsApplyingMarkerSizes": "Applying…", + "@settingsApplyingMarkerSizes": { + "description": "The same button while the sizes are being applied." + }, + "settingsNewStrategyDefaultsTitle": "New strategy defaults", + "@settingsNewStrategyDefaultsTitle": { + "description": "Settings section title for values new strategies start with." + }, + "settingsDefaultAgentMarkersTitle": "Default agent markers", + "settingsDefaultAgentMarkersDescription": "Default size for agent markers in new strategies.", + "settingsDefaultAbilityMarkersTitle": "Default ability markers", + "settingsDefaultAbilityMarkersDescription": "Default size for ability markers in new strategies.", + "settingsDefaultNeutralColorsTitle": "Neutral marker colors by default", + "settingsDefaultNeutralColorsDescription": "Use grey ally and enemy accents for new strategies.", + "settingsWorkspaceBehaviorTitle": "Workspace behavior", + "@settingsWorkspaceBehaviorTitle": { + "description": "Settings section title holding autosave and Discord presence." + }, + "settingsAutosaveTitle": "Autosave", + "@settingsAutosaveTitle": { + "description": "Checkbox title: autosave strategies on this device." + }, + "settingsAutosaveDescription": "Save strategies on this device after 15 seconds idle. When off, Icarus asks before you leave unsaved work. Cloud strategies sync either way.", + "settingsDiscordPresenceTitle": "Discord Rich Presence", + "@settingsDiscordPresenceTitle": { + "description": "Checkbox title. Rich Presence is Discord's feature name." + }, + "settingsDiscordPresenceDescription": "Show the current map and attack or defense side on your Discord profile. Strategy names are never shared.", + "settingsMapVisibilityTitle": "Workspace map visibility", + "@settingsMapVisibilityTitle": { + "description": "Settings section title for what the map shows." + }, + "settingsSpawnBarriersTitle": "Spawn barriers", + "settingsSpawnBarriersDescription": "Keep round-start barrier guides visible on the map.", + "settingsRegionNamesTitle": "Region names", + "settingsRegionNamesDescription": "Show map callout names directly on the canvas.", + "settingsUltOrbsTitle": "Ultimate orbs", + "settingsUltOrbsDescription": "Display orb pickup markers on supported maps.", + "settingsCaptionsTitle": "Captions", + "@settingsCaptionsTitle": { + "description": "Checkbox title: show captions, the notes a user keeps on placed items." + }, + "settingsCaptionsDescription": "Show the notes kept on placed items. This device only.", + "settingsBackgroundDotsTitle": "Background dots", + "settingsBackgroundDotsDescription": "How strongly the dot grid shows behind the map and your library. 0% hides it.", + "settingsViewConesTitle": "View cones", + "settingsViewConesDescription": "How strongly view cones show on the map, here and in screenshots and videos.", + "settingsPercentValue": "{percent}%", + "@settingsPercentValue": { + "description": "Value shown beside an opacity slider.", + "placeholders": { + "percent": { + "type": "int" + } + } + }, + "settingsAnalyticsTitle": "Anonymous analytics", + "settingsAnalyticsDescription": "Share app opens and a few feature-use events — never strategy content or personal details.", + "settingsAccountSignedInDescription": "Your cloud identity and sync connection.", + "settingsAccountSignedOutDescription": "Sign in to sync strategies to the cloud and share them.", + "settingsAccountNotSignedIn": "Not signed in", + "settingsSyncNeedsNewerIcarus": "Cloud sync needs a newer Icarus", + "@settingsSyncNeedsNewerIcarus": { + "description": "Account status line when the server refuses this build of the app." + }, + "settingsSyncActive": "Cloud sync active", + "settingsSyncConnecting": "Connecting to cloud…", + "settingsSyncOffline": "Offline. Cloud sync resumes when you're back online", + "settingsSyncNeedsAttention": "Cloud connection needs attention", + "settingsSyncInactive": "Cloud sync inactive", + "settingsCopyVersionTooltip": "Copy version", + "@settingsCopyVersionTooltip": { + "description": "Tooltip on the app version at the bottom of the settings navigation." + }, + "settingsVersionCopied": "Version copied", + "@settingsVersionCopied": { + "description": "Toast after copying the app version." + }, + "settingsKeybindsRestoreDefaults": "Restore Defaults", + "@settingsKeybindsRestoreDefaults": { + "description": "Button that resets every keyboard shortcut." + }, + "settingsKeybindsSearchHint": "Search actions or keys...", + "@settingsKeybindsSearchHint": { + "description": "Placeholder in the keyboard shortcut search field." + }, + "settingsKeybindsActionColumn": "Action", + "@settingsKeybindsActionColumn": { + "description": "Column header in the keyboard shortcuts table." + }, + "settingsKeybindsBindingColumn": "Binding", + "@settingsKeybindsBindingColumn": { + "description": "Column header in the keyboard shortcuts table: the key or keys. The column is 132 px wide." + }, + "settingsKeybindsResetTooltip": "Reset to default", + "@settingsKeybindsResetTooltip": { + "description": "Tooltip on the button that resets one shortcut." + }, + "settingsKeybindsCapturePrompt": "Press new shortcut...", + "@settingsKeybindsCapturePrompt": { + "description": "Shown in the field waiting for the user to press a new key combination." + }, + "settingsKeybindsCaptureHelp": "Escape cancels. Ctrl and Cmd are saved as one platform-aware Primary modifier.", + "@settingsKeybindsCaptureHelp": { + "description": "Help under the shortcut capture field. Escape, Ctrl and Cmd are key names." + }, + "settingsKeybindsAlreadyBound": "{binding} is already bound to {action}.", + "@settingsKeybindsAlreadyBound": { + "description": "Error when the pressed keys already belong to another shortcut.", + "placeholders": { + "binding": { + "type": "String", + "example": "Ctrl S" + }, + "action": { + "type": "String", + "example": "Save Strategy" + } + } + }, + "settingsKeybindsNoMatches": "No shortcuts match that search.", + "settingsKeybindsSnapToMap": "Snap to map lines while dragging", + "@settingsKeybindsSnapToMap": { + "description": "A row in the shortcuts table: holding Alt while dragging an item snaps it to the map's lines. Search matches this text." + }, + "settingsKeybindsHoldAlt": "Hold Alt", + "@settingsKeybindsHoldAlt": { + "description": "The key for the snap row, shown in a small key pill (132 px column). Alt is the key name." + }, + "shortcutUndo": "Undo", + "@shortcutUndo": { + "description": "Keyboard shortcut action name." + }, + "shortcutRedo": "Redo", + "@shortcutRedo": { + "description": "Keyboard shortcut action name." + }, + "shortcutDraw": "Draw", + "@shortcutDraw": { + "description": "Keyboard shortcut action name: the drawing tool." + }, + "shortcutEraser": "Eraser", + "@shortcutEraser": { + "description": "Keyboard shortcut action name: the eraser tool." + }, + "shortcutAddText": "Add Text", + "@shortcutAddText": { + "description": "Keyboard shortcut action name." + }, + "shortcutNavigation": "Navigation", + "@shortcutNavigation": { + "description": "Keyboard shortcut action name: the select/move pointer tool." + }, + "shortcutToggleAgentFilter": "Toggle Agent Filter", + "@shortcutToggleAgentFilter": { + "description": "Keyboard shortcut action name." + }, + "shortcutNextPage": "Next Page", + "@shortcutNextPage": { + "description": "Keyboard shortcut action name." + }, + "shortcutPreviousPage": "Previous Page", + "@shortcutPreviousPage": { + "description": "Keyboard shortcut action name." + }, + "shortcutAddPage": "Add Page", + "@shortcutAddPage": { + "description": "Keyboard shortcut action name." + }, + "shortcutAddLineup": "Add Lineup", + "@shortcutAddLineup": { + "description": "Keyboard shortcut action name." + }, + "shortcutSwitchSide": "Switch Side", + "@shortcutSwitchSide": { + "description": "Keyboard shortcut action name: switch between attack and defense on all pages." + }, + "shortcutSwitchSideThisPage": "Switch Side (This Page)", + "@shortcutSwitchSideThisPage": { + "description": "Keyboard shortcut action name: switch side on the current page only." + }, + "shortcutOpenDeleteMenu": "Open Delete Menu", + "@shortcutOpenDeleteMenu": { + "description": "Keyboard shortcut action name." + }, + "shortcutSelectAll": "Select All", + "@shortcutSelectAll": { + "description": "Keyboard shortcut action name." + }, + "shortcutRotateClockwise": "Rotate Clockwise", + "@shortcutRotateClockwise": { + "description": "Keyboard shortcut action name." + }, + "shortcutRotateCounterclockwise": "Rotate Counterclockwise", + "@shortcutRotateCounterclockwise": { + "description": "Keyboard shortcut action name." + }, + "shortcutSaveStrategy": "Save Strategy", + "@shortcutSaveStrategy": { + "description": "Keyboard shortcut action name." + }, + "shortcutPaste": "Paste", + "@shortcutPaste": { + "description": "Keyboard shortcut action name." + }, + "shortcutOpenDebugLog": "Open Debug Log", + "@shortcutOpenDebugLog": { + "description": "Keyboard shortcut action name." + }, + "mapThemeTitle": "Map theme", + "@mapThemeTitle": { + "description": "Settings section title (and its navigation item) for map color themes." + }, + "mapThemeCustomCount": "{count}/{limit} custom", + "@mapThemeCustomCount": { + "description": "Beside the map theme title: how many custom profiles exist out of the allowed number.", + "placeholders": { + "count": { + "type": "int" + }, + "limit": { + "type": "int" + } + } + }, + "mapThemeCustomRowTitle": "Custom", + "@mapThemeCustomRowTitle": { + "description": "Name of the row for colors customized only in the open strategy." + }, + "mapThemeThisStrategyOnlyTag": "This strategy only", + "@mapThemeThisStrategyOnlyTag": { + "description": "Small tag beside the Custom row." + }, + "mapThemeSaveAsProfile": "Save as profile", + "@mapThemeSaveAsProfile": { + "description": "Button: save the strategy's custom colors as a reusable profile." + }, + "mapThemeDefaultTag": "Default", + "@mapThemeDefaultTag": { + "description": "Small tag beside the profile new strategies use. Tags are joined with ' · '." + }, + "mapThemeBuiltInTag": "Built-in", + "@mapThemeBuiltInTag": { + "description": "Small tag beside a profile that ships with the app." + }, + "mapThemeNewProfile": "New profile", + "@mapThemeNewProfile": { + "description": "Row at the end of the profiles list that creates a profile." + }, + "mapThemeImportProfileCodeAction": "Import profile code", + "@mapThemeImportProfileCodeAction": { + "description": "Row at the end of the profiles list that opens the import dialog." + }, + "mapThemeProfileAdded": "{name} added", + "@mapThemeProfileAdded": { + "description": "Toast after importing a profile.", + "placeholders": { + "name": { + "type": "String", + "example": "Night ops" + } + } + }, + "mapThemeUseIt": "Use it", + "@mapThemeUseIt": { + "description": "Action button on the profile added toast: apply it to the open strategy." + }, + "mapThemeCustomize": "Customize", + "@mapThemeCustomize": { + "description": "Button on the active profile row: customize its colors for this strategy." + }, + "mapThemeSetAsDefault": "Set as default", + "@mapThemeSetAsDefault": { + "description": "Profile menu item." + }, + "mapThemeEditColors": "Edit colors", + "@mapThemeEditColors": { + "description": "Profile menu item." + }, + "mapThemeCopyProfileCode": "Copy profile code", + "@mapThemeCopyProfileCode": { + "description": "Profile menu item: copy a shareable code for the profile." + }, + "mapThemeRenameFailed": "Couldn't rename this profile.", + "mapThemeCopyFailed": "Couldn't copy the profile code.", + "mapThemeCodeCopied": "Profile code copied", + "mapThemeDefaultUpdated": "Default profile updated.", + "mapThemeProfileDeleted": "Profile deleted.", + "mapThemeRenameDialogTitle": "Rename Profile", + "mapThemeProfileNameHint": "Profile name", + "mapThemeImportDialogTitle": "Import profile code", + "@mapThemeImportDialogTitle": { + "description": "Title of the dialog for pasting a shared profile code." + }, + "mapThemeImportDialogDescription": "Paste a code someone shared to add their map colors to your profiles.", + "mapThemeAddProfile": "Add profile", + "@mapThemeAddProfile": { + "description": "Confirm button in the import profile code dialog." + }, + "mapThemeCodeHint": "Paste a profile code", + "mapThemeCodeInvalid": "That isn't an Icarus profile code.", + "mapThemeCodeIncomplete": "This code is incomplete. Copy the whole code and paste it again.", + "mapThemeCodeNewerVersion": "This code is from a newer version of Icarus. Update Icarus to import it.", + "mapThemeCodeDuplicate": "You already have these colors as “{name}”.", + "@mapThemeCodeDuplicate": { + "description": "Under the pasted code when an existing profile has the same colors.", + "placeholders": { + "name": { + "type": "String", + "example": "Night ops" + } + } + }, + "mapThemeCodePastedFromClipboard": "Pasted from your clipboard.", + "mapThemeAddFailed": "Couldn't add this profile.", + "mapThemePreviewEmpty": "The colors show here once you paste a code.", + "mapThemePreviewSemantics": "Profile code preview", + "@mapThemePreviewSemantics": { + "description": "Screen reader label for the map drawn in the pasted colors." + }, + "mapThemeDiscardCustomTitle": "Discard custom colors?", + "mapThemeDiscardCustomBody": "This strategy's custom colors will be replaced with \"{name}\" and can't be brought back.", + "@mapThemeDiscardCustomBody": { + "description": "Confirmation when picking a profile would replace the strategy's custom colors.", + "placeholders": { + "name": { + "type": "String", + "example": "Night ops" + } + } + }, + "mapThemeProfileWasDeleted": "{name} was deleted.", + "@mapThemeProfileWasDeleted": { + "description": "Toast when the user taps Use it on a profile that has since been deleted.", + "placeholders": { + "name": { + "type": "String", + "example": "Night ops" + } + } + }, + "mapSelectorOutOfRotation": "Out of rotation", + "@mapSelectorOutOfRotation": { + "description": "Divider label in the map picker above maps not in the current competitive pool." + }, + "mapSelectorAttack": "Attack", + "@mapSelectorAttack": { + "description": "Label under the side toggle when the page is on attack. Space is tight (about 60 px)." + }, + "mapSelectorDefense": "Defense", + "@mapSelectorDefense": { + "description": "Label under the side toggle when the page is on defense. Space is tight (about 60 px)." + }, + "mapSelectorSideTooltip": "Switch side on all pages\nShift+click: this page only", + "@mapSelectorSideTooltip": { + "description": "Tooltip on the side toggle. Shift is a key name." + }, + "mapSelectorSideTooltipMixedToAttack": "Pages are on mixed sides\nClick: all pages to Attack\nShift+click: this page only", + "@mapSelectorSideTooltipMixedToAttack": { + "description": "Tooltip on the side toggle when pages differ and a click moves them all to attack." + }, + "mapSelectorSideTooltipMixedToDefense": "Pages are on mixed sides\nClick: all pages to Defense\nShift+click: this page only", + "@mapSelectorSideTooltipMixedToDefense": { + "description": "Tooltip on the side toggle when pages differ and a click moves them all to defense." + }, + "mouseWatchDeleteOrigin": "Delete origin", + "@mouseWatchDeleteOrigin": { + "description": "Context menu item on a lineup's origin (where the agent stands): deletes it." + }, + "newPageCopyNotSaved": "The new page couldn't be saved on this computer, so it wasn't added.", + "newPageCopyWaitingForCloud": "The new page hasn't reached the cloud yet. It will appear once it does.", + "newPageCopyUnsavedEdits": "Changes to the page you copied that didn't save won't be in the copy.", + "pagesBarRenameTitle": "Rename page", + "pagesBarRenameDescription": "Enter a new name for the page:", + "pagesBarAddPageTooltip": "Add page", + "pagesBarRecentlyDeletedTooltip": "Recently deleted", + "@pagesBarRecentlyDeletedTooltip": { + "description": "Tooltip on the button that opens the strategy's deleted pages." + }, + "pagesBarPageDeleted": "Deleted '{name}'", + "@pagesBarPageDeleted": { + "description": "Toast after deleting a page, with an Undo button.", + "placeholders": { + "name": { + "type": "String", + "example": "Page 2" + } + } + }, + "pagesBarUndo": "Undo", + "@pagesBarUndo": { + "description": "Action button on the page deleted toast." + }, + "pagesBarRestored": "Restored '{name}'.", + "@pagesBarRestored": { + "placeholders": { + "name": { + "type": "String", + "example": "Page 2" + } + } + }, + "pagesBarRestoredNotOpenedListed": "Restored '{name}', but could not open it. It is in the pages list.", + "@pagesBarRestoredNotOpenedListed": { + "placeholders": { + "name": { + "type": "String", + "example": "Page 2" + } + } + }, + "pagesBarRestoredNotOpened": "Restored '{name}', but could not open it.", + "@pagesBarRestoredNotOpened": { + "placeholders": { + "name": { + "type": "String", + "example": "Page 2" + } + } + }, + "pagesBarRestoreGone": "'{name}' can no longer be restored.", + "@pagesBarRestoreGone": { + "placeholders": { + "name": { + "type": "String", + "example": "Page 2" + } + } + }, + "pagesBarRestoreUnconfirmed": "Could not confirm that '{name}' was restored. Once you're connected, check the pages list or Recently deleted.", + "@pagesBarRestoreUnconfirmed": { + "description": "Undo of a page delete got no confirmation; the user is still in the page's strategy. Recently deleted is the name of the deleted pages list.", + "placeholders": { + "name": { + "type": "String", + "example": "Page 2" + } + } + }, + "pagesBarRestoreUnconfirmedLeft": "Could not confirm that '{name}' was restored. Once you're connected, check the pages list or its strategy's Recently deleted.", + "@pagesBarRestoreUnconfirmedLeft": { + "description": "As pagesBarRestoreUnconfirmed, after the user left the strategy and its name is unknown.", + "placeholders": { + "name": { + "type": "String", + "example": "Page 2" + } + } + }, + "pagesBarRestoreUnconfirmedIn": "Could not confirm that '{name}' was restored in {strategy}. Once you're connected, check the pages list or that strategy's Recently deleted.", + "@pagesBarRestoreUnconfirmedIn": { + "description": "As pagesBarRestoreUnconfirmed, after the user left the strategy named {strategy}.", + "placeholders": { + "name": { + "type": "String", + "example": "Page 2" + }, + "strategy": { + "type": "String", + "example": "B split" + } + } + }, + "strategyBadgeDeviceOnly": "On this device", + "@strategyBadgeDeviceOnly": { + "description": "Small pill on a strategy thumbnail (11 px text)." + }, + "strategyBadgeDeviceOnlyTooltip": "Saved only on this computer", + "strategyBadgeNeedsConnection": "Needs a connection", + "@strategyBadgeNeedsConnection": { + "description": "Small pill on a cloud strategy thumbnail while offline." + }, + "strategyBadgeNeedsConnectionTooltip": "Needs a connection the first time you open it", + "strategyBadgeMovingToCloud": "Moving to cloud", + "@strategyBadgeMovingToCloud": { + "description": "Small pill on a strategy thumbnail while it moves to the cloud." + }, + "strategyBadgeMovingToCloudTooltip": "Stays on this computer until all of it is in the cloud", + "strategyBadgeSharedCanEdit": "Shared · Can edit", + "@strategyBadgeSharedCanEdit": { + "description": "Small pill on a strategy someone shared with the user, with edit access." + }, + "strategyBadgeSharedCanEditTooltip": "Shared with you. You can edit it.", + "strategyBadgeSharedViewOnly": "Shared · View only", + "@strategyBadgeSharedViewOnly": { + "description": "Small pill on a strategy someone shared with the user, read-only." + }, + "strategyBadgeSharedViewOnlyTooltip": "Shared with you. Ask the owner for edit access.", + "strategyAccessChangedToViewOnly": "Your access to this strategy changed to view only.", + "strategyAccessCanOpenAgain": "You can open this strategy again, so nothing was discarded.", + "strategyAccessCouldNotConfirm": "Couldn't confirm you lost access, so nothing was discarded.", + "strategyAccessDiscardFailed": "Couldn't discard everything. Try again from the library.", + "strategyAccessLostTitle": "You no longer have access", + "strategyAccessLostBodyNamed": "The owner removed you from \"{name}\", or stopped sharing it.{unsent, plural, =0{} =1{ 1 change you made here hasn't reached the cloud and can't be saved.} other{ {unsent} changes you made here haven't reached the cloud and can't be saved.}}", + "@strategyAccessLostBodyNamed": { + "description": "Dialog body when the owner stopped sharing the open strategy. The second sentence appears only when unsent > 0.", + "placeholders": { + "name": { + "type": "String", + "example": "B split" + }, + "unsent": { + "type": "int" + } + } + }, + "strategyAccessLostBodyUnnamed": "The owner removed you from this strategy, or stopped sharing it.{unsent, plural, =0{} =1{ 1 change you made here hasn't reached the cloud and can't be saved.} other{ {unsent} changes you made here haven't reached the cloud and can't be saved.}}", + "@strategyAccessLostBodyUnnamed": { + "description": "As strategyAccessLostBodyNamed, when the strategy's name is not known.", + "placeholders": { + "unsent": { + "type": "int" + } + } + }, + "strategyAccessBackToLibrary": "Back to library", + "strategyPresenceViewing": "{name} (viewing)", + "@strategyPresenceViewing": { + "description": "Tooltip on the avatar of someone looking at the strategy with view-only access.", + "placeholders": { + "name": { + "type": "String", + "example": "Sam" + } + } + }, + "quickSwitcherNameEmpty": "Strategy name cannot be empty.", + "quickSwitcherCannotRename": "You can't rename this strategy.", + "quickSwitcherUntitled": "Untitled Strategy", + "@quickSwitcherUntitled": { + "description": "Shown for the open strategy's name when it has none, and as the rename field's placeholder." + }, + "quickSwitcherNoRecent": "No recent strategies", + "quickSwitcherLoadToRename": "Load a strategy to rename it", + "quickSwitcherRenameTooltip": "Rename strategy", + "quickSwitcherViewOnlyTooltip": "You can view this strategy but not rename it", + "strategyTileSideAttack": "Attack", + "@strategyTileSideAttack": { + "description": "Side a strategy's pages are on, on a strategy card and in the recent strategies list." + }, + "strategyTileSideDefend": "Defend", + "@strategyTileSideDefend": { + "description": "Side a strategy's pages are on, on a strategy card and in the recent strategies list." + }, + "strategyTileSideMixed": "Mixed", + "@strategyTileSideMixed": { + "description": "A strategy whose pages are on both sides." + }, + "strategyTileSideUnknown": "Unknown", + "@strategyTileSideUnknown": { + "description": "A strategy with no pages, so no side." + }, + "strategyTileEditedJustNow": "Just now", + "@strategyTileEditedJustNow": { + "description": "When a strategy was last edited, on its card." + }, + "strategyTileEditedMinutesAgo": "{count, plural, =1{1 min ago} other{{count} mins ago}}", + "@strategyTileEditedMinutesAgo": { + "description": "When a strategy was last edited, on its card.", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "strategyTileEditedHoursAgo": "{count, plural, =1{1 hour ago} other{{count} hours ago}}", + "@strategyTileEditedHoursAgo": { + "description": "When a strategy was last edited, on its card.", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "strategyTileEditedDaysAgo": "{count, plural, =1{1 day ago} other{{count} days ago}}", + "@strategyTileEditedDaysAgo": { + "description": "When a strategy was last edited, on its card.", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "strategyTileEditedMonthsAgo": "{count, plural, =1{1 month ago} other{{count} months ago}}", + "@strategyTileEditedMonthsAgo": { + "description": "When a strategy was last edited, on its card. A month is 30 days.", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "strategyTileMoreActions": "More actions for {name}", + "@strategyTileMoreActions": { + "description": "Screen reader label for the menu button on a strategy card.", + "placeholders": { + "name": { + "type": "String", + "example": "B split" + } + } + }, + "strategyTilePin": "Pin", + "@strategyTilePin": { + "description": "Strategy card menu item." + }, + "strategyTileUnpin": "Unpin", + "@strategyTileUnpin": { + "description": "Strategy card menu item." + }, + "strategyTileDuplicate": "Duplicate", + "@strategyTileDuplicate": { + "description": "Strategy card menu item." + }, + "strategyTileMoveToCloud": "Move to cloud", + "@strategyTileMoveToCloud": { + "description": "Strategy card menu item." + }, + "saveButtonFailed": "Couldn't save this strategy. Try again.", + "saveButtonSaved": "Saved", + "@saveButtonSaved": { + "description": "Toast after pressing save when the work is saved." + }, + "saveButtonEditNotSynced": "Edit not synced yet", + "saveButtonSyncingMedia": "Saved on this device. Media still syncing.", + "saveButtonSyncing": "Saved on this device. Syncing…", + "saveButtonMediaNeedsRetry": "Saved on this device. Media sync needs retry.", + "saveButtonNeedsAttention": "Saved on this device. Sync needs attention.", + "saveButtonViewOnlyTooltip": "View only", + "@saveButtonViewOnlyTooltip": { + "description": "Tooltip on the disabled save button for someone who can only view." + }, + "strategyViewSkeletonMapSemantics": "Map", + "@strategyViewSkeletonMapSemantics": { + "description": "Screen reader label for the grey map drawn while a strategy loads." + }, + "stripWhatsNewTooltip": "What's new", + "@stripWhatsNewTooltip": { + "description": "Tooltip on the window strip icon that opens release notes." + }, + "stripUpdateAvailableTooltip": "Update available", + "touchViewerLibrary": "Library", + "@touchViewerLibrary": { + "description": "Screen reader label for the button that leaves the viewer back to the library." + }, + "touchViewerViewOnly": "View only", + "@touchViewerViewOnly": { + "description": "Under the strategy name in the phone viewer's top strip." + }, + "touchViewerAddToLibrary": "Add to library", + "@touchViewerAddToLibrary": { + "description": "Button: keep a strategy opened through a share link." + }, + "turnPhoneTitle": "Turn your phone sideways to edit", + "turnPhoneRotationLockHint": "If nothing happens, check that rotation lock is off.", + "turnPhoneBackToLibrary": "Back to My Library", + "@turnPhoneBackToLibrary": { + "description": "Button. My Library is the name of the library tab." + }, + "webBetaTagSemantics": "About the Icarus web beta", + "webBetaTag": "Beta", + "@webBetaTag": { + "description": "Small tag in the library strip of the web build (22 px tall)." + }, + "youtubeNotAVideo": "This link doesn't point at a YouTube video.", + "pagesBarAutoName": "Page {number}", + "@pagesBarAutoName": { + "description": "The name shown for a page the user has not named, e.g. Page 3. Short: it sits in a narrow list.", + "placeholders": { + "number": { + "type": "int" + } + } + }, + "settingsLanguage": "Language", + "@settingsLanguage": { + "description": "Settings section and navigation item for choosing the app language." + }, + "settingsLanguageTitle": "App language", + "@settingsLanguageTitle": { + "description": "Title of the language row in settings." + }, + "settingsLanguageDescription": "Icarus follows your system's language unless you pick one here.", + "@settingsLanguageDescription": { + "description": "Explains the language row in settings." + }, + "settingsLanguageSystem": "System default", + "@settingsLanguageSystem": { + "description": "The language option that follows the computer or browser language." + }, + "sidebarAgentsTitle": "Agents", + "@sidebarAgentsTitle": { + "description": "Heading above the agent list in the editor sidebar." + }, + "sidebarShowAllAgentsTooltip": "Show all agents", + "@sidebarShowAllAgentsTooltip": { + "description": "Tooltip on the favorites star in the sidebar while only favorite agents are shown." + }, + "sidebarShowFavoriteAgentsTooltip": "Show only favorite agents", + "@sidebarShowFavoriteAgentsTooltip": { + "description": "Tooltip on the favorites star in the sidebar while all agents are shown." + }, + "sidebarFavoritesOnlyIndicator": "Filtering: Favorites only", + "@sidebarFavoritesOnlyIndicator": { + "description": "Small orange line under the agent filters while only favorite agents are listed." + }, + "sidebarNoAgentAvailable": "No agent available", + "@sidebarNoAgentAvailable": { + "description": "Shown in the sidebar agent list when the filters leave no agents." + }, + "sidebarToolsTitle": "Tools", + "@sidebarToolsTitle": { + "description": "Heading above the tool buttons at the top of the editor sidebar." + }, + "sidebarDrawTooltip": "Draw", + "@sidebarDrawTooltip": { + "description": "Tooltip on the sidebar button that turns on the pen." + }, + "sidebarEraserTooltip": "Eraser", + "@sidebarEraserTooltip": { + "description": "Tooltip on the sidebar button that turns on the eraser." + }, + "sidebarAddTextTooltip": "Add Text", + "@sidebarAddTextTooltip": { + "description": "Tooltip on the sidebar button that opens the text tools." + }, + "sidebarAddImageTooltip": "Add Image", + "@sidebarAddImageTooltip": { + "description": "Tooltip on the sidebar button that opens the image upload dialog." + }, + "sidebarImageUploadFailed": "Upload failed", + "@sidebarImageUploadFailed": { + "description": "Error toast when an image picked in the upload dialog could not be added to the page." + }, + "sidebarAddLineupTooltip": "Add Lineup", + "@sidebarAddLineupTooltip": { + "description": "Tooltip on the sidebar button that starts placing a lineup." + }, + "sidebarVisionConeToolsTooltip": "Vision Cone Tools", + "@sidebarVisionConeToolsTooltip": { + "description": "Tooltip on the sidebar button that opens the vision cone presets." + }, + "sidebarCustomShapesTooltip": "Shapes", + "@sidebarCustomShapesTooltip": { + "description": "Tooltip on the sidebar button that opens the custom shape tools." + }, + "sidebarAgentRolesTooltip": "Agent Roles", + "@sidebarAgentRolesTooltip": { + "description": "Tooltip on the sidebar button that opens the agent role icons." + }, + "sidebarTagColorLabel": "Tag Color", + "@sidebarTagColorLabel": { + "description": "Label above the color swatches in the text tools panel; the color tags a placed text box." + }, + "sidebarPlaceTextButton": "+ Place Text", + "@sidebarPlaceTextButton": { + "description": "Button in the text tools panel that places a new text box in the middle of the map." + }, + "sidebarDragOrClickToPlace": "Drag or click to place", + "@sidebarDragOrClickToPlace": { + "description": "Small italic hint under a sidebar tool panel: drag the item onto the map or click its button." + }, + "sidebarDragToPlace": "Drag to place", + "@sidebarDragToPlace": { + "description": "Small italic hint under the vision cone presets." + }, + "sidebarAgentRoleIconsTitle": "Agent Role Icons", + "@sidebarAgentRoleIconsTitle": { + "description": "Heading of the sidebar panel with draggable role icons." + }, + "sidebarVisionConesTitle": "Vision Cones", + "@sidebarVisionConesTitle": { + "description": "Heading of the sidebar panel with vision cone presets." + }, + "sidebarViewConePresetTooltip": "View Cone {degrees}°", + "@sidebarViewConePresetTooltip": { + "description": "Tooltip on a vision cone preset button.", + "placeholders": { + "degrees": { + "type": "String", + "description": "The cone's angle as digits, e.g. 60 or 360." + } + } + }, + "sidebarFilterAll": "All", + "@sidebarFilterAll": { + "description": "Agent list filter tab: show every agent. Space is tight." + }, + "sidebarFilterOnMap": "On Map", + "@sidebarFilterOnMap": { + "description": "Agent list filter tab: show only agents placed on the current page. Space is tight." + }, + "sidebarFilterRole": "Role", + "@sidebarFilterRole": { + "description": "Agent list filter tab: filter agents by role. Space is tight." + }, + "sidebarTeamAlly": "Ally", + "@sidebarTeamAlly": { + "description": "Sidebar toggle: new agents are placed on the ally team. The toggle is only 50 px wide." + }, + "sidebarTeamEnemy": "Enemy", + "@sidebarTeamEnemy": { + "description": "Sidebar toggle: new agents are placed on the enemy team. The toggle is only 50 px wide." + }, + "sidebarAddColorTitle": "Add color", + "@sidebarAddColorTitle": { + "description": "Title of the color picker dialog that adds a color to the color library." + }, + "sidebarEditColorTitle": "Edit color", + "@sidebarEditColorTitle": { + "description": "Title of the color picker dialog that changes a saved color in the color library." + }, + "sidebarDeleteAllTooltip": "Delete all", + "@sidebarDeleteAllTooltip": { + "description": "Tooltip on the button that deletes everything on the page." + }, + "sidebarDeleteAllButton": "Delete all", + "@sidebarDeleteAllButton": { + "description": "Small button in the delete menu that deletes everything on the page. Space is tight." + }, + "sidebarClearAgentsTooltip": "Clear Agents", + "@sidebarClearAgentsTooltip": { + "description": "Tooltip on the delete menu button that removes every agent from the page." + }, + "sidebarClearAbilitiesTooltip": "Clear Abilities", + "@sidebarClearAbilitiesTooltip": { + "description": "Tooltip on the delete menu button that removes every ability from the page." + }, + "sidebarClearDrawingsTooltip": "Clear Drawings", + "@sidebarClearDrawingsTooltip": { + "description": "Tooltip on the delete menu button that removes every drawing from the page." + }, + "sidebarClearTextTooltip": "Clear Text", + "@sidebarClearTextTooltip": { + "description": "Tooltip on the delete menu button that removes every text box from the page." + }, + "sidebarClearImagesTooltip": "Clear Images", + "@sidebarClearImagesTooltip": { + "description": "Tooltip on the delete menu button that removes every image from the page." + }, + "sidebarClearUtilitiesTooltip": "Clear Utilities", + "@sidebarClearUtilitiesTooltip": { + "description": "Tooltip on the delete menu button that removes every utility (vision cones, shapes, role icons, spike) from the page." + }, + "drawingToolsColorLabel": "Color", + "@drawingToolsColorLabel": { + "description": "Label above the pen color swatches." + }, + "drawingToolsShapeLabel": "Shape", + "@drawingToolsShapeLabel": { + "description": "Label above the pen shape buttons (free draw, line, rectangle, ellipse)." + }, + "drawingToolsFreeDrawTooltip": "Free draw", + "drawingToolsStraightLineTooltip": "Straight line", + "drawingToolsRectangleTooltip": "Rectangle", + "drawingToolsEllipseTooltip": "Ellipse", + "drawingToolsStrokeLabel": "Stroke", + "@drawingToolsStrokeLabel": { + "description": "Label above the dotted line and arrow toggles for the pen." + }, + "drawingToolsDottedLineTooltip": "Dotted line", + "drawingToolsArrowTooltip": "Arrow", + "@drawingToolsArrowTooltip": { + "description": "Tooltip on the toggle that ends a drawn line with an arrowhead." + }, + "drawingToolsThicknessLabel": "Thickness", + "@drawingToolsThicknessLabel": { + "description": "Label above the pen thickness choices." + }, + "drawingToolsTraversalTimeLabel": "Traversal Time", + "@drawingToolsTraversalTimeLabel": { + "description": "Label above the movement speed choices that show how long a drawn path takes to walk." + }, + "drawingToolsRunningTooltip": "Running w/ knife out", + "@drawingToolsRunningTooltip": { + "description": "Traversal time speed: running with the knife equipped. 'w/' is short for 'with'." + }, + "drawingToolsWalkingTooltip": "Walking w/ knife out", + "@drawingToolsWalkingTooltip": { + "description": "Traversal time speed: walking with the knife equipped. 'w/' is short for 'with'." + }, + "drawingToolsAbilityWithKnifeTooltip": "{abilityName} w/ knife out", + "@drawingToolsAbilityWithKnifeTooltip": { + "description": "Traversal time speed: running under an ability's speed boost with the knife equipped. 'w/' is short for 'with'.", + "placeholders": { + "abilityName": { + "type": "String", + "description": "Agent and ability name, kept in English, e.g. 'Brimstone Stim'." + } + } + }, + "drawingToolsAgentRunTooltip": "{agentName} run", + "@drawingToolsAgentRunTooltip": { + "description": "Traversal time speed: an agent's sprint ability.", + "placeholders": { + "agentName": { + "type": "String", + "description": "Agent name as written today, lowercase, e.g. 'neon'." + } + } + }, + "shapeToolsTitle": "Shapes", + "@shapeToolsTitle": { + "description": "Heading of the custom shape tools panel in the sidebar." + }, + "shapeToolsDiameterLabel": "Diameter", + "@shapeToolsDiameterLabel": { + "description": "Label above the circle diameter input, in meters." + }, + "shapeToolsDiameterShortLabel": "D", + "@shapeToolsDiameterShortLabel": { + "description": "One-letter label inside the diameter input (D for diameter). Room for one or two characters." + }, + "shapeToolsDiameterHint": "Enter Diameter", + "shapeToolsDimensionsLabel": "Dimensions", + "@shapeToolsDimensionsLabel": { + "description": "Label above the rectangle length and width inputs." + }, + "shapeToolsLengthShortLabel": "L", + "@shapeToolsLengthShortLabel": { + "description": "One-letter label inside the rectangle length input (L for length). Room for one or two characters." + }, + "shapeToolsLengthHint": "Enter Length", + "shapeToolsWidthShortLabel": "W", + "@shapeToolsWidthShortLabel": { + "description": "One-letter label inside the rectangle width input (W for width). Room for one or two characters." + }, + "shapeToolsWidthHint": "Enter Width", + "shapeToolsOpacityLabel": "Opacity", + "@shapeToolsOpacityLabel": { + "description": "Label above the shape opacity input, in percent." + }, + "shapeToolsOpacityHint": "Enter Opacity", + "shapeToolsColorLabel": "Color", + "@shapeToolsColorLabel": { + "description": "Label above the shape color swatches." + }, + "canvasItemTextPlaceholder": "Write here...", + "@canvasItemTextPlaceholder": { + "description": "Grey placeholder inside an empty text box on the map." + }, + "canvasItemPlacedTextSemanticLabel": "Placed text", + "@canvasItemPlacedTextSemanticLabel": { + "description": "Screen reader label for a text box on the map while it is being edited." + }, + "shapeToolsRotationShortLabel": "R", + "@shapeToolsRotationShortLabel": { + "description": "One-letter label (R for rotation) in the small tooltip shown while rotating a custom rectangle, followed by the angle. Room for one or two characters." + }, + "shapeToolsColorMenu": "Color", + "@shapeToolsColorMenu": { + "description": "Right-click menu entry on a custom shape that opens its color choices." + }, + "shapeToolsCustomColorTitle": "Custom color", + "@shapeToolsCustomColorTitle": { + "description": "Title of the color picker dialog opened from a custom shape's right-click menu." + }, + "shapeToolsCustomColorMenu": "Custom color…", + "@shapeToolsCustomColorMenu": { + "description": "Right-click menu entry on a custom shape that opens a color picker." + }, + "shapeToolsColorWhite": "White", + "shapeToolsColorRed": "Red", + "shapeToolsColorBlue": "Blue", + "shapeToolsColorYellow": "Yellow", + "shapeToolsColorGreen": "Green", + "shapeToolsSavedColor": "Saved #{hex}", + "@shapeToolsSavedColor": { + "description": "Name of a color the user saved to their color library, in a custom shape's color menu.", + "placeholders": { + "hex": { + "type": "String", + "description": "Six-digit hex color code, e.g. FF8800." + } + } + }, + "agentMenuAddLineup": "Add lineup", + "@agentMenuAddLineup": { + "description": "Right-click menu entry on a lineup's agent: add another lineup from this throw spot." + }, + "agentMenuEditPlacement": "Edit placement", + "@agentMenuEditPlacement": { + "description": "Right-click menu entry on a lineup's agent: move the lineup's pieces on the map." + }, + "agentMenuDeleteOrigin": "Delete origin", + "@agentMenuDeleteOrigin": { + "description": "Right-click menu entry on a lineup's agent: delete the throw spot and its lineups." + }, + "agentMenuDetachViewCone": "Detach view cone", + "@agentMenuDetachViewCone": { + "description": "Right-click menu entry on an agent with a view cone: split the cone off as its own item." + }, + "agentMenuRemoveViewCone": "Remove view cone", + "agentMenuDetachCircle": "Detach circle", + "@agentMenuDetachCircle": { + "description": "Right-click menu entry on an agent with a range circle: split the circle off as its own item." + }, + "agentMenuRemoveCircle": "Remove circle", + "agentMenuCreateLineup": "Create lineup", + "@agentMenuCreateLineup": { + "description": "Right-click menu entry on an agent: start a lineup thrown from this agent." + }, + "agentMenuWeapon": "Weapon", + "@agentMenuWeapon": { + "description": "Right-click menu entry on an agent that opens the weapon choices." + }, + "agentMenuNoWeapon": "None", + "@agentMenuNoWeapon": { + "description": "First entry in an agent's weapon menu: take the weapon away." + }, + "abilityMenuCloseStackSelectorLabel": "Close lineup ability stack selector", + "@abilityMenuCloseStackSelectorLabel": { + "description": "Screen reader label for the area outside the picker shown when several lineup abilities overlap." + }, + "abilityMenuCloseContextMenuLabel": "Close lineup ability context menu", + "@abilityMenuCloseContextMenuLabel": { + "description": "Screen reader label for the area outside a lineup ability's right-click menu." + }, + "abilityMenuRemoveWall": "Remove wall", + "@abilityMenuRemoveWall": { + "description": "Right-click menu entry on a wall ability: remove the drawn wall." + }, + "abilityMenuDrawWall": "Draw wall", + "@abilityMenuDrawWall": { + "description": "Right-click menu entry on a wall ability: add a wall to it." + }, + "abilityMenuAddLineupHere": "Add lineup here", + "@abilityMenuAddLineupHere": { + "description": "Right-click menu entry on a lineup's landing spot: add another lineup that lands here." + }, + "abilityMenuShowLineups": "Show lineups", + "@abilityMenuShowLineups": { + "description": "Right-click menu entry on a landing spot with several lineups: open the list of them." + }, + "abilityMenuEditMedia": "Edit media", + "@abilityMenuEditMedia": { + "description": "Right-click menu entry on a landing spot with one lineup: edit its images, video and notes." + }, + "abilityMenuEditPlacement": "Edit placement", + "@abilityMenuEditPlacement": { + "description": "Right-click menu entry on a landing spot: move the lineup's pieces on the map." + }, + "abilityMenuDeleteSpot": "Delete spot", + "@abilityMenuDeleteSpot": { + "description": "Right-click menu entry on a landing spot with several lineups: delete the spot and all its lineups." + }, + "abilityMenuDeleteLineup": "Delete lineup", + "@abilityMenuDeleteLineup": { + "description": "Right-click menu entry on a landing spot with one lineup: delete it." + }, + "abilityMenuVisionCone": "Vision cone", + "@abilityMenuVisionCone": { + "description": "Checkbox in an ability's right-click menu: show the ability's vision cone." + }, + "abilityMenuActive": "Active", + "@abilityMenuActive": { + "description": "Checkbox in an ability's right-click menu: draw the ability as active rather than faded." + }, + "abilityMenuRangeOutline": "Range outline", + "@abilityMenuRangeOutline": { + "description": "Checkbox in an ability's right-click menu: show the outline of its range." + }, + "abilityMenuInnerOutline": "Inner outline", + "@abilityMenuInnerOutline": { + "description": "Checkbox in an ability's right-click menu: show the outline of its inner range." + }, + "abilityMenuInnerFill": "Inner fill", + "@abilityMenuInnerFill": { + "description": "Checkbox in an ability's right-click menu: fill its inner range." + }, + "abilityMenuRangeFill": "Range fill", + "@abilityMenuRangeFill": { + "description": "Checkbox in an ability's right-click menu: fill its range." + }, + "abilityMenuMesh": "Mesh", + "@abilityMenuMesh": { + "description": "Checkbox in a barrier mesh ability's right-click menu: show the mesh." + }, + "abilityMenuRange": "Range", + "@abilityMenuRange": { + "description": "Checkbox in a square ability's right-click menu: show its range." + }, + "canvasItemCreateCaption": "Create caption", + "@canvasItemCreateCaption": { + "description": "Right-click menu entry on a placed item with no caption: write one." + }, + "canvasItemEditCaption": "Edit caption", + "@canvasItemEditCaption": { + "description": "Right-click menu entry on a placed item that has a caption: change it." + }, + "canvasItemResetTagToGray": "Reset tag to gray", + "@canvasItemResetTagToGray": { + "description": "Right-click menu entry on a text box or image: remove its color tag." + }, + "canvasItemGreenTag": "Green tag", + "canvasItemBlueTag": "Blue tag", + "canvasItemAmberTag": "Amber tag", + "canvasItemRedTag": "Red tag", + "canvasItemPurpleTag": "Purple tag", + "canvasItemCustomColorTag": "#{hex} custom tag", + "@canvasItemCustomColorTag": { + "description": "Right-click menu entry on an image: tag it with a color the user saved.", + "placeholders": { + "hex": { + "type": "String", + "description": "Six-digit hex color code, e.g. FF8800." + } + } + }, + "canvasItemDefaultColorTag": "#{hex} default tag", + "@canvasItemDefaultColorTag": { + "description": "Right-click menu entry on an image: tag it with one of the built-in colors.", + "placeholders": { + "hex": { + "type": "String", + "description": "Six-digit hex color code, e.g. FF8800." + } + } + }, + "canvasItemCopyToPreviousPage": "Copy to previous page", + "@canvasItemCopyToPreviousPage": { + "description": "Right-click menu entry on a placed item: copy it onto the page before this one." + }, + "canvasItemCopyToNextPage": "Copy to next page", + "@canvasItemCopyToNextPage": { + "description": "Right-click menu entry on a placed item: copy it onto the page after this one." + }, + "canvasItemNextPageAlreadyHasIt": "The next page already has it.", + "@canvasItemNextPageAlreadyHasIt": { + "description": "Toast after Copy to next page when that page already holds the item." + }, + "canvasItemPreviousPageAlreadyHasIt": "The previous page already has it.", + "@canvasItemPreviousPageAlreadyHasIt": { + "description": "Toast after Copy to previous page when that page already holds the item." + }, + "canvasItemCopyUnreachable": "Couldn't reach the cloud, so nothing was copied.", + "@canvasItemCopyUnreachable": { + "description": "Error toast when copying an item to another page fails because the server can't be reached." + }, + "canvasItemCopyNotSaved": "Couldn't save the copy on this device, so nothing was copied.", + "@canvasItemCopyNotSaved": { + "description": "Error toast when copying an item to another page fails to save locally." + }, + "canvasItemImageSyncing": "Syncing image", + "@canvasItemImageSyncing": { + "description": "Shown inside a placed image while it downloads from the cloud." + }, + "canvasItemImageUnavailable": "Image unavailable", + "@canvasItemImageUnavailable": { + "description": "Shown inside a placed image that could not be loaded." + }, + "canvasItemBoldTooltip": "Bold ({shortcut})", + "@canvasItemBoldTooltip": { + "description": "Tooltip on the bold button of the text formatting bar.", + "placeholders": { + "shortcut": { + "type": "String", + "description": "Keyboard shortcut, e.g. Ctrl+B." + } + } + }, + "canvasItemItalicTooltip": "Italic ({shortcut})", + "@canvasItemItalicTooltip": { + "description": "Tooltip on the italic button of the text formatting bar.", + "placeholders": { + "shortcut": { + "type": "String", + "description": "Keyboard shortcut, e.g. Ctrl+I." + } + } + }, + "canvasItemBulletListTooltip": "Bullet list", + "canvasItemNumberedListTooltip": "Numbered list", + "canvasItemHeadingTooltip": "Heading", + "@canvasItemHeadingTooltip": { + "description": "Tooltip on the text formatting bar button that makes the line a heading." + }, + "canvasItemViewElevationMenu": "View elevation", + "@canvasItemViewElevationMenu": { + "description": "Right-click menu entry on a vision cone: choose the eye height the cone is seen from." + }, + "canvasItemViewElevationGround": "Ground", + "@canvasItemViewElevationGround": { + "description": "Eye height choice: standing on the ground." + }, + "canvasItemViewElevationAutoTrailing": "{surface} (auto)", + "@canvasItemViewElevationAutoTrailing": { + "description": "Shown at the right of the View elevation menu entry while the height is picked automatically.", + "placeholders": { + "surface": { + "type": "String", + "description": "Name of the surface the eye stands on, from the map's height data (English), or the word Ground." + } + } + }, + "canvasItemViewElevationAutoUnmatchedTrailing": "{surface} (auto*)", + "@canvasItemViewElevationAutoUnmatchedTrailing": { + "description": "Like the automatic label, with an asterisk: the saved height no longer exists on the map, so the automatic one is used.", + "placeholders": { + "surface": { + "type": "String", + "description": "Name of the surface the eye stands on, from the map's height data (English), or the word Ground." + } + } + }, + "canvasItemViewElevationAutomatic": "Automatic ({surface})", + "@canvasItemViewElevationAutomatic": { + "description": "Eye height choice: let Icarus pick the height.", + "placeholders": { + "surface": { + "type": "String", + "description": "Name of the surface it picks, from the map's height data (English), or the word Ground." + } + } + }, + "canvasItemViewElevationAutomaticUnavailable": "Automatic (saved height unavailable)", + "@canvasItemViewElevationAutomaticUnavailable": { + "description": "Eye height choice shown when the cone's saved height no longer exists on the map." + }, + "canvasItemViewElevationGroundHeight": "Ground ({height})", + "@canvasItemViewElevationGroundHeight": { + "description": "Eye height choice: standing on the ground.", + "placeholders": { + "height": { + "type": "String", + "description": "Height with unit, e.g. +1.50 m." + } + } + }, + "canvasItemViewElevationSurfaceHeight": "{surface} ({height})", + "@canvasItemViewElevationSurfaceHeight": { + "description": "Eye height choice: standing on a named surface.", + "placeholders": { + "surface": { + "type": "String", + "description": "Name of the surface, from the map's height data (English)." + }, + "height": { + "type": "String", + "description": "Height with unit, e.g. +6.75 m." + } + } + }, + "replayBackToLibraryTooltip": "Replays", + "@replayBackToLibraryTooltip": { + "description": "Tooltip on the home button in the replay viewer's title bar; it goes back to the Replays tab." + }, + "replayReading": "Reading replay", + "@replayReading": { + "description": "Shown over the progress bar while a replay file is decoded." + }, + "replayOpenThisFailed": "Couldn't open this replay", + "@replayOpenThisFailed": { + "description": "Heading in the replay viewer when the replay failed to load; the reason is shown under it." + }, + "replayMapNotSupported": "This replay's map isn't in Icarus yet.", + "@replayMapNotSupported": { + "description": "Why a replay can't be watched: it was played on a map Icarus doesn't have." + }, + "replayBackToReplays": "Back to replays", + "@replayBackToReplays": { + "description": "Button under a replay that failed to load; returns to the Replays tab." + }, + "replayOpenFailed": "Couldn't open the replay.", + "@replayOpenFailed": { + "description": "Error toast when a replay file dropped on the window or opened from Explorer could not be opened." + }, + "replayRoundNumber": "Round {number}", + "@replayRoundNumber": { + "description": "A round of the match, in the replay viewer.", + "placeholders": { + "number": { + "type": "int", + "description": "Round number, starting at 1." + } + } + }, + "replayDockPinStoryTooltip": "Pin round story", + "@replayDockPinStoryTooltip": { + "description": "Tooltip on the pin in the replay timeline: keep the round's event timeline open." + }, + "replayDockUnpinStoryTooltip": "Unpin round story", + "@replayDockUnpinStoryTooltip": { + "description": "Tooltip on the pin in the replay timeline while the round's event timeline is pinned open." + }, + "replayDockOvertimeMark": "OT", + "@replayDockOvertimeMark": { + "description": "Tiny mark in the replay's round strip where overtime starts. Short for overtime; two or three characters at most." + }, + "replayDockPlaybackSpeedTooltip": "Playback speed", + "@replayDockPlaybackSpeedTooltip": { + "description": "Tooltip on the replay speed button (1×, 2×...)." + }, + "replayLibraryListFailed": "Couldn't list replays", + "@replayLibraryListFailed": { + "description": "Heading on the Replays tab when the replay folders could not be read; the error follows." + }, + "replayLibraryEmptyTitle": "No replays yet", + "@replayLibraryEmptyTitle": { + "description": "Heading on the empty Replays tab." + }, + "replayLibraryEmptyHint": "Download a match from your career page in Valorant, or drop a .vrf file here", + "@replayLibraryEmptyHint": { + "description": "Hint under the empty Replays tab heading. .vrf is the replay file extension." + }, + "replayLibraryKept": "Kept in Icarus. Valorant can no longer remove it.", + "@replayLibraryKept": { + "description": "Toast after copying a replay from Valorant's folder into Icarus." + }, + "replayLibraryKeepFailed": "Couldn't keep this replay.", + "@replayLibraryKeepFailed": { + "description": "Error toast when copying a replay into Icarus failed." + }, + "replayLibraryRemoveTitle": "Remove this replay?", + "@replayLibraryRemoveTitle": { + "description": "Title of the dialog that confirms removing a replay." + }, + "replayLibraryRemoveValorantCopyBody": "This moves Icarus's copy to the Recycle Bin. Valorant still has its own, so the replay stays in the list until Valorant deletes it.", + "@replayLibraryRemoveValorantCopyBody": { + "description": "Body of the remove replay dialog when Valorant also has the replay. Recycle Bin is the Windows feature." + }, + "replayLibraryRemoveBody": "This moves the replay file to the Recycle Bin. Pages you captured from it stay in your strategies.", + "@replayLibraryRemoveBody": { + "description": "Body of the remove replay dialog. Recycle Bin is the Windows feature." + }, + "replayLibraryRemoveConfirm": "Remove", + "@replayLibraryRemoveConfirm": { + "description": "Confirm button of the remove replay dialog." + }, + "replayLibraryRecycleBinDeleted": "Windows deleted this replay instead of moving it to the Recycle Bin.", + "@replayLibraryRecycleBinDeleted": { + "description": "Toast after removing a replay when Windows deleted the file outright." + }, + "replayLibraryRecycleBinFailed": "Windows couldn't move this replay to the Recycle Bin, so Icarus left it where it is.", + "@replayLibraryRecycleBinFailed": { + "description": "Error toast when Windows refused to move a replay to the Recycle Bin." + }, + "replayLibraryRemoveFileChanged": "This replay changed after you chose Remove. Nothing was deleted.", + "@replayLibraryRemoveFileChanged": { + "description": "Error toast when the replay file changed between confirming and removing." + }, + "replayLibraryRemoveNotOwned": "Icarus only removes replays from its own folder. Nothing was deleted.", + "@replayLibraryRemoveNotOwned": { + "description": "Error toast when the user tried to remove a replay that isn't in Icarus's folder." + }, + "replayLibraryRemoveFailed": "Couldn't remove this replay.", + "@replayLibraryRemoveFailed": { + "description": "Error toast when removing a replay failed." + }, + "replayLibraryKeepInIcarus": "Keep in Icarus", + "@replayLibraryKeepInIcarus": { + "description": "Right-click menu entry on a replay in Valorant's folder: copy it into Icarus." + }, + "replayLibraryRemoveFromIcarus": "Remove from Icarus", + "@replayLibraryRemoveFromIcarus": { + "description": "Right-click menu entry on a replay kept in Icarus: move it to the Recycle Bin." + }, + "replayLibraryNotAReplay": "This file isn't a Valorant replay.", + "@replayLibraryNotAReplay": { + "description": "Toast when the user opens a file in the Replays tab that isn't a replay." + }, + "replayLibraryPatchUnsupported": "Replays from patch {patch} can't be read yet. An Icarus update will add them.", + "@replayLibraryPatchUnsupported": { + "description": "Toast when the user opens a replay from a Valorant patch Icarus can't read yet.", + "placeholders": { + "patch": { + "type": "String", + "description": "Valorant patch number, e.g. 13.06." + } + } + }, + "replayLibraryUnreadableFile": "Unreadable file", + "@replayLibraryUnreadableFile": { + "description": "Shown in place of the map name on a replay card whose file couldn't be read." + }, + "replayLibraryUnknownMap": "Unknown map", + "@replayLibraryUnknownMap": { + "description": "Shown in place of the map name on a replay card whose map Icarus doesn't have." + }, + "replayLibraryNotAReplayStatus": "Not a replay", + "@replayLibraryNotAReplayStatus": { + "description": "Short status at the right of a replay card whose file isn't a replay. Space is tight." + }, + "replayLibraryPatchStatus": "Patch {patch}", + "@replayLibraryPatchStatus": { + "description": "Short status at the right of a replay card that can't be opened yet, naming its Valorant patch. Space is tight.", + "placeholders": { + "patch": { + "type": "String", + "description": "Valorant patch number, e.g. 13.06." + } + } + }, + "replayLibraryKeepTooltip": "Only in Valorant's replay folder, which Valorant may clear.\nKeep a copy in Icarus.", + "@replayLibraryKeepTooltip": { + "description": "Tooltip on the Keep button of a replay card. Two lines." + }, + "replayLibraryKeepButton": "Keep", + "@replayLibraryKeepButton": { + "description": "Small button on a replay card that copies the replay from Valorant's folder into Icarus. Space is tight." + }, + "replayLibraryAddFailed": "Couldn't add replays.", + "@replayLibraryAddFailed": { + "description": "Error toast when adding replay files failed outright." + }, + "replayLibraryAdded": "Added {count, plural, =1{1 replay} other{{count} replays}}.", + "@replayLibraryAdded": { + "description": "Sentence in the toast after adding replay files. Other sentences from this toast may follow it, joined with a space.", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "replayLibraryAlreadyListed": "{count, plural, =1{That match is already in Replays.} other{Those {count} matches are already in Replays.}}", + "@replayLibraryAlreadyListed": { + "description": "Sentence in the toast after adding replay files when none were new. Replays is the name of the tab.", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "replayLibrarySomeAlreadyListed": "{count, plural, =1{1 was already in Replays.} other{{count} were already in Replays.}}", + "@replayLibrarySomeAlreadyListed": { + "description": "Sentence in the toast after adding replay files, after the Added sentence, counting the files that were already listed. Replays is the name of the tab.", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "replayLibraryUnreadableOne": "{fileName} isn't a replay Icarus can read.", + "@replayLibraryUnreadableOne": { + "description": "Sentence in the toast after adding files, naming the one file that wasn't a readable replay.", + "placeholders": { + "fileName": { + "type": "String" + } + } + }, + "replayLibraryUnreadableMany": "{count, plural, other{Skipped {count} files that aren't replays Icarus can read.}}", + "@replayLibraryUnreadableMany": { + "description": "Sentence in the toast after adding files, counting the files that weren't readable replays. Count is always 2 or more.", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "replayLibraryCopyFailedOne": "Couldn't copy {fileName}.", + "@replayLibraryCopyFailedOne": { + "description": "Sentence in the toast after adding files, naming the one replay that couldn't be copied.", + "placeholders": { + "fileName": { + "type": "String" + } + } + }, + "replayLibraryCopyFailedMany": "{count, plural, other{Couldn't copy {count} replays.}}", + "@replayLibraryCopyFailedMany": { + "description": "Sentence in the toast after adding files, counting the replays that couldn't be copied. Count is always 2 or more.", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "replayLibraryDropToAdd": "Add replays", + "@replayLibraryDropToAdd": { + "description": "Large label over the Replays tab while files are dragged over it." + }, + "replayMatchCaptureFailed": "Couldn't capture this moment.", + "@replayMatchCaptureFailed": { + "description": "Error toast when saving the replay's current moment as a strategy page failed." + }, + "replayMatchStrategyDeleted": "This strategy was deleted, so it can't be opened.", + "@replayMatchStrategyDeleted": { + "description": "Error toast when the strategy the replay captured into was deleted." + }, + "replayMatchStrategyMapChanged": "This strategy is on another map now, so the replay's captures can't go there.", + "@replayMatchStrategyMapChanged": { + "description": "Error toast when the strategy the replay captured into was moved to another map." + }, + "replayMatchCaptureUnsaved": "A capture couldn't be saved, so the strategy can't open on it yet. Retry or discard it first.", + "@replayMatchCaptureUnsaved": { + "description": "Error toast when opening the captured strategy waits on a capture that failed to save." + }, + "replayMatchBackToReplay": "Back to replay", + "@replayMatchBackToReplay": { + "description": "Back button label in the strategy editor when it was opened from a replay." + }, + "replayMatchRetryFailed": "Couldn't retry the capture.", + "replayMatchDiscardFailed": "Couldn't discard the capture.", + "replayMatchDiscarded": "Discarded. It may still appear in your library.", + "@replayMatchDiscarded": { + "description": "Toast after discarding a capture whose send may already have reached the cloud." + }, + "replayMatchWarmup": "Warmup", + "@replayMatchWarmup": { + "description": "Shown in place of the round number while the replay is before the first round." + }, + "replayMatchAttack": "Attack", + "@replayMatchAttack": { + "description": "Side the watched team plays this round, next to the round number." + }, + "replayMatchDefense": "Defense", + "@replayMatchDefense": { + "description": "Side the watched team plays this round, next to the round number." + }, + "replayMatchCapture": "Capture", + "@replayMatchCapture": { + "description": "Button that saves the replay's current moment as a page of a strategy." + }, + "replayMatchDrawTooltip": "Draw ({shortcut})", + "@replayMatchDrawTooltip": { + "description": "Tooltip on the replay's pen button.", + "placeholders": { + "shortcut": { + "type": "String", + "description": "Keyboard shortcut, e.g. D." + } + } + }, + "replayMatchStopDrawingTooltip": "Stop drawing ({shortcut})", + "@replayMatchStopDrawingTooltip": { + "description": "Tooltip on the replay's pen button while drawing.", + "placeholders": { + "shortcut": { + "type": "String", + "description": "Keyboard shortcut, e.g. D." + } + } + }, + "replayMatchEraserTooltip": "Eraser ({shortcut})", + "@replayMatchEraserTooltip": { + "description": "Tooltip on the replay's eraser button.", + "placeholders": { + "shortcut": { + "type": "String", + "description": "Keyboard shortcut, e.g. E." + } + } + }, + "replayMatchStopErasingTooltip": "Stop erasing ({shortcut})", + "@replayMatchStopErasingTooltip": { + "description": "Tooltip on the replay's eraser button while erasing.", + "placeholders": { + "shortcut": { + "type": "String", + "description": "Keyboard shortcut, e.g. E." + } + } + }, + "replayMatchSwitchSideTooltip": "See the other team's side", + "@replayMatchSwitchSideTooltip": { + "description": "Tooltip on the replay button that swaps which team is shown as allies." + }, + "replayMatchPagesCaptured": "{count, plural, =1{1 page captured} other{{count} pages captured}}", + "@replayMatchPagesCaptured": { + "description": "How many pages this replay has captured into its strategy.", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "replayMatchOpenStrategy": "Open", + "@replayMatchOpenStrategy": { + "description": "Link that opens the strategy the replay captured into." + }, + "replayMatchSending": "Sending…", + "@replayMatchSending": { + "description": "Shown in place of Open while the captured page is still on its way to the cloud." + }, + "replayMatchCloudSaved": "Saved to your cloud library", + "@replayMatchCloudSaved": { + "description": "Status under the replay's captures once every capture reached the cloud." + }, + "replayMatchCloudUpdateRequired": "Update Icarus to finish saving {count}", + "@replayMatchCloudUpdateRequired": { + "description": "Status under the replay's captures when the server needs a newer Icarus before it takes them.", + "placeholders": { + "count": { + "type": "int", + "description": "Number of captures still to save." + } + } + }, + "replayMatchCloudWaiting": "{count} waiting for the connection", + "@replayMatchCloudWaiting": { + "description": "Status under the replay's captures while offline.", + "placeholders": { + "count": { + "type": "int", + "description": "Number of captures still to save." + } + } + }, + "replayMatchCloudUnconfirmed": "{count} sending. Couldn't confirm the last send, trying again.", + "@replayMatchCloudUnconfirmed": { + "description": "Status under the replay's captures when a send's answer was lost.", + "placeholders": { + "count": { + "type": "int", + "description": "Number of captures still to save." + } + } + }, + "replayMatchCloudSending": "{count} sending", + "@replayMatchCloudSending": { + "description": "Status under the replay's captures while they are sent.", + "placeholders": { + "count": { + "type": "int", + "description": "Number of captures still to save." + } + } + }, + "replayMatchCapturesFailed": "{count, plural, =1{1 capture couldn't be saved} other{{count} captures couldn't be saved}}", + "@replayMatchCapturesFailed": { + "description": "Red status under the replay's captures when some failed; the reason follows on the next line.", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "replayMatchFailureDeleted": "Its strategy was deleted.", + "@replayMatchFailureDeleted": { + "description": "Why a replay capture couldn't be saved." + }, + "replayMatchFailureMapChanged": "Its strategy is on another map now.", + "@replayMatchFailureMapChanged": { + "description": "Why a replay capture couldn't be saved." + }, + "replayMatchFailurePageDeleted": "Its page was deleted.", + "@replayMatchFailurePageDeleted": { + "description": "Why a replay capture couldn't be saved." + }, + "replayMatchFailureRefused": "The cloud refused it.", + "@replayMatchFailureRefused": { + "description": "Why a replay capture couldn't be saved." + }, + "replayRosterAllies": "Allies", + "@replayRosterAllies": { + "description": "Heading of the watched team's players in the replay roster." + }, + "replayRosterEnemies": "Enemies", + "@replayRosterEnemies": { + "description": "Heading of the other team's players in the replay roster." + }, + "replayRosterAlive": "{count} alive", + "@replayRosterAlive": { + "description": "How many players of a team are alive, at the right of the team heading in the replay roster.", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "replayRosterWatchTooltip": "Watch {name}", + "@replayRosterWatchTooltip": { + "description": "Tooltip on a player row in the replay roster: show the replay from this player's view.", + "placeholders": { + "name": { + "type": "String", + "description": "Player's in-game name, or their agent's name." + } + } + }, + "replayRosterWatchWithKeyTooltip": "Watch {name} ({key})", + "@replayRosterWatchWithKeyTooltip": { + "description": "Tooltip on a player row in the replay roster, with the number key that does the same.", + "placeholders": { + "name": { + "type": "String", + "description": "Player's in-game name, or their agent's name." + }, + "key": { + "type": "String", + "description": "Keyboard key, e.g. 3." + } + } + }, + "replayRosterStopWatchingTooltip": "Stop watching {name}", + "@replayRosterStopWatchingTooltip": { + "description": "Tooltip on the watched player's row while the camera follows them.", + "placeholders": { + "name": { + "type": "String", + "description": "Player's in-game name, or their agent's name." + } + } + }, + "replayRosterStopWatchingWithKeyTooltip": "Stop watching {name} ({key})", + "@replayRosterStopWatchingWithKeyTooltip": { + "description": "Tooltip on the watched player's row while the camera follows them, with the number key that does the same.", + "placeholders": { + "name": { + "type": "String", + "description": "Player's in-game name, or their agent's name." + }, + "key": { + "type": "String", + "description": "Keyboard key, e.g. 3." + } + } + }, + "replayRosterFollowTooltip": "Follow {name}", + "@replayRosterFollowTooltip": { + "description": "Tooltip on the watched player's row while the camera doesn't follow them: make the camera follow.", + "placeholders": { + "name": { + "type": "String", + "description": "Player's in-game name, or their agent's name." + } + } + }, + "replayRosterFollowWithKeyTooltip": "Follow {name} ({key})", + "@replayRosterFollowWithKeyTooltip": { + "description": "Tooltip on the watched player's row while the camera doesn't follow them, with the number key that does the same.", + "placeholders": { + "name": { + "type": "String", + "description": "Player's in-game name, or their agent's name." + }, + "key": { + "type": "String", + "description": "Keyboard key, e.g. 3." + } + } + }, + "replayRosterUnknownAgent": "Unknown agent", + "@replayRosterUnknownAgent": { + "description": "Shown for a player in the replay roster whose name and agent are unknown." + }, + "replayRosterFilterKills": "Kills", + "@replayRosterFilterKills": { + "description": "Killfeed filter chip in the replay roster: list kills." + }, + "replayRosterFilterAbilities": "Abilities", + "@replayRosterFilterAbilities": { + "description": "Killfeed filter chip in the replay roster: list ability uses." + }, + "replayRosterNoKills": "No kills yet this round", + "@replayRosterNoKills": { + "description": "Empty killfeed in the replay roster while kills are shown." + }, + "replayRosterNoPlantOrAbilities": "No plant or abilities yet this round", + "@replayRosterNoPlantOrAbilities": { + "description": "Empty killfeed while spike events and abilities are shown." + }, + "replayRosterNoPlant": "No plant yet this round", + "@replayRosterNoPlant": { + "description": "Empty killfeed while only spike events are shown." + }, + "replayRosterNoAbilities": "No abilities used yet this round", + "@replayRosterNoAbilities": { + "description": "Empty killfeed while only abilities are shown." + }, + "replayRosterNothingChosen": "Choose what to show", + "@replayRosterNothingChosen": { + "description": "Empty killfeed when every filter chip is off." + }, + "strategyViewMapSemanticLabel": "Map", + "@strategyViewMapSemanticLabel": { + "description": "Screen reader label for the map picture on the canvas." + }, + "strategyViewBarrierSemanticLabel": "Barrier", + "@strategyViewBarrierSemanticLabel": { + "description": "Screen reader label for the spawn barrier picture drawn over the map." + }, + "strategyViewCalloutsSemanticLabel": "Callouts", + "@strategyViewCalloutsSemanticLabel": { + "description": "Screen reader label for the callout names layer drawn over the map." + }, + "strategyViewUltOrbsSemanticLabel": "Ult Orbs", + "@strategyViewUltOrbsSemanticLabel": { + "description": "Screen reader label for the ultimate orb markers drawn over the map." + }, + "strategyViewLoadFailed": "Could not load strategy.", + "@strategyViewLoadFailed": { + "description": "Error toast when the editor failed to open a strategy." + }, + "strategyViewShareLinkDisabled": "This share link was disabled by its owner.", + "@strategyViewShareLinkDisabled": { + "description": "Error toast when the owner turns off the share link the user is viewing a strategy through; the editor closes." + }, + "strategyViewBackToLibraryTooltip": "Library", + "@strategyViewBackToLibraryTooltip": { + "description": "Tooltip on the home button in the editor's title bar; it goes back to the library." + }, + "strategyViewDiscordPrompt": "Have any bugs? Join the Discord", + "@strategyViewDiscordPrompt": { + "description": "Button (and its tooltip on narrow windows) at the right of the editor's title bar that opens the Icarus Discord server." + }, + "appImportFailed": "Failed to import strategy file.", + "@appImportFailed": { + "description": "Error toast when a .ica strategy file opened from outside Icarus could not be imported." + }, + "screenshotImagesStillLoading": "Images on this page are still loading. Try again in a moment.", + "@screenshotImagesStillLoading": { + "description": "Shown when a screenshot or video export stops because an image on the page hasn't finished loading." + }, + "screenshotImagesLoadFailed": "Couldn't load the images on this page. Check your connection and try again.", + "@screenshotImagesLoadFailed": { + "description": "Shown when a screenshot or video export stops because an image on the page couldn't be loaded." + }, + "authMsgDisplayNameFallback": "Discord user", + "@authMsgDisplayNameFallback": { + "description": "Name shown for the signed-in account (account menu, settings, share dialog owner row) when the account has no name or email." + }, + "authMsgStreamError": "Auth stream error: {error}", + "@authMsgStreamError": { + "description": "Sign-in error shown in the sign-in dialog when the sign-in service's event stream fails.", + "placeholders": { + "error": { + "type": "String", + "example": "AuthException(...)" + } + } + }, + "authMsgDiscordSignInFailed": "Discord sign-in failed: {error}", + "@authMsgDiscordSignInFailed": { + "description": "Sign-in dialog error when the Discord sign-in could not start.", + "placeholders": { + "error": { + "type": "String", + "description": "The technical error text, untranslated." + } + } + }, + "authMsgSignInIncomplete": "Sign in didn't complete. Please try again.", + "@authMsgSignInIncomplete": { + "description": "Sign-in dialog error when email sign-in returned no session." + }, + "authMsgIncorrectCredentials": "Incorrect email or password.", + "@authMsgIncorrectCredentials": { + "description": "Sign-in dialog error." + }, + "authMsgEmailNotConfirmed": "Confirm your email first — check your inbox for the confirmation link.", + "@authMsgEmailNotConfirmed": { + "description": "Sign-in dialog error when the account's email address has not been confirmed yet." + }, + "authMsgTooManyAttempts": "Too many attempts. Wait a moment and try again.", + "@authMsgTooManyAttempts": { + "description": "Sign-in or sign-up dialog error when the sign-in service rate-limits the user." + }, + "authMsgServerUnreachable": "Couldn't reach the server. Check your connection and try again.", + "@authMsgServerUnreachable": { + "description": "Sign-in, sign-up, password reset or new password error when there is no network." + }, + "authMsgSignInFailed": "Sign in failed. Please try again.", + "@authMsgSignInFailed": { + "description": "Generic sign-in dialog error." + }, + "authMsgSignUpAccountExists": "An account with this email already exists. Try signing in instead.", + "@authMsgSignUpAccountExists": { + "description": "Sign-up dialog error when the email is already registered." + }, + "authMsgSignUpAccountExistsMaybeDiscord": "An account with this email already exists. Sign in instead, or continue with Discord if you signed up that way.", + "@authMsgSignUpAccountExistsMaybeDiscord": { + "description": "Sign-up dialog message when the email already has an account, possibly one made with Discord." + }, + "authMsgConfirmationEmailUnavailable": "We couldn't send your confirmation email right now. Try again later, or continue with Discord.", + "@authMsgConfirmationEmailUnavailable": { + "description": "Sign-up dialog error when the sign-in service could not send the confirmation email." + }, + "authMsgSignUpFailed": "Sign up failed. Please try again.", + "@authMsgSignUpFailed": { + "description": "Generic sign-up dialog error." + }, + "authMsgPasswordResetAlreadySentBrowser": "A link is already on its way. Check your inbox and spam, and open it in this browser within 5 minutes.", + "@authMsgPasswordResetAlreadySentBrowser": { + "description": "Forgot-password message on the web app when a reset link was asked for under a minute ago. The link only works in the same browser." + }, + "authMsgPasswordResetAlreadySentComputer": "A link is already on its way. Check your inbox and spam, and open it on this computer within 5 minutes.", + "@authMsgPasswordResetAlreadySentComputer": { + "description": "Forgot-password message in the desktop app when a reset link was asked for under a minute ago. The link only works on the same computer." + }, + "authMsgPasswordResetSentBrowser": "If an account uses this email, a link to set a new password is on its way. Open it in this browser within 5 minutes.", + "@authMsgPasswordResetSentBrowser": { + "description": "Forgot-password message on the web app after the reset email was requested." + }, + "authMsgPasswordResetSentComputer": "If an account uses this email, a link to set a new password is on its way. Open it on this computer within 5 minutes.", + "@authMsgPasswordResetSentComputer": { + "description": "Forgot-password message in the desktop app after the reset email was requested." + }, + "authMsgPasswordResetRateLimited": "A link was sent a moment ago. Wait a minute, then try again.", + "@authMsgPasswordResetRateLimited": { + "description": "Forgot-password error when the service refuses another email this soon." + }, + "authMsgPasswordResetFailed": "Couldn't send the email. Please try again.", + "@authMsgPasswordResetFailed": { + "description": "Forgot-password error when the reset email could not be requested." + }, + "authMsgNewPasswordSignedOut": "You were signed out. Ask for a new reset link from Sign in.", + "@authMsgNewPasswordSignedOut": { + "description": "Set-new-password dialog error when the reset session is gone. 'Sign in' names the sign-in dialog." + }, + "authMsgNewPasswordSame": "That's already your password. Choose a different one.", + "@authMsgNewPasswordSame": { + "description": "Set-new-password dialog error." + }, + "authMsgNewPasswordWeak": "Choose a longer password.", + "@authMsgNewPasswordWeak": { + "description": "Set-new-password dialog error when the password is too weak." + }, + "authMsgNewPasswordFailed": "Couldn't save your password. Please try again.", + "@authMsgNewPasswordFailed": { + "description": "Generic set-new-password dialog error." + }, + "authMsgSignOutFailed": "Sign out failed. Please try again.", + "@authMsgSignOutFailed": { + "description": "Shown in the sign-out failure dialog and the sign-in status when signing out failed." + }, + "authMsgSignedOutLocalKept": "Signed out. Your local strategies stay on this device.", + "@authMsgSignedOutLocalKept": { + "description": "Toast after signing out, on platforms that keep a local library." + }, + "authMsgSignedOut": "Signed out.", + "@authMsgSignedOut": { + "description": "Toast after signing out, on platforms with no local library (web)." + }, + "authMsgLinkIgnoredWhileSignedIn": "You're already signed in, so that link wasn't used. To set a new password, sign out and ask for a new link.", + "@authMsgLinkIgnoredWhileSignedIn": { + "description": "Toast when a sign-in or password reset link is opened while already signed in." + }, + "authMsgSignInLinkFailed": "Failed to complete login. Please try again.", + "@authMsgSignInLinkFailed": { + "description": "Toast and sign-in error when a sign-in link (Discord return or reset link) could not be completed. Also written to the in-app error log." + }, + "authMsgCloudSessionExpired": "Your cloud session expired. Reconnect to resume syncing, or sign out.", + "@authMsgCloudSessionExpired": { + "description": "Sign-in status error when the cloud server stopped accepting the session." + }, + "authMsgCloudSyncConnectFailed": "Couldn't connect to cloud sync. Please retry.", + "@authMsgCloudSyncConnectFailed": { + "description": "Sign-in status error when connecting to the cloud failed after signing in." + }, + "authMsgIncidentTitle": "Cloud connection lost", + "@authMsgIncidentTitle": { + "description": "Title of the dialog shown when the cloud stops accepting the user's session." + }, + "authMsgIncidentDescription": "Your cloud session is no longer valid, so syncing is paused. Reconnect to keep your cloud strategies up to date, or sign out.", + "@authMsgIncidentDescription": { + "description": "Body of the 'Cloud connection lost' dialog, on platforms with no local library (web)." + }, + "authMsgIncidentDescriptionWithLocal": "Your cloud session is no longer valid, so syncing is paused. Reconnect to keep your cloud strategies up to date, or sign out. Local strategies are unaffected.", + "@authMsgIncidentDescriptionWithLocal": { + "description": "Body of the 'Cloud connection lost' dialog, on platforms that also keep a local library." + }, + "authMsgIncidentNotNow": "Not Now", + "@authMsgIncidentNotNow": { + "description": "Button in the 'Cloud connection lost' dialog that closes it without reconnecting." + }, + "updateMsgUpdateAvailable": "Update Available", + "@updateMsgUpdateAvailable": { + "description": "Windows desktop updater: heading when an update is available." + }, + "updateMsgNewVersionAvailable": "{appName} {version} is available", + "@updateMsgNewVersionAvailable": { + "description": "Windows desktop updater: line naming the new version. The updater fills the app name and version.", + "placeholders": { + "appName": { + "type": "String", + "example": "Icarus" + }, + "version": { + "type": "String", + "example": "4.5.0" + } + } + }, + "updateMsgNewVersionLong": "A desktop update is ready. Downloading will fetch {size} MB of files.", + "@updateMsgNewVersionLong": { + "description": "Windows desktop updater: longer description of the update.", + "placeholders": { + "size": { + "type": "String", + "example": "52" + } + } + }, + "updateMsgDownload": "Download Update", + "@updateMsgDownload": { + "description": "Windows desktop updater button that downloads the update." + }, + "updateMsgRestartToUpdate": "Restart to update", + "@updateMsgRestartToUpdate": { + "description": "Windows desktop updater button shown once the update is downloaded." + }, + "updateMsgLater": "Later", + "@updateMsgLater": { + "description": "Windows desktop updater button that skips this update for now." + }, + "updateMsgRestartRequiredTitle": "Restart Required", + "@updateMsgRestartRequiredTitle": { + "description": "Title of the confirmation before Icarus restarts to install an update." + }, + "updateMsgRestartWarning": "Icarus needs to restart to finish installing the update. Unsaved changes will be lost. Restart now?", + "@updateMsgRestartWarning": { + "description": "Body of the confirmation before Icarus restarts to install an update." + }, + "updateMsgRestartCancel": "Not now", + "@updateMsgRestartCancel": { + "description": "Cancel button of the restart-to-update confirmation." + }, + "updateMsgRestartConfirm": "Restart", + "@updateMsgRestartConfirm": { + "description": "Confirm button of the restart-to-update confirmation." + }, + "shareMsgMissingCode": "That share link is missing a share code.", + "@shareMsgMissingCode": { + "description": "Toast when an opened share link has no code in it." + }, + "shareMsgFolderAdded": "Shared folder added to your library.", + "@shareMsgFolderAdded": { + "description": "Toast after a share link added a folder to the user's library." + }, + "shareMsgStrategyAdded": "Shared strategy added to your library.", + "@shareMsgStrategyAdded": { + "description": "Toast after a share link added a strategy to the user's library." + }, + "shareMsgRedeemFailed": "Failed to redeem share link.", + "@shareMsgRedeemFailed": { + "description": "Toast when a share link could not be added to the library." + }, + "shareMsgSignInForFolder": "Sign in to open this shared folder.", + "@shareMsgSignInForFolder": { + "description": "Toast when a signed-out person opens a share link to a folder." + }, + "shareMsgLinkDisabled": "This share link was disabled by its owner.", + "@shareMsgLinkDisabled": { + "description": "Toast when an opened share link has been turned off." + }, + "shareMsgLinkNotFound": "This share link does not exist.", + "@shareMsgLinkNotFound": { + "description": "Toast when an opened share link is unknown to the server." + }, + "shareMsgOpenStrategyFailed": "Could not open the shared strategy. Check your connection and reload.", + "@shareMsgOpenStrategyFailed": { + "description": "Toast when a signed-out person's share link could not be opened, most likely offline. 'reload' means reload the web page." + }, + "shareMsgOpenLinkFailed": "Could not open the shared link. Check your connection and try again.", + "@shareMsgOpenLinkFailed": { + "description": "Toast when a signed-in person's share link could not be looked up, most likely offline." + }, + "folderMsgCreateCloudFailed": "Couldn't create the cloud folder. Check your connection and try again.", + "@folderMsgCreateCloudFailed": { + "description": "Toast when creating a folder in the cloud library failed." + }, + "folderMsgDeleteCloudFailed": "Couldn't delete this cloud folder. Try again.", + "@folderMsgDeleteCloudFailed": { + "description": "Error when deleting a folder in the cloud library failed." + }, + "folderMsgDeleteWhileMoving": "Something in this folder is moving to the cloud. Delete it once the move ends.", + "@folderMsgDeleteWhileMoving": { + "description": "Error when the user deletes a local folder while something in it is being moved to the cloud." + }, + "folderMsgUpdateCloudFailed": "Couldn't update this cloud folder. Try again.", + "@folderMsgUpdateCloudFailed": { + "description": "Error when renaming or restyling a cloud folder failed." + }, + "folderMsgMoveCloudFailed": "Couldn't move this cloud folder. Try again.", + "@folderMsgMoveCloudFailed": { + "description": "Error when moving a cloud folder into another folder failed." + }, + "strategyMsgCreatedNotOpened": "Strategy created, but it could not be opened. Open it from your cloud library.", + "@strategyMsgCreatedNotOpened": { + "description": "Toast when a new cloud strategy was made but the editor could not open it." + }, + "strategyMsgTooLargeToDuplicate": "This strategy is too large to duplicate.", + "@strategyMsgTooLargeToDuplicate": { + "description": "Message when the server refuses to duplicate a cloud strategy because of its size." + }, + "strategyMsgDuplicateFailed": "Couldn't duplicate this strategy. Try again.", + "@strategyMsgDuplicateFailed": { + "description": "Message when duplicating a cloud strategy failed." + }, + "strategyMsgDuplicateImagesFailed": "Couldn't duplicate this strategy: its images could not be copied.", + "@strategyMsgDuplicateImagesFailed": { + "description": "Message when duplicating a local strategy failed because its picture files could not be copied." + }, + "strategyMsgDuplicatedMissingPictures": "{count, plural, =1{Duplicated. One picture was already missing from the original, so the copy is missing it too.} other{Duplicated. {count} pictures were already missing from the original, so the copy is missing them too.}}", + "@strategyMsgDuplicatedMissingPictures": { + "description": "Message after duplicating a local strategy whose original already had missing picture files.", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "strategyMsgDeleteCloudFailed": "Couldn't delete this cloud strategy. Try again.", + "@strategyMsgDeleteCloudFailed": { + "description": "Error when deleting a cloud strategy failed." + }, + "strategyMsgDeleteWhileMoving": "This strategy is moving to the cloud. Delete it once the move ends.", + "@strategyMsgDeleteWhileMoving": { + "description": "Error when the user deletes a local strategy while it is being moved to the cloud." + }, + "strategyMsgMoveCloudFailed": "Couldn't move this cloud strategy. Try again.", + "@strategyMsgMoveCloudFailed": { + "description": "Error when moving a cloud strategy into another folder failed." + }, + "strategyMsgMoveFailed": "Couldn't move this strategy. Try again.", + "@strategyMsgMoveFailed": { + "description": "Error when moving a local strategy into another folder failed." + }, + "strategyMsgPageUnreachable": "Couldn't open {pageName}. Check your connection and try again.", + "@strategyMsgPageUnreachable": { + "description": "Toast when a cloud page the user switched to could not be loaded.", + "placeholders": { + "pageName": { + "type": "String", + "description": "The page's name as the pages bar shows it.", + "example": "Page 2" + } + } + }, + "strategyMsgPageUnreachableUnnamed": "Couldn't open that page. Check your connection and try again.", + "@strategyMsgPageUnreachableUnnamed": { + "description": "Toast when a cloud page the user switched to could not be loaded and its name is unknown." + }, + "strategyMsgDeletedPageFallbackName": "This page", + "@strategyMsgDeletedPageFallbackName": { + "description": "Stands in for a page's name in the deleted-page dialog (deletedPageDescription, deletedPageGone) when the page's name is unknown. Shown inside quotes there." + }, + "prefsMsgSortAlphabetical": "Alphabetical", + "@prefsMsgSortAlphabetical": { + "description": "Library sort option: by strategy name." + }, + "prefsMsgSortDateCreated": "Date Created", + "@prefsMsgSortDateCreated": { + "description": "Library sort option." + }, + "prefsMsgSortDateUpdated": "Date Updated", + "@prefsMsgSortDateUpdated": { + "description": "Library sort option." + }, + "prefsMsgSortAscending": "Ascending", + "@prefsMsgSortAscending": { + "description": "Library sort order option." + }, + "prefsMsgSortDescending": "Descending", + "@prefsMsgSortDescending": { + "description": "Library sort order option." + }, + "authMsgSignUpCheckInbox": "Account created! Check your inbox for the confirmation link, then sign in.", + "@authMsgSignUpCheckInbox": { + "description": "Shown in the sign-up dialog after an account is created. Good news, drawn as information." + }, + "authMsgSignUpAlreadySent": "We already sent a confirmation email to this address. Check your inbox and spam, then open the link.", + "@authMsgSignUpAlreadySent": { + "description": "Shown in the sign-up dialog when the user asks for a second confirmation email within a minute." + }, + "syncErrorClientUpgradeWeb": "Icarus was updated. Reload to keep syncing.", + "@syncErrorClientUpgradeWeb": { + "description": "Shown in the web app while the server refuses this build. A Reload button sits beside it." + }, + "syncErrorClientUpgradeAvailable": "Icarus was updated. Install the update to keep syncing.", + "@syncErrorClientUpgradeAvailable": { + "description": "Shown in the desktop app while the server refuses this build and an update is ready to install." + }, + "syncErrorClientUpgradeChecking": "Icarus was updated. Looking for the update this app needs to keep syncing.", + "@syncErrorClientUpgradeChecking": { + "description": "Shown in the desktop app while the server refuses this build and the app is checking for the update." + }, + "syncErrorClientUpgradeUnavailable": "Icarus was updated, but the update isn't available for this app yet. Your saved work stays on this device and syncs once the update is installed.", + "@syncErrorClientUpgradeUnavailable": { + "description": "Shown in the desktop app while the server refuses this build and no update can be installed yet." + }, + "syncErrorHeldPageDeleteChanged": "{pageName} changed since you deleted it. Review before deleting.", + "@syncErrorHeldPageDeleteChanged": { + "description": "Sync explanation: a page delete is held because a teammate changed the page after this user deleted it.", + "placeholders": { + "pageName": { + "type": "String", + "example": "Page 2" + } + } + }, + "syncErrorHeldPageDeleteUnchecked": "Icarus couldn't check {pageName} for a teammate's changes. Review it before deleting.", + "@syncErrorHeldPageDeleteUnchecked": { + "description": "Sync explanation: a page delete is held because this device could not check the page for a teammate's changes.", + "placeholders": { + "pageName": { + "type": "String", + "example": "Page 2" + } + } + }, + "syncErrorIncompletePageCopy": "The copy of {pageName} didn't finish saving on this computer, so it was not sent. Copy the page again, or choose Use cloud to discard it.", + "@syncErrorIncompletePageCopy": { + "description": "Sync explanation: a page copied on this computer was only partly saved. 'Use cloud' is the name of a button.", + "placeholders": { + "pageName": { + "type": "String", + "example": "Page 2" + } + } + }, + "syncErrorIncompletePageCopyWithOtherWork": "The copy of {pageName} didn't finish saving on this computer, so it was not sent. Copy the page again, or choose Use cloud to discard it. Other saved work needs attention too.", + "@syncErrorIncompletePageCopyWithOtherWork": { + "description": "Same as syncErrorIncompletePageCopy, when other saved work needs attention as well. 'Use cloud' is the name of a button.", + "placeholders": { + "pageName": { + "type": "String", + "example": "Page 2" + } + } + }, + "syncErrorStrategyDeleted": "This strategy was deleted, so its unsent changes cannot be saved. Discard them from the library.", + "@syncErrorStrategyDeleted": { + "description": "Sync explanation: the cloud strategy was deleted while changes were waiting to be sent." + }, + "syncErrorUnreadableSavedWork": "A saved cloud change could not be read. It remains on this device; keep this strategy open and recover the outbox before continuing.", + "@syncErrorUnreadableSavedWork": { + "description": "Sync explanation: a change saved on this device for sending could not be read back." + }, + "syncErrorUnverifiedCloudWork": "Icarus could not verify that this change was saved on this device. Nothing was sent. Keep this strategy open and retry.", + "@syncErrorUnverifiedCloudWork": { + "description": "Sync explanation: a change may not have been saved on this device, and nothing was sent." + }, + "syncErrorEditAccessRemoved": "You no longer have edit access to this strategy. These changes stay on this device and were not saved to the cloud.", + "@syncErrorEditAccessRemoved": { + "description": "Sync explanation: the strategy's owner removed this account's edit access while changes were waiting." + }, + "syncErrorForbidden": "This account does not have permission to save these changes. They remain on this device. Ask the owner for edit access, then retry.", + "@syncErrorForbidden": { + "description": "Sync explanation: the server refused the changes because this account may not edit the strategy." + }, + "syncErrorRetryPaused": "A saved cloud change is paused after repeated failures. Retry when the connection and account are healthy.", + "@syncErrorRetryPaused": { + "description": "Sync explanation: sending stopped after repeated failures." + }, + "syncErrorPageTooLargeToCopy": "A new page was too large for the cloud to copy, so it was not added. Keep mine tries again; Use cloud drops it.", + "@syncErrorPageTooLargeToCopy": { + "description": "Sync explanation. 'Keep mine' and 'Use cloud' are the names of buttons." + }, + "syncErrorTooLargeForCloudSync": "A saved change is too large for cloud sync. It remains saved on this device. Reduce it, then choose Keep mine to retry, or Use cloud to drop it.", + "@syncErrorTooLargeForCloudSync": { + "description": "Sync explanation. 'Keep mine' and 'Use cloud' are the names of buttons." + }, + "syncErrorLineupOverlap": "This lineup shares a spot with lineups saved separately, so it was not saved to the cloud. It remains on this device. Keep mine sends it again, and is refused while the spot is still shared; Use cloud removes it here.", + "@syncErrorLineupOverlap": { + "description": "Sync explanation. 'Keep mine' and 'Use cloud' are the names of buttons." + }, + "syncErrorLineupPageMismatch": "This lineup clashes with one on another page, so it was not saved to the cloud. It remains on this device; Use cloud removes it here.", + "@syncErrorLineupPageMismatch": { + "description": "Sync explanation. 'Use cloud' is the name of a button." + }, + "syncErrorTeammateDeletedCannotRestore": "A teammate deleted what this change edits, and this change cannot bring it back. Use cloud to let it go.", + "@syncErrorTeammateDeletedCannotRestore": { + "description": "Sync explanation. 'Use cloud' is the name of a button." + }, + "syncErrorTeammateDeleted": "A teammate deleted what you changed. Keep mine brings it back as you have it; Use cloud lets it go.", + "@syncErrorTeammateDeleted": { + "description": "Sync explanation. 'Keep mine' and 'Use cloud' are the names of buttons." + }, + "syncErrorRetiredLineupOp": "A lineup change saved by an older version of Icarus cannot be sent to the cloud, so it is not in this strategy. Redo it, then choose Use cloud to clear this.", + "@syncErrorRetiredLineupOp": { + "description": "Sync explanation. 'Use cloud' is the name of a button." + }, + "syncErrorUndrawableWork": "A saved change no longer fits this page as the cloud has it now, so it was not sent. It remains on this device. Keep mine replaces the cloud's version with yours, whole; Use cloud drops yours.", + "@syncErrorUndrawableWork": { + "description": "Sync explanation. 'Keep mine' and 'Use cloud' are the names of buttons." + }, + "syncErrorPageDeleted": "The page these changes were on was deleted, so they were not saved. They remain on this device; Use cloud drops them.", + "@syncErrorPageDeleted": { + "description": "Sync explanation. 'Use cloud' is the name of a button." + }, + "syncErrorConflict": "Another edit reached the cloud first. Your version remains saved on this device.", + "@syncErrorConflict": { + "description": "Sync explanation: another edit to the same thing landed on the server first." + }, + "syncErrorCannotRetryAutomatically": "The server cannot match this retained edit to a current cloud revision. It remains saved on this device.", + "@syncErrorCannotRetryAutomatically": { + "description": "Sync explanation: a kept change can't be retried because the server has no matching revision." + }, + "syncErrorSessionExpired": "Your cloud session needs to be refreshed — retry, or sign in again from the library.", + "@syncErrorSessionExpired": { + "description": "Sync explanation: the sign-in session must be refreshed." + }, + "syncErrorUnreachable": "The cloud could not be reached.", + "@syncErrorUnreachable": { + "description": "Sync explanation: no connection to the server." + }, + "syncErrorStartingUp": "Cloud sync is still starting up.", + "@syncErrorStartingUp": { + "description": "Sync explanation: the cloud account is not set up yet." + }, + "syncErrorGeneric": "Some changes haven't reached the cloud yet.", + "@syncErrorGeneric": { + "description": "Sync explanation when no more specific reason is known." + }, + "syncStatusOfflineNothingWaiting": "Offline, changes stay on this device", + "@syncStatusOfflineNothingWaiting": { + "description": "Sync status line while offline with no changes waiting to be sent." + }, + "syncStatusOfflineChangesSaved": "Offline · {count, plural, =1{1 change} other{{count} changes}} saved on this computer", + "@syncStatusOfflineChangesSaved": { + "description": "Sync status line while offline, with the number of changes saved on this computer that have not reached the server.", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "syncStatusStrategyNeedsConnection": "This strategy needs a connection the first time you open it.", + "@syncStatusStrategyNeedsConnection": { + "description": "Shown when a cloud strategy is opened offline and this computer has no copy of it yet." + }, + "mediaUploadFailedToast": "Media upload failed. Tap Save to retry cloud sync.", + "@mediaUploadFailedToast": { + "description": "Toast when an image in a cloud strategy fails to upload. 'Save' is the name of a button." + }, + "mediaUploadComplete": "Image upload complete", + "@mediaUploadComplete": { + "description": "Upload progress toast once every image has uploaded." + }, + "mediaUploadProgress": "{count, plural, =1{Uploading image...} other{Uploading {count} images...}}", + "@mediaUploadProgress": { + "description": "Upload progress toast while images upload, with the number still uploading.", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "mediaUploadImageTooLarge": "This image is over 15 MB. Pick a smaller one.", + "@mediaUploadImageTooLarge": { + "description": "Toast when the user adds an image too large for a cloud strategy." + }, + "moveToCloudMoved": "Moved \"{name}\" to the cloud.", + "@moveToCloudMoved": { + "description": "Toast after a strategy or folder moved from this device to the cloud.", + "placeholders": { + "name": { + "type": "String", + "example": "Ascent defaults" + } + } + }, + "moveToCloudMovedToMyLibrary": "Moved \"{name}\" to the cloud. It's in My Library.", + "@moveToCloudMovedToMyLibrary": { + "description": "Toast after a strategy moved to the cloud and left its folder, so it now sits at the top of My Library.", + "placeholders": { + "name": { + "type": "String", + "example": "B split retake" + } + } + }, + "moveToCloudNotMoved": "Couldn't move \"{name}\" to the cloud. It's still on this device.", + "@moveToCloudNotMoved": { + "description": "Toast when a strategy or folder could not move to the cloud.", + "placeholders": { + "name": { + "type": "String", + "example": "B split retake" + } + } + }, + "moveToCloudChangedMeanwhile": "\"{name}\" changed while it was moving, so it stays on this device. Move it again to put the latest version in the cloud.", + "@moveToCloudChangedMeanwhile": { + "description": "Toast when a strategy was edited during its move to the cloud.", + "placeholders": { + "name": { + "type": "String", + "example": "B split retake" + } + } + }, + "moveToCloudLocalCopyKept": "Moved \"{name}\" to the cloud, but its copy on this device couldn't be removed.", + "@moveToCloudLocalCopyKept": { + "description": "Toast when a strategy moved to the cloud but its copy on this device could not be deleted.", + "placeholders": { + "name": { + "type": "String", + "example": "B split retake" + } + } + }, + "moveToCloudChangedHere": "Moved \"{name}\" to the cloud. It changed while moving, so this device keeps the latest version too.", + "@moveToCloudChangedHere": { + "description": "Toast when a strategy moved to the cloud but was edited during the move, so this device keeps a copy.", + "placeholders": { + "name": { + "type": "String", + "example": "B split retake" + } + } + }, + "moveToCloudFoldersStayed": "Moved the strategies in \"{name}\" to the cloud, but some of its folders stayed on this device.", + "@moveToCloudFoldersStayed": { + "description": "Toast when every strategy in a folder moved to the cloud but some of its folders did not.", + "placeholders": { + "name": { + "type": "String", + "example": "Ascent defaults" + } + } + }, + "moveToCloudSomeMoved": "Moved {moved} of {total} strategies in \"{name}\". The rest are still on this device.", + "@moveToCloudSomeMoved": { + "description": "Toast when only some strategies of a folder moved to the cloud.", + "placeholders": { + "moved": { + "type": "int", + "example": "3" + }, + "total": { + "type": "int", + "example": "5" + }, + "name": { + "type": "String", + "example": "Ascent defaults" + } + } + }, + "moveToCloudFolderFailed": "Couldn't finish moving \"{name}\". What didn't move is still on this device.", + "@moveToCloudFolderFailed": { + "description": "Toast when moving a folder to the cloud failed partway.", + "placeholders": { + "name": { + "type": "String", + "example": "Ascent defaults" + } + } + }, + "moveToCloudSignedOut": "You signed out, so \"{name}\" stays on this device.", + "@moveToCloudSignedOut": { + "description": "Toast when an import into the cloud could not start because the user signed out.", + "placeholders": { + "name": { + "type": "String", + "example": "Ascent defaults" + } + } + }, + "moveToCloudImporting": "Importing \"{name}\" to the cloud…", + "@moveToCloudImporting": { + "description": "Toast when an imported file starts moving into a cloud folder.", + "placeholders": { + "name": { + "type": "String", + "example": "Ascent defaults" + } + } + }, + "moveToCloudSignedOutRest": "You signed out, so the rest of \"{name}\" stays on this device.", + "@moveToCloudSignedOutRest": { + "description": "Toast when the user signed out partway through importing into the cloud.", + "placeholders": { + "name": { + "type": "String", + "example": "Ascent defaults" + } + } + }, + "moveToCloudOtherAccount": "You signed in to another account, so \"{name}\" stays on this device.", + "@moveToCloudOtherAccount": { + "description": "Toast when a move to the cloud did not start because another account is signed in now.", + "placeholders": { + "name": { + "type": "String", + "example": "B split retake" + } + } + }, + "moveToCloudOffline": "Can't reach the cloud right now, so \"{name}\" stays on this device. Try again when you are online.", + "@moveToCloudOffline": { + "description": "Toast when a move to the cloud did not start because the cloud can't be reached.", + "placeholders": { + "name": { + "type": "String", + "example": "B split retake" + } + } + }, + "opQueueReplayCaptureFolderGone": "\"{strategyName}\" is at the top level of My Library: the folder it was captured into is gone or not yours.", + "@opQueueReplayCaptureFolderGone": { + "description": "Toast when a strategy captured from a match replay was saved at the top of My Library because its folder was deleted or belongs to someone else.", + "placeholders": { + "strategyName": { + "type": "String", + "example": "Lotus 13-11" + } + } + }, + "importExportCloudUnreadable": "Couldn't read your cloud library, so nothing was exported. Check your connection and try again.", + "@importExportCloudUnreadable": { + "description": "Error when a library or folder export stops because the cloud library could not be read." + }, + "importExportCloudNotSynced": "Some of your cloud changes haven't synced yet, so nothing was exported. Let them sync, or review them in your library, then try again.", + "@importExportCloudNotSynced": { + "description": "Error when a library or folder export stops because cloud changes made on this device have not reached the server." + }, + "importExportCloudWorkUnreadable": "Icarus can't read some cloud changes saved on this device, so nothing was exported. Review them, then try again.", + "@importExportCloudWorkUnreadable": { + "description": "Error when a library or folder export stops because unsent cloud changes saved on this device could not be read." + }, + "importExportCloudChanged": "Your cloud library changed while exporting, so nothing was exported. Try again.", + "@importExportCloudChanged": { + "description": "Error when a library or folder export stops because the cloud library changed during the export." + }, + "importExportNewerVersion": "This file was created in a newer version of Icarus. Please update the app and try again.", + "@importExportNewerVersion": { + "description": "Error when an imported .ica file or backup was made by a newer Icarus than this one." + }, + "importExportDefaultStrategyFileName": "new strategy", + "@importExportDefaultStrategyFileName": { + "description": "File name (without the .ica extension) offered in the save dialog when exporting a strategy that has no name. Lowercase; characters not allowed in file names are replaced." + }, + "importExportSaveDialogTitle": "Please select an output file:", + "@importExportSaveDialogTitle": { + "description": "Title of the desktop save dialog that asks where to save an export." + }, + "unsavedGuardTitle": "Save changes?", + "@unsavedGuardTitle": { + "description": "Title of the dialog shown when leaving a local strategy with unsaved changes." + }, + "unsavedGuardDescription": "This strategy has unsaved changes. Do you want to save before leaving?", + "@unsavedGuardDescription": { + "description": "Body of the dialog shown when leaving a local strategy with unsaved changes." + }, + "unsavedGuardDontSave": "Don't save", + "@unsavedGuardDontSave": { + "description": "Button that leaves the strategy and throws its unsaved changes away." + }, + "unsavedGuardCloudPendingTitle": "Cloud sync pending", + "@unsavedGuardCloudPendingTitle": { + "description": "Title of the dialog shown when leaving a cloud strategy whose changes have not all reached the cloud." + }, + "unsavedGuardStayHere": "Stay here", + "@unsavedGuardStayHere": { + "description": "Button in the cloud sync pending dialog that keeps the strategy open." + }, + "unsavedGuardRetrySignIn": "Retry sign-in", + "@unsavedGuardRetrySignIn": { + "description": "Button in the cloud sync pending dialog that tries the cloud sign-in again." + }, + "unsavedGuardLeaveAnyway": "Leave anyway", + "@unsavedGuardLeaveAnyway": { + "description": "Button in the cloud sync pending dialog that leaves while work is still waiting, saved on this device." + }, + "unsavedGuardRetrySync": "Retry sync", + "@unsavedGuardRetrySync": { + "description": "Button in the cloud sync pending dialog that sends the waiting changes again." + }, + "unsavedGuardTextEditSyncFailed": "Failed to sync the active text edit before leaving.", + "@unsavedGuardTextEditSyncFailed": { + "description": "Error toast when the text being typed could not be saved while leaving a cloud strategy." + }, + "unsavedGuardSaveFailed": "Failed to save strategy before leaving.", + "@unsavedGuardSaveFailed": { + "description": "Error toast when the strategy could not be saved while leaving it." + }, + "unsavedGuardOffline": "Icarus is offline, so these changes have not reached the cloud.", + "@unsavedGuardOffline": { + "description": "First sentence of the cloud sync pending dialog body while offline." + }, + "unsavedGuardImagesPending": "Some images have not reached the cloud.", + "@unsavedGuardImagesPending": { + "description": "First sentence of the cloud sync pending dialog body when image uploads failed." + }, + "unsavedGuardStillSending": "Icarus is still sending cloud edits and images.", + "@unsavedGuardStillSending": { + "description": "First sentence of the cloud sync pending dialog body while work is still being sent." + }, + "unsavedGuardLeaveKeepsUnreadable": "{reason} Leaving will not delete the saved device records. You can return to this strategy and retry.", + "@unsavedGuardLeaveKeepsUnreadable": { + "description": "Cloud sync pending dialog body when some saved work on this device could not be read. {reason} is one whole sentence saying why the work has not reached the cloud.", + "placeholders": { + "reason": { + "type": "String", + "example": "Icarus is offline, so these changes have not reached the cloud." + } + } + }, + "unsavedGuardLeaveChooseLater": "{reason} You can leave; when you come back, choose what to keep from the sync button.", + "@unsavedGuardLeaveChooseLater": { + "description": "Cloud sync pending dialog body when the server refused some work and the user must choose what to keep. {reason} is one whole sentence saying why the work has not reached the cloud.", + "placeholders": { + "reason": { + "type": "String", + "example": "Another edit reached the cloud first. Your version remains saved on this device." + } + } + }, + "unsavedGuardLeaveRetryFromButton": "{reason} You can leave; nothing is sent until you retry from the sync button.", + "@unsavedGuardLeaveRetryFromButton": { + "description": "Cloud sync pending dialog body when sending is paused until the user retries. {reason} is one whole sentence saying why the work has not reached the cloud.", + "placeholders": { + "reason": { + "type": "String", + "example": "A saved cloud change is paused after repeated failures. Retry when the connection and account are healthy." + } + } + }, + "unsavedGuardLeaveRetriesLater": "{reason} The pending work is saved on this device. You can leave and Icarus will retry it later.", + "@unsavedGuardLeaveRetriesLater": { + "description": "Cloud sync pending dialog body when the waiting work is safely saved on this device. {reason} is one whole sentence saying why the work has not reached the cloud.", + "placeholders": { + "reason": { + "type": "String", + "example": "Icarus is offline, so these changes have not reached the cloud." + } + } + }, + "unsavedGuardStayAndRetry": "{reason} Stay here and retry because Icarus could not confirm that all pending work is saved on this device.", + "@unsavedGuardStayAndRetry": { + "description": "Cloud sync pending dialog body when leaving could lose work. {reason} is one whole sentence saying why the work has not reached the cloud.", + "placeholders": { + "reason": { + "type": "String", + "example": "Icarus is offline, so these changes have not reached the cloud." + } + } + }, + "signOutGuardCantSignOutTitle": "Can't sign out yet", + "@signOutGuardCantSignOutTitle": { + "description": "Title of the dialog that refuses to sign out because pending cloud work could not be checked." + }, + "signOutGuardUnverifiedDescription": "Icarus could not verify pending cloud work. Stay signed in and try again.", + "@signOutGuardUnverifiedDescription": { + "description": "Body of the dialog that refuses to sign out when the app could not check for pending cloud work." + }, + "signOutGuardUnconfirmedDescription": "Icarus could not confirm that all pending cloud work is saved on this device. Stay signed in and try again.", + "@signOutGuardUnconfirmedDescription": { + "description": "Body of the dialog that refuses to sign out when pending cloud work may not be saved on this device." + }, + "signOutGuardStaySignedIn": "Stay Signed In", + "@signOutGuardStaySignedIn": { + "description": "Button that closes the dialog refusing to sign out." + }, + "signOutGuardPendingTitle": "Cloud work is still waiting", + "@signOutGuardPendingTitle": { + "description": "Title of the sign-out confirmation when saved changes have not reached the cloud yet." + }, + "signOutGuardConfirmTitle": "Sign out?", + "@signOutGuardConfirmTitle": { + "description": "Title of the sign-out confirmation when nothing is waiting to be sent." + }, + "signOutGuardPendingDescription": "{workCount, plural, =1{1 saved change} other{{workCount} saved changes}} across {strategyCount, plural, =1{1 strategy is} other{{strategyCount} strategies are}} still waiting: {strategies}. The work remains on this device and resumes only when this same account signs in again.", + "@signOutGuardPendingDescription": { + "description": "Body of the sign-out confirmation when saved changes have not reached the cloud. {strategies} is a comma-separated list of the strategies' names.", + "placeholders": { + "workCount": { + "type": "int" + }, + "strategyCount": { + "type": "int" + }, + "strategies": { + "type": "String", + "example": "B Split, Ascent retakes" + } + } + }, + "signOutGuardUnnamedStrategy": "a cloud strategy", + "@signOutGuardUnnamedStrategy": { + "description": "Stands for a strategy whose name is unknown, inside the list of strategies in the sign-out confirmation." + }, + "signOutGuardNoPendingDescription": "Cloud strategies stay online. Pending work on this device will remain tied to this account.", + "@signOutGuardNoPendingDescription": { + "description": "Body of the sign-out confirmation when nothing is waiting to be sent." + }, + "signOutGuardSignOutAnyway": "Sign Out Anyway", + "@signOutGuardSignOutAnyway": { + "description": "Button that signs out although saved changes have not reached the cloud." + }, + "signOutGuardFailedTitle": "Sign out failed", + "@signOutGuardFailedTitle": { + "description": "Title of the dialog shown when signing out failed." + }, + "signOutGuardFailedFallback": "Icarus could not sign out. Please try again.", + "@signOutGuardFailedFallback": { + "description": "Body of the sign out failed dialog when the server gave no reason." + }, + "signOutGuardOk": "OK", + "@signOutGuardOk": { + "description": "Button that closes the sign out failed dialog." + }, + "shareLinkCopyDescriptionBrowserStrategy": "Anyone with a link can view this strategy, no account needed. People who add it to their library show up here, and links stay active until you disable them.", + "@shareLinkCopyDescriptionBrowserStrategy": { + "description": "Description at the top of the share dialog for a strategy shared from the web app, where a link opens read-only in a browser." + }, + "shareLinkCopyDescription": "People who join through a link show up here, and links stay active until you disable them.", + "@shareLinkCopyDescription": { + "description": "Description at the top of the share dialog for a folder, or a strategy shared from the desktop app." + }, + "shareLinkCopyEditLinkWarning": "Anyone who gets this link can change it, including people it is forwarded to. Send it only to your team.", + "@shareLinkCopyEditLinkWarning": { + "description": "Warning in the share dialog while the link kind \"Can edit\" is picked." + }, + "shareLinkCopyDisableTitle": "Disable this link?", + "@shareLinkCopyDisableTitle": { + "description": "Title of the dialog confirming that a share link is disabled." + }, + "shareLinkCopyDisableDescription": "No one new can join or view through it. People who already joined keep their access unless you remove them.", + "@shareLinkCopyDisableDescription": { + "description": "Body of the dialog confirming that a share link is disabled." + }, + "shareLinkCopyRemoveJoinedOne": "Also remove the person who joined through it", + "@shareLinkCopyRemoveJoinedOne": { + "description": "Checkbox in the disable link dialog when exactly one person joined through the link." + }, + "shareLinkCopyRemoveJoinedMany": "{count, plural, other{Also remove the {count} people who joined through it}}", + "@shareLinkCopyRemoveJoinedMany": { + "description": "Checkbox in the disable link dialog when several people joined through the link.", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "shareLinkCopyRemoveJoinedAtLeast": "{count, plural, other{Also remove the {count}+ people who joined through it}}", + "@shareLinkCopyRemoveJoinedAtLeast": { + "description": "Checkbox in the disable link dialog when at least {count} people joined through the link and the exact number is not known; the + means \"or more\".", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "shareLinkCopyDisableAction": "Disable link", + "@shareLinkCopyDisableAction": { + "description": "Confirm button in the disable link dialog." + }, + "shareLinkCopyDisableFailure": "Failed to disable share link.", + "@shareLinkCopyDisableFailure": { + "description": "Error toast when a share link could not be disabled." + }, + "shareLinkCopyDisabledStatus": "Disabled", + "@shareLinkCopyDisabledStatus": { + "description": "Status label on a share link that was disabled. Short." + }, + "shareLinkCopyDisabledTooltip": "Link disabled", + "@shareLinkCopyDisabledTooltip": { + "description": "Tooltip on the disable button of a share link that is already disabled." + }, + "shareLinkCopyDisableTooltip": "Disable link", + "@shareLinkCopyDisableTooltip": { + "description": "Tooltip on the button that disables a share link." + }, + "shareLinkCopyRemoveMemberTitle": "Remove {name}?", + "@shareLinkCopyRemoveMemberTitle": { + "description": "Title of the dialog confirming that a person loses access to a shared strategy or folder.", + "placeholders": { + "name": { + "type": "String", + "example": "Sova main" + } + } + }, + "shareLinkCopyRemoveMemberDescription": "They lose access right away, and it leaves their Shared tab. If they still have an active link, they can join again until you disable it.", + "@shareLinkCopyRemoveMemberDescription": { + "description": "Body of the dialog confirming that a person loses access. \"Shared\" is the name of a library tab." + }, + "shareLinkCopyRemoveMemberAction": "Remove", + "@shareLinkCopyRemoveMemberAction": { + "description": "Confirm button in the dialog that removes a person's access." + }, + "shareLinkCopyRemoveMemberFailure": "Couldn't remove them. Try again.", + "@shareLinkCopyRemoveMemberFailure": { + "description": "Error toast when a person's access could not be removed." + }, + "shareLinkCopyRoleChangeFailure": "Couldn't change their access. Try again.", + "@shareLinkCopyRoleChangeFailure": { + "description": "Error toast when a person's access level could not be changed." + }, + "cloudActionReconnect": "Reconnect to Icarus Cloud, then try again.", + "@cloudActionReconnect": { + "description": "Error when a cloud library action needs the user to sign in to the cloud again." + }, + "cloudActionExportFailed": "Couldn't export this cloud strategy. Try again.", + "@cloudActionExportFailed": { + "description": "Error toast when exporting a cloud strategy to an .ica file failed." + }, + "cloudActionNeedsConnection": "Needs a connection. Your cloud library can't change while you're offline.", + "@cloudActionNeedsConnection": { + "description": "Toast when the user tries to change the cloud library while it shows the offline copy." + }, + "cloudActionExportRefusedUnreadable": "Icarus can't read some cloud changes saved on this device, so nothing was exported. Review them, then try again.", + "@cloudActionExportRefusedUnreadable": { + "description": "Toast when exporting one cloud strategy stops because unsent changes saved on this device could not be read." + }, + "cloudActionExportRefusedNotSynced": "Some changes to this strategy haven't synced yet, so nothing was exported. Let them sync, or review them, then try again.", + "@cloudActionExportRefusedNotSynced": { + "description": "Toast when exporting one cloud strategy stops because its changes have not reached the server." + }, + "cloudActionDuplicateRefusedUnreadable": "Icarus can't read some cloud changes saved on this device, so it wasn't duplicated. Review them, then try again.", + "@cloudActionDuplicateRefusedUnreadable": { + "description": "Toast when duplicating a cloud strategy stops because unsent changes saved on this device could not be read." + }, + "cloudActionDuplicateRefusedNotSynced": "Some changes to this strategy haven't synced yet, so it wasn't duplicated. Let them sync, or review them, then try again.", + "@cloudActionDuplicateRefusedNotSynced": { + "description": "Toast when duplicating a cloud strategy stops because its changes have not reached the server." + }, + "videoExportCaptionsDontFitThisPage": "Captions on this page don't fit. Shorten them, or turn off Include captions.", + "@videoExportCaptionsDontFitThisPage": { + "description": "Why a screenshot or video can't be saved: the page's captions can't all be placed. \"Include captions\" is the name of a switch in the same dialog." + }, + "videoExportCaptionsDontFitPage": "Captions on page {page} don't fit. Shorten them, or turn off Include captions.", + "@videoExportCaptionsDontFitPage": { + "description": "Why a screenshot or video can't be saved: one page's captions can't all be placed. {page} is the page's number, from 1. \"Include captions\" is the name of a switch in the same dialog.", + "placeholders": { + "page": { + "type": "int" + } + } + }, + "videoExportNotSynced": "Your latest changes haven't synced yet. Export once the strategy shows as synced.", + "@videoExportNotSynced": { + "description": "Error when a video or screenshot export of a cloud strategy stops because its changes have not reached the server." + }, + "videoExportPagesChanged": "A page you selected was deleted. Check the pages and export again.", + "@videoExportPagesChanged": { + "description": "Error when a page picked for a video or screenshot export was deleted meanwhile." + }, + "videoExportNoPagesSelected": "No pages selected.", + "@videoExportNoPagesSelected": { + "description": "Video export failure when no page was picked." + }, + "videoExportMovementPathsFailed": "Movement paths could not load. Please try the export again.", + "@videoExportMovementPathsFailed": { + "description": "Video export failure when the routes agents travel between pages could not be worked out." + }, + "videoExportProgressPreparingMap": "Preparing map", + "@videoExportProgressPreparingMap": { + "description": "Progress label in the video export dialog. Short." + }, + "videoExportProgressWaitingForPictures": "Waiting for pictures", + "@videoExportProgressWaitingForPictures": { + "description": "Progress label in the video export dialog while images on the pages load. Short." + }, + "videoExportProgressRenderingFrames": "Rendering frames ({rendered}/{total})", + "@videoExportProgressRenderingFrames": { + "description": "Progress label in the video export dialog. Short.", + "placeholders": { + "rendered": { + "type": "int" + }, + "total": { + "type": "int" + } + } + }, + "videoExportProgressEncoding": "Encoding video", + "@videoExportProgressEncoding": { + "description": "Progress label in the video export dialog. Short." + }, + "videoExportWriteFailed": "Could not write the video to the selected location: {error}", + "@videoExportWriteFailed": { + "description": "Video export failure when the finished file could not be saved. {error} is the system's technical error text, in English.", + "placeholders": { + "error": { + "type": "String", + "example": "FileSystemException: Cannot open file" + } + } + }, + "videoExportBrowserCannotEncode": "This browser cannot encode H.264 video at {width}x{height}, {fps} fps.", + "@videoExportBrowserCannotEncode": { + "description": "Video export failure in the web app. {width}x{height} is the video size in pixels; H.264 is a video format name.", + "placeholders": { + "width": { + "type": "int" + }, + "height": { + "type": "int" + }, + "fps": { + "type": "int" + } + } + }, + "videoExportBrowserCannotScale": "This browser cannot scale video frames.", + "@videoExportBrowserCannotScale": { + "description": "Video export failure in the web app." + }, + "videoExportEncoderStartFailed": "Could not start the video encoder: {error}", + "@videoExportEncoderStartFailed": { + "description": "Video export failure in the web app. {error} is the browser's technical error text, in English.", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "videoExportEncoderNoVideo": "The video encoder produced no video.", + "@videoExportEncoderNoVideo": { + "description": "Video export failure in the web app." + }, + "videoExportEncoderOutOfOrder": "The video encoder returned frames out of order.", + "@videoExportEncoderOutOfOrder": { + "description": "Video export failure in the web app." + }, + "videoExportEncoderFailed": "The video encoder failed: {error}", + "@videoExportEncoderFailed": { + "description": "Video export failure in the web app. {error} is the browser's technical error text, in English.", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "discordPresenceBrowsingLibrary": "Browsing the strategy library", + "@discordPresenceBrowsingLibrary": { + "description": "Discord status line other people see while the user is in the library." + }, + "discordPresenceTagline": "Valorant strategy planner", + "@discordPresenceTagline": { + "description": "Second Discord status line while the user is in the library; describes Icarus." + }, + "discordPresencePlanningAttack": "Planning an attack on {mapName}", + "@discordPresencePlanningAttack": { + "description": "Discord status line other people see while the user edits an attack strategy. {mapName} is a Valorant map name.", + "placeholders": { + "mapName": { + "type": "String", + "example": "Ascent" + } + } + }, + "discordPresencePlanningDefense": "Planning a defense on {mapName}", + "@discordPresencePlanningDefense": { + "description": "Discord status line other people see while the user edits a defense strategy. {mapName} is a Valorant map name.", + "placeholders": { + "mapName": { + "type": "String", + "example": "Ascent" + } + } + }, + "discordPresenceEmptyBoard": "Starting from an empty board", + "@discordPresenceEmptyBoard": { + "description": "Second Discord status line while the open page has no agents or abilities." + }, + "discordPresenceBoardAgents": "{agentCount, plural, =1{1 agent} other{{agentCount} agents}} on the board", + "@discordPresenceBoardAgents": { + "description": "Second Discord status line: how many agents are on the open page, and no abilities.", + "placeholders": { + "agentCount": { + "type": "int" + } + } + }, + "discordPresenceBoardAbilities": "{abilityCount, plural, =1{1 ability} other{{abilityCount} abilities}} on the board", + "@discordPresenceBoardAbilities": { + "description": "Second Discord status line: how many abilities are on the open page, and no agents.", + "placeholders": { + "abilityCount": { + "type": "int" + } + } + }, + "discordPresenceBoardAgentsAndAbilities": "{agentCount, plural, =1{1 agent} other{{agentCount} agents}} · {abilityCount, plural, =1{1 ability} other{{abilityCount} abilities}} on the board", + "@discordPresenceBoardAgentsAndAbilities": { + "description": "Second Discord status line: how many agents and abilities are on the open page.", + "placeholders": { + "agentCount": { + "type": "int" + }, + "abilityCount": { + "type": "int" + } + } + }, + "serviceMsgOpenLogs": "Open Logs", + "@serviceMsgOpenLogs": { + "description": "Action button on an error toast that opens the app's log viewer." + }, + "serviceMsgClipboardBusy": "Another program is using the clipboard. Try again in a moment.", + "@serviceMsgClipboardBusy": { + "description": "Error when pasting or copying fails because another program keeps the clipboard open." + }, + "serviceMsgAuthEmailConfirmed": "Your email is confirmed. Sign in to continue.", + "@serviceMsgAuthEmailConfirmed": { + "description": "Notice at the top of the sign-in dialog after a confirmation email link was opened." + }, + "serviceMsgAuthLinkExpired": "This link has expired or was already used. Try signing in. If your email isn't confirmed yet, create your account again to get a new link.", + "@serviceMsgAuthLinkExpired": { + "description": "Notice at the top of the sign-in dialog when an email link no longer works." + }, + "serviceMsgAuthResetLinkTimedOut": "This reset link took too long to open. Send a new one and open it within 5 minutes.", + "@serviceMsgAuthResetLinkTimedOut": { + "description": "Notice in the password reset dialog when the reset email link was opened too late." + }, + "serviceMsgFeatureExport": "Export", + "@serviceMsgFeatureExport": { + "description": "Name of a feature, used as the subject of a sentence (\"Export is desktop-only for now.\") and in a list of features." + }, + "serviceMsgFeatureImport": "Import", + "@serviceMsgFeatureImport": { + "description": "Name of a feature, used as the subject of a sentence and in a list of features." + }, + "serviceMsgFeatureScreenshot": "Screenshot", + "@serviceMsgFeatureScreenshot": { + "description": "Name of a feature, used as the subject of a sentence and in a list of features." + }, + "serviceMsgFeatureVideoExport": "Video export", + "@serviceMsgFeatureVideoExport": { + "description": "Name of a feature, used as the subject of a sentence and in a list of features." + }, + "serviceMsgFeatureFileDrop": "Drag and drop", + "@serviceMsgFeatureFileDrop": { + "description": "Name of a feature (dropping files onto the app), used as the subject of a sentence and in a list of features." + }, + "serviceMsgFeatureAddImages": "Adding images", + "@serviceMsgFeatureAddImages": { + "description": "Name of a feature, used as the subject of a sentence and in a list of features." + }, + "serviceMsgFeatureAddLineups": "Adding lineups", + "@serviceMsgFeatureAddLineups": { + "description": "Name of a feature, used as the subject of a sentence and in a list of features." + }, + "serviceMsgFeatureReplays": "Replays", + "@serviceMsgFeatureReplays": { + "description": "Name of a feature (playing back Valorant match recordings), used as the subject of a sentence and in a list of features." + }, + "serviceMsgFeatureDesktopOnly": "{feature} is desktop-only for now.", + "@serviceMsgFeatureDesktopOnly": { + "description": "Toast in the web app when a feature exists only in the desktop app. {feature} is a feature name such as \"Video export\".", + "placeholders": { + "feature": { + "type": "String", + "example": "Export" + } + } + }, + "serviceMsgFeatureComingToWeb": "{feature} is coming to the web beta.", + "@serviceMsgFeatureComingToWeb": { + "description": "Toast in the web app when a feature is planned for it but not there yet. {feature} is a feature name such as \"Video export\".", + "placeholders": { + "feature": { + "type": "String", + "example": "Replays" + } + } + }, + "serviceMsgStoreUpdateTitle": "Update Available", + "@serviceMsgStoreUpdateTitle": { + "description": "Title of the dialog saying a newer Icarus is in the Microsoft Store." + }, + "serviceMsgStoreUpdateVersionAvailable": "Version {version} is available", + "@serviceMsgStoreUpdateVersionAvailable": { + "description": "First line of the store update dialog when the update has no title of its own.", + "placeholders": { + "version": { + "type": "String", + "example": "4.5.0" + } + } + }, + "serviceMsgStoreUpdateDefaultNotes": "An update is available in the Microsoft Store.", + "@serviceMsgStoreUpdateDefaultNotes": { + "description": "Shown under the release notes heading of the store update dialog when the update has no notes." + }, + "serviceMsgStoreUpdateReleaseNotes": "Release Notes:", + "@serviceMsgStoreUpdateReleaseNotes": { + "description": "Heading above the release notes in the store update dialog." + }, + "serviceMsgStoreUpdateWhatsNew": "What is new:", + "@serviceMsgStoreUpdateWhatsNew": { + "description": "Heading above the list of new features in the store update dialog." + }, + "serviceMsgStoreUpdateLater": "Later", + "@serviceMsgStoreUpdateLater": { + "description": "Button that closes the store update dialog without updating." + }, + "serviceMsgStoreUpdateNow": "Update Now", + "@serviceMsgStoreUpdateNow": { + "description": "Button that opens the Microsoft Store page to update." + }, + "lineupChangeAdded": "added", + "@lineupChangeAdded": { + "description": "One of the things that changed in a lineup, listed after its name in the sync conflict popover, e.g. 'Heaven · renamed, notes edited'. Lower case in English." + }, + "lineupChangeDeleted": "deleted", + "@lineupChangeDeleted": { + "description": "One of the things that changed in a lineup, listed after its name in the sync conflict popover, e.g. 'Heaven · renamed, notes edited'. Lower case in English." + }, + "lineupChangeRenamed": "renamed", + "@lineupChangeRenamed": { + "description": "One of the things that changed in a lineup, listed after its name in the sync conflict popover, e.g. 'Heaven · renamed, notes edited'. Lower case in English." + }, + "lineupChangeNotesEdited": "notes edited", + "@lineupChangeNotesEdited": { + "description": "One of the things that changed in a lineup, listed after its name in the sync conflict popover, e.g. 'Heaven · renamed, notes edited'. Lower case in English." + }, + "lineupChangeVideoChanged": "video changed", + "@lineupChangeVideoChanged": { + "description": "One of the things that changed in a lineup, listed after its name in the sync conflict popover, e.g. 'Heaven · renamed, notes edited'. Lower case in English." + }, + "lineupChangeImagesChanged": "images changed", + "@lineupChangeImagesChanged": { + "description": "One of the things that changed in a lineup, listed after its name in the sync conflict popover, e.g. 'Heaven · renamed, notes edited'. Lower case in English." + }, + "lineupChangeStandingSpotMoved": "standing spot moved", + "@lineupChangeStandingSpotMoved": { + "description": "One of the things that changed in a lineup, listed after its name in the sync conflict popover, e.g. 'Heaven · renamed, notes edited'. Lower case in English." + }, + "lineupChangeLandingMoved": "landing moved", + "@lineupChangeLandingMoved": { + "description": "One of the things that changed in a lineup, listed after its name in the sync conflict popover, e.g. 'Heaven · renamed, notes edited'. Lower case in English." + }, + "lineupChangeSeparator": ", ", + "@lineupChangeSeparator": { + "description": "Goes between the listed changes of one lineup." + }, + "lineupChangeUnnamed": "Unnamed lineup", + "@lineupChangeUnnamed": { + "description": "Stands in for a lineup with no name and no agent, in the sync conflict popover." + }, + "lineupChangeAgentLineup": "{agent} lineup", + "@lineupChangeAgentLineup": { + "description": "Stands in for a lineup the user did not name, by its agent, e.g. 'Sova lineup'.", + "placeholders": { + "agent": { + "type": "String" + } + } + }, + "gameSpike": "Spike", + "@gameSpike": { + "description": "The Spike, Valorant's bomb. Use the game's own name for it in this language." + }, + "weaponCategorySidearms": "Sidearms", + "@weaponCategorySidearms": { + "description": "A weapon category in the agent's weapon menu. Use Valorant's own buy-menu name for it in this language." + }, + "weaponCategoryRifles": "Rifles", + "@weaponCategoryRifles": { + "description": "A weapon category in the agent's weapon menu. Use Valorant's own buy-menu name for it in this language." + }, + "weaponCategoryShotguns": "Shotguns", + "@weaponCategoryShotguns": { + "description": "A weapon category in the agent's weapon menu. Use Valorant's own buy-menu name for it in this language." + }, + "weaponCategorySmgs": "SMGs", + "@weaponCategorySmgs": { + "description": "A weapon category in the agent's weapon menu. Use Valorant's own buy-menu name for it in this language." + }, + "weaponCategorySnipers": "Snipers", + "@weaponCategorySnipers": { + "description": "A weapon category in the agent's weapon menu. Use Valorant's own buy-menu name for it in this language." + }, + "weaponCategoryHeavies": "Heavies", + "@weaponCategoryHeavies": { + "description": "A weapon category in the agent's weapon menu. Use Valorant's own buy-menu name for it in this language." + }, + "lineupDragPinnedAgent": "Drag {agent} for this landing spot.", + "@lineupDragPinnedAgent": { + "description": "Toast when the user drags the wrong agent while adding a lineup to a landing spot that belongs to one agent.", + "placeholders": { + "agent": { + "type": "String" + } + } + }, + "lineupDragPinnedAgentUnknown": "Drag the agent for this landing spot.", + "@lineupDragPinnedAgentUnknown": { + "description": "The same toast when the agent's name is not known." + }, + "lineupOriginPinned": "The origin is pinned. Drag an ability instead.", + "@lineupOriginPinned": { + "description": "Toast while adding a lineup from a fixed standing spot, when the user drags an agent." + }, + "lineupLandingPinned": "The landing spot is pinned. Drag an agent instead.", + "@lineupLandingPinned": { + "description": "Toast while adding a lineup to a fixed landing spot, when the user drags an ability." + }, + "lineupSelectAgentFirst": "Please select an agent first.", + "@lineupSelectAgentFirst": { + "description": "Toast when the user drags an ability for a lineup before placing its agent." + }, + "lineupAbilityAgentMismatch": "Ability does not match the selected agent.", + "@lineupAbilityAgentMismatch": { + "description": "Toast when the ability dragged for a lineup belongs to a different agent." + }, + "lineupPlacementEditClosed": "The page changed, so the placement edit closed without saving.", + "@lineupPlacementEditClosed": { + "description": "Toast when the page changes while the user is moving a lineup's spots." + }, + "startupCloudOutboxUnavailable": "Unsent cloud changes on this device couldn't be opened, so cloud sync is off for now. They are kept as they are. Your library on this device works as usual.", + "@startupCloudOutboxUnavailable": { + "description": "Toast at startup when the store of unsent cloud changes on this device could not be opened. Cloud sync stays off for this run; the local library works." + }, + "startupCloudClientsUnavailable": "Cloud sync couldn't start, so sign-in is unavailable. Your library on this device works as usual. Restart Icarus to try again.", + "@startupCloudClientsUnavailable": { + "description": "Toast at startup when the cloud connection could not be set up. Sign-in is unavailable for this run; the local library works." + }, + "appErrorUnexpected": "An unexpected application error occurred.", + "@appErrorUnexpected": { + "description": "Toast when an error nothing else caught happens. It has an Open logs button." + }, + "appErrorUi": "A UI error occurred.", + "@appErrorUi": { + "description": "Toast when something on screen failed to draw or respond. It has an Open logs button." + }, + "appErrorAsync": "An unexpected asynchronous error occurred.", + "@appErrorAsync": { + "description": "Toast when an error nothing else caught happens in background work. It has an Open logs button." + }, + "mediaUploadReleaseBytesFailed": "Failed to release pending media bytes.", + "@mediaUploadReleaseBytesFailed": { + "description": "Toast when Icarus could not free the stored copy of an image that no longer needs uploading. It has an Open logs button." + }, + "mediaUploadPruneBytesFailed": "Failed to prune pending media bytes.", + "@mediaUploadPruneBytesFailed": { + "description": "Toast when Icarus could not clean up stored copies of images no upload needs any more. It has an Open logs button." + }, + "mediaUploadOutboxUpdateFailed": "Failed to update the durable media outbox.", + "@mediaUploadOutboxUpdateFailed": { + "description": "Toast when Icarus could not record, on this device, the images it still has to upload to the cloud. It has an Open logs button." + }, + "replayFailureUnsupportedBuild": "Replays from this patch can't be read yet. An Icarus update will add them.", + "@replayFailureUnsupportedBuild": { + "description": "Why a replay can't be opened: it was recorded on a Valorant patch Icarus can't read, and the patch number isn't known." + }, + "replayFailureUnsupportedCompression": "This replay is compressed in a way Icarus can't read yet.", + "@replayFailureUnsupportedCompression": { + "description": "Why a replay can't be opened: the file uses a compression format Icarus can't read." + }, + "replayFailureTransformCheckFailed": "Icarus couldn't decode this replay. An Icarus update may fix it.", + "@replayFailureTransformCheckFailed": { + "description": "Why a replay can't be opened: its data didn't come out right when decoded, usually because Valorant changed something." + }, + "replayFailureUnreadable": "This file couldn't be read.", + "@replayFailureUnreadable": { + "description": "Why a replay can't be opened: the file is damaged or incomplete." + }, + "replayFailureDesktopOnly": "Replays can only be opened in the desktop app.", + "@replayFailureDesktopOnly": { + "description": "Why a replay can't be opened in the web version of Icarus." + }, + "replayFailureDecoderMissing": "The replay decoder is missing from this install ({detail}).", + "@replayFailureDecoderMissing": { + "description": "Why a replay can't be opened: the part of Icarus that reads replays isn't installed. detail is a technical error in English, kept for support.", + "placeholders": { + "detail": { + "type": "String" + } + } + }, + "replayMatchStuckStorage": "Icarus couldn't store its progress on this device.", + "@replayMatchStuckStorage": { + "description": "Under 'N captures couldn't be saved' on a replay's card: saving a capture stopped because this device's storage failed. Retry and Discard buttons follow." + }, + "replayMatchStuckDevice": "Something on this device stopped it.", + "@replayMatchStuckDevice": { + "description": "Under 'N captures couldn't be saved' on a replay's card: saving a capture stopped because of an error on this device. 'it' is the capture. Retry and Discard buttons follow." + }, + "sightlineReportCopy": "Copy sightline report", + "@sightlineReportCopy": { + "description": "Right-click menu item on a placed view cone. Copies a technical report about what the cone can see, for reporting a wrong cone to the Icarus developers." + }, + "sightlineReportCopied": "Sightline report copied", + "@sightlineReportCopied": { + "description": "Toast after Copy sightline report put the report on the clipboard." + }, + "sightlineReportBuildFailed": "Could not build the sightline report.", + "@sightlineReportBuildFailed": { + "description": "Toast when Copy sightline report failed. It has an Open logs button." + }, + "sightlineReportSaveFailed": "Sightline report copied, but its files could not be saved.", + "@sightlineReportSaveFailed": { + "description": "Toast when the sightline report reached the clipboard but saving its copy on disk failed. It has an Open logs button." + }, + "sightlineReportCropLabelAttack": "{map} attack · eye {height} m", + "@sightlineReportCropLabelAttack": { + "description": "Caption drawn on the sightline report image, for a cone on the attack side. map is the map's name; height is the eye height in metres, or — when unknown. m is metres.", + "placeholders": { + "map": { + "type": "String" + }, + "height": { + "type": "String" + } + } + }, + "sightlineReportCropLabelDefense": "{map} defense · eye {height} m", + "@sightlineReportCropLabelDefense": { + "description": "Caption drawn on the sightline report image, for a cone on the defense side. map is the map's name; height is the eye height in metres, or — when unknown. m is metres.", + "placeholders": { + "map": { + "type": "String" + }, + "height": { + "type": "String" + } + } + }, + "shapeToolsCircle": "Circle", + "@shapeToolsCircle": { + "description": "The shape kind button for a circle, a range drawn over the map." + }, + "shapeToolsZone": "Zone", + "@shapeToolsZone": { + "description": "The shape kind button for a zone: a rectangle that colours the map floor inside it." + }, + "shapeToolsZoneHint": "Drag or click to place. Colours the map floor inside it.", + "@shapeToolsZoneHint": { + "description": "Hint under the shape tools while Zone is picked. Small italic text." + }, + "shapeToolsPlaceZoneButton": "+ Place zone", + "@shapeToolsPlaceZoneButton": { + "description": "Button that places a zone at the middle of the map." + }, + "shapeToolsPlaceCircleButton": "+ Place circle", + "@shapeToolsPlaceCircleButton": { + "description": "Button that places a circle at the middle of the map." + } +} diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb new file mode 100644 index 000000000..909ec072c --- /dev/null +++ b/lib/l10n/app_ja.arb @@ -0,0 +1,1279 @@ +{ + "@@locale": "ja", + "commonCancel": "キャンセル", + "commonRetry": "再試行", + "commonTryAgain": "もう一度試す", + "commonDelete": "削除", + "commonRename": "名前を変更", + "commonEdit": "編集", + "commonClear": "クリア", + "commonClose": "閉じる", + "commonSave": "保存", + "commonDone": "完了", + "commonApply": "適用", + "commonDiscard": "破棄", + "commonExport": "エクスポート", + "commonShare": "共有", + "commonLeave": "退出", + "commonLogIn": "ログイン", + "commonSignOut": "ログアウト", + "commonReconnect": "再接続", + "commonSentenceSpace": "", + "confirmDialogConfirm": "確認", + "deleteFolderTitle": "「{name}」を削除しますか?", + "deleteFolderDescription": "中にある作戦とサブフォルダーもすべて削除されます。", + "deleteFolderFailed": "このフォルダーを削除できませんでした。もう一度お試しください。", + "deleteFolderDeleting": "削除中...", + "deletePageTitle": "「{name}」を削除しますか?", + "deletePageRestorableFor": "{days}日間は「最近削除した項目」から復元できます。", + "deletePageCannotUndo": "この操作は元に戻せません。", + "deletePageConfirm": "このページを削除しますか?{afterwards}", + "deletePagePeerOne": "{name}がこのページを開いています。それでも削除しますか?{afterwards}", + "deletePagePeersTwo": "{first}と{second}がこのページを開いています。それでも削除しますか?{afterwards}", + "deletePagePeersMany": "{first}、{second}{others, plural, other{ほか{others}人}}がこのページを開いています。それでも削除しますか?{afterwards}", + "deletePageDeleteAnyway": "削除する", + "deletedPageTitle": "このページは削除されました", + "deletedPageDescription": "編集中に「{name}」が削除されました。復元して最新の変更を残すか、変更を破棄してください。", + "deletedPageGone": "「{name}」はもう復元できないため、最新の変更を保存できませんでした。", + "deletedPageRestoreFailed": "ページの復元を確認できませんでした。接続を確認してもう一度お試しいただくか、変更を破棄してください。", + "deletedPageLeaveFailed": "まだこのページから移動できません。変更を送信中か、別のページを読み込めませんでした。少し待ってからもう一度お試しください。", + "deletedPageDiscardChanges": "変更を破棄", + "deletedPageRestore": "ページを復元", + "deletedPageRestoring": "復元中…", + "recentlyDeletedTitle": "最近削除した項目", + "recentlyDeletedDescription": "削除したページは{days}日間ここに残り、その後完全に削除されます。", + "recentlyDeletedLoadFailed": "削除したページを読み込めませんでした。接続を確認してもう一度お試しください。", + "recentlyDeletedLoading": "読み込み中…", + "recentlyDeletedEmpty": "削除したページはありません。", + "recentlyDeletedGone": "もう復元できません。", + "recentlyDeletedRestoreFailed": "復元を確認できませんでした。接続を確認してもう一度お試しください。", + "recentlyDeletedRestore": "復元", + "recentlyDeletedRestoring": "復元中…", + "recentlyDeletedRowDetail": "{deleted} · {timeLeft}", + "recentlyDeletedByYou": "自分が削除 · {when}", + "recentlyDeletedBy": "{name}が削除 · {when}", + "recentlyDeletedWhen": "削除 · {when}", + "recentlyDeletedJustNow": "たった今", + "recentlyDeletedMinutesAgo": "{count, plural, other{{count}分}}前", + "recentlyDeletedHoursAgo": "{count, plural, other{{count}時間}}前", + "recentlyDeletedDaysAgo": "{count, plural, other{{count}日}}前", + "recentlyDeletedDaysLeft": "{count, plural, other{残り{count}日}}", + "recentlyDeletedLessThanADayLeft": "残り1日未満", + "releaseNotesTitle": "新機能", + "releaseNotesShowWelcomeTour": "ウェルカムツアーを表示", + "releaseNotesInstalled": "インストール済み", + "releaseNotesAvailable": "配信中", + "releaseNotesLoadFailed": "パッチノートを読み込めませんでした。接続を確認してもう一度お試しください。", + "webBetaTitle": "Icarus Webベータ", + "webBetaDescription": "ここで作った作戦はIcarusアカウントに保存され、デスクトップアプリでも開けます。", + "webBetaDownloadForWindows": "Windows版をダウンロード", + "webBetaDesktopOnly": "現在はデスクトップ版のみ", + "webBetaComing": "Webベータに今後追加", + "webViewTitle": "WebView", + "webViewNotInstalled": "WebViewがインストールされていません。YouTube動画を使うにはインストールしてください。", + "webViewInstall": "インストール", + "authInvalidEmail": "有効なメールアドレスを入力してください。", + "authPasswordTooShort": "パスワードは6文字以上にしてください。", + "authPasswordsDoNotMatch": "パスワードが一致しません。", + "authDiscordLoginFailed": "ログインを完了できませんでした。もう一度お試しください。", + "authSignInTitle": "ログイン", + "authSignUpTitle": "アカウントを作成", + "authResetPasswordTitle": "パスワードをリセット", + "authSignInDescription": "メールアドレスとパスワードでログインします。", + "authSignUpDescription": "メールアドレスとパスワードでアカウントを作成します。", + "authResetPasswordDescription": "アカウントのメールアドレスを入力すると、新しいパスワードを設定するリンクを送信します。Discordで作成したアカウントにも使えます。", + "authEmailHint": "メールアドレス", + "authPasswordHint": "パスワード", + "authConfirmPasswordHint": "パスワード(確認)", + "authForgotPassword": "パスワードを忘れた場合", + "authSwitchToSignUp": "アカウントを作成", + "authSwitchToSignIn": "ログインに戻る", + "authBackToSignIn": "ログインに戻る", + "authContinueWithDiscord": "Discordで続ける", + "authSignInButton": "ログイン", + "authSigningIn": "ログイン中…", + "authSignUpButton": "アカウントを作成", + "authCreating": "作成中…", + "authSendResetLink": "リセットリンクを送信", + "authSending": "送信中…", + "authDiscordPending": "ブラウザーでDiscordのログインを完了してください。このダイアログは自動で閉じます。", + "setPasswordTitle": "新しいパスワードを設定", + "setPasswordDescription": "ログインに使うパスワードを決めてください。", + "setPasswordDescriptionSignedIn": "{email}でログインしています。ログインに使うパスワードを決めてください。", + "setPasswordNewHint": "新しいパスワード", + "setPasswordConfirmHint": "新しいパスワード(確認)", + "setPasswordNotNow": "後で", + "setPasswordSave": "パスワードを保存", + "setPasswordSaving": "保存中…", + "setPasswordSaved": "パスワードを保存しました。今後はメールアドレスとこのパスワードでログインしてください。", + "createStrategyPickMap": "マップを選択", + "createStrategyOutOfRotation": "ローテーション外", + "deleteStrategyTitle": "{name}を削除", + "deleteStrategyConfirm": "{name}を削除しますか?この操作は元に戻せません。", + "deleteStrategyFailed": "この作戦を削除できませんでした。もう一度お試しください。", + "deleteStrategyDeleting": "削除中...", + "renameStrategyTitle": "作戦の名前を変更", + "renameStrategyEmptyName": "作戦名を入力してください。", + "lineupMediaName": "名前", + "lineupMediaNameHint": "任意。ここにある他の定点と区別するための名前", + "lineupMediaYoutubeLink": "YouTubeリンク", + "lineupMediaYoutubeLinkHint": "YouTubeリンクを貼り付け...", + "lineupMediaImages": "画像", + "lineupMediaNotes": "メモ", + "lineupMediaNotesHint": "メモを追加...", + "lineupMediaAddImages": "クリックして画像を追加", + "lineupMediaPaste": "貼り付け", + "lineupMediaPasteFromClipboard": "クリップボードから貼り付け", + "createLineupNoClipboardImage": "クリップボードに画像がありません", + "createLineupClipboardTypeUnsupported": "クリップボードの画像形式に対応していません", + "createLineupLostEditAccess": "この作戦を編集できなくなったため、この定点は保存されませんでした。", + "createLineupQueueImagesFailed": "これらの画像をクラウド同期の待機列に追加できませんでした。画像はこのデバイスに残っています。閉じる前にもう一度お試しください。", + "createLineupCommitPending": "定点はエディターに残っていますが、クラウドへの反映がまだ完了していません。離れる前に「保存」を押してください。", + "createLineupCreateTitle": "定点を作成", + "createLineupEditTitle": "定点を編集", + "createLineupEditPlacement": "配置を編集", + "lineupPanelUnnamed": "定点{number}", + "lineupPanelRenameTitle": "定点の名前を変更", + "lineupPanelRenameLostEditAccess": "この作戦を編集できなくなったため、名前は保存されませんでした。", + "lineupPanelOriginFallback": "立ち位置", + "lineupPanelLandHere": "{count, plural, other{ここに着弾する定点:{count}件}}", + "lineupPanelFromHere": "{count, plural, other{ここからの定点:{count}件}}", + "lineupPanelEditMedia": "メディアを編集", + "lineupPanelDeleteLineup": "定点を削除", + "mapThemeEditorPreviewLabel": "マップテーマのプレビュー", + "mapThemeEditorNewProfileTitle": "新しいプロファイル", + "mapThemeEditorEditProfileTitle": "プロファイルを編集", + "mapThemeEditorCustomizeTitle": "マップテーマをカスタマイズ", + "mapThemeEditorCustomizeDescription": "この作戦にのみ適用されます。", + "mapThemeEditorProfileNameHint": "プロファイル名", + "mapThemeEditorBase": "ベース", + "mapThemeEditorDetail": "ディテール", + "mapThemeEditorHighlight": "強調", + "mapThemeEditorSaveProfile": "プロファイルを保存", + "mapThemeEditorSaveChanges": "変更を保存", + "mapThemeEditorCreateFailed": "プロファイルの上限に達しているか、名前が無効です。", + "mapThemeEditorSavedAndApplied": "プロファイルを保存して適用しました。", + "mapThemeEditorSaved": "プロファイルを保存しました。", + "mapThemeEditorEnterName": "プロファイル名を入力してください。", + "mapThemeEditorUpdateFailed": "このプロファイルを更新できませんでした。", + "mapThemeEditorUpdated": "プロファイルを更新しました。", + "mapThemeEditorRenameFailed": "色は保存しましたが、名前を変更できませんでした。", + "uploadImageUnsupportedFormat": "対応していない画像形式です", + "uploadImageTitle": "画像をアップロード", + "uploadImageDescriptionDrop": "画像をここにドロップするか、クリックしてファイルを選択します。", + "uploadImageDescriptionClick": "クリックしてファイルを選択します。", + "uploadImageUseImage": "この画像を使用", + "uploadImageDragTip": "ヒント:デスクトップから画像をドラッグ&ドロップすることもできます。", + "uploadImageTagColor": "タグの色", + "uploadImageDropToUpload": "ドロップしてアップロード", + "uploadImageDropOrClick": "ドロップまたはクリックしてアップロード", + "uploadImageCheckingClipboard": "クリップボードを確認中…", + "uploadImageSelectedImage": "選択した画像", + "screenshotDialogDefaultFileName": "新しい画像", + "screenshotDialogChooseFolder": "スクリーンショットの保存先フォルダーを選択", + "screenshotDialogFolderPickerFailed": "フォルダーの選択画面を開けませんでした。もう一度お試しください。", + "screenshotDialogSavedToFolder": "{count}枚のスクリーンショットを{folder}に保存しました。", + "screenshotDialogCancelled": "スクリーンショットを中止しました。", + "screenshotDialogExportFailedMany": "スクリーンショットを書き出せませんでした。もう一度お試しください。", + "screenshotDialogExportFailedOne": "スクリーンショットを書き出せませんでした。もう一度お試しください。", + "screenshotDialogSavingManyTitle": "スクリーンショットを保存中", + "screenshotDialogSavingOneTitle": "スクリーンショットを保存中", + "screenshotDialogSaveOneTitle": "スクリーンショットを保存", + "screenshotDialogSaveManyTitle": "スクリーンショットを保存", + "screenshotDialogTransparentBackground": "背景を透過", + "screenshotDialogIncludeCaptions": "キャプションを含める", + "screenshotDialogIncludeCaptionsDescription": "マップ上に番号、左右の余白にメモを表示します。", + "screenshotDialogSaveManyButton": "{count}枚を保存", + "screenshotDialogSaveOneButton": "スクリーンショットを保存", + "screenshotDialogStop": "停止", + "screenshotDialogStoppingButton": "停止中…", + "screenshotDialogClearAll": "すべてクリア", + "screenshotDialogSelectAll": "すべて選択", + "screenshotDialogCaptured": "撮影済み", + "screenshotDialogStoppingStatus": "停止中", + "screenshotDialogWaitingForPictures": "画像を待機中", + "screenshotDialogCapturing": "撮影中", + "exportVideoIncludeCaptionsDescription": "マップ上に番号、左右の余白にメモを表示します。", + "exportVideoCaptionsTooSlow": "ページ{page}は読むのに約{seconds}秒かかります。各ステップの表示は{step}秒です。", + "exportVideoDefaultFileName": "新しい動画", + "exportVideoBrowserUnsupported": "このブラウザーでは動画を書き出せません。最新のChromeかEdge、またはデスクトップアプリをお使いください。", + "exportVideoEncoderNotFound": "動画エンコーダーが見つかりません。Icarusを再インストールすると直ります。", + "exportVideoSavingChanges": "変更を保存中", + "exportVideoPreparing": "準備中", + "exportVideoExported": "動画を書き出しました。", + "exportVideoCancelled": "動画の書き出しを中止しました。", + "exportVideoFailed": "動画の書き出しに失敗しました:{error}", + "exportVideoSeconds": "{seconds}秒", + "exportVideoMinutesSeconds": "{minutes}分{seconds}秒", + "exportVideoExportingTitle": "動画を書き出し中", + "exportVideoCancelExport": "書き出しを中止", + "exportVideoKeepTabOpen": "動画のダウンロードが終わるまでこのタブを開いたままにしてください。", + "exportVideoTitle": "動画を書き出す", + "exportVideoDescription": "選択したページを順番に再生します。各ステップは設定した時間だけ表示され、次のステップへ切り替わります。", + "exportVideoStepDuration": "ステップの長さ", + "exportVideoStepDurationDescription": "切り替わる前に各ステップを画面に表示する時間です。", + "exportVideoTransparentBackground": "背景を透過", + "exportVideoTransparentBackgroundDescription": "背景とドットを除きます。透過を保った可逆圧縮の.movで保存されるので、動画編集ソフト向けです。", + "exportVideoIncludeCaptions": "キャプションを含める", + "exportVideoQuality": "画質", + "exportVideoTransparentQualityNote": "透過動画は30fpsの可逆圧縮になるため、これらは適用されません。", + "exportVideoPages": "ページ", + "exportVideoPagesSelected": "{selected}/{total}", + "exportVideoClearAll": "すべてクリア", + "exportVideoSelectAll": "すべて選択", + "exportVideoQualityPotato": "低画質", + "exportVideoQualitySocial": "SNS", + "exportVideoQualityMax": "最高", + "exportVideoQualityPotatoCaption": "約10 MB", + "exportVideoQualitySocialCaption": "約20 MB", + "exportVideoQualityMaxCaption": "60 fps、サイズ上限なし", + "exportVideoSelectAPage": "書き出すページを1つ以上選択してください。", + "exportVideoSummaryLength": "約{duration}の動画", + "exportVideoSummaryFormat": " · {height}p · {format}", + "shareLinksTitle": "「{name}」を共有", + "shareLinksJustNow": "たった今", + "shareLinksMinutesAgo": "{count}分前", + "shareLinksHoursAgo": "{count}時間前", + "shareLinksDaysAgo": "{count}日前", + "shareLinksTeammateFallback": "チームメイト", + "shareLinksLoadMoreFailed": "これ以上のユーザーを読み込めませんでした。", + "shareLinksLinkCopied": "共有リンクをクリップボードにコピーしました。", + "shareLinksCodeCopied": "共有コードをクリップボードにコピーしました。", + "shareLinksCreateFailed": "共有リンクを作成できませんでした。", + "shareLinksLoadFailed": "共有リンクを読み込めませんでした。", + "shareLinksPeopleWithAccess": "アクセスできるユーザー", + "shareLinksLoadPeopleFailed": "アクセスできる他のユーザーを読み込めませんでした。", + "shareLinksLoadingMore": "読み込み中…", + "shareLinksShowMore": "さらに表示", + "shareLinksFolderAccess": "フォルダー「{folder}」にアクセスできるユーザーもこれを開けます。", + "shareLinksJoined": "参加 · {when}", + "shareLinksJoinedThroughEditLink": "編集リンクから参加 · {when}", + "shareLinksJoinedThroughViewLink": "閲覧リンクから参加 · {when}", + "shareLinksJoinedThroughLink": "リンクから参加 · {when}", + "shareLinksNewLink": "新しいリンク", + "shareLinksCanEdit": "編集可", + "shareLinksViewOnly": "閲覧のみ", + "shareLinksViewOnlyLinkDescription": "リンクを知っている人は全ページを閲覧できます", + "shareLinksCanEditLinkDescription": "リンクを知っている人はページ、描画、メディアを変更できます", + "shareLinksCreating": "作成中…", + "shareLinksCreateAndCopy": "作成してコピー", + "shareLinksActiveLinks": "有効なリンク", + "shareLinksNoLinks": "リンクはまだありません。上で作成して共有しましょう。", + "shareLinksYouSuffix": "(自分)", + "shareLinksOwner": "オーナー", + "shareLinksCanView": "閲覧可", + "shareLinksCanViewMemberDescription": "全ページを閲覧でき、変更はできません", + "shareLinksCanEditMemberDescription": "ページ、描画、メディアを変更できます", + "shareLinksRemoveMember": "{name}を削除", + "shareLinksNameSeparator": "、", + "shareLinksJoinedNamesAndOthers": "{names}ほか", + "shareLinksEditBadge": "編集", + "shareLinksViewBadge": "閲覧", + "shareLinksCreated": "作成 · {when}", + "shareLinksCreatedRemoving": "作成 · {when} · ユーザーを削除中…", + "shareLinksCreatedJoined": "作成 · {when} · {count}人が参加", + "shareLinksCreatedJoinedMore": "作成 · {when} · {count}人以上が参加", + "shareLinksCopyLinkTooltip": "リンクをコピー", + "shareLinksCopyCodeTooltip": "コードをコピー", + "addSharedItemEmpty": "先に共有リンクかコードを貼り付けてください。", + "addSharedItemInvalid": "Icarusの共有リンクまたはコードではないようです。", + "addSharedItemFailed": "追加できませんでした。リンクが無効になっているか、ログインが必要な可能性があります。", + "addSharedItemTitle": "共有アイテムを追加", + "addSharedItemDescription": "Icarusの共有リンクを貼り付けるか共有コードを入力して、「共有」に追加します。", + "addSharedItemAdd": "追加", + "addSharedItemAdding": "追加中…", + "addSharedItemPlaceholder": "{origin}/share/… または ICR-XXXX-XXXX-XXXX-XXXX", + "leaveSharedTitle": "「{name}」から退出しますか?", + "leaveSharedDescription": "アクセスできなくなり、「共有」タブからも消えます。再びアクセスするには、オーナーからリンクをもらう必要があります。", + "leaveSharedFailed": "退出できませんでした。接続を確認してもう一度お試しください。", + "welcomeTourKicker": "このアップデートの新機能", + "welcomeTourLibraryTitle": "ライブラリはそのままです", + "welcomeTourLibraryBody": "このアップデート以前に作ったものはすべて、このPCにそのまま保存されています。自分で移動しない限り、何も動きません。", + "welcomeTourCloudTitle": "ログインして同期・共有", + "welcomeTourCloudBody": "ログイン中に作った作戦はアカウントに保存されるので、どのPCでも開けて、チームと共有できます。以前の作戦も持っていくには、右クリックして**クラウドに移動**を選んでください。", + "welcomeTourReplaysTitle": "試合を作戦にしよう", + "welcomeTourReplaysBody": "**リプレイ**タブには、VALORANTでダウンロードした試合が表示されます。好きな場面で一時停止して**キャプチャ**を押すと、作戦のページとして保存できます。", + "welcomeTourBack": "戻る", + "welcomeTourNext": "次へ", + "welcomeTourNotNow": "後で", + "welcomeTourSignIn": "ログイン", + "welcomeTourOpenReplays": "リプレイを開く", + "welcomeTourStepLabel": "ステップ{step}/{count}", + "welcomeTourPictureASiteExecute": "Aサイト攻め", + "welcomeTourPictureBRetake": "Bリテイク vs Viper", + "welcomeTourPicturePistolDefaults": "ピストル基本配置", + "welcomeTourPictureRetakeDrills": "リテイク練習", + "welcomeTourPictureOnThisDevice": "このデバイスのみ", + "welcomeTourPictureMoving": "移動中…", + "welcomeTourPictureInYourAccount": "アカウント内", + "welcomeTourPictureReplayDate": "10月13日(火)", + "welcomeTourPictureMoveToCloud": "クラウドに移動", + "welcomeTourPictureCapture": "キャプチャ", + "createLineupPasteFailed": "貼り付けた画像を追加できませんでした:{error}", + "syncButtonConflictToast": "別の編集が先にクラウドに届きました。あなたの版はこのデバイスに残っていて、対応が必要です。", + "syncButtonUseCloudFailed": "クラウド版を読み込めませんでした。保存済みのあなたの版は変更されていません。", + "syncButtonKeepBothUnchanged": "クラウド版を読み込めませんでした。何も変更されていません。", + "syncButtonKeepBothCopiesOnly": "あなたの版をコピーとして追加しましたが、競合はまだ解決していません。", + "syncButtonKeepBothFailed": "両方の版を保持できませんでした。あなたの版はこのデバイスに保存されたままです。", + "syncButtonViewOnlyTooltip": "閲覧のみ · {status}", + "syncButtonOfflineCopyTooltip": "{count, plural, =0{オフライン。このPCのコピーを表示中。変更はこのデバイスに残ります} other{オフライン。このPCのコピーを表示中 · {count}件の変更をこのPCに保存済み}}", + "syncButtonOfflineCopyFromTooltip": "{count, plural, =0{オフライン。{when}時点のこのPCのコピーを表示中。変更はこのデバイスに残ります} other{オフライン。{when}時点のこのPCのコピーを表示中 · {count}件の変更をこのPCに保存済み}}", + "syncButtonCopyDateTime": "{date} {time}", + "syncButtonSyncedTooltip": "同期済み", + "syncButtonSyncedAtTooltip": "{time}に同期済み", + "syncButtonEditNotSynced": "編集はまだ同期されていません", + "syncButtonSyncingTooltip": "同期中…", + "syncButtonNeedsAttention": "同期に対応が必要です", + "syncButtonDeleteAnyway": "それでも削除", + "syncButtonKeepHeldPages": "{count, plural, other{ページを残す}}", + "syncButtonUseCloud": "クラウド版を使用", + "syncButtonKeepBoth": "両方を保持", + "syncButtonKeepMine": "自分の変更を保持", + "syncButtonRetrySync": "同期を再試行", + "syncButtonLastSyncedAt": "最終同期 {time}", + "syncButtonSyncedTitle": "すべての変更を同期済み", + "syncButtonSyncingTitle": "変更を同期中", + "syncButtonOfflineTitle": "オフラインで作業中", + "syncButtonSyncedExplanation": "作戦はクラウドに安全に保存されています。", + "syncButtonEditingExplanation": "編集を終えるかページを切り替えると、この変更がクラウドに送信されます。", + "syncButtonSyncingOtherExplanation": "クラウドライブラリの保存済みの変更をバックグラウンドで送信しています。", + "syncButtonSyncingExplanation": "編集内容をクラウドに送信しています。バックグラウンドで行われるので、そのまま作業を続けられます。", + "syncButtonOfflineExplanation": "変更はこのデバイスに保存され、接続が戻ると自動的に同期されます。", + "syncButtonOtherStrategyAlsoNeedsAttention": "別の作戦の保存済みの作業にも対応が必要です。クラウドライブラリから開いて理由を確認してください。", + "syncButtonOtherStrategyNeedsAttention": "別の作戦の保存済みの作業に対応が必要です。クラウドライブラリから開いて理由を確認してください。", + "syncButtonLineupsNoLongerFit": "作業中にチームメイトがこれらの定点を変更したため、あなたの変更は送信されませんでした。ページにはあなたの版が表示されていて、このデバイスに保存されたままです。", + "syncButtonLineupsNoLongerFitChoices": "「自分の変更を保持」は、表示どおりにこれらの定点を保存し、あなたが編集していない定点も含めて相手の変更をすべて置き換えます。「クラウド版を使用」はあなたの変更を置き換え、「両方を保持」はあなたの版をコピーとして相手の版の横に追加します。", + "syncButtonLineupConflict": "作業中にチームメイトがこれらの定点を変更しました。あなたの版はこのデバイスに保存されたままです。", + "syncButtonLineupConflictChoices": "「自分の変更を保持」は相手の変更を置き換え、「クラウド版を使用」はあなたの変更を置き換え、「両方を保持」はあなたの版をコピーとして相手の版の横に追加します。", + "syncButtonRejected": "別の編集が先にクラウドに届きました。あなたの版はこのデバイスに保存されたままです。", + "syncButtonRejectedChoice": "{count, plural, =1{この競合する変更について、残す版を選んでください。} other{選択は競合する{count}件の変更すべてに適用されます。}}", + "syncButtonOtherChangesNotSaved": "ここでのほかの変更も保存されていません。このデバイスに残っています。", + "syncButtonChoiceAppliesToAll": "選択は対応が必要な{count}件の変更すべてに適用されます。", + "syncButtonImagesFailed": "{count, plural, other{{count}件の画像をアップロードできませんでした。}}", + "syncButtonChangesNotSent": "一部の変更がまだクラウドに届いていません。", + "syncButtonRetryHint": "再試行すると今すぐ送信します。", + "syncButtonLineupDiffTitle": "あなたの版とクラウド版の違い", + "syncButtonYourChangesTitle": "あなたの変更", + "syncButtonCloudChangesTitle": "クラウドの変更", + "syncButtonNoLineupChanges": "定点の変更はありません", + "syncButtonMoreLineups": "{count, plural, other{ほか{count}件の定点}}", + "outboxBannerUpgradeTitle": "クラウドの作業には新しいIcarusが必要です", + "outboxBannerUpgradeDetail": "{count, plural, other{{count}件の保存済みの変更がこのデバイスに残っています。{message}}}", + "outboxBannerAttentionTitle": "クラウドの作業に対応が必要です", + "outboxBannerSyncingTitle": "クラウドの作業を同期中", + "outboxBannerOfflineTitle": "オフラインで作業中", + "outboxBannerUnreadableDetail": "このデバイスにある保存済みのクラウドの作業の一部を、Icarusが読み取れません。ログアウトせずに内容を確認してください。", + "outboxBannerReconnectDetail": "保存済みの作業を送信するには、このアカウントに再接続してください。", + "outboxBannerDeletedStrategyDetail": "{count, plural, other{削除された作戦への未送信の変更{count}件は保存できません。破棄するとこの表示は消えます。}}", + "outboxBannerAttentionDetail": "{strategyCount, plural, other{{strategyCount}件の作戦}}で{count, plural, other{{count}件の保存済みの変更}}の確認が必要です。", + "outboxBannerSendingDetail": "{strategyCount, plural, other{{strategyCount}件の作戦}}から{count, plural, other{{count}件の保存済みの変更}}を送信しています。", + "outboxBannerOfflineDetail": "{count, plural, other{{count}件の保存済みの変更がこのデバイスで待機中です。接続が戻ると再開します。}}", + "outboxBannerRetryCaptures": "{count, plural, other{リプレイのキャプチャを再試行}}", + "outboxBannerDiscardCaptures": "{count, plural, =1{リプレイのキャプチャを破棄} other{リプレイのキャプチャ{count}件を破棄}}", + "outboxBannerDiscardChangesTo": "「{strategyName}」への変更を破棄", + "outboxBannerDiscardDeletedStrategyChanges": "削除された作戦への変更を破棄", + "outboxBannerCaptureDiscardedToast": "破棄しました。ライブラリにはまだ表示される場合があります。", + "outboxBannerUnnamedStrategy": "クラウドの作戦", + "outboxBannerReviewSync": "{strategyName}:同期を確認", + "outboxBannerImagesFailed": "{strategyName}:{count, plural, other{画像{count}件が失敗}}", + "outboxBannerReview": "{strategyName}:確認", + "searchFieldHint": "検索", + "libraryTabMyLibrary": "マイライブラリ", + "libraryTabReplays": "リプレイ", + "libraryTabShared": "共有", + "libraryTabSharedSemantics": "共有ライブラリ", + "libraryTabCommunity": "コミュニティ", + "libraryTabCommunitySemantics": "コミュニティライブラリ", + "libraryAddByLinkOrCode": "リンクかコードで追加", + "libraryNewStrategy": "新しい作戦", + "libraryNewFolder": "新しいフォルダー", + "libraryNewMenuSemantics": "新規作成", + "libraryAddReplay": "リプレイを追加", + "librarySortBy": "並べ替え", + "librarySortTooltip": "並べ替え", + "libraryImportIca": ".icaをインポート", + "libraryImportBackup": "バックアップをインポート", + "libraryExportLibrary": "ライブラリをエクスポート", + "libraryLogInSemantics": "Icarusにログイン", + "libraryAccountSemantics": "{displayName}のアカウント", + "folderEmptyTitle": "このフォルダーに作戦はありません", + "folderEmptyLocalSubtitle": "新しい作戦を作成するか、作戦、フォルダー、.zipアーカイブをドロップしてください", + "folderCreateStrategy": "作戦を作成", + "folderLibraryEmptyTitle": "ライブラリは空です", + "folderLibraryEmptyCloudSubtitle": "作成した作戦はアカウントに保存されます。", + "folderLibraryEmptyLocalSubtitle": "新しい作戦を作成するか、作戦、フォルダー、.zipアーカイブをここにドロップしてください。", + "folderSharedEmptyTitle": "共有された項目はまだありません", + "folderSharedEmptySubtitle": "チームメイトの共有リンクかコードを追加すると、ここに保存されます。", + "folderSharedFolderEmptySubtitle": "このフォルダーに共有された作戦がここに表示されます。", + "folderEmptyCloudSubtitle": "新しい作戦を作成してください。", + "folderNoMatchesTitle": "一致する項目がありません", + "folderNoMatchesSubtitle": "別のキーワードで検索してください", + "folderCloudUnavailableTitle": "クラウドを利用できません", + "folderCloudUnavailableRetrySubtitle": "再試行してオンラインの作戦にアクセスするか、ライブラリに戻って作業を続けてください。", + "folderCloudUnavailableSignInSubtitle": "再度ログインしてオンラインの作戦にアクセスするか、ライブラリに戻って作業を続けてください。", + "folderBackToMyLibrary": "マイライブラリに戻る", + "folderCloudLoadFailedTitle": "クラウドライブラリを読み込めませんでした", + "folderCheckConnectionSubtitle": "接続を確認して、もう一度お試しください。", + "folderSharedNotKeptTitle": "「共有」はまだこのPCにありません", + "folderNotKeptTitle": "このフォルダーはまだこのPCにありません", + "folderNotKeptSubtitle": "Icarusはオンライン中に開いたものを保持します。オンラインに戻ると読み込まれます。", + "folderCloudNeedsUpgradeTitle": "クラウドライブラリには新しいIcarusが必要です", + "folderCloudUnreachableTitle": "クラウドライブラリに接続できませんでした", + "folderSignInTitle": "ログインしてWebベータを使う", + "folderSignInSubtitle": "ここで作成した作戦はIcarusアカウントに保存されます。", + "folderCommunityTitle": "コミュニティの作戦は近日公開", + "folderCommunitySubtitle": "公開された定点やチームのエントリー、作戦パックを見つけられる場所になる予定です。", + "folderCloudLoadingBanner": "クラウドライブラリを読み込み中です。今はこのPCにある内容を表示しています。", + "folderCloudOfflineBanner": "オフライン。{when}時点のクラウドライブラリを表示しています。変更はオンラインに戻ると同期されます。", + "folderCopyDateTime": "{date} {time}", + "folderCloudErrorBanner": "クラウドライブラリを読み込めませんでした。このPCにある内容を表示しています。", + "colorPickerTitle": "色を選択", + "colorPickerRed": "赤", + "colorPickerGreen": "緑", + "colorPickerBlue": "青", + "colorPickerAlpha": "不透明度", + "colorPickerHue": "色相", + "colorPickerSaturation": "彩度", + "colorPickerLightness": "輝度", + "colorPickerValue": "明度", + "folderEditSaveFailed": "このフォルダーを保存できませんでした。もう一度お試しください。", + "folderEditTitleEdit": "フォルダーを編集", + "folderEditTitleAdd": "フォルダーを追加", + "folderEditNameHint": "フォルダー名", + "folderEditSaving": "保存中...", + "folderEditCustomColorTitle": "カスタムカラーを選択", + "folderEditIconFilterAll": "すべて", + "folderEditIconFilterSymbols": "シンボル", + "folderEditIconFilterRoles": "ロール", + "folderEditIconFilterModes": "モード", + "folderCardMovingToCloud": "クラウドに移動中", + "folderCardEmpty": "空", + "folderCardStrategyCount": "{count, plural, other{{count}件の作戦}}", + "folderCardLocalOnlyTooltip": "このPCにのみ保存", + "folderCardPin": "ピン留め", + "folderCardUnpin": "ピン留めを外す", + "folderCardMoveToCloud": "クラウドに移動", + "folderCardExportFailed": "フォルダーをエクスポートできませんでした。", + "icaDropAllNewerVersion": "これらのファイルは新しいバージョンのIcarusで作成されています。アプリをアップデートしてもう一度お試しください。", + "icaDropSomeNewerVersion": "{count, plural, other{{count}件は新しいバージョンのIcarusで作成されています。アプリをアップデートしてもう一度お試しください。}}", + "icaDropImportedThemeProfiles": "{count, plural, other{テーマプロファイルを{count}件インポートしました。}}", + "icaDropRestoredLibrarySettings": "ライブラリの設定を復元しました。", + "icaDropNothingImported": "インポートできる作戦やフォルダーはありませんでした。", + "icaDropSkippedFiles": "{count, plural, other{{count}件のファイルをスキップしました。}}", + "icaDropImportedStrategiesIntoFolders": "{strategyCount, plural, other{{strategyCount}件の作戦}}を{folderCount, plural, other{{folderCount}個のフォルダー}}にインポートしました。", + "icaDropImportedStrategies": "{count, plural, other{作戦を{count}件インポートしました。}}", + "icaDropImportedFolders": "{count, plural, other{フォルダーを{count}個インポートしました。}}", + "icaDropImportFailed": "ドロップした項目をインポートできませんでした。", + "icaDropOverlayLabel": "作戦、フォルダー、バックアップ、リプレイをインポート", + "libraryImportIcaFailed": "作戦ファイルをインポートできませんでした。", + "libraryImportBackupFailed": "バックアップをインポートできませんでした。", + "libraryExportFailed": "ライブラリをエクスポートできませんでした。", + "libraryCreateCloudStrategyFailed": "クラウドの作戦を作成できませんでした。もう一度ログインしてください。", + "libraryCreateStrategyFailed": "作戦を作成できませんでした。", + "libraryCreateFolderMenu": "フォルダーを作成", + "libraryCreateStrategyMenu": "作戦を作成", + "desktopUpdateRequiredBadge": "必須", + "desktopUpdateRestartToUpdate": "再起動してアップデート", + "desktopUpdateDownload": "アップデートをダウンロード", + "desktopUpdateDownloadFailed": "デスクトップ版のアップデートをダウンロードできませんでした。もう一度お試しください。", + "desktopUpdateRestartRequiredTitle": "再起動が必要です", + "desktopUpdateRestartWarning": "アップデートのインストールを完了するには再起動が必要です。\n保存していない変更は失われます。今すぐ再起動しますか?", + "desktopUpdateRestartConfirm": "再起動", + "desktopUpdateRestartCancel": "後で", + "desktopUpdateApplyFailed": "ダウンロードしたデスクトップ版のアップデートを適用できませんでした。もう一度ダウンロードしてください。", + "desktopUpdateDownloading": "ダウンロード中… {percent}%", + "captionCardAgentName": "エージェント", + "captionCardTextName": "テキスト", + "captionCardImageName": "画像", + "captionCardVisionConeName": "視界コーン", + "captionCardShapeName": "図形", + "captionCardItemGone": "その項目は削除されたため、キャプションは保存されませんでした。クリップボードにコピーしてあります。", + "captionCardHint": "キャプションを入力", + "captionCardSaveShortcut": "{shortcut}で保存", + "lineupControlPlaceBoth": "エージェントとアビリティを配置してください", + "lineupControlPlaceAgent": "エージェントを配置してください", + "lineupControlPlaceAbility": "アビリティを配置してください", + "lineupControlFinalize": "定点の詳細を設定", + "lineupControlNext": "次へ", + "canvasPasteNoImage": "クリップボードに画像がありません", + "canvasPasteFailed": "コピーした内容を貼り付けられませんでした。", + "canvasPasteAddingPicture": "画像を追加中…", + "canvasPasteUnsupportedImage": "この形式のクリップボード画像には対応していません", + "canvasPasteOtherMap": "コピーした内容は別のマップのものです。", + "canvasPasteFromMap": "コピーした内容は{mapName}のものです。{mapName}の作戦に貼り付けてください。", + "canvasPastePicturesMissing": "コピーした内容の画像を、コピー時に読み取れなかったため、何も貼り付けていません。接続を確認して、もう一度コピーしてください。", + "canvasPastePictureTooLarge": "コピーした内容に15 MBを超える画像があり、クラウドの作戦の上限を超えるため、何も貼り付けていません。", + "canvasPastePageChanged": "貼り付けが終わる前にページが変わったため、何も貼り付けていません。", + "editorToolbarViewOnlySignIn": "閲覧のみ · ログインしてライブラリに追加", + "editorToolbarViewOnlyAddToLibrary": "閲覧のみ · マイライブラリに追加", + "editorToolbarExportIca": ".icaをエクスポート", + "editorToolbarExportVideo": "動画を書き出す", + "editorToolbarScreenshot": "スクリーンショット", + "editorToolbarSettings": "設定", + "clientUpgradeUpdate": "アップデート", + "clientUpgradeReload": "再読み込み", + "clientUpgradeCheckAgain": "再確認", + "mapArtworkLoadFailedTitle": "マップを読み込めませんでした", + "mapArtworkLoadFailedRetrying": "再試行しています。作戦には影響ありません。", + "imageDropOverlayLabel": "画像ファイルをインポート(.png、.jpg、.webp、.gif、.bmp)", + "canvasSelectionHint": "マップ上でドラッグすると選択するようになりました。Spaceを押しながらドラッグするとマップを動かせます。", + "globalShortcutsFileSaved": "ファイルを保存しました", + "globalShortcutsSaveFailed": "保存に失敗しました", + "libraryBreadcrumbSharedWithMe": "共有", + "lineupMediaNoImages": "画像がありません", + "lineupEditorsOneOnLineup": "{name}がこの定点を編集中です。", + "lineupEditorsOneOnLineups": "{name}がこれらの定点を編集中です。", + "lineupEditorsTwoOnLineup": "{first}と{second}がこの定点を編集中です。", + "lineupEditorsTwoOnLineups": "{first}と{second}がこれらの定点を編集中です。", + "lineupEditorsManyOnLineup": "{name}とほか{count, plural, other{{count}人}}がこの定点を編集中です。", + "lineupEditorsManyOnLineups": "{name}とほか{count, plural, other{{count}人}}がこれらの定点を編集中です。", + "lineupEditStatusOne": "{agentName}かアビリティを新しい位置にドラッグ", + "lineupEditStatusOneUnknownAgent": "エージェントかアビリティを新しい位置にドラッグ", + "lineupEditStatusSharedSpot": "{agentName}かアビリティをドラッグ。この{count}件の定点は立ち位置が同じなので一緒に動きます", + "lineupEditStatusSharedSpotUnknownAgent": "エージェントかアビリティをドラッグ。この{count}件の定点は立ち位置が同じなので一緒に動きます", + "lineupPlacementDragAbility": "アビリティを着弾点にドラッグ", + "lineupPlacementDragAnAgent": "エージェントを立ち位置にドラッグ", + "lineupPlacementDragAgent": "{agentName}を立ち位置にドラッグ", + "lineupPlacementDragUnknownAgent": "エージェントを立ち位置にドラッグ", + "settingsTitle": "設定", + "settingsNavCurrentStrategy": "この作戦", + "settingsNavAppWide": "アプリ全体", + "settingsNavDefaults": "デフォルト", + "settingsNavAutosave": "自動保存", + "settingsNavMapLayers": "マップ表示", + "settingsObjectStyling": "マーカーの見た目", + "settingsAccount": "アカウント", + "settingsPrivacy": "プライバシー", + "settingsKeybinds": "キー割り当て", + "settingsAgentMarkersTitle": "エージェントマーカー", + "settingsAgentMarkersDescription": "この作戦で配置したエージェントと視界ツールのサイズを変更します。", + "settingsAbilityMarkersTitle": "アビリティマーカー", + "settingsAbilityMarkersDescription": "この作戦のユーティリティアイコンと配置補助のサイズを変更します。", + "settingsNeutralTeamColorsTitle": "チームマーカーを無彩色に", + "settingsNeutralTeamColorsDescription": "味方と敵のマーカーの色を、同じ明るさのグレーで表示します。", + "settingsMarkerSizesDiffer": "ページごとにマーカーのサイズが異なります。このサイズをすべてのページに適用しますか?", + "settingsApplyMarkerSizesToAllPages": "全ページに適用", + "settingsApplyingMarkerSizes": "適用中…", + "settingsNewStrategyDefaultsTitle": "新しい作戦のデフォルト", + "settingsDefaultAgentMarkersTitle": "エージェントマーカーの初期値", + "settingsDefaultAgentMarkersDescription": "新しい作戦でのエージェントマーカーの初期サイズです。", + "settingsDefaultAbilityMarkersTitle": "アビリティマーカーの初期値", + "settingsDefaultAbilityMarkersDescription": "新しい作戦でのアビリティマーカーの初期サイズです。", + "settingsDefaultNeutralColorsTitle": "マーカーを最初から無彩色に", + "settingsDefaultNeutralColorsDescription": "新しい作戦では味方と敵の色をグレーにします。", + "settingsWorkspaceBehaviorTitle": "ワークスペースの動作", + "settingsAutosaveTitle": "自動保存", + "settingsAutosaveDescription": "操作が15秒止まると、作戦をこのデバイスに保存します。オフのときは、未保存の作業を残して移動する前に確認します。クラウドの作戦はどちらでも同期されます。", + "settingsDiscordPresenceTitle": "Discord Rich Presence", + "settingsDiscordPresenceDescription": "現在のマップと攻め・守りをDiscordのプロフィールに表示します。作戦名が共有されることはありません。", + "settingsMapVisibilityTitle": "マップの表示", + "settingsSpawnBarriersTitle": "スポーンバリア", + "settingsSpawnBarriersDescription": "ラウンド開始時のバリアの目安をマップに表示します。", + "settingsRegionNamesTitle": "エリア名", + "settingsRegionNamesDescription": "マップのエリア名をキャンバス上に直接表示します。", + "settingsUltOrbsTitle": "アルティメットオーブ", + "settingsUltOrbsDescription": "対応マップでオーブの位置を表示します。", + "settingsCaptionsTitle": "キャプション", + "settingsCaptionsDescription": "配置したアイテムに付けたメモを表示します。このデバイスのみ。", + "settingsBackgroundDotsTitle": "背景のドット", + "settingsBackgroundDotsDescription": "マップとライブラリの背後に表示するドットの濃さです。0%で非表示になります。", + "settingsViewConesTitle": "視界コーン", + "settingsViewConesDescription": "マップ上の視界コーンの濃さです。スクリーンショットと動画にも反映されます。", + "settingsPercentValue": "{percent}%", + "settingsAnalyticsTitle": "匿名の利用状況データ", + "settingsAnalyticsDescription": "アプリの起動といくつかの機能の利用状況を送信します。作戦の内容や個人情報は送信しません。", + "settingsAccountSignedInDescription": "クラウドのアカウントと同期の接続状況です。", + "settingsAccountSignedOutDescription": "ログインすると、作戦をクラウドに同期して共有できます。", + "settingsAccountNotSignedIn": "ログインしていません", + "settingsSyncNeedsNewerIcarus": "クラウド同期には新しいIcarusが必要です", + "settingsSyncActive": "クラウド同期は有効です", + "settingsSyncConnecting": "クラウドに接続中…", + "settingsSyncOffline": "オフラインです。オンラインに戻るとクラウド同期を再開します", + "settingsSyncNeedsAttention": "クラウド接続を確認してください", + "settingsSyncInactive": "クラウド同期は無効です", + "settingsCopyVersionTooltip": "バージョンをコピー", + "settingsVersionCopied": "バージョンをコピーしました", + "settingsKeybindsRestoreDefaults": "デフォルトに戻す", + "settingsKeybindsSearchHint": "操作やキーを検索…", + "settingsKeybindsActionColumn": "操作", + "settingsKeybindsBindingColumn": "キー", + "settingsKeybindsResetTooltip": "デフォルトに戻す", + "settingsKeybindsCapturePrompt": "新しいショートカットを押してください…", + "settingsKeybindsCaptureHelp": "Escでキャンセル。CtrlとCmdは、OSに合わせて動く1つのメイン修飾キーとして保存されます。", + "settingsKeybindsAlreadyBound": "{binding}はすでに「{action}」に割り当てられています。", + "settingsKeybindsNoMatches": "一致するショートカットはありません。", + "settingsKeybindsSnapToMap": "ドラッグ中にマップの線に吸着", + "settingsKeybindsHoldAlt": "Altを長押し", + "shortcutUndo": "元に戻す", + "shortcutRedo": "やり直す", + "shortcutDraw": "描画", + "shortcutEraser": "消しゴム", + "shortcutAddText": "テキストを追加", + "shortcutNavigation": "選択・移動", + "shortcutToggleAgentFilter": "エージェントフィルターの切り替え", + "shortcutNextPage": "次のページ", + "shortcutPreviousPage": "前のページ", + "shortcutAddPage": "ページを追加", + "shortcutAddLineup": "定点を追加", + "shortcutSwitchSide": "攻守を切り替え", + "shortcutSwitchSideThisPage": "攻守を切り替え(このページ)", + "shortcutOpenDeleteMenu": "削除メニューを開く", + "shortcutSelectAll": "すべて選択", + "shortcutRotateClockwise": "右に回転", + "shortcutRotateCounterclockwise": "左に回転", + "shortcutSaveStrategy": "作戦を保存", + "shortcutPaste": "貼り付け", + "shortcutOpenDebugLog": "デバッグログを開く", + "mapThemeTitle": "マップテーマ", + "mapThemeCustomCount": "カスタム{count}/{limit}", + "mapThemeCustomRowTitle": "カスタム", + "mapThemeThisStrategyOnlyTag": "この作戦のみ", + "mapThemeSaveAsProfile": "プロファイルとして保存", + "mapThemeDefaultTag": "デフォルト", + "mapThemeBuiltInTag": "標準", + "mapThemeNewProfile": "新しいプロファイル", + "mapThemeImportProfileCodeAction": "プロファイルコードをインポート", + "mapThemeProfileAdded": "{name}を追加しました", + "mapThemeUseIt": "使用する", + "mapThemeCustomize": "カスタマイズ", + "mapThemeSetAsDefault": "デフォルトに設定", + "mapThemeEditColors": "色を編集", + "mapThemeCopyProfileCode": "プロファイルコードをコピー", + "mapThemeRenameFailed": "このプロファイルの名前を変更できませんでした。", + "mapThemeCopyFailed": "プロファイルコードをコピーできませんでした。", + "mapThemeCodeCopied": "プロファイルコードをコピーしました", + "mapThemeDefaultUpdated": "デフォルトのプロファイルを更新しました。", + "mapThemeProfileDeleted": "プロファイルを削除しました。", + "mapThemeRenameDialogTitle": "プロファイル名を変更", + "mapThemeProfileNameHint": "プロファイル名", + "mapThemeImportDialogTitle": "プロファイルコードをインポート", + "mapThemeImportDialogDescription": "共有されたコードを貼り付けると、そのマップの色がプロファイルに追加されます。", + "mapThemeAddProfile": "プロファイルを追加", + "mapThemeCodeHint": "プロファイルコードを貼り付け", + "mapThemeCodeInvalid": "Icarusのプロファイルコードではありません。", + "mapThemeCodeIncomplete": "コードが不完全です。コード全体をコピーして、もう一度貼り付けてください。", + "mapThemeCodeNewerVersion": "このコードは新しいバージョンのIcarusで作成されています。インポートするにはIcarusをアップデートしてください。", + "mapThemeCodeDuplicate": "この色は「{name}」としてすでに登録済みです。", + "mapThemeCodePastedFromClipboard": "クリップボードから貼り付けました。", + "mapThemeAddFailed": "このプロファイルを追加できませんでした。", + "mapThemePreviewEmpty": "コードを貼り付けると、ここに色が表示されます。", + "mapThemePreviewSemantics": "プロファイルコードのプレビュー", + "mapThemeDiscardCustomTitle": "カスタムの色を破棄しますか?", + "mapThemeDiscardCustomBody": "この作戦のカスタムの色は「{name}」に置き換えられ、元に戻せません。", + "mapThemeProfileWasDeleted": "{name}は削除されています。", + "mapSelectorOutOfRotation": "ローテーション外", + "mapSelectorAttack": "攻め", + "mapSelectorDefense": "守り", + "mapSelectorSideTooltip": "全ページの攻守を切り替え\nShift+クリック:このページのみ", + "mapSelectorSideTooltipMixedToAttack": "ページによって攻守が異なります\nクリック:全ページを攻めに\nShift+クリック:このページのみ", + "mapSelectorSideTooltipMixedToDefense": "ページによって攻守が異なります\nクリック:全ページを守りに\nShift+クリック:このページのみ", + "mouseWatchDeleteOrigin": "立ち位置を削除", + "newPageCopyNotSaved": "新しいページをこのPCに保存できなかったため、追加されませんでした。", + "newPageCopyWaitingForCloud": "新しいページはまだクラウドに届いていません。届くと表示されます。", + "newPageCopyUnsavedEdits": "コピー元のページで保存されていない変更は、コピーに含まれません。", + "pagesBarRenameTitle": "ページ名を変更", + "pagesBarRenameDescription": "ページの新しい名前を入力してください:", + "pagesBarAddPageTooltip": "ページを追加", + "pagesBarRecentlyDeletedTooltip": "最近削除した項目", + "pagesBarPageDeleted": "「{name}」を削除しました", + "pagesBarUndo": "元に戻す", + "pagesBarRestored": "「{name}」を復元しました。", + "pagesBarRestoredNotOpenedListed": "「{name}」を復元しましたが、開けませんでした。ページ一覧にあります。", + "pagesBarRestoredNotOpened": "「{name}」を復元しましたが、開けませんでした。", + "pagesBarRestoreGone": "「{name}」はもう復元できません。", + "pagesBarRestoreUnconfirmed": "「{name}」を復元できたか確認できませんでした。接続したら、ページ一覧か「最近削除した項目」を確認してください。", + "pagesBarRestoreUnconfirmedLeft": "「{name}」を復元できたか確認できませんでした。接続したら、ページ一覧かその作戦の「最近削除した項目」を確認してください。", + "pagesBarRestoreUnconfirmedIn": "「{strategy}」で「{name}」を復元できたか確認できませんでした。接続したら、ページ一覧かその作戦の「最近削除した項目」を確認してください。", + "strategyBadgeDeviceOnly": "このデバイスのみ", + "strategyBadgeDeviceOnlyTooltip": "このPCにのみ保存されています", + "strategyBadgeNeedsConnection": "接続が必要", + "strategyBadgeNeedsConnectionTooltip": "初めて開くときは接続が必要です", + "strategyBadgeMovingToCloud": "クラウドに移動中", + "strategyBadgeMovingToCloudTooltip": "すべてクラウドに移るまで、このPCにも残ります", + "strategyBadgeSharedCanEdit": "共有 · 編集可", + "strategyBadgeSharedCanEditTooltip": "あなたに共有されています。編集できます。", + "strategyBadgeSharedViewOnly": "共有 · 閲覧のみ", + "strategyBadgeSharedViewOnlyTooltip": "あなたに共有されています。編集するにはオーナーに権限を依頼してください。", + "strategyAccessChangedToViewOnly": "この作戦へのアクセス権が閲覧のみに変更されました。", + "strategyAccessCanOpenAgain": "この作戦をまた開けるようになったため、何も破棄されていません。", + "strategyAccessCouldNotConfirm": "アクセス権がなくなったか確認できなかったため、何も破棄されていません。", + "strategyAccessDiscardFailed": "すべてを破棄できませんでした。ライブラリからもう一度試してください。", + "strategyAccessLostTitle": "アクセス権がなくなりました", + "strategyAccessLostBodyNamed": "オーナーが「{name}」からあなたを削除したか、共有を停止しました。{unsent, plural, =0{} other{ここで行った{unsent}件の変更はクラウドに届いておらず、保存できません。}}", + "strategyAccessLostBodyUnnamed": "オーナーがこの作戦からあなたを削除したか、共有を停止しました。{unsent, plural, =0{} other{ここで行った{unsent}件の変更はクラウドに届いておらず、保存できません。}}", + "strategyAccessBackToLibrary": "ライブラリに戻る", + "strategyPresenceViewing": "{name}(閲覧中)", + "quickSwitcherNameEmpty": "作戦名を入力してください。", + "quickSwitcherCannotRename": "この作戦の名前は変更できません。", + "quickSwitcherUntitled": "無題の作戦", + "quickSwitcherNoRecent": "最近の作戦はありません", + "quickSwitcherLoadToRename": "名前を変更するには作戦を開いてください", + "quickSwitcherRenameTooltip": "作戦名を変更", + "quickSwitcherViewOnlyTooltip": "この作戦は閲覧のみで、名前は変更できません", + "strategyTileSideAttack": "攻め", + "strategyTileSideDefend": "守り", + "strategyTileSideMixed": "混合", + "strategyTileSideUnknown": "不明", + "strategyTileEditedJustNow": "たった今", + "strategyTileEditedMinutesAgo": "{count, plural, other{{count}分前}}", + "strategyTileEditedHoursAgo": "{count, plural, other{{count}時間前}}", + "strategyTileEditedDaysAgo": "{count, plural, other{{count}日前}}", + "strategyTileEditedMonthsAgo": "{count, plural, other{{count}か月前}}", + "strategyTileMoreActions": "{name}のその他の操作", + "strategyTilePin": "ピン留め", + "strategyTileUnpin": "ピン留めを外す", + "strategyTileDuplicate": "複製", + "strategyTileMoveToCloud": "クラウドに移動", + "saveButtonFailed": "この作戦を保存できませんでした。もう一度試してください。", + "saveButtonSaved": "保存しました", + "saveButtonEditNotSynced": "編集はまだ同期されていません", + "saveButtonSyncingMedia": "このデバイスに保存しました。メディアは同期中です。", + "saveButtonSyncing": "このデバイスに保存しました。同期中…", + "saveButtonMediaNeedsRetry": "このデバイスに保存しました。メディアの同期を再試行してください。", + "saveButtonNeedsAttention": "このデバイスに保存しました。同期を確認してください。", + "saveButtonViewOnlyTooltip": "閲覧のみ", + "strategyViewSkeletonMapSemantics": "マップ", + "stripWhatsNewTooltip": "新機能", + "stripUpdateAvailableTooltip": "アップデートがあります", + "touchViewerLibrary": "ライブラリ", + "touchViewerViewOnly": "閲覧のみ", + "touchViewerAddToLibrary": "ライブラリに追加", + "turnPhoneTitle": "編集するにはスマホを横向きにしてください", + "turnPhoneRotationLockHint": "変わらない場合は、画面の回転ロックがオフになっているか確認してください。", + "turnPhoneBackToLibrary": "マイライブラリに戻る", + "webBetaTagSemantics": "IcarusのWebベータについて", + "webBetaTag": "ベータ", + "youtubeNotAVideo": "このリンクはYouTubeの動画ではありません。", + "pagesBarAutoName": "ページ{number}", + "settingsLanguage": "言語", + "settingsLanguageTitle": "アプリの言語", + "settingsLanguageDescription": "ここで選ばない限り、Icarusはシステムの言語に従います。", + "settingsLanguageSystem": "システムの既定", + "sidebarAgentsTitle": "エージェント", + "sidebarShowAllAgentsTooltip": "すべてのエージェントを表示", + "sidebarShowFavoriteAgentsTooltip": "お気に入りのエージェントのみ表示", + "sidebarFavoritesOnlyIndicator": "絞り込み:お気に入りのみ", + "sidebarNoAgentAvailable": "該当するエージェントがいません", + "sidebarToolsTitle": "ツール", + "sidebarDrawTooltip": "描画", + "sidebarEraserTooltip": "消しゴム", + "sidebarAddTextTooltip": "テキストを追加", + "sidebarAddImageTooltip": "画像を追加", + "sidebarImageUploadFailed": "アップロードに失敗しました", + "sidebarAddLineupTooltip": "定点を追加", + "sidebarVisionConeToolsTooltip": "視界コーンツール", + "sidebarCustomShapesTooltip": "図形", + "sidebarAgentRolesTooltip": "エージェントロール", + "sidebarTagColorLabel": "タグの色", + "sidebarPlaceTextButton": "+ テキストを配置", + "sidebarDragOrClickToPlace": "ドラッグかクリックで配置", + "sidebarDragToPlace": "ドラッグで配置", + "sidebarAgentRoleIconsTitle": "ロールアイコン", + "sidebarVisionConesTitle": "視界コーン", + "sidebarViewConePresetTooltip": "視界コーン{degrees}°", + "sidebarFilterAll": "すべて", + "sidebarFilterOnMap": "マップ上", + "sidebarFilterRole": "ロール", + "sidebarTeamAlly": "味方", + "sidebarTeamEnemy": "敵", + "sidebarAddColorTitle": "色を追加", + "sidebarEditColorTitle": "色を編集", + "sidebarDeleteAllTooltip": "すべて削除", + "sidebarDeleteAllButton": "すべて削除", + "sidebarClearAgentsTooltip": "エージェントをクリア", + "sidebarClearAbilitiesTooltip": "アビリティをクリア", + "sidebarClearDrawingsTooltip": "描画をクリア", + "sidebarClearTextTooltip": "テキストをクリア", + "sidebarClearImagesTooltip": "画像をクリア", + "sidebarClearUtilitiesTooltip": "ユーティリティをクリア", + "drawingToolsColorLabel": "色", + "drawingToolsShapeLabel": "図形", + "drawingToolsFreeDrawTooltip": "フリーハンド", + "drawingToolsStraightLineTooltip": "直線", + "drawingToolsRectangleTooltip": "長方形", + "drawingToolsEllipseTooltip": "楕円", + "drawingToolsStrokeLabel": "線", + "drawingToolsDottedLineTooltip": "点線", + "drawingToolsArrowTooltip": "矢印", + "drawingToolsThicknessLabel": "太さ", + "drawingToolsTraversalTimeLabel": "移動時間", + "drawingToolsRunningTooltip": "ナイフ装備で走る", + "drawingToolsWalkingTooltip": "ナイフ装備で歩く", + "drawingToolsAbilityWithKnifeTooltip": "{abilityName}(ナイフ装備)", + "drawingToolsAgentRunTooltip": "{agentName}のスプリント", + "shapeToolsTitle": "図形", + "shapeToolsDiameterLabel": "直径", + "shapeToolsDiameterShortLabel": "径", + "shapeToolsDiameterHint": "直径を入力", + "shapeToolsDimensionsLabel": "サイズ", + "shapeToolsLengthShortLabel": "長", + "shapeToolsLengthHint": "長さを入力", + "shapeToolsWidthShortLabel": "幅", + "shapeToolsWidthHint": "幅を入力", + "shapeToolsOpacityLabel": "不透明度", + "shapeToolsOpacityHint": "不透明度を入力", + "shapeToolsColorLabel": "色", + "canvasItemTextPlaceholder": "ここに入力...", + "canvasItemPlacedTextSemanticLabel": "配置したテキスト", + "shapeToolsRotationShortLabel": "回転", + "shapeToolsColorMenu": "色", + "shapeToolsCustomColorTitle": "カスタムカラー", + "shapeToolsCustomColorMenu": "カスタムカラー…", + "shapeToolsColorWhite": "白", + "shapeToolsColorRed": "赤", + "shapeToolsColorBlue": "青", + "shapeToolsColorYellow": "黄", + "shapeToolsColorGreen": "緑", + "shapeToolsSavedColor": "#{hex}(保存済み)", + "agentMenuAddLineup": "定点を追加", + "agentMenuEditPlacement": "配置を編集", + "agentMenuDeleteOrigin": "立ち位置を削除", + "agentMenuDetachViewCone": "視界コーンを切り離す", + "agentMenuRemoveViewCone": "視界コーンを削除", + "agentMenuDetachCircle": "円を切り離す", + "agentMenuRemoveCircle": "円を削除", + "agentMenuCreateLineup": "定点を作成", + "agentMenuWeapon": "武器", + "agentMenuNoWeapon": "なし", + "abilityMenuCloseStackSelectorLabel": "定点アビリティの選択を閉じる", + "abilityMenuCloseContextMenuLabel": "定点アビリティのメニューを閉じる", + "abilityMenuRemoveWall": "壁を削除", + "abilityMenuDrawWall": "壁を描く", + "abilityMenuAddLineupHere": "ここに定点を追加", + "abilityMenuShowLineups": "定点一覧を表示", + "abilityMenuEditMedia": "メディアを編集", + "abilityMenuEditPlacement": "配置を編集", + "abilityMenuDeleteSpot": "着弾点を削除", + "abilityMenuDeleteLineup": "定点を削除", + "abilityMenuVisionCone": "視界コーン", + "abilityMenuActive": "アクティブ", + "abilityMenuRangeOutline": "範囲の輪郭", + "abilityMenuInnerOutline": "内側の輪郭", + "abilityMenuInnerFill": "内側の塗り", + "abilityMenuRangeFill": "範囲の塗り", + "abilityMenuMesh": "メッシュ", + "abilityMenuRange": "範囲", + "canvasItemCreateCaption": "キャプションを作成", + "canvasItemEditCaption": "キャプションを編集", + "canvasItemResetTagToGray": "タグをグレーに戻す", + "canvasItemGreenTag": "緑のタグ", + "canvasItemBlueTag": "青のタグ", + "canvasItemAmberTag": "アンバーのタグ", + "canvasItemRedTag": "赤のタグ", + "canvasItemPurpleTag": "紫のタグ", + "canvasItemCustomColorTag": "#{hex}のカスタムタグ", + "canvasItemDefaultColorTag": "#{hex}のデフォルトタグ", + "canvasItemCopyToPreviousPage": "前のページにコピー", + "canvasItemCopyToNextPage": "次のページにコピー", + "canvasItemNextPageAlreadyHasIt": "次のページにはすでにあります。", + "canvasItemPreviousPageAlreadyHasIt": "前のページにはすでにあります。", + "canvasItemCopyUnreachable": "クラウドに接続できなかったため、コピーされませんでした。", + "canvasItemCopyNotSaved": "このデバイスにコピーを保存できなかったため、コピーされませんでした。", + "canvasItemImageSyncing": "画像を同期中", + "canvasItemImageUnavailable": "画像を表示できません", + "canvasItemBoldTooltip": "太字({shortcut})", + "canvasItemItalicTooltip": "斜体({shortcut})", + "canvasItemBulletListTooltip": "箇条書き", + "canvasItemNumberedListTooltip": "番号付きリスト", + "canvasItemHeadingTooltip": "見出し", + "canvasItemViewElevationMenu": "目線の高さ", + "canvasItemViewElevationGround": "地面", + "canvasItemViewElevationAutoTrailing": "{surface}(自動)", + "canvasItemViewElevationAutoUnmatchedTrailing": "{surface}(自動*)", + "canvasItemViewElevationAutomatic": "自動({surface})", + "canvasItemViewElevationAutomaticUnavailable": "自動(保存した高さがありません)", + "canvasItemViewElevationGroundHeight": "地面({height})", + "canvasItemViewElevationSurfaceHeight": "{surface}({height})", + "replayBackToLibraryTooltip": "リプレイ", + "replayReading": "リプレイを読み込み中", + "replayOpenThisFailed": "このリプレイを開けませんでした", + "replayMapNotSupported": "このリプレイのマップはまだIcarusにありません。", + "replayBackToReplays": "リプレイ一覧に戻る", + "replayOpenFailed": "リプレイを開けませんでした。", + "replayRoundNumber": "ラウンド{number}", + "replayDockPinStoryTooltip": "ラウンドの流れを固定", + "replayDockUnpinStoryTooltip": "ラウンドの流れの固定を解除", + "replayDockOvertimeMark": "延長", + "replayDockPlaybackSpeedTooltip": "再生速度", + "replayLibraryListFailed": "リプレイを読み込めませんでした", + "replayLibraryEmptyTitle": "リプレイはまだありません", + "replayLibraryEmptyHint": "Valorantのキャリアページから試合をダウンロードするか、ここに.vrfファイルをドロップしてください", + "replayLibraryKept": "Icarusに保管しました。Valorantに削除されることはありません。", + "replayLibraryKeepFailed": "このリプレイを保管できませんでした。", + "replayLibraryRemoveTitle": "このリプレイを削除しますか?", + "replayLibraryRemoveValorantCopyBody": "Icarusのコピーをごみ箱に移動します。Valorant側にも同じリプレイがあるため、Valorantが削除するまで一覧に残ります。", + "replayLibraryRemoveBody": "リプレイファイルをごみ箱に移動します。このリプレイからキャプチャしたページは作戦に残ります。", + "replayLibraryRemoveConfirm": "削除", + "replayLibraryRecycleBinDeleted": "Windowsはこのリプレイをごみ箱に移動せず、完全に削除しました。", + "replayLibraryRecycleBinFailed": "Windowsがこのリプレイをごみ箱に移動できなかったため、Icarusはそのまま残しました。", + "replayLibraryRemoveFileChanged": "削除を選んだ後にこのリプレイが変更されました。何も削除されていません。", + "replayLibraryRemoveNotOwned": "Icarusが削除できるのは自身のフォルダー内のリプレイだけです。何も削除されていません。", + "replayLibraryRemoveFailed": "このリプレイを削除できませんでした。", + "replayLibraryKeepInIcarus": "Icarusに保管", + "replayLibraryRemoveFromIcarus": "Icarusから削除", + "replayLibraryNotAReplay": "このファイルはValorantのリプレイではありません。", + "replayLibraryPatchUnsupported": "パッチ{patch}のリプレイはまだ読み込めません。今後のIcarusのアップデートで対応します。", + "replayLibraryUnreadableFile": "読み込めないファイル", + "replayLibraryUnknownMap": "不明なマップ", + "replayLibraryNotAReplayStatus": "リプレイ以外", + "replayLibraryPatchStatus": "パッチ{patch}", + "replayLibraryKeepTooltip": "Valorantのリプレイフォルダーにしかなく、Valorantに消去される場合があります。\nIcarusにコピーを保管します。", + "replayLibraryKeepButton": "保管", + "replayLibraryAddFailed": "リプレイを追加できませんでした。", + "replayLibraryAdded": "{count, plural, other{{count}件のリプレイ}}を追加しました。", + "replayLibraryAlreadyListed": "{count, plural, other{{count}件の試合はすでに「リプレイ」にあります。}}", + "replayLibrarySomeAlreadyListed": "{count, plural, other{{count}件はすでに「リプレイ」にありました。}}", + "replayLibraryUnreadableOne": "「{fileName}」はIcarusで読み込めるリプレイではありません。", + "replayLibraryUnreadableMany": "{count, plural, other{Icarusで読み込めるリプレイではないファイル{count}件をスキップしました。}}", + "replayLibraryCopyFailedOne": "「{fileName}」をコピーできませんでした。", + "replayLibraryCopyFailedMany": "{count, plural, other{{count}件のリプレイをコピーできませんでした。}}", + "replayLibraryDropToAdd": "リプレイを追加", + "replayMatchCaptureFailed": "この場面をキャプチャできませんでした。", + "replayMatchStrategyDeleted": "この作戦は削除されたため、開けません。", + "replayMatchStrategyMapChanged": "この作戦は別のマップに変更されたため、リプレイのキャプチャを追加できません。", + "replayMatchCaptureUnsaved": "保存できなかったキャプチャがあるため、まだ作戦を開けません。先に再試行するか破棄してください。", + "replayMatchBackToReplay": "リプレイに戻る", + "replayMatchRetryFailed": "キャプチャを再試行できませんでした。", + "replayMatchDiscardFailed": "キャプチャを破棄できませんでした。", + "replayMatchDiscarded": "破棄しました。ライブラリにまだ表示される場合があります。", + "replayMatchWarmup": "ウォームアップ", + "replayMatchAttack": "攻め", + "replayMatchDefense": "守り", + "replayMatchCapture": "キャプチャ", + "replayMatchDrawTooltip": "描画({shortcut})", + "replayMatchStopDrawingTooltip": "描画を終了({shortcut})", + "replayMatchEraserTooltip": "消しゴム({shortcut})", + "replayMatchStopErasingTooltip": "消しゴムを終了({shortcut})", + "replayMatchSwitchSideTooltip": "相手チーム側から見る", + "replayMatchPagesCaptured": "{count, plural, other{{count}ページをキャプチャ済み}}", + "replayMatchOpenStrategy": "開く", + "replayMatchSending": "送信中…", + "replayMatchCloudSaved": "クラウドのライブラリに保存済み", + "replayMatchCloudUpdateRequired": "{count}件の保存を完了するにはIcarusのアップデートが必要です", + "replayMatchCloudWaiting": "{count}件が接続待ち", + "replayMatchCloudUnconfirmed": "{count}件を送信中。前回の送信を確認できなかったため、再試行しています。", + "replayMatchCloudSending": "{count}件を送信中", + "replayMatchCapturesFailed": "{count, plural, other{{count}件のキャプチャを保存できませんでした}}", + "replayMatchFailureDeleted": "作戦が削除されました。", + "replayMatchFailureMapChanged": "作戦が別のマップに変更されました。", + "replayMatchFailurePageDeleted": "ページが削除されました。", + "replayMatchFailureRefused": "クラウドに拒否されました。", + "replayRosterAllies": "味方", + "replayRosterEnemies": "敵", + "replayRosterAlive": "生存{count}人", + "replayRosterWatchTooltip": "{name}の視点で見る", + "replayRosterWatchWithKeyTooltip": "{name}の視点で見る({key})", + "replayRosterStopWatchingTooltip": "{name}の視点を解除", + "replayRosterStopWatchingWithKeyTooltip": "{name}の視点を解除({key})", + "replayRosterFollowTooltip": "{name}を追従", + "replayRosterFollowWithKeyTooltip": "{name}を追従({key})", + "replayRosterUnknownAgent": "不明なエージェント", + "replayRosterFilterKills": "キル", + "replayRosterFilterAbilities": "アビリティ", + "replayRosterNoKills": "このラウンドのキルはまだありません", + "replayRosterNoPlantOrAbilities": "このラウンドの設置やアビリティ使用はまだありません", + "replayRosterNoPlant": "このラウンドの設置はまだありません", + "replayRosterNoAbilities": "このラウンドのアビリティ使用はまだありません", + "replayRosterNothingChosen": "表示する項目を選択", + "strategyViewMapSemanticLabel": "マップ", + "strategyViewBarrierSemanticLabel": "スポーンバリア", + "strategyViewCalloutsSemanticLabel": "エリア名", + "strategyViewUltOrbsSemanticLabel": "アルティメットオーブ", + "strategyViewLoadFailed": "作戦を読み込めませんでした。", + "strategyViewShareLinkDisabled": "この共有リンクはオーナーによって無効にされました。", + "strategyViewBackToLibraryTooltip": "ライブラリ", + "strategyViewDiscordPrompt": "バグ報告はDiscordへ", + "appImportFailed": "作戦ファイルをインポートできませんでした。", + "screenshotImagesStillLoading": "このページの画像を読み込み中です。しばらくしてからもう一度お試しください。", + "screenshotImagesLoadFailed": "このページの画像を読み込めませんでした。接続を確認して、もう一度お試しください。", + "authMsgDisplayNameFallback": "Discordユーザー", + "authMsgStreamError": "認証ストリームのエラー:{error}", + "authMsgDiscordSignInFailed": "Discordでのログインに失敗しました:{error}", + "authMsgSignInIncomplete": "ログインが完了しませんでした。もう一度お試しください。", + "authMsgIncorrectCredentials": "メールアドレスまたはパスワードが正しくありません。", + "authMsgEmailNotConfirmed": "先にメールアドレスを確認してください。受信トレイに届いた確認リンクを開いてください。", + "authMsgTooManyAttempts": "試行回数が多すぎます。しばらく待ってからもう一度お試しください。", + "authMsgServerUnreachable": "サーバーに接続できませんでした。接続を確認して、もう一度お試しください。", + "authMsgSignInFailed": "ログインに失敗しました。もう一度お試しください。", + "authMsgSignUpAccountExists": "このメールアドレスのアカウントはすでに存在します。ログインしてください。", + "authMsgSignUpAccountExistsMaybeDiscord": "このメールアドレスのアカウントはすでに存在します。ログインするか、Discordで登録した場合はDiscordで続行してください。", + "authMsgConfirmationEmailUnavailable": "現在、確認メールを送信できません。後でもう一度試すか、Discordで続行してください。", + "authMsgSignUpFailed": "新規登録に失敗しました。もう一度お試しください。", + "authMsgPasswordResetAlreadySentBrowser": "リンクはすでに送信済みです。受信トレイと迷惑メールを確認し、5分以内にこのブラウザーで開いてください。", + "authMsgPasswordResetAlreadySentComputer": "リンクはすでに送信済みです。受信トレイと迷惑メールを確認し、5分以内にこのPCで開いてください。", + "authMsgPasswordResetSentBrowser": "このメールアドレスのアカウントがある場合、新しいパスワードを設定するリンクを送信しました。5分以内にこのブラウザーで開いてください。", + "authMsgPasswordResetSentComputer": "このメールアドレスのアカウントがある場合、新しいパスワードを設定するリンクを送信しました。5分以内にこのPCで開いてください。", + "authMsgPasswordResetRateLimited": "先ほどリンクを送信しました。1分待ってから、もう一度お試しください。", + "authMsgPasswordResetFailed": "メールを送信できませんでした。もう一度お試しください。", + "authMsgNewPasswordSignedOut": "ログアウトされました。「ログイン」から新しいリセットリンクをリクエストしてください。", + "authMsgNewPasswordSame": "現在のパスワードと同じです。別のパスワードを選んでください。", + "authMsgNewPasswordWeak": "もっと長いパスワードを選んでください。", + "authMsgNewPasswordFailed": "パスワードを保存できませんでした。もう一度お試しください。", + "authMsgSignOutFailed": "ログアウトに失敗しました。もう一度お試しください。", + "authMsgSignedOutLocalKept": "ログアウトしました。ローカルの作戦はこのデバイスに残ります。", + "authMsgSignedOut": "ログアウトしました。", + "authMsgLinkIgnoredWhileSignedIn": "すでにログインしているため、そのリンクは使用されませんでした。新しいパスワードを設定するには、ログアウトして新しいリンクをリクエストしてください。", + "authMsgSignInLinkFailed": "ログインを完了できませんでした。もう一度お試しください。", + "authMsgCloudSessionExpired": "クラウドのセッションが期限切れになりました。再接続して同期を再開するか、ログアウトしてください。", + "authMsgCloudSyncConnectFailed": "クラウド同期に接続できませんでした。再試行してください。", + "authMsgIncidentTitle": "クラウドとの接続が切れました", + "authMsgIncidentDescription": "クラウドのセッションが無効になったため、同期を一時停止しています。再接続してクラウドの作戦を最新の状態に保つか、ログアウトしてください。", + "authMsgIncidentDescriptionWithLocal": "クラウドのセッションが無効になったため、同期を一時停止しています。再接続してクラウドの作戦を最新の状態に保つか、ログアウトしてください。ローカルの作戦には影響しません。", + "authMsgIncidentNotNow": "後で", + "updateMsgUpdateAvailable": "アップデートがあります", + "updateMsgNewVersionAvailable": "{appName} {version}が利用可能です", + "updateMsgNewVersionLong": "デスクトップ版のアップデートの準備ができました。ダウンロードするファイルは{size} MBです。", + "updateMsgDownload": "アップデートをダウンロード", + "updateMsgRestartToUpdate": "再起動してアップデート", + "updateMsgLater": "後で", + "updateMsgRestartRequiredTitle": "再起動が必要です", + "updateMsgRestartWarning": "アップデートのインストールを完了するには、Icarusを再起動する必要があります。保存していない変更は失われます。今すぐ再起動しますか?", + "updateMsgRestartCancel": "後で", + "updateMsgRestartConfirm": "再起動", + "shareMsgMissingCode": "この共有リンクには共有コードがありません。", + "shareMsgFolderAdded": "共有フォルダーをライブラリに追加しました。", + "shareMsgStrategyAdded": "共有された作戦をライブラリに追加しました。", + "shareMsgRedeemFailed": "共有リンクを追加できませんでした。", + "shareMsgSignInForFolder": "この共有フォルダーを開くにはログインしてください。", + "shareMsgLinkDisabled": "この共有リンクはオーナーによって無効にされました。", + "shareMsgLinkNotFound": "この共有リンクは存在しません。", + "shareMsgOpenStrategyFailed": "共有された作戦を開けませんでした。接続を確認して、ページを再読み込みしてください。", + "shareMsgOpenLinkFailed": "共有リンクを開けませんでした。接続を確認して、もう一度お試しください。", + "folderMsgCreateCloudFailed": "クラウドのフォルダーを作成できませんでした。接続を確認して、もう一度お試しください。", + "folderMsgDeleteCloudFailed": "このクラウドのフォルダーを削除できませんでした。もう一度お試しください。", + "folderMsgDeleteWhileMoving": "このフォルダー内の項目をクラウドに移動中です。移動が終わってから削除してください。", + "folderMsgUpdateCloudFailed": "このクラウドのフォルダーを更新できませんでした。もう一度お試しください。", + "folderMsgMoveCloudFailed": "このクラウドのフォルダーを移動できませんでした。もう一度お試しください。", + "strategyMsgCreatedNotOpened": "作戦を作成しましたが、開けませんでした。クラウドのライブラリから開いてください。", + "strategyMsgTooLargeToDuplicate": "この作戦は大きすぎるため複製できません。", + "strategyMsgDuplicateFailed": "この作戦を複製できませんでした。もう一度お試しください。", + "strategyMsgDuplicateImagesFailed": "この作戦を複製できませんでした:画像をコピーできませんでした。", + "strategyMsgDuplicatedMissingPictures": "{count, plural, other{複製しました。元の作戦で画像{count}枚がすでに見つからなかったため、コピーにもその画像はありません。}}", + "strategyMsgDeleteCloudFailed": "このクラウドの作戦を削除できませんでした。もう一度お試しください。", + "strategyMsgDeleteWhileMoving": "この作戦はクラウドに移動中です。移動が終わってから削除してください。", + "strategyMsgMoveCloudFailed": "このクラウドの作戦を移動できませんでした。もう一度お試しください。", + "strategyMsgMoveFailed": "この作戦を移動できませんでした。もう一度お試しください。", + "strategyMsgPageUnreachable": "「{pageName}」を開けませんでした。接続を確認して、もう一度お試しください。", + "strategyMsgPageUnreachableUnnamed": "そのページを開けませんでした。接続を確認して、もう一度お試しください。", + "strategyMsgDeletedPageFallbackName": "このページ", + "prefsMsgSortAlphabetical": "名前順", + "prefsMsgSortDateCreated": "作成日", + "prefsMsgSortDateUpdated": "更新日", + "prefsMsgSortAscending": "昇順", + "prefsMsgSortDescending": "降順", + "authMsgSignUpCheckInbox": "アカウントを作成しました。受信トレイの確認リンクを開いてから、ログインしてください。", + "authMsgSignUpAlreadySent": "このメールアドレスには確認メールを送信済みです。受信トレイと迷惑メールを確認して、リンクを開いてください。", + "syncErrorClientUpgradeWeb": "Icarusがアップデートされました。同期を続けるには再読み込みしてください。", + "syncErrorClientUpgradeAvailable": "Icarusがアップデートされました。同期を続けるにはアップデートをインストールしてください。", + "syncErrorClientUpgradeChecking": "Icarusがアップデートされました。同期を続けるのに必要なアップデートを確認しています。", + "syncErrorClientUpgradeUnavailable": "Icarusがアップデートされましたが、このアプリ向けのアップデートはまだ利用できません。保存した作業はこのデバイスに残り、アップデートをインストールすると同期されます。", + "syncErrorHeldPageDeleteChanged": "「{pageName}」は削除した後に変更されています。確認してから削除してください。", + "syncErrorHeldPageDeleteUnchecked": "「{pageName}」にチームメイトの変更があるか確認できませんでした。確認してから削除してください。", + "syncErrorIncompletePageCopy": "「{pageName}」のコピーはこのPCへの保存が完了しなかったため、送信されていません。もう一度ページをコピーするか、「クラウド版を使用」を選んで破棄してください。", + "syncErrorIncompletePageCopyWithOtherWork": "「{pageName}」のコピーはこのPCへの保存が完了しなかったため、送信されていません。もう一度ページをコピーするか、「クラウド版を使用」を選んで破棄してください。ほかの保存済みの作業にも対応が必要です。", + "syncErrorStrategyDeleted": "この作戦は削除されたため、未送信の変更は保存できません。ライブラリから破棄してください。", + "syncErrorUnreadableSavedWork": "保存済みのクラウドの変更を読み込めませんでした。変更はこのデバイスに残っています。この作戦を開いたまま、送信待ちの作業を復旧してから続けてください。", + "syncErrorUnverifiedCloudWork": "この変更がこのデバイスに保存されたか確認できませんでした。何も送信されていません。この作戦を開いたまま再試行してください。", + "syncErrorEditAccessRemoved": "この作戦の編集権限がなくなりました。これらの変更はこのデバイスに残り、クラウドには保存されていません。", + "syncErrorForbidden": "このアカウントにはこれらの変更を保存する権限がありません。変更はこのデバイスに残っています。オーナーに編集権限をもらってから再試行してください。", + "syncErrorRetryPaused": "保存済みのクラウドの変更は、失敗が続いたため一時停止しています。接続とアカウントに問題がなくなったら再試行してください。", + "syncErrorPageTooLargeToCopy": "新しいページが大きすぎてクラウドでコピーできなかったため、追加されていません。「自分の変更を保持」で再試行し、「クラウド版を使用」で破棄します。", + "syncErrorTooLargeForCloudSync": "保存済みの変更がクラウド同期には大きすぎます。変更はこのデバイスに保存されたままです。小さくしてから「自分の変更を保持」で再試行するか、「クラウド版を使用」で破棄してください。", + "syncErrorLineupOverlap": "この定点は別々に保存された定点と位置が重なっているため、クラウドに保存されていません。このデバイスには残っています。「自分の変更を保持」で再送信しますが、位置が重なっている間は拒否されます。「クラウド版を使用」でこのデバイスから削除します。", + "syncErrorLineupPageMismatch": "この定点は別のページの定点と競合しているため、クラウドに保存されていません。このデバイスには残っています。「クラウド版を使用」でこのデバイスから削除します。", + "syncErrorTeammateDeletedCannotRestore": "この変更の対象はチームメイトが削除しました。この変更では元に戻せません。「クラウド版を使用」で破棄してください。", + "syncErrorTeammateDeleted": "変更した対象をチームメイトが削除しました。「自分の変更を保持」で手元の状態のまま復元し、「クラウド版を使用」で破棄します。", + "syncErrorRetiredLineupOp": "以前のバージョンのIcarusで保存された定点の変更はクラウドに送信できないため、この作戦には含まれていません。もう一度変更してから「クラウド版を使用」を選んで、この表示を消してください。", + "syncErrorUndrawableWork": "保存済みの変更が現在のクラウド上のこのページと合わなくなったため、送信されていません。このデバイスには残っています。「自分の変更を保持」でクラウド版を自分の版で丸ごと置き換え、「クラウド版を使用」で自分の版を破棄します。", + "syncErrorPageDeleted": "これらの変更があったページが削除されたため、保存されていません。変更はこのデバイスに残っています。「クラウド版を使用」で破棄します。", + "syncErrorConflict": "別の編集が先にクラウドに届きました。あなたの版はこのデバイスに保存されたままです。", + "syncErrorCannotRetryAutomatically": "保持中のこの編集に対応する現在のクラウドのリビジョンがサーバーで見つかりません。編集はこのデバイスに保存されたままです。", + "syncErrorSessionExpired": "クラウドのセッションを更新する必要があります。再試行するか、ライブラリからもう一度ログインしてください。", + "syncErrorUnreachable": "クラウドに接続できませんでした。", + "syncErrorStartingUp": "クラウド同期はまだ起動中です。", + "syncErrorGeneric": "一部の変更はまだクラウドに届いていません。", + "syncStatusOfflineNothingWaiting": "オフライン、変更はこのデバイスに残ります", + "syncStatusOfflineChangesSaved": "オフライン · {count, plural, other{{count}件の変更}}をこのPCに保存済み", + "syncStatusStrategyNeedsConnection": "この作戦を初めて開くにはインターネット接続が必要です。", + "mediaUploadFailedToast": "メディアのアップロードに失敗しました。「保存」を押してクラウド同期を再試行してください。", + "mediaUploadComplete": "画像のアップロードが完了しました", + "mediaUploadProgress": "{count, plural, other{画像{count}枚をアップロード中...}}", + "mediaUploadImageTooLarge": "この画像は15 MBを超えています。小さい画像を選んでください。", + "moveToCloudMoved": "「{name}」をクラウドに移動しました。", + "moveToCloudMovedToMyLibrary": "「{name}」をクラウドに移動しました。マイライブラリにあります。", + "moveToCloudNotMoved": "「{name}」をクラウドに移動できませんでした。このデバイスに残っています。", + "moveToCloudChangedMeanwhile": "「{name}」は移動中に変更されたため、このデバイスに残しています。最新版をクラウドに置くには、もう一度移動してください。", + "moveToCloudLocalCopyKept": "「{name}」をクラウドに移動しましたが、このデバイスのコピーを削除できませんでした。", + "moveToCloudChangedHere": "「{name}」をクラウドに移動しました。移動中に変更されたため、このデバイスにも最新版を残しています。", + "moveToCloudFoldersStayed": "「{name}」内の作戦をクラウドに移動しましたが、一部のフォルダーはこのデバイスに残っています。", + "moveToCloudSomeMoved": "「{name}」内の作戦{total}件のうち{moved}件を移動しました。残りはこのデバイスにあります。", + "moveToCloudFolderFailed": "「{name}」の移動を完了できませんでした。移動できなかったものはこのデバイスに残っています。", + "moveToCloudSignedOut": "ログアウトしたため、「{name}」はこのデバイスに残ります。", + "moveToCloudImporting": "「{name}」をクラウドにインポート中…", + "moveToCloudSignedOutRest": "ログアウトしたため、「{name}」の残りはこのデバイスに残ります。", + "moveToCloudOtherAccount": "別のアカウントでログインしたため、「{name}」はこのデバイスに残ります。", + "moveToCloudOffline": "現在クラウドに接続できないため、「{name}」はこのデバイスに残ります。オンラインになってからもう一度お試しください。", + "opQueueReplayCaptureFolderGone": "「{strategyName}」はマイライブラリの最上位にあります。キャプチャ先のフォルダーが削除されたか、あなたのものではありません。", + "importExportCloudUnreadable": "クラウドのライブラリを読み込めなかったため、何もエクスポートされていません。接続を確認して、もう一度お試しください。", + "importExportCloudNotSynced": "まだ同期されていないクラウドの変更があるため、何もエクスポートされていません。同期を待つか、ライブラリで変更を確認してから、もう一度お試しください。", + "importExportCloudWorkUnreadable": "このデバイスに保存された一部のクラウドの変更を読み込めないため、何もエクスポートされていません。変更を確認してから、もう一度お試しください。", + "importExportCloudChanged": "エクスポート中にクラウドのライブラリが変更されたため、何もエクスポートされていません。もう一度お試しください。", + "importExportNewerVersion": "このファイルは新しいバージョンのIcarusで作成されています。アプリをアップデートしてから、もう一度お試しください。", + "importExportDefaultStrategyFileName": "新しい作戦", + "importExportSaveDialogTitle": "出力ファイルを選択:", + "unsavedGuardTitle": "変更を保存しますか?", + "unsavedGuardDescription": "この作戦には保存していない変更があります。退出する前に保存しますか?", + "unsavedGuardDontSave": "保存しない", + "unsavedGuardCloudPendingTitle": "クラウド同期の待機中", + "unsavedGuardStayHere": "ここに留まる", + "unsavedGuardRetrySignIn": "ログインを再試行", + "unsavedGuardLeaveAnyway": "このまま退出", + "unsavedGuardRetrySync": "同期を再試行", + "unsavedGuardTextEditSyncFailed": "退出する前に、入力中のテキストを同期できませんでした。", + "unsavedGuardSaveFailed": "退出する前に作戦を保存できませんでした。", + "unsavedGuardOffline": "オフラインのため、これらの変更はクラウドに届いていません。", + "unsavedGuardImagesPending": "一部の画像がクラウドに届いていません。", + "unsavedGuardStillSending": "まだクラウドの編集と画像を送信しています。", + "unsavedGuardLeaveKeepsUnreadable": "{reason}退出しても、このデバイスに保存された記録は削除されません。この作戦に戻って再試行できます。", + "unsavedGuardLeaveChooseLater": "{reason}退出しても問題ありません。戻ってきたら、同期ボタンから残す内容を選んでください。", + "unsavedGuardLeaveRetryFromButton": "{reason}退出しても問題ありません。同期ボタンから再試行するまで何も送信されません。", + "unsavedGuardLeaveRetriesLater": "{reason}送信待ちの作業はこのデバイスに保存されています。退出しても、Icarusが後で再試行します。", + "unsavedGuardStayAndRetry": "{reason}送信待ちの作業がすべてこのデバイスに保存されたか確認できなかったため、退出せずに再試行してください。", + "signOutGuardCantSignOutTitle": "まだログアウトできません", + "signOutGuardUnverifiedDescription": "送信待ちのクラウドの作業を確認できませんでした。ログインしたまま、もう一度お試しください。", + "signOutGuardUnconfirmedDescription": "送信待ちのクラウドの作業がすべてこのデバイスに保存されたか確認できませんでした。ログインしたまま、もう一度お試しください。", + "signOutGuardStaySignedIn": "ログインしたままにする", + "signOutGuardPendingTitle": "クラウドの作業が送信待ちです", + "signOutGuardConfirmTitle": "ログアウトしますか?", + "signOutGuardPendingDescription": "{strategyCount, plural, other{{strategyCount}件の作戦}}で、{workCount, plural, other{保存済みの変更{workCount}件}}がまだ送信待ちです:{strategies}。作業はこのデバイスに残り、同じアカウントで再びログインしたときにのみ再開されます。", + "signOutGuardUnnamedStrategy": "クラウドの作戦", + "signOutGuardNoPendingDescription": "クラウドの作戦はオンラインに残ります。このデバイスの送信待ちの作業は、引き続きこのアカウントに紐付けられます。", + "signOutGuardSignOutAnyway": "このままログアウト", + "signOutGuardFailedTitle": "ログアウトに失敗しました", + "signOutGuardFailedFallback": "ログアウトできませんでした。もう一度お試しください。", + "signOutGuardOk": "OK", + "shareLinkCopyDescriptionBrowserStrategy": "リンクを知っている人は誰でも、アカウントなしでこの作戦を閲覧できます。ライブラリに追加した人はここに表示され、リンクは無効にするまで有効です。", + "shareLinkCopyDescription": "リンクから参加した人はここに表示され、リンクは無効にするまで有効です。", + "shareLinkCopyEditLinkWarning": "このリンクを受け取った人は、転送された相手も含めて誰でも編集できます。チームにだけ送ってください。", + "shareLinkCopyDisableTitle": "このリンクを無効にしますか?", + "shareLinkCopyDisableDescription": "このリンクから新たに参加したり閲覧したりできなくなります。すでに参加した人は、削除しない限りアクセスを維持します。", + "shareLinkCopyRemoveJoinedOne": "このリンクから参加した人も削除する", + "shareLinkCopyRemoveJoinedMany": "{count, plural, other{このリンクから参加した{count}人も削除する}}", + "shareLinkCopyRemoveJoinedAtLeast": "{count, plural, other{このリンクから参加した{count}人以上も削除する}}", + "shareLinkCopyDisableAction": "リンクを無効にする", + "shareLinkCopyDisableFailure": "共有リンクを無効にできませんでした。", + "shareLinkCopyDisabledStatus": "無効", + "shareLinkCopyDisabledTooltip": "リンクは無効です", + "shareLinkCopyDisableTooltip": "リンクを無効にする", + "shareLinkCopyRemoveMemberTitle": "{name}を削除しますか?", + "shareLinkCopyRemoveMemberDescription": "すぐにアクセスできなくなり、相手の「共有」タブからも消えます。有効なリンクを持っている場合は、そのリンクを無効にするまで再び参加できます。", + "shareLinkCopyRemoveMemberAction": "削除", + "shareLinkCopyRemoveMemberFailure": "削除できませんでした。もう一度お試しください。", + "shareLinkCopyRoleChangeFailure": "アクセス権を変更できませんでした。もう一度お試しください。", + "cloudActionReconnect": "Icarus Cloudに再接続してから、もう一度お試しください。", + "cloudActionExportFailed": "このクラウドの作戦をエクスポートできませんでした。もう一度お試しください。", + "cloudActionNeedsConnection": "接続が必要です。オフラインの間はクラウドのライブラリを変更できません。", + "cloudActionExportRefusedUnreadable": "このデバイスに保存された一部のクラウドの変更を読み込めないため、何もエクスポートされていません。変更を確認してから、もう一度お試しください。", + "cloudActionExportRefusedNotSynced": "この作戦にまだ同期されていない変更があるため、何もエクスポートされていません。同期を待つか、変更を確認してから、もう一度お試しください。", + "cloudActionDuplicateRefusedUnreadable": "このデバイスに保存された一部のクラウドの変更を読み込めないため、複製されていません。変更を確認してから、もう一度お試しください。", + "cloudActionDuplicateRefusedNotSynced": "この作戦にまだ同期されていない変更があるため、複製されていません。同期を待つか、変更を確認してから、もう一度お試しください。", + "videoExportCaptionsDontFitThisPage": "このページのキャプションが収まりません。短くするか、「キャプションを含める」をオフにしてください。", + "videoExportCaptionsDontFitPage": "ページ{page}のキャプションが収まりません。短くするか、「キャプションを含める」をオフにしてください。", + "videoExportNotSynced": "最新の変更がまだ同期されていません。作戦が同期済みになってからエクスポートしてください。", + "videoExportPagesChanged": "選択したページが削除されました。ページを確認して、もう一度エクスポートしてください。", + "videoExportNoPagesSelected": "ページが選択されていません。", + "videoExportMovementPathsFailed": "移動経路を読み込めませんでした。もう一度エクスポートしてください。", + "videoExportProgressPreparingMap": "マップを準備中", + "videoExportProgressWaitingForPictures": "画像を待機中", + "videoExportProgressRenderingFrames": "フレームをレンダリング中({rendered}/{total})", + "videoExportProgressEncoding": "動画をエンコード中", + "videoExportWriteFailed": "選択した場所に動画を書き込めませんでした:{error}", + "videoExportBrowserCannotEncode": "このブラウザーは{width}x{height}、{fps} fpsのH.264動画をエンコードできません。", + "videoExportBrowserCannotScale": "このブラウザーは動画フレームを拡大縮小できません。", + "videoExportEncoderStartFailed": "動画エンコーダーを起動できませんでした:{error}", + "videoExportEncoderNoVideo": "動画エンコーダーが動画を出力しませんでした。", + "videoExportEncoderOutOfOrder": "動画エンコーダーが返したフレームの順序が正しくありません。", + "videoExportEncoderFailed": "動画エンコーダーでエラーが発生しました:{error}", + "discordPresenceBrowsingLibrary": "作戦ライブラリを閲覧中", + "discordPresenceTagline": "Valorantの作戦プランナー", + "discordPresencePlanningAttack": "{mapName}の攻めを計画中", + "discordPresencePlanningDefense": "{mapName}の守りを計画中", + "discordPresenceEmptyBoard": "空のボードから作成中", + "discordPresenceBoardAgents": "ボードに{agentCount, plural, other{エージェント{agentCount}体}}", + "discordPresenceBoardAbilities": "ボードに{abilityCount, plural, other{アビリティ{abilityCount}個}}", + "discordPresenceBoardAgentsAndAbilities": "ボードに{agentCount, plural, other{エージェント{agentCount}体}} · {abilityCount, plural, other{アビリティ{abilityCount}個}}", + "serviceMsgOpenLogs": "ログを開く", + "serviceMsgClipboardBusy": "別のプログラムがクリップボードを使用中です。少し待ってからもう一度お試しください。", + "serviceMsgAuthEmailConfirmed": "メールアドレスを確認しました。ログインして続けてください。", + "serviceMsgAuthLinkExpired": "このリンクは期限切れか、すでに使用されています。ログインしてみてください。メールアドレスをまだ確認していない場合は、アカウントをもう一度作成して新しいリンクを受け取ってください。", + "serviceMsgAuthResetLinkTimedOut": "このリセットリンクは開くまでに時間がかかりすぎました。新しいリンクを送信して、5分以内に開いてください。", + "serviceMsgFeatureExport": "エクスポート", + "serviceMsgFeatureImport": "インポート", + "serviceMsgFeatureScreenshot": "スクリーンショット", + "serviceMsgFeatureVideoExport": "動画の書き出し", + "serviceMsgFeatureFileDrop": "ドラッグ&ドロップ", + "serviceMsgFeatureAddImages": "画像の追加", + "serviceMsgFeatureAddLineups": "定点の追加", + "serviceMsgFeatureReplays": "リプレイ", + "serviceMsgFeatureDesktopOnly": "{feature}は現在デスクトップ版でのみ利用できます。", + "serviceMsgFeatureComingToWeb": "{feature}はWebベータに対応予定です。", + "serviceMsgStoreUpdateTitle": "アップデートがあります", + "serviceMsgStoreUpdateVersionAvailable": "バージョン{version}が利用可能です", + "serviceMsgStoreUpdateDefaultNotes": "Microsoft Storeでアップデートが利用可能です。", + "serviceMsgStoreUpdateReleaseNotes": "リリースノート:", + "serviceMsgStoreUpdateWhatsNew": "新機能:", + "serviceMsgStoreUpdateLater": "後で", + "serviceMsgStoreUpdateNow": "今すぐアップデート", + "lineupChangeAdded": "追加", + "lineupChangeDeleted": "削除", + "lineupChangeRenamed": "名前を変更", + "lineupChangeNotesEdited": "メモを編集", + "lineupChangeVideoChanged": "動画を変更", + "lineupChangeImagesChanged": "画像を変更", + "lineupChangeStandingSpotMoved": "立ち位置を移動", + "lineupChangeLandingMoved": "着弾点を移動", + "lineupChangeSeparator": "、", + "lineupChangeUnnamed": "名前のない定点", + "lineupChangeAgentLineup": "{agent}の定点", + "gameSpike": "スパイク", + "weaponCategorySidearms": "サイドアーム", + "weaponCategoryRifles": "ライフル", + "weaponCategoryShotguns": "ショットガン", + "weaponCategorySmgs": "SMG", + "weaponCategorySnipers": "スナイパーライフル", + "weaponCategoryHeavies": "マシンガン", + "lineupDragPinnedAgent": "この着弾点には{agent}をドラッグしてください。", + "lineupDragPinnedAgentUnknown": "この着弾点のエージェントをドラッグしてください。", + "lineupOriginPinned": "立ち位置は固定されています。アビリティをドラッグしてください。", + "lineupLandingPinned": "着弾点は固定されています。エージェントをドラッグしてください。", + "lineupSelectAgentFirst": "先にエージェントを選択してください。", + "lineupAbilityAgentMismatch": "選択したエージェントのアビリティではありません。", + "lineupPlacementEditClosed": "ページが変わったため、配置の編集は保存されずに閉じました。", + "startupCloudOutboxUnavailable": "このデバイスにある未送信のクラウド変更を開けなかったため、クラウド同期を一時的にオフにしています。変更はそのまま保持されます。このデバイスのライブラリは通常どおり使えます。", + "startupCloudClientsUnavailable": "クラウド同期を開始できなかったため、ログインできません。このデバイスのライブラリは通常どおり使えます。Icarusを再起動してもう一度試してください。", + "appErrorUnexpected": "予期しないアプリケーションエラーが発生しました。", + "appErrorUi": "UIエラーが発生しました。", + "appErrorAsync": "予期しない非同期エラーが発生しました。", + "mediaUploadReleaseBytesFailed": "保留中のメディアデータを解放できませんでした。", + "mediaUploadPruneBytesFailed": "保留中のメディアデータを整理できませんでした。", + "mediaUploadOutboxUpdateFailed": "送信待ちのメディアを更新できませんでした。", + "replayFailureUnsupportedBuild": "このパッチのリプレイはまだ読み込めません。Icarusのアップデートで対応予定です。", + "replayFailureUnsupportedCompression": "このリプレイは、Icarusがまだ読み込めない形式で圧縮されています。", + "replayFailureTransformCheckFailed": "このリプレイをデコードできませんでした。Icarusのアップデートで解決する可能性があります。", + "replayFailureUnreadable": "このファイルを読み込めませんでした。", + "replayFailureDesktopOnly": "リプレイはデスクトップアプリでのみ開けます。", + "replayFailureDecoderMissing": "このインストールにはリプレイデコーダーがありません({detail})。", + "replayMatchStuckStorage": "進行状況をこのデバイスに保存できませんでした。", + "replayMatchStuckDevice": "このデバイス上の問題で停止しました。", + "sightlineReportCopy": "視線レポートをコピー", + "sightlineReportCopied": "視線レポートをコピーしました", + "sightlineReportBuildFailed": "視線レポートを作成できませんでした。", + "sightlineReportSaveFailed": "視線レポートはコピーしましたが、ファイルを保存できませんでした。", + "sightlineReportCropLabelAttack": "{map} 攻め · 目線 {height} m", + "sightlineReportCropLabelDefense": "{map} 守り · 目線 {height} m", + "shapeToolsCircle": "サークル", + "shapeToolsZone": "ゾーン", + "shapeToolsZoneHint": "ドラッグかクリックで配置。範囲内のマップの床に色を付けます。", + "shapeToolsPlaceZoneButton": "+ ゾーンを配置", + "shapeToolsPlaceCircleButton": "+ サークルを配置" +} diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb new file mode 100644 index 000000000..1575ae7d2 --- /dev/null +++ b/lib/l10n/app_zh.arb @@ -0,0 +1,1279 @@ +{ + "@@locale": "zh", + "commonCancel": "取消", + "commonRetry": "重试", + "commonTryAgain": "再试一次", + "commonDelete": "删除", + "commonRename": "重命名", + "commonEdit": "编辑", + "commonClear": "清除", + "commonClose": "关闭", + "commonSave": "保存", + "commonDone": "完成", + "commonApply": "应用", + "commonDiscard": "放弃", + "commonExport": "导出", + "commonShare": "分享", + "commonLeave": "离开", + "commonLogIn": "登录", + "commonSignOut": "退出登录", + "commonReconnect": "重新连接", + "commonSentenceSpace": "", + "confirmDialogConfirm": "确认", + "deleteFolderTitle": "删除“{name}”?", + "deleteFolderDescription": "其中的所有战术和子文件夹也会一并删除。", + "deleteFolderFailed": "无法删除此文件夹,请再试一次。", + "deleteFolderDeleting": "正在删除...", + "deletePageTitle": "删除“{name}”?", + "deletePageRestorableFor": "{days} 天内可在“最近删除”中恢复。", + "deletePageCannotUndo": "此操作无法撤销。", + "deletePageConfirm": "确定要删除此页面吗?{afterwards}", + "deletePagePeerOne": "{name} 正在此页面上。仍要删除吗?{afterwards}", + "deletePagePeersTwo": "{first} 和 {second} 正在此页面上。仍要删除吗?{afterwards}", + "deletePagePeersMany": "{first}、{second} 和另外 {others, plural, other{{others} 人}}正在此页面上。仍要删除吗?{afterwards}", + "deletePageDeleteAnyway": "仍要删除", + "deletedPageTitle": "此页面已被删除", + "deletedPageDescription": "你编辑时,“{name}”被删除了。恢复它以保留你最新的更改,或放弃这些更改。", + "deletedPageGone": "“{name}”已无法恢复,你对它的最新更改未能保存。", + "deletedPageRestoreFailed": "无法确认页面已恢复。请检查网络连接后重试,或放弃更改。", + "deletedPageLeaveFailed": "暂时无法离开此页面:它的更改仍在发送中,或另一个页面无法加载。请稍后再试。", + "deletedPageDiscardChanges": "放弃更改", + "deletedPageRestore": "恢复页面", + "deletedPageRestoring": "正在恢复…", + "recentlyDeletedTitle": "最近删除", + "recentlyDeletedDescription": "删除的页面会在这里保留 {days} 天,之后将被永久删除。", + "recentlyDeletedLoadFailed": "无法加载已删除的页面。请检查网络连接后重试。", + "recentlyDeletedLoading": "正在加载…", + "recentlyDeletedEmpty": "没有已删除的页面。", + "recentlyDeletedGone": "已无法恢复。", + "recentlyDeletedRestoreFailed": "无法确认已恢复。请检查网络连接后重试。", + "recentlyDeletedRestore": "恢复", + "recentlyDeletedRestoring": "正在恢复…", + "recentlyDeletedRowDetail": "{deleted} · {timeLeft}", + "recentlyDeletedByYou": "由你删除,{when}", + "recentlyDeletedBy": "由 {name} 删除,{when}", + "recentlyDeletedWhen": "{when}删除", + "recentlyDeletedJustNow": "刚刚", + "recentlyDeletedMinutesAgo": "{count, plural, other{{count} 分钟}}前", + "recentlyDeletedHoursAgo": "{count, plural, other{{count} 小时}}前", + "recentlyDeletedDaysAgo": "{count, plural, other{{count} 天}}前", + "recentlyDeletedDaysLeft": "{count, plural, other{剩余 {count} 天}}", + "recentlyDeletedLessThanADayLeft": "剩余不到 1 天", + "releaseNotesTitle": "更新内容", + "releaseNotesShowWelcomeTour": "查看新手引导", + "releaseNotesInstalled": "已安装", + "releaseNotesAvailable": "可更新", + "releaseNotesLoadFailed": "无法加载更新说明。请检查网络连接后重试。", + "webBetaTitle": "Icarus 网页测试版", + "webBetaDescription": "在这里创建的战术会保存到你的 Icarus 账号,在桌面应用中也能打开。", + "webBetaDownloadForWindows": "下载 Windows 版", + "webBetaDesktopOnly": "暂仅限桌面版", + "webBetaComing": "即将登陆网页测试版", + "webViewTitle": "WebView", + "webViewNotInstalled": "你的系统未安装 WebView。请先安装才能使用 YouTube 视频。", + "webViewInstall": "安装", + "authInvalidEmail": "请输入有效的邮箱地址。", + "authPasswordTooShort": "密码至少需要 6 个字符。", + "authPasswordsDoNotMatch": "两次输入的密码不一致。", + "authDiscordLoginFailed": "登录未能完成,请再试一次。", + "authSignInTitle": "登录", + "authSignUpTitle": "注册账号", + "authResetPasswordTitle": "重置密码", + "authSignInDescription": "使用邮箱和密码登录。", + "authSignUpDescription": "使用邮箱和密码注册账号。", + "authResetPasswordDescription": "输入账号的邮箱,我们会发送一个设置新密码的链接。通过 Discord 注册的账号也适用。", + "authEmailHint": "邮箱", + "authPasswordHint": "密码", + "authConfirmPasswordHint": "确认密码", + "authForgotPassword": "忘记密码?", + "authSwitchToSignUp": "注册账号", + "authSwitchToSignIn": "改为登录", + "authBackToSignIn": "返回登录", + "authContinueWithDiscord": "使用 Discord 继续", + "authSignInButton": "登录", + "authSigningIn": "正在登录…", + "authSignUpButton": "注册账号", + "authCreating": "正在注册…", + "authSendResetLink": "发送重置链接", + "authSending": "正在发送…", + "authDiscordPending": "请在浏览器中完成 Discord 登录,此窗口会自动关闭。", + "setPasswordTitle": "设置新密码", + "setPasswordDescription": "设置你以后登录要用的密码。", + "setPasswordDescriptionSignedIn": "你已登录为 {email}。设置你以后登录要用的密码。", + "setPasswordNewHint": "新密码", + "setPasswordConfirmHint": "确认新密码", + "setPasswordNotNow": "以后再说", + "setPasswordSave": "保存密码", + "setPasswordSaving": "正在保存…", + "setPasswordSaved": "密码已保存。以后请使用你的邮箱和此密码登录。", + "createStrategyPickMap": "选择地图", + "createStrategyOutOfRotation": "非轮换地图", + "deleteStrategyTitle": "删除 {name}", + "deleteStrategyConfirm": "确定要删除 {name} 吗?此操作无法撤销。", + "deleteStrategyFailed": "无法删除此战术,请再试一次。", + "deleteStrategyDeleting": "正在删除...", + "renameStrategyTitle": "重命名战术", + "renameStrategyEmptyName": "战术名称不能为空。", + "lineupMediaName": "名称", + "lineupMediaNameHint": "可选,用于和这里的其他点位区分", + "lineupMediaYoutubeLink": "YouTube 链接", + "lineupMediaYoutubeLinkHint": "在此粘贴 YouTube 链接...", + "lineupMediaImages": "图片", + "lineupMediaNotes": "备注", + "lineupMediaNotesHint": "在此添加备注...", + "lineupMediaAddImages": "点击此处添加图片", + "lineupMediaPaste": "粘贴", + "lineupMediaPasteFromClipboard": "从剪贴板粘贴", + "createLineupNoClipboardImage": "剪贴板中没有图片", + "createLineupClipboardTypeUnsupported": "不支持剪贴板中的图片格式", + "createLineupLostEditAccess": "你已无法编辑此战术,因此这个点位未保存。", + "createLineupQueueImagesFailed": "无法将这些图片加入云端同步队列。它们仍保存在此设备上。请在关闭前重试。", + "createLineupCommitPending": "点位已保留在编辑器中,但云端操作仍未完成。离开前请点击“保存”。", + "createLineupCreateTitle": "创建点位", + "createLineupEditTitle": "编辑点位", + "createLineupEditPlacement": "调整位置", + "lineupPanelUnnamed": "点位 {number}", + "lineupPanelRenameTitle": "重命名点位", + "lineupPanelRenameLostEditAccess": "你已无法编辑此战术,因此名称未保存。", + "lineupPanelOriginFallback": "站位", + "lineupPanelLandHere": "{count, plural, other{{count} 个点位落在此处}}", + "lineupPanelFromHere": "{count, plural, other{从此处出发的 {count} 个点位}}", + "lineupPanelEditMedia": "编辑媒体", + "lineupPanelDeleteLineup": "删除点位", + "mapThemeEditorPreviewLabel": "地图主题预览", + "mapThemeEditorNewProfileTitle": "新建配置", + "mapThemeEditorEditProfileTitle": "编辑配置", + "mapThemeEditorCustomizeTitle": "自定义地图主题", + "mapThemeEditorCustomizeDescription": "仅对此战术生效。", + "mapThemeEditorProfileNameHint": "配置名称", + "mapThemeEditorBase": "底色", + "mapThemeEditorDetail": "细节", + "mapThemeEditorHighlight": "高光", + "mapThemeEditorSaveProfile": "保存配置", + "mapThemeEditorSaveChanges": "保存更改", + "mapThemeEditorCreateFailed": "配置数量已达上限或名称无效。", + "mapThemeEditorSavedAndApplied": "配置已保存并应用。", + "mapThemeEditorSaved": "配置已保存。", + "mapThemeEditorEnterName": "请输入配置名称。", + "mapThemeEditorUpdateFailed": "无法更新此配置。", + "mapThemeEditorUpdated": "配置已更新。", + "mapThemeEditorRenameFailed": "颜色已保存,但名称未能更改。", + "uploadImageUnsupportedFormat": "不支持的图片格式", + "uploadImageTitle": "上传图片", + "uploadImageDescriptionDrop": "将图片拖到这里,或点击选择文件。", + "uploadImageDescriptionClick": "点击选择文件。", + "uploadImageUseImage": "使用图片", + "uploadImageDragTip": "提示:也可以直接从桌面拖入图片。", + "uploadImageTagColor": "标签颜色", + "uploadImageDropToUpload": "松开以上传", + "uploadImageDropOrClick": "拖入或点击上传", + "uploadImageCheckingClipboard": "正在检查剪贴板…", + "uploadImageSelectedImage": "已选图片", + "screenshotDialogDefaultFileName": "新图片", + "screenshotDialogChooseFolder": "选择截图保存的文件夹", + "screenshotDialogFolderPickerFailed": "无法打开文件夹选择器,请再试一次。", + "screenshotDialogSavedToFolder": "已将 {count} 张截图保存到 {folder}。", + "screenshotDialogCancelled": "已取消截图。", + "screenshotDialogExportFailedMany": "无法导出截图,请再试一次。", + "screenshotDialogExportFailedOne": "无法导出截图,请再试一次。", + "screenshotDialogSavingManyTitle": "正在保存截图", + "screenshotDialogSavingOneTitle": "正在保存截图", + "screenshotDialogSaveOneTitle": "保存截图", + "screenshotDialogSaveManyTitle": "保存截图", + "screenshotDialogTransparentBackground": "透明背景", + "screenshotDialogIncludeCaptions": "包含说明", + "screenshotDialogIncludeCaptionsDescription": "地图上显示编号,两侧边栏显示说明。", + "screenshotDialogSaveManyButton": "保存 {count} 张截图", + "screenshotDialogSaveOneButton": "保存截图", + "screenshotDialogStop": "停止", + "screenshotDialogStoppingButton": "正在停止…", + "screenshotDialogClearAll": "全部取消", + "screenshotDialogSelectAll": "全选", + "screenshotDialogCaptured": "已截图", + "screenshotDialogStoppingStatus": "正在停止", + "screenshotDialogWaitingForPictures": "等待图片", + "screenshotDialogCapturing": "截图中", + "exportVideoIncludeCaptionsDescription": "地图上显示编号,两侧边栏显示说明。", + "exportVideoCaptionsTooSlow": "第 {page} 页约需 {seconds} 秒阅读,而每个步骤只停留 {step} 秒。", + "exportVideoDefaultFileName": "新视频", + "exportVideoBrowserUnsupported": "此浏览器无法导出视频。请使用最新版 Chrome 或 Edge,或使用桌面应用。", + "exportVideoEncoderNotFound": "未找到视频编码器。重新安装 Icarus 应可解决。", + "exportVideoSavingChanges": "正在保存你的更改", + "exportVideoPreparing": "正在准备", + "exportVideoExported": "视频已导出。", + "exportVideoCancelled": "已取消导出视频。", + "exportVideoFailed": "导出视频失败:{error}", + "exportVideoSeconds": "{seconds} 秒", + "exportVideoMinutesSeconds": "{minutes} 分 {seconds} 秒", + "exportVideoExportingTitle": "正在导出视频", + "exportVideoCancelExport": "取消导出", + "exportVideoKeepTabOpen": "视频下载完成前,请不要关闭此标签页。", + "exportVideoTitle": "导出视频", + "exportVideoDescription": "按顺序播放所选页面。每个步骤停留设定的时长,然后过渡到下一步。", + "exportVideoStepDuration": "步骤时长", + "exportVideoStepDurationDescription": "每个步骤在过渡动画播放前停留的时长。", + "exportVideoTransparentBackground": "透明背景", + "exportVideoTransparentBackgroundDescription": "不含背景和圆点。保存为保留透明度的无损 .mov,适合视频剪辑软件。", + "exportVideoIncludeCaptions": "包含说明", + "exportVideoQuality": "画质", + "exportVideoTransparentQualityNote": "透明视频为 30 fps 无损格式,因此这些选项不适用。", + "exportVideoPages": "页面", + "exportVideoPagesSelected": "{selected}/{total}", + "exportVideoClearAll": "全部取消", + "exportVideoSelectAll": "全选", + "exportVideoQualityPotato": "低画质", + "exportVideoQualitySocial": "社交", + "exportVideoQualityMax": "最高", + "exportVideoQualityPotatoCaption": "约 10 MB", + "exportVideoQualitySocialCaption": "约 20 MB", + "exportVideoQualityMaxCaption": "60 fps,不限大小", + "exportVideoSelectAPage": "请至少选择一个要导出的页面。", + "exportVideoSummaryLength": "视频约 {duration}", + "exportVideoSummaryFormat": " · {height}p · {format}", + "shareLinksTitle": "分享“{name}”", + "shareLinksJustNow": "刚刚", + "shareLinksMinutesAgo": "{count} 分钟前", + "shareLinksHoursAgo": "{count} 小时前", + "shareLinksDaysAgo": "{count} 天前", + "shareLinksTeammateFallback": "队友", + "shareLinksLoadMoreFailed": "无法加载更多成员。", + "shareLinksLinkCopied": "分享链接已复制到剪贴板。", + "shareLinksCodeCopied": "分享码已复制到剪贴板。", + "shareLinksCreateFailed": "创建分享链接失败。", + "shareLinksLoadFailed": "无法加载分享链接。", + "shareLinksPeopleWithAccess": "有权访问的成员", + "shareLinksLoadPeopleFailed": "无法加载其他有权访问的成员。", + "shareLinksLoadingMore": "正在加载…", + "shareLinksShowMore": "显示更多", + "shareLinksFolderAccess": "有权访问文件夹“{folder}”的成员也能打开此项。", + "shareLinksJoined": "{when}加入", + "shareLinksJoinedThroughEditLink": "通过编辑链接加入 · {when}", + "shareLinksJoinedThroughViewLink": "通过查看链接加入 · {when}", + "shareLinksJoinedThroughLink": "通过链接加入 · {when}", + "shareLinksNewLink": "新建链接", + "shareLinksCanEdit": "可编辑", + "shareLinksViewOnly": "仅查看", + "shareLinksViewOnlyLinkDescription": "拥有链接的任何人都能打开所有页面", + "shareLinksCanEditLinkDescription": "拥有链接的任何人都能修改页面、绘制内容和媒体", + "shareLinksCreating": "正在创建…", + "shareLinksCreateAndCopy": "创建并复制", + "shareLinksActiveLinks": "有效链接", + "shareLinksNoLinks": "还没有链接。在上方创建一个即可分享。", + "shareLinksYouSuffix": "(你)", + "shareLinksOwner": "所有者", + "shareLinksCanView": "可查看", + "shareLinksCanViewMemberDescription": "可打开所有页面,不能修改", + "shareLinksCanEditMemberDescription": "可修改页面、绘制内容和媒体", + "shareLinksRemoveMember": "移除 {name}", + "shareLinksNameSeparator": "、", + "shareLinksJoinedNamesAndOthers": "{names} 等人", + "shareLinksEditBadge": "编辑", + "shareLinksViewBadge": "查看", + "shareLinksCreated": "{when}创建", + "shareLinksCreatedRemoving": "{when}创建 · 正在移除成员…", + "shareLinksCreatedJoined": "{when}创建 · {count} 人已加入", + "shareLinksCreatedJoinedMore": "{when}创建 · {count}+ 人已加入", + "shareLinksCopyLinkTooltip": "复制链接", + "shareLinksCopyCodeTooltip": "复制分享码", + "addSharedItemEmpty": "请先粘贴分享链接或分享码。", + "addSharedItemInvalid": "这看起来不是 Icarus 的分享链接或分享码。", + "addSharedItemFailed": "无法添加此项。链接可能已失效,或者你需要先登录。", + "addSharedItemTitle": "添加共享项", + "addSharedItemDescription": "粘贴 Icarus 分享链接或输入分享码,将其添加到“与我共享”。", + "addSharedItemAdd": "添加", + "addSharedItemAdding": "正在添加…", + "addSharedItemPlaceholder": "{origin}/share/… 或 ICR-XXXX-XXXX-XXXX-XXXX", + "leaveSharedTitle": "离开“{name}”?", + "leaveSharedDescription": "你将失去访问权限,它也会从你的“共享”标签页中移除。只有通过所有者提供的链接才能重新获得访问权限。", + "leaveSharedFailed": "无法离开。请检查网络连接后重试。", + "welcomeTourKicker": "本次更新新增", + "welcomeTourLibraryTitle": "你的资料库都还在", + "welcomeTourLibraryBody": "更新前创建的所有内容都原样保存在这台电脑上。除非你自己移动,否则不会有任何改动。", + "welcomeTourCloudTitle": "登录以同步和分享", + "welcomeTourCloudBody": "登录后创建的战术会保存到你的账号,因此在任何电脑上都能打开,还可以分享给队伍。要带上旧战术,右键点击它并选择 **移至云端**。", + "welcomeTourReplaysTitle": "把你的比赛变成战术", + "welcomeTourReplaysBody": "**回放** 标签页会列出你在 VALORANT 中下载的比赛。在任意时刻暂停并点击 **截取**,即可将其保存为战术页面。", + "welcomeTourBack": "上一步", + "welcomeTourNext": "下一步", + "welcomeTourNotNow": "以后再说", + "welcomeTourSignIn": "登录", + "welcomeTourOpenReplays": "打开回放", + "welcomeTourStepLabel": "第 {step} 步,共 {count} 步", + "welcomeTourPictureASiteExecute": "A 点进攻", + "welcomeTourPictureBRetake": "B 点回防 vs Viper", + "welcomeTourPicturePistolDefaults": "手枪局默认站位", + "welcomeTourPictureRetakeDrills": "回防练习", + "welcomeTourPictureOnThisDevice": "仅此设备", + "welcomeTourPictureMoving": "正在移动…", + "welcomeTourPictureInYourAccount": "已在账号中", + "welcomeTourPictureReplayDate": "10月13日 周二", + "welcomeTourPictureMoveToCloud": "移至云端", + "welcomeTourPictureCapture": "截取", + "createLineupPasteFailed": "无法添加粘贴的图片:{error}", + "syncButtonConflictToast": "另一处编辑先同步到了云端。你的版本仍在此设备上,需要处理。", + "syncButtonUseCloudFailed": "无法加载云端版本。你保存的版本没有改动。", + "syncButtonKeepBothUnchanged": "无法加载云端版本。没有做任何改动。", + "syncButtonKeepBothCopiesOnly": "你的版本已添加为副本,但冲突仍未解决。", + "syncButtonKeepBothFailed": "无法保留两个版本。你的版本仍保存在此设备上。", + "syncButtonViewOnlyTooltip": "仅查看 · {status}", + "syncButtonOfflineCopyTooltip": "{count, plural, =0{离线。正在显示这台电脑上的副本;更改会保留在此设备上} other{离线。正在显示这台电脑上的副本 · {count} 项更改已保存在这台电脑上}}", + "syncButtonOfflineCopyFromTooltip": "{count, plural, =0{离线。正在显示这台电脑上 {when} 的副本;更改会保留在此设备上} other{离线。正在显示这台电脑上 {when} 的副本 · {count} 项更改已保存在这台电脑上}}", + "syncButtonCopyDateTime": "{date} {time}", + "syncButtonSyncedTooltip": "已同步", + "syncButtonSyncedAtTooltip": "已于 {time} 同步", + "syncButtonEditNotSynced": "编辑尚未同步", + "syncButtonSyncingTooltip": "正在同步…", + "syncButtonNeedsAttention": "同步需要处理", + "syncButtonDeleteAnyway": "仍然删除", + "syncButtonKeepHeldPages": "{count, plural, other{保留页面}}", + "syncButtonUseCloud": "使用云端", + "syncButtonKeepBoth": "两者都保留", + "syncButtonKeepMine": "保留我的", + "syncButtonRetrySync": "重试同步", + "syncButtonLastSyncedAt": "上次同步于 {time}", + "syncButtonSyncedTitle": "所有更改已同步", + "syncButtonSyncingTitle": "正在同步更改", + "syncButtonOfflineTitle": "离线工作中", + "syncButtonSyncedExplanation": "你的战术已安全保存在云端。", + "syncButtonEditingExplanation": "完成编辑或切换页面,即可将此更改发送到云端。", + "syncButtonSyncingOtherExplanation": "云端资料库中已保存的更改正在后台发送。", + "syncButtonSyncingExplanation": "你的编辑正在发送到云端。可以继续工作,这会在后台完成。", + "syncButtonOfflineExplanation": "更改会保留在此设备上,网络恢复后会自动同步。", + "syncButtonOtherStrategyAlsoNeedsAttention": "另一个战术中已保存的内容也需要处理。请从云端资料库打开它查看原因。", + "syncButtonOtherStrategyNeedsAttention": "另一个战术中已保存的内容需要处理。请从云端资料库打开它查看原因。", + "syncButtonLineupsNoLongerFit": "你编辑这些点位时,队友修改了它们,所以你的更改没有发送。页面显示的是你的版本,仍保存在此设备上。", + "syncButtonLineupsNoLongerFitChoices": "“保留我的”会按当前显示保存这些点位,替换对方的所有更改,包括你没有编辑的点位。“使用云端”会替换你的版本,“两者都保留”会把你的版本作为副本放在对方的旁边。", + "syncButtonLineupConflict": "你编辑这些点位时,队友修改了它们。你的版本仍保存在此设备上。", + "syncButtonLineupConflictChoices": "“保留我的”会替换对方的更改,“使用云端”会替换你的版本,“两者都保留”会把你的版本作为副本放在对方的旁边。", + "syncButtonRejected": "另一处编辑先同步到了云端。你的版本仍保存在此设备上。", + "syncButtonRejectedChoice": "{count, plural, =1{请选择这项冲突更改要保留的版本。} other{你的选择将应用于全部 {count} 项冲突更改。}}", + "syncButtonOtherChangesNotSaved": "这里的其他更改也没有保存,仍保留在此设备上。", + "syncButtonChoiceAppliesToAll": "你的选择将应用于全部 {count} 项需要处理的更改。", + "syncButtonImagesFailed": "{count, plural, other{{count} 张图片上传失败。}}", + "syncButtonChangesNotSent": "部分更改尚未发送到云端。", + "syncButtonRetryHint": "重试即可立即发送。", + "syncButtonLineupDiffTitle": "你的版本与云端的差异", + "syncButtonYourChangesTitle": "你的更改", + "syncButtonCloudChangesTitle": "云端更改", + "syncButtonNoLineupChanges": "点位没有更改", + "syncButtonMoreLineups": "{count, plural, other{还有 {count} 个点位}}", + "outboxBannerUpgradeTitle": "云端内容需要更新版本的 Icarus", + "outboxBannerUpgradeDetail": "{count, plural, other{{count} 项已保存的更改保留在此设备上。{message}}}", + "outboxBannerAttentionTitle": "云端内容需要处理", + "outboxBannerSyncingTitle": "正在同步云端内容", + "outboxBannerOfflineTitle": "离线工作中", + "outboxBannerUnreadableDetail": "Icarus 无法读取此设备上部分已保存的云端内容。请保持登录并查看。", + "outboxBannerReconnectDetail": "重新连接此账号后,Icarus 才能发送已保存的内容。", + "outboxBannerDeletedStrategyDetail": "{count, plural, other{{count} 项未发送的更改属于已删除的战术,无法保存。放弃这些更改即可清除此提示。}}", + "outboxBannerAttentionDetail": "{count, plural, other{{count} 项已保存的更改}}需要查看,涉及 {strategyCount, plural, other{{strategyCount} 个战术}}。", + "outboxBannerSendingDetail": "正在从 {strategyCount, plural, other{{strategyCount} 个战术}}发送 {count, plural, other{{count} 项已保存的更改}}。", + "outboxBannerOfflineDetail": "{count, plural, other{{count} 项已保存的更改正在此设备上等待,网络恢复后会继续发送。}}", + "outboxBannerRetryCaptures": "{count, plural, other{重试回放截取}}", + "outboxBannerDiscardCaptures": "{count, plural, =1{放弃回放截取} other{放弃 {count} 个回放截取}}", + "outboxBannerDiscardChangesTo": "放弃对“{strategyName}”的更改", + "outboxBannerDiscardDeletedStrategyChanges": "放弃对已删除战术的更改", + "outboxBannerCaptureDiscardedToast": "已放弃。它可能仍会出现在你的资料库中。", + "outboxBannerUnnamedStrategy": "一个云端战术", + "outboxBannerReviewSync": "{strategyName}:查看同步", + "outboxBannerImagesFailed": "{strategyName}:{count, plural, other{{count} 张图片失败}}", + "outboxBannerReview": "{strategyName}:查看", + "searchFieldHint": "搜索", + "libraryTabMyLibrary": "我的资料库", + "libraryTabReplays": "回放", + "libraryTabShared": "共享", + "libraryTabSharedSemantics": "共享资料库", + "libraryTabCommunity": "社区", + "libraryTabCommunitySemantics": "社区资料库", + "libraryAddByLinkOrCode": "通过链接或分享码添加", + "libraryNewStrategy": "新建战术", + "libraryNewFolder": "新建文件夹", + "libraryNewMenuSemantics": "新建", + "libraryAddReplay": "添加回放", + "librarySortBy": "排序方式", + "librarySortTooltip": "排序", + "libraryImportIca": "导入 .ica", + "libraryImportBackup": "导入备份", + "libraryExportLibrary": "导出资料库", + "libraryLogInSemantics": "登录 Icarus", + "libraryAccountSemantics": "{displayName} 的账号", + "folderEmptyTitle": "此文件夹中没有战术", + "folderEmptyLocalSubtitle": "新建战术,或拖入战术、文件夹或 .zip 压缩包", + "folderCreateStrategy": "创建战术", + "folderLibraryEmptyTitle": "你的资料库是空的", + "folderLibraryEmptyCloudSubtitle": "你创建的战术会保存到你的账号。", + "folderLibraryEmptyLocalSubtitle": "新建战术,或将战术、文件夹或 .zip 压缩包拖到这里。", + "folderSharedEmptyTitle": "还没有人与你共享内容", + "folderSharedEmptySubtitle": "添加队友的分享链接或分享码,即可保存在这里。", + "folderSharedFolderEmptySubtitle": "共享到此文件夹的战术会显示在这里。", + "folderEmptyCloudSubtitle": "新建一个战术来填充它。", + "folderNoMatchesTitle": "没有匹配结果", + "folderNoMatchesSubtitle": "换个关键词试试", + "folderCloudUnavailableTitle": "云端不可用", + "folderCloudUnavailableRetrySubtitle": "重试以访问你的在线战术,或返回资料库继续工作。", + "folderCloudUnavailableSignInSubtitle": "重新登录以访问你的在线战术,或返回资料库继续工作。", + "folderBackToMyLibrary": "返回我的资料库", + "folderCloudLoadFailedTitle": "无法加载你的云端资料库", + "folderCheckConnectionSubtitle": "请检查网络连接,然后再试一次。", + "folderSharedNotKeptTitle": "“共享”尚未保存到这台电脑", + "folderNotKeptTitle": "此文件夹尚未保存到这台电脑", + "folderNotKeptSubtitle": "Icarus 会保留你在线时打开过的内容。恢复联网后会加载这里。", + "folderCloudNeedsUpgradeTitle": "你的云端资料库需要更新版本的 Icarus", + "folderCloudUnreachableTitle": "无法连接到你的云端资料库", + "folderSignInTitle": "登录以使用网页测试版", + "folderSignInSubtitle": "你在这里创建的战术会保存到你的 Icarus 账号。", + "folderCommunityTitle": "社区战术即将上线", + "folderCommunitySubtitle": "这里将用于展示公开点位、队伍进点打法和可供发现的战术合集。", + "folderCloudLoadingBanner": "正在加载你的云端资料库。目前显示的是这台电脑上的内容。", + "folderCloudOfflineBanner": "离线。正在显示你的云端资料库截至 {when} 的内容。恢复联网后会同步更改。", + "folderCopyDateTime": "{date} {time}", + "folderCloudErrorBanner": "无法加载你的云端资料库。正在显示这台电脑上的内容。", + "colorPickerTitle": "选择颜色", + "colorPickerRed": "红", + "colorPickerGreen": "绿", + "colorPickerBlue": "蓝", + "colorPickerAlpha": "透明度", + "colorPickerHue": "色相", + "colorPickerSaturation": "饱和度", + "colorPickerLightness": "亮度", + "colorPickerValue": "明度", + "folderEditSaveFailed": "无法保存此文件夹。请再试一次。", + "folderEditTitleEdit": "编辑文件夹", + "folderEditTitleAdd": "添加文件夹", + "folderEditNameHint": "文件夹名称", + "folderEditSaving": "正在保存…", + "folderEditCustomColorTitle": "选择自定义颜色", + "folderEditIconFilterAll": "全部", + "folderEditIconFilterSymbols": "符号", + "folderEditIconFilterRoles": "定位", + "folderEditIconFilterModes": "模式", + "folderCardMovingToCloud": "正在移至云端", + "folderCardEmpty": "空", + "folderCardStrategyCount": "{count, plural, other{{count} 个战术}}", + "folderCardLocalOnlyTooltip": "仅保存在这台电脑上", + "folderCardPin": "置顶", + "folderCardUnpin": "取消置顶", + "folderCardMoveToCloud": "移至云端", + "folderCardExportFailed": "导出文件夹失败。", + "icaDropAllNewerVersion": "这些文件是用更新版本的 Icarus 创建的。请更新应用后再试一次。", + "icaDropSomeNewerVersion": "{count, plural, other{其中 {count} 个是用更新版本的 Icarus 创建的。请更新应用后再试一次。}}", + "icaDropImportedThemeProfiles": "{count, plural, other{已导入 {count} 个配置。}}", + "icaDropRestoredLibrarySettings": "已恢复资料库设置。", + "icaDropNothingImported": "没有导入任何兼容的战术或文件夹。", + "icaDropSkippedFiles": "{count, plural, other{已跳过 {count} 个文件。}}", + "icaDropImportedStrategiesIntoFolders": "已将 {strategyCount, plural, other{{strategyCount} 个战术}}导入 {folderCount, plural, other{{folderCount} 个文件夹}}。", + "icaDropImportedStrategies": "{count, plural, other{已导入 {count} 个战术。}}", + "icaDropImportedFolders": "{count, plural, other{已导入 {count} 个文件夹。}}", + "icaDropImportFailed": "导入拖入的项目失败。", + "icaDropOverlayLabel": "导入战术、文件夹、备份或回放", + "libraryImportIcaFailed": "导入战术文件失败。", + "libraryImportBackupFailed": "导入备份压缩包失败。", + "libraryExportFailed": "导出资料库失败。", + "libraryCreateCloudStrategyFailed": "暂时无法创建云端战术。请尝试重新登录。", + "libraryCreateStrategyFailed": "暂时无法创建战术。", + "libraryCreateFolderMenu": "创建文件夹", + "libraryCreateStrategyMenu": "创建战术", + "desktopUpdateRequiredBadge": "必需", + "desktopUpdateRestartToUpdate": "重启以更新", + "desktopUpdateDownload": "下载更新", + "desktopUpdateDownloadFailed": "下载桌面版更新失败。请再试一次。", + "desktopUpdateRestartRequiredTitle": "需要重启", + "desktopUpdateRestartWarning": "需要重启才能完成更新安装。\n所有未保存的更改都将丢失。要立即重启吗?", + "desktopUpdateRestartConfirm": "重启", + "desktopUpdateRestartCancel": "以后再说", + "desktopUpdateApplyFailed": "无法应用已下载的桌面版更新。请尝试重新下载。", + "desktopUpdateDownloading": "正在下载… {percent}%", + "captionCardAgentName": "英雄", + "captionCardTextName": "文字", + "captionCardImageName": "图片", + "captionCardVisionConeName": "视野锥", + "captionCardShapeName": "形状", + "captionCardItemGone": "该项目已不存在,所以你的说明没有保存。它已复制到剪贴板。", + "captionCardHint": "写下说明", + "captionCardSaveShortcut": "{shortcut} 保存", + "lineupControlPlaceBoth": "放置英雄和技能后才能继续", + "lineupControlPlaceAgent": "放置英雄后才能继续", + "lineupControlPlaceAbility": "放置技能后才能继续", + "lineupControlFinalize": "完善点位详情", + "lineupControlNext": "下一步", + "canvasPasteNoImage": "剪贴板中没有图片", + "canvasPasteFailed": "无法粘贴你复制的内容。", + "canvasPasteAddingPicture": "正在添加图片…", + "canvasPasteUnsupportedImage": "不支持剪贴板中的图片类型", + "canvasPasteOtherMap": "你复制的内容来自另一张地图。", + "canvasPasteFromMap": "你复制的内容来自 {mapName}。请粘贴到 {mapName} 的战术中。", + "canvasPastePicturesMissing": "复制时,其中一张图片无法读取,所以没有粘贴任何内容。请检查网络连接后重新复制。", + "canvasPastePictureTooLarge": "复制的内容中有一张图片超过 15 MB,超出了云端战术的上限,所以没有粘贴任何内容。", + "canvasPastePageChanged": "粘贴完成前页面发生了变化,所以没有粘贴任何内容。", + "editorToolbarViewOnlySignIn": "仅查看 · 登录后可添加到你的资料库", + "editorToolbarViewOnlyAddToLibrary": "仅查看 · 添加到我的资料库", + "editorToolbarExportIca": "导出 .ica", + "editorToolbarExportVideo": "导出视频", + "editorToolbarScreenshot": "截图", + "editorToolbarSettings": "设置", + "clientUpgradeUpdate": "更新", + "clientUpgradeReload": "刷新", + "clientUpgradeCheckAgain": "重新检查", + "mapArtworkLoadFailedTitle": "地图未能加载", + "mapArtworkLoadFailedRetrying": "正在重试。你的战术不受影响。", + "imageDropOverlayLabel": "导入图片文件(.png、.jpg、.webp、.gif、.bmp)", + "canvasSelectionHint": "现在在地图上拖动会进行框选。按住 Space 并拖动可移动地图。", + "globalShortcutsFileSaved": "文件已保存", + "globalShortcutsSaveFailed": "保存失败", + "libraryBreadcrumbSharedWithMe": "与我共享", + "lineupMediaNoImages": "没有可用的图片", + "lineupEditorsOneOnLineup": "{name} 正在编辑这个点位。", + "lineupEditorsOneOnLineups": "{name} 正在编辑这些点位。", + "lineupEditorsTwoOnLineup": "{first} 和 {second} 正在编辑这个点位。", + "lineupEditorsTwoOnLineups": "{first} 和 {second} 正在编辑这些点位。", + "lineupEditorsManyOnLineup": "{name} 和另外 {count, plural, other{{count} 人}}正在编辑这个点位。", + "lineupEditorsManyOnLineups": "{name} 和另外 {count, plural, other{{count} 人}}正在编辑这些点位。", + "lineupEditStatusOne": "将 {agentName} 或技能拖到新位置", + "lineupEditStatusOneUnknownAgent": "将英雄或技能拖到新位置", + "lineupEditStatusSharedSpot": "拖动 {agentName} 或技能。这 {count} 个点位共用一个位置,会一起移动", + "lineupEditStatusSharedSpotUnknownAgent": "拖动英雄或技能。这 {count} 个点位共用一个位置,会一起移动", + "lineupPlacementDragAbility": "将技能拖到它的落点", + "lineupPlacementDragAnAgent": "将英雄拖到投掷的位置", + "lineupPlacementDragAgent": "将 {agentName} 拖到投掷的位置", + "lineupPlacementDragUnknownAgent": "将英雄拖到投掷的位置", + "settingsTitle": "设置", + "settingsNavCurrentStrategy": "当前战术", + "settingsNavAppWide": "全局", + "settingsNavDefaults": "默认值", + "settingsNavAutosave": "自动保存", + "settingsNavMapLayers": "地图图层", + "settingsObjectStyling": "对象样式", + "settingsAccount": "账号", + "settingsPrivacy": "隐私", + "settingsKeybinds": "按键绑定", + "settingsAgentMarkersTitle": "英雄标记", + "settingsAgentMarkersDescription": "调整此战术中已放置英雄和视野工具的大小。", + "settingsAbilityMarkersTitle": "技能标记", + "settingsAbilityMarkersDescription": "调整此战术中道具图标和放置辅助的大小。", + "settingsNeutralTeamColorsTitle": "中性队伍标记颜色", + "settingsNeutralTeamColorsDescription": "将友方和敌方标记的强调色显示为亮度一致的灰色。", + "settingsMarkerSizesDiffer": "各页面的标记大小不同。要将这些大小应用到所有页面吗?", + "settingsApplyMarkerSizesToAllPages": "应用到所有页面", + "settingsApplyingMarkerSizes": "正在应用…", + "settingsNewStrategyDefaultsTitle": "新战术默认值", + "settingsDefaultAgentMarkersTitle": "默认英雄标记", + "settingsDefaultAgentMarkersDescription": "新战术中英雄标记的默认大小。", + "settingsDefaultAbilityMarkersTitle": "默认技能标记", + "settingsDefaultAbilityMarkersDescription": "新战术中技能标记的默认大小。", + "settingsDefaultNeutralColorsTitle": "默认使用中性标记颜色", + "settingsDefaultNeutralColorsDescription": "新战术中友方和敌方使用灰色强调色。", + "settingsWorkspaceBehaviorTitle": "工作区行为", + "settingsAutosaveTitle": "自动保存", + "settingsAutosaveDescription": "闲置 15 秒后将战术保存到此设备。关闭后,离开未保存的内容前 Icarus 会先询问。云端战术始终会同步。", + "settingsDiscordPresenceTitle": "Discord Rich Presence", + "settingsDiscordPresenceDescription": "在你的 Discord 个人资料上显示当前地图及进攻或防守方。不会分享战术名称。", + "settingsMapVisibilityTitle": "工作区地图显示", + "settingsSpawnBarriersTitle": "出生点屏障", + "settingsSpawnBarriersDescription": "在地图上保留回合开始时的屏障参考线。", + "settingsRegionNamesTitle": "区域名称", + "settingsRegionNamesDescription": "在画布上直接显示地图区域名称。", + "settingsUltOrbsTitle": "终极技能球", + "settingsUltOrbsDescription": "在支持的地图上显示技能球拾取点。", + "settingsCaptionsTitle": "说明", + "settingsCaptionsDescription": "显示已放置对象上的说明。仅限此设备。", + "settingsBackgroundDotsTitle": "背景点阵", + "settingsBackgroundDotsDescription": "地图和资料库背后点阵的显示强度。0% 为隐藏。", + "settingsViewConesTitle": "视野锥", + "settingsViewConesDescription": "视野锥在地图上的显示强度,同样作用于截图和视频。", + "settingsPercentValue": "{percent}%", + "settingsAnalyticsTitle": "匿名分析", + "settingsAnalyticsDescription": "分享应用启动次数和少量功能使用事件,绝不包含战术内容或个人信息。", + "settingsAccountSignedInDescription": "你的云端身份和同步连接。", + "settingsAccountSignedOutDescription": "登录后即可将战术同步到云端并分享。", + "settingsAccountNotSignedIn": "未登录", + "settingsSyncNeedsNewerIcarus": "云端同步需要更新版本的 Icarus", + "settingsSyncActive": "云端同步已开启", + "settingsSyncConnecting": "正在连接云端…", + "settingsSyncOffline": "离线。恢复联网后会继续云端同步", + "settingsSyncNeedsAttention": "云端连接需要处理", + "settingsSyncInactive": "云端同步未开启", + "settingsCopyVersionTooltip": "复制版本号", + "settingsVersionCopied": "已复制版本号", + "settingsKeybindsRestoreDefaults": "恢复默认", + "settingsKeybindsSearchHint": "搜索操作或按键...", + "settingsKeybindsActionColumn": "操作", + "settingsKeybindsBindingColumn": "按键", + "settingsKeybindsResetTooltip": "重置为默认", + "settingsKeybindsCapturePrompt": "按下新快捷键...", + "settingsKeybindsCaptureHelp": "按 Escape 取消。Ctrl 和 Cmd 会保存为同一个随平台变化的主修饰键。", + "settingsKeybindsAlreadyBound": "{binding} 已绑定到“{action}”。", + "settingsKeybindsNoMatches": "没有匹配的快捷键。", + "settingsKeybindsSnapToMap": "拖动时吸附到地图线条", + "settingsKeybindsHoldAlt": "按住 Alt", + "shortcutUndo": "撤销", + "shortcutRedo": "重做", + "shortcutDraw": "绘制", + "shortcutEraser": "橡皮擦", + "shortcutAddText": "添加文字", + "shortcutNavigation": "选择", + "shortcutToggleAgentFilter": "切换英雄筛选", + "shortcutNextPage": "下一页", + "shortcutPreviousPage": "上一页", + "shortcutAddPage": "添加页面", + "shortcutAddLineup": "添加点位", + "shortcutSwitchSide": "切换攻防", + "shortcutSwitchSideThisPage": "切换攻防(此页面)", + "shortcutOpenDeleteMenu": "打开删除菜单", + "shortcutSelectAll": "全选", + "shortcutRotateClockwise": "顺时针旋转", + "shortcutRotateCounterclockwise": "逆时针旋转", + "shortcutSaveStrategy": "保存战术", + "shortcutPaste": "粘贴", + "shortcutOpenDebugLog": "打开调试日志", + "mapThemeTitle": "地图主题", + "mapThemeCustomCount": "自定义 {count}/{limit}", + "mapThemeCustomRowTitle": "自定义", + "mapThemeThisStrategyOnlyTag": "仅此战术", + "mapThemeSaveAsProfile": "存为配置", + "mapThemeDefaultTag": "默认", + "mapThemeBuiltInTag": "内置", + "mapThemeNewProfile": "新建配置", + "mapThemeImportProfileCodeAction": "导入配置代码", + "mapThemeProfileAdded": "已添加“{name}”", + "mapThemeUseIt": "使用", + "mapThemeCustomize": "自定义", + "mapThemeSetAsDefault": "设为默认", + "mapThemeEditColors": "编辑颜色", + "mapThemeCopyProfileCode": "复制配置代码", + "mapThemeRenameFailed": "无法重命名此配置。", + "mapThemeCopyFailed": "无法复制配置代码。", + "mapThemeCodeCopied": "已复制配置代码", + "mapThemeDefaultUpdated": "已更新默认配置。", + "mapThemeProfileDeleted": "已删除配置。", + "mapThemeRenameDialogTitle": "重命名配置", + "mapThemeProfileNameHint": "配置名称", + "mapThemeImportDialogTitle": "导入配置代码", + "mapThemeImportDialogDescription": "粘贴别人分享的代码,将其地图颜色添加到你的配置中。", + "mapThemeAddProfile": "添加配置", + "mapThemeCodeHint": "粘贴配置代码", + "mapThemeCodeInvalid": "这不是 Icarus 配置代码。", + "mapThemeCodeIncomplete": "代码不完整。请复制完整代码后重新粘贴。", + "mapThemeCodeNewerVersion": "此代码来自更新版本的 Icarus。请更新 Icarus 后再导入。", + "mapThemeCodeDuplicate": "你已有相同颜色的配置“{name}”。", + "mapThemeCodePastedFromClipboard": "已从剪贴板粘贴。", + "mapThemeAddFailed": "无法添加此配置。", + "mapThemePreviewEmpty": "粘贴代码后,颜色会显示在这里。", + "mapThemePreviewSemantics": "配置代码预览", + "mapThemeDiscardCustomTitle": "放弃自定义颜色?", + "mapThemeDiscardCustomBody": "此战术的自定义颜色将被替换为“{name}”,且无法恢复。", + "mapThemeProfileWasDeleted": "“{name}”已被删除。", + "mapSelectorOutOfRotation": "不在轮换中", + "mapSelectorAttack": "进攻", + "mapSelectorDefense": "防守", + "mapSelectorSideTooltip": "切换所有页面的攻防\nShift+点击:仅此页面", + "mapSelectorSideTooltipMixedToAttack": "各页面攻防不一\n点击:所有页面切换为进攻\nShift+点击:仅此页面", + "mapSelectorSideTooltipMixedToDefense": "各页面攻防不一\n点击:所有页面切换为防守\nShift+点击:仅此页面", + "mouseWatchDeleteOrigin": "删除站位", + "newPageCopyNotSaved": "新页面无法保存到这台电脑,因此未添加。", + "newPageCopyWaitingForCloud": "新页面尚未上传到云端,上传后就会出现。", + "newPageCopyUnsavedEdits": "被复制页面中未保存的更改不会出现在副本中。", + "pagesBarRenameTitle": "重命名页面", + "pagesBarRenameDescription": "输入页面的新名称:", + "pagesBarAddPageTooltip": "添加页面", + "pagesBarRecentlyDeletedTooltip": "最近删除", + "pagesBarPageDeleted": "已删除“{name}”", + "pagesBarUndo": "撤销", + "pagesBarRestored": "已恢复“{name}”。", + "pagesBarRestoredNotOpenedListed": "已恢复“{name}”,但无法打开。它在页面列表中。", + "pagesBarRestoredNotOpened": "已恢复“{name}”,但无法打开。", + "pagesBarRestoreGone": "“{name}”已无法恢复。", + "pagesBarRestoreUnconfirmed": "无法确认“{name}”是否已恢复。联网后请查看页面列表或“最近删除”。", + "pagesBarRestoreUnconfirmedLeft": "无法确认“{name}”是否已恢复。联网后请查看页面列表或其所属战术的“最近删除”。", + "pagesBarRestoreUnconfirmedIn": "无法确认“{name}”是否已在“{strategy}”中恢复。联网后请查看页面列表或该战术的“最近删除”。", + "strategyBadgeDeviceOnly": "仅此设备", + "strategyBadgeDeviceOnlyTooltip": "仅保存在这台电脑上", + "strategyBadgeNeedsConnection": "需要联网", + "strategyBadgeNeedsConnectionTooltip": "首次打开时需要联网", + "strategyBadgeMovingToCloud": "正在移至云端", + "strategyBadgeMovingToCloudTooltip": "全部上传到云端前会保留在这台电脑上", + "strategyBadgeSharedCanEdit": "共享 · 可编辑", + "strategyBadgeSharedCanEditTooltip": "已与你共享。你可以编辑。", + "strategyBadgeSharedViewOnly": "共享 · 仅查看", + "strategyBadgeSharedViewOnlyTooltip": "已与你共享。可向所有者申请编辑权限。", + "strategyAccessChangedToViewOnly": "你对此战术的权限已改为仅查看。", + "strategyAccessCanOpenAgain": "你可以再次打开此战术,因此没有放弃任何内容。", + "strategyAccessCouldNotConfirm": "无法确认你已失去权限,因此没有放弃任何内容。", + "strategyAccessDiscardFailed": "无法全部放弃。请在资料库中重试。", + "strategyAccessLostTitle": "你已无权访问", + "strategyAccessLostBodyNamed": "所有者已将你从“{name}”中移除,或已停止分享。{unsent, plural, =0{} other{你在此做的 {unsent} 项更改尚未上传到云端,无法保存。}}", + "strategyAccessLostBodyUnnamed": "所有者已将你从此战术中移除,或已停止分享。{unsent, plural, =0{} other{你在此做的 {unsent} 项更改尚未上传到云端,无法保存。}}", + "strategyAccessBackToLibrary": "返回资料库", + "strategyPresenceViewing": "{name}(查看中)", + "quickSwitcherNameEmpty": "战术名称不能为空。", + "quickSwitcherCannotRename": "你无法重命名此战术。", + "quickSwitcherUntitled": "未命名战术", + "quickSwitcherNoRecent": "没有最近的战术", + "quickSwitcherLoadToRename": "加载战术后才能重命名", + "quickSwitcherRenameTooltip": "重命名战术", + "quickSwitcherViewOnlyTooltip": "你可以查看此战术,但不能重命名", + "strategyTileSideAttack": "进攻", + "strategyTileSideDefend": "防守", + "strategyTileSideMixed": "混合", + "strategyTileSideUnknown": "未知", + "strategyTileEditedJustNow": "刚刚", + "strategyTileEditedMinutesAgo": "{count, plural, other{{count} 分钟前}}", + "strategyTileEditedHoursAgo": "{count, plural, other{{count} 小时前}}", + "strategyTileEditedDaysAgo": "{count, plural, other{{count} 天前}}", + "strategyTileEditedMonthsAgo": "{count, plural, other{{count} 个月前}}", + "strategyTileMoreActions": "“{name}”的更多操作", + "strategyTilePin": "置顶", + "strategyTileUnpin": "取消置顶", + "strategyTileDuplicate": "创建副本", + "strategyTileMoveToCloud": "移至云端", + "saveButtonFailed": "无法保存此战术。请重试。", + "saveButtonSaved": "已保存", + "saveButtonEditNotSynced": "更改尚未同步", + "saveButtonSyncingMedia": "已保存到此设备。媒体仍在同步。", + "saveButtonSyncing": "已保存到此设备。正在同步…", + "saveButtonMediaNeedsRetry": "已保存到此设备。媒体同步需要重试。", + "saveButtonNeedsAttention": "已保存到此设备。同步需要处理。", + "saveButtonViewOnlyTooltip": "仅查看", + "strategyViewSkeletonMapSemantics": "地图", + "stripWhatsNewTooltip": "更新内容", + "stripUpdateAvailableTooltip": "有可用更新", + "touchViewerLibrary": "资料库", + "touchViewerViewOnly": "仅查看", + "touchViewerAddToLibrary": "添加到资料库", + "turnPhoneTitle": "将手机横过来即可编辑", + "turnPhoneRotationLockHint": "如果没有反应,请检查是否已关闭旋转锁定。", + "turnPhoneBackToLibrary": "返回我的资料库", + "webBetaTagSemantics": "关于 Icarus 网页测试版", + "webBetaTag": "测试版", + "youtubeNotAVideo": "此链接不是 YouTube 视频。", + "pagesBarAutoName": "页面 {number}", + "settingsLanguage": "语言", + "settingsLanguageTitle": "应用语言", + "settingsLanguageDescription": "除非在此选择语言,否则 Icarus 会跟随系统语言。", + "settingsLanguageSystem": "跟随系统", + "sidebarAgentsTitle": "英雄", + "sidebarShowAllAgentsTooltip": "显示所有英雄", + "sidebarShowFavoriteAgentsTooltip": "仅显示收藏的英雄", + "sidebarFavoritesOnlyIndicator": "筛选:仅收藏", + "sidebarNoAgentAvailable": "没有可用的英雄", + "sidebarToolsTitle": "工具", + "sidebarDrawTooltip": "绘制", + "sidebarEraserTooltip": "橡皮擦", + "sidebarAddTextTooltip": "添加文字", + "sidebarAddImageTooltip": "添加图片", + "sidebarImageUploadFailed": "上传失败", + "sidebarAddLineupTooltip": "添加点位", + "sidebarVisionConeToolsTooltip": "视野锥工具", + "sidebarCustomShapesTooltip": "形状", + "sidebarAgentRolesTooltip": "英雄定位", + "sidebarTagColorLabel": "标签颜色", + "sidebarPlaceTextButton": "+ 放置文字", + "sidebarDragOrClickToPlace": "拖动或点击以放置", + "sidebarDragToPlace": "拖动以放置", + "sidebarAgentRoleIconsTitle": "英雄定位图标", + "sidebarVisionConesTitle": "视野锥", + "sidebarViewConePresetTooltip": "视野锥 {degrees}°", + "sidebarFilterAll": "全部", + "sidebarFilterOnMap": "地图上", + "sidebarFilterRole": "定位", + "sidebarTeamAlly": "友方", + "sidebarTeamEnemy": "敌方", + "sidebarAddColorTitle": "添加颜色", + "sidebarEditColorTitle": "编辑颜色", + "sidebarDeleteAllTooltip": "全部删除", + "sidebarDeleteAllButton": "全部删除", + "sidebarClearAgentsTooltip": "清除英雄", + "sidebarClearAbilitiesTooltip": "清除技能", + "sidebarClearDrawingsTooltip": "清除绘制内容", + "sidebarClearTextTooltip": "清除文字", + "sidebarClearImagesTooltip": "清除图片", + "sidebarClearUtilitiesTooltip": "清除道具", + "drawingToolsColorLabel": "颜色", + "drawingToolsShapeLabel": "形状", + "drawingToolsFreeDrawTooltip": "自由绘制", + "drawingToolsStraightLineTooltip": "直线", + "drawingToolsRectangleTooltip": "矩形", + "drawingToolsEllipseTooltip": "椭圆", + "drawingToolsStrokeLabel": "线条", + "drawingToolsDottedLineTooltip": "虚线", + "drawingToolsArrowTooltip": "箭头", + "drawingToolsThicknessLabel": "粗细", + "drawingToolsTraversalTimeLabel": "移动耗时", + "drawingToolsRunningTooltip": "持刀奔跑", + "drawingToolsWalkingTooltip": "持刀静步", + "drawingToolsAbilityWithKnifeTooltip": "{abilityName}(持刀)", + "drawingToolsAgentRunTooltip": "{agentName}疾跑", + "shapeToolsTitle": "形状", + "shapeToolsDiameterLabel": "直径", + "shapeToolsDiameterShortLabel": "直径", + "shapeToolsDiameterHint": "输入直径", + "shapeToolsDimensionsLabel": "尺寸", + "shapeToolsLengthShortLabel": "长", + "shapeToolsLengthHint": "输入长度", + "shapeToolsWidthShortLabel": "宽", + "shapeToolsWidthHint": "输入宽度", + "shapeToolsOpacityLabel": "不透明度", + "shapeToolsOpacityHint": "输入不透明度", + "shapeToolsColorLabel": "颜色", + "canvasItemTextPlaceholder": "在此输入...", + "canvasItemPlacedTextSemanticLabel": "已放置的文字", + "shapeToolsRotationShortLabel": "旋转", + "shapeToolsColorMenu": "颜色", + "shapeToolsCustomColorTitle": "自定义颜色", + "shapeToolsCustomColorMenu": "自定义颜色…", + "shapeToolsColorWhite": "白色", + "shapeToolsColorRed": "红色", + "shapeToolsColorBlue": "蓝色", + "shapeToolsColorYellow": "黄色", + "shapeToolsColorGreen": "绿色", + "shapeToolsSavedColor": "已保存 #{hex}", + "agentMenuAddLineup": "添加点位", + "agentMenuEditPlacement": "调整位置", + "agentMenuDeleteOrigin": "删除站位", + "agentMenuDetachViewCone": "分离视野锥", + "agentMenuRemoveViewCone": "移除视野锥", + "agentMenuDetachCircle": "分离范围圈", + "agentMenuRemoveCircle": "移除范围圈", + "agentMenuCreateLineup": "创建点位", + "agentMenuWeapon": "武器", + "agentMenuNoWeapon": "无", + "abilityMenuCloseStackSelectorLabel": "关闭点位技能选择器", + "abilityMenuCloseContextMenuLabel": "关闭点位技能菜单", + "abilityMenuRemoveWall": "移除墙体", + "abilityMenuDrawWall": "绘制墙体", + "abilityMenuAddLineupHere": "在此添加点位", + "abilityMenuShowLineups": "显示点位", + "abilityMenuEditMedia": "编辑媒体", + "abilityMenuEditPlacement": "调整位置", + "abilityMenuDeleteSpot": "删除落点", + "abilityMenuDeleteLineup": "删除点位", + "abilityMenuVisionCone": "视野锥", + "abilityMenuActive": "激活", + "abilityMenuRangeOutline": "范围轮廓", + "abilityMenuInnerOutline": "内圈轮廓", + "abilityMenuInnerFill": "内圈填充", + "abilityMenuRangeFill": "范围填充", + "abilityMenuMesh": "网格", + "abilityMenuRange": "范围", + "canvasItemCreateCaption": "添加说明", + "canvasItemEditCaption": "编辑说明", + "canvasItemResetTagToGray": "重置为灰色标签", + "canvasItemGreenTag": "绿色标签", + "canvasItemBlueTag": "蓝色标签", + "canvasItemAmberTag": "琥珀色标签", + "canvasItemRedTag": "红色标签", + "canvasItemPurpleTag": "紫色标签", + "canvasItemCustomColorTag": "#{hex} 自定义标签", + "canvasItemDefaultColorTag": "#{hex} 默认标签", + "canvasItemCopyToPreviousPage": "复制到上一页", + "canvasItemCopyToNextPage": "复制到下一页", + "canvasItemNextPageAlreadyHasIt": "下一页已有此项。", + "canvasItemPreviousPageAlreadyHasIt": "上一页已有此项。", + "canvasItemCopyUnreachable": "无法连接云端,未复制任何内容。", + "canvasItemCopyNotSaved": "无法在此设备上保存副本,未复制任何内容。", + "canvasItemImageSyncing": "正在同步图片", + "canvasItemImageUnavailable": "图片不可用", + "canvasItemBoldTooltip": "加粗({shortcut})", + "canvasItemItalicTooltip": "斜体({shortcut})", + "canvasItemBulletListTooltip": "项目符号列表", + "canvasItemNumberedListTooltip": "编号列表", + "canvasItemHeadingTooltip": "标题", + "canvasItemViewElevationMenu": "视线高度", + "canvasItemViewElevationGround": "地面", + "canvasItemViewElevationAutoTrailing": "{surface}(自动)", + "canvasItemViewElevationAutoUnmatchedTrailing": "{surface}(自动*)", + "canvasItemViewElevationAutomatic": "自动({surface})", + "canvasItemViewElevationAutomaticUnavailable": "自动(保存的高度已不存在)", + "canvasItemViewElevationGroundHeight": "地面({height})", + "canvasItemViewElevationSurfaceHeight": "{surface}({height})", + "replayBackToLibraryTooltip": "回放", + "replayReading": "正在读取回放", + "replayOpenThisFailed": "无法打开此回放", + "replayMapNotSupported": "Icarus 暂不支持此回放的地图。", + "replayBackToReplays": "返回回放列表", + "replayOpenFailed": "无法打开回放。", + "replayRoundNumber": "第 {number} 回合", + "replayDockPinStoryTooltip": "固定回合事件", + "replayDockUnpinStoryTooltip": "取消固定回合事件", + "replayDockOvertimeMark": "加时", + "replayDockPlaybackSpeedTooltip": "播放速度", + "replayLibraryListFailed": "无法读取回放列表", + "replayLibraryEmptyTitle": "还没有回放", + "replayLibraryEmptyHint": "在 Valorant 的生涯页面下载比赛,或将 .vrf 文件拖到这里", + "replayLibraryKept": "已保留到 Icarus,Valorant 无法再将其删除。", + "replayLibraryKeepFailed": "无法保留此回放。", + "replayLibraryRemoveTitle": "移除此回放?", + "replayLibraryRemoveValorantCopyBody": "这会将 Icarus 的副本移到回收站。Valorant 还有自己的副本,因此在 Valorant 删除它之前,此回放会一直留在列表中。", + "replayLibraryRemoveBody": "这会将回放文件移到回收站。从中截取的页面仍会保留在你的战术中。", + "replayLibraryRemoveConfirm": "移除", + "replayLibraryRecycleBinDeleted": "Windows 直接删除了此回放,而没有将其移到回收站。", + "replayLibraryRecycleBinFailed": "Windows 无法将此回放移到回收站,因此 Icarus 将其保留在原处。", + "replayLibraryRemoveFileChanged": "你选择移除后,此回放发生了变化。未删除任何内容。", + "replayLibraryRemoveNotOwned": "Icarus 只会移除其自身文件夹中的回放。未删除任何内容。", + "replayLibraryRemoveFailed": "无法移除此回放。", + "replayLibraryKeepInIcarus": "保留到 Icarus", + "replayLibraryRemoveFromIcarus": "从 Icarus 移除", + "replayLibraryNotAReplay": "此文件不是 Valorant 回放。", + "replayLibraryPatchUnsupported": "暂时无法读取 {patch} 版本的回放。Icarus 更新后将会支持。", + "replayLibraryUnreadableFile": "无法读取的文件", + "replayLibraryUnknownMap": "未知地图", + "replayLibraryNotAReplayStatus": "非回放", + "replayLibraryPatchStatus": "版本 {patch}", + "replayLibraryKeepTooltip": "仅存在于 Valorant 的回放文件夹中,Valorant 可能会清理该文件夹。\n在 Icarus 中保留一份副本。", + "replayLibraryKeepButton": "保留", + "replayLibraryAddFailed": "无法添加回放。", + "replayLibraryAdded": "已添加 {count, plural, other{{count} 个回放}}。", + "replayLibraryAlreadyListed": "{count, plural, other{这 {count} 场比赛已在“回放”中。}}", + "replayLibrarySomeAlreadyListed": "{count, plural, other{其中 {count} 个已在“回放”中。}}", + "replayLibraryUnreadableOne": "{fileName} 不是 Icarus 可读取的回放。", + "replayLibraryUnreadableMany": "{count, plural, other{已跳过 {count} 个 Icarus 无法读取的文件。}}", + "replayLibraryCopyFailedOne": "无法复制 {fileName}。", + "replayLibraryCopyFailedMany": "{count, plural, other{有 {count} 个回放无法复制。}}", + "replayLibraryDropToAdd": "添加回放", + "replayMatchCaptureFailed": "无法截取此刻。", + "replayMatchStrategyDeleted": "此战术已被删除,无法打开。", + "replayMatchStrategyMapChanged": "此战术现已改为其他地图,回放截取的内容无法放入。", + "replayMatchCaptureUnsaved": "有一项截取未能保存,因此暂时无法打开到该页面。请先重试或放弃该截取。", + "replayMatchBackToReplay": "返回回放", + "replayMatchRetryFailed": "无法重试截取。", + "replayMatchDiscardFailed": "无法放弃截取。", + "replayMatchDiscarded": "已放弃。它可能仍会出现在你的资料库中。", + "replayMatchWarmup": "热身", + "replayMatchAttack": "进攻", + "replayMatchDefense": "防守", + "replayMatchCapture": "截取", + "replayMatchDrawTooltip": "绘制({shortcut})", + "replayMatchStopDrawingTooltip": "停止绘制({shortcut})", + "replayMatchEraserTooltip": "橡皮擦({shortcut})", + "replayMatchStopErasingTooltip": "停止擦除({shortcut})", + "replayMatchSwitchSideTooltip": "查看另一队的视角", + "replayMatchPagesCaptured": "{count, plural, other{已截取 {count} 个页面}}", + "replayMatchOpenStrategy": "打开", + "replayMatchSending": "正在发送…", + "replayMatchCloudSaved": "已保存到你的云端资料库", + "replayMatchCloudUpdateRequired": "更新 Icarus 才能保存剩余 {count} 项", + "replayMatchCloudWaiting": "{count} 项正在等待连接", + "replayMatchCloudUnconfirmed": "{count} 项正在发送。未能确认上次发送,正在重试。", + "replayMatchCloudSending": "{count} 项正在发送", + "replayMatchCapturesFailed": "{count, plural, other{{count} 项截取未能保存}}", + "replayMatchFailureDeleted": "所属战术已被删除。", + "replayMatchFailureMapChanged": "所属战术现已改为其他地图。", + "replayMatchFailurePageDeleted": "所属页面已被删除。", + "replayMatchFailureRefused": "云端拒绝了此项。", + "replayRosterAllies": "友方", + "replayRosterEnemies": "敌方", + "replayRosterAlive": "{count} 人存活", + "replayRosterWatchTooltip": "观看 {name}", + "replayRosterWatchWithKeyTooltip": "观看 {name}({key})", + "replayRosterStopWatchingTooltip": "停止观看 {name}", + "replayRosterStopWatchingWithKeyTooltip": "停止观看 {name}({key})", + "replayRosterFollowTooltip": "跟随 {name}", + "replayRosterFollowWithKeyTooltip": "跟随 {name}({key})", + "replayRosterUnknownAgent": "未知英雄", + "replayRosterFilterKills": "击杀", + "replayRosterFilterAbilities": "技能", + "replayRosterNoKills": "本回合暂无击杀", + "replayRosterNoPlantOrAbilities": "本回合暂无下包或技能", + "replayRosterNoPlant": "本回合尚未下包", + "replayRosterNoAbilities": "本回合尚未使用技能", + "replayRosterNothingChosen": "选择要显示的内容", + "strategyViewMapSemanticLabel": "地图", + "strategyViewBarrierSemanticLabel": "出生点屏障", + "strategyViewCalloutsSemanticLabel": "区域名称", + "strategyViewUltOrbsSemanticLabel": "终极技能球", + "strategyViewLoadFailed": "无法加载战术。", + "strategyViewShareLinkDisabled": "此分享链接已被所有者停用。", + "strategyViewBackToLibraryTooltip": "资料库", + "strategyViewDiscordPrompt": "遇到 bug?加入 Discord", + "appImportFailed": "无法导入战术文件。", + "screenshotImagesStillLoading": "此页面上的图片仍在加载。请稍后再试。", + "screenshotImagesLoadFailed": "无法加载此页面上的图片。请检查网络连接后重试。", + "authMsgDisplayNameFallback": "Discord 用户", + "authMsgStreamError": "登录服务连接出错:{error}", + "authMsgDiscordSignInFailed": "Discord 登录失败:{error}", + "authMsgSignInIncomplete": "登录未完成,请再试一次。", + "authMsgIncorrectCredentials": "邮箱或密码错误。", + "authMsgEmailNotConfirmed": "请先确认邮箱——在收件箱中查找确认链接。", + "authMsgTooManyAttempts": "尝试次数过多。请稍等片刻再试。", + "authMsgServerUnreachable": "无法连接服务器。请检查网络后再试。", + "authMsgSignInFailed": "登录失败,请再试一次。", + "authMsgSignUpAccountExists": "此邮箱已有账号。请直接登录。", + "authMsgSignUpAccountExistsMaybeDiscord": "此邮箱已有账号。请直接登录;如果当初是用 Discord 注册的,请使用 Discord 继续。", + "authMsgConfirmationEmailUnavailable": "暂时无法发送确认邮件。请稍后再试,或使用 Discord 继续。", + "authMsgSignUpFailed": "注册失败,请再试一次。", + "authMsgPasswordResetAlreadySentBrowser": "链接已在发送途中。请查看收件箱和垃圾邮件,并在 5 分钟内用此浏览器打开。", + "authMsgPasswordResetAlreadySentComputer": "链接已在发送途中。请查看收件箱和垃圾邮件,并在 5 分钟内在这台电脑上打开。", + "authMsgPasswordResetSentBrowser": "如果有账号使用此邮箱,设置新密码的链接已在发送途中。请在 5 分钟内用此浏览器打开。", + "authMsgPasswordResetSentComputer": "如果有账号使用此邮箱,设置新密码的链接已在发送途中。请在 5 分钟内在这台电脑上打开。", + "authMsgPasswordResetRateLimited": "刚刚已发送过链接。请等一分钟后再试。", + "authMsgPasswordResetFailed": "邮件发送失败,请再试一次。", + "authMsgNewPasswordSignedOut": "你已退出登录。请在“登录”中重新获取重置链接。", + "authMsgNewPasswordSame": "这已经是你当前的密码。请换一个。", + "authMsgNewPasswordWeak": "请设置更长的密码。", + "authMsgNewPasswordFailed": "密码保存失败,请再试一次。", + "authMsgSignOutFailed": "退出登录失败,请再试一次。", + "authMsgSignedOutLocalKept": "已退出登录。你的本地战术仍保留在此设备上。", + "authMsgSignedOut": "已退出登录。", + "authMsgLinkIgnoredWhileSignedIn": "你已登录,因此该链接未被使用。如需设置新密码,请退出登录后重新获取链接。", + "authMsgSignInLinkFailed": "登录未能完成,请再试一次。", + "authMsgCloudSessionExpired": "云端会话已过期。重新连接以继续同步,或退出登录。", + "authMsgCloudSyncConnectFailed": "无法连接云端同步,请重试。", + "authMsgIncidentTitle": "云端连接已断开", + "authMsgIncidentDescription": "你的云端会话已失效,同步已暂停。重新连接以让云端战术保持最新,或退出登录。", + "authMsgIncidentDescriptionWithLocal": "你的云端会话已失效,同步已暂停。重新连接以让云端战术保持最新,或退出登录。本地战术不受影响。", + "authMsgIncidentNotNow": "以后再说", + "updateMsgUpdateAvailable": "有可用更新", + "updateMsgNewVersionAvailable": "{appName} {version} 已发布", + "updateMsgNewVersionLong": "桌面版更新已准备好,需下载 {size} MB 文件。", + "updateMsgDownload": "下载更新", + "updateMsgRestartToUpdate": "重启以更新", + "updateMsgLater": "稍后", + "updateMsgRestartRequiredTitle": "需要重启", + "updateMsgRestartWarning": "Icarus 需要重启以完成更新安装。未保存的更改将会丢失。现在重启?", + "updateMsgRestartCancel": "以后再说", + "updateMsgRestartConfirm": "重启", + "shareMsgMissingCode": "该分享链接缺少分享码。", + "shareMsgFolderAdded": "共享文件夹已添加到你的资料库。", + "shareMsgStrategyAdded": "共享战术已添加到你的资料库。", + "shareMsgRedeemFailed": "无法通过分享链接添加内容。", + "shareMsgSignInForFolder": "登录后才能打开此共享文件夹。", + "shareMsgLinkDisabled": "此分享链接已被所有者停用。", + "shareMsgLinkNotFound": "此分享链接不存在。", + "shareMsgOpenStrategyFailed": "无法打开共享战术。请检查网络后刷新页面。", + "shareMsgOpenLinkFailed": "无法打开分享链接。请检查网络后再试。", + "folderMsgCreateCloudFailed": "无法创建云端文件夹。请检查网络后再试。", + "folderMsgDeleteCloudFailed": "无法删除此云端文件夹。请再试一次。", + "folderMsgDeleteWhileMoving": "此文件夹中有内容正在移至云端。请等移动完成后再删除。", + "folderMsgUpdateCloudFailed": "无法更新此云端文件夹。请再试一次。", + "folderMsgMoveCloudFailed": "无法移动此云端文件夹。请再试一次。", + "strategyMsgCreatedNotOpened": "战术已创建,但无法打开。请从云端资料库中打开。", + "strategyMsgTooLargeToDuplicate": "此战术太大,无法创建副本。", + "strategyMsgDuplicateFailed": "无法创建此战术的副本。请再试一次。", + "strategyMsgDuplicateImagesFailed": "无法创建此战术的副本:其中的图片无法复制。", + "strategyMsgDuplicatedMissingPictures": "{count, plural, other{已创建副本。原战术中已缺失 {count} 张图片,因此副本中也缺少这些图片。}}", + "strategyMsgDeleteCloudFailed": "无法删除此云端战术。请再试一次。", + "strategyMsgDeleteWhileMoving": "此战术正在移至云端。请等移动完成后再删除。", + "strategyMsgMoveCloudFailed": "无法移动此云端战术。请再试一次。", + "strategyMsgMoveFailed": "无法移动此战术。请再试一次。", + "strategyMsgPageUnreachable": "无法打开“{pageName}”。请检查网络后再试。", + "strategyMsgPageUnreachableUnnamed": "无法打开该页面。请检查网络后再试。", + "strategyMsgDeletedPageFallbackName": "此页面", + "prefsMsgSortAlphabetical": "名称", + "prefsMsgSortDateCreated": "创建日期", + "prefsMsgSortDateUpdated": "更新日期", + "prefsMsgSortAscending": "升序", + "prefsMsgSortDescending": "降序", + "authMsgSignUpCheckInbox": "账号已创建!请在收件箱中查收确认链接,然后登录。", + "authMsgSignUpAlreadySent": "我们已向此邮箱发送过确认邮件。请查看收件箱和垃圾邮件,然后打开链接。", + "syncErrorClientUpgradeWeb": "Icarus 已更新。刷新页面以继续同步。", + "syncErrorClientUpgradeAvailable": "Icarus 已更新。安装更新以继续同步。", + "syncErrorClientUpgradeChecking": "Icarus 已更新。正在查找此应用继续同步所需的更新。", + "syncErrorClientUpgradeUnavailable": "Icarus 已更新,但此应用暂时还无法获取该更新。已保存的内容会保留在此设备上,安装更新后即会同步。", + "syncErrorHeldPageDeleteChanged": "“{pageName}”在你删除后被修改过。请先查看再删除。", + "syncErrorHeldPageDeleteUnchecked": "Icarus 无法检查“{pageName}”是否有队友的修改。请先查看再删除。", + "syncErrorIncompletePageCopy": "“{pageName}”的副本未能在这台电脑上完整保存,因此未发送。请重新复制该页面,或选择“使用云端”放弃这个副本。", + "syncErrorIncompletePageCopyWithOtherWork": "“{pageName}”的副本未能在这台电脑上完整保存,因此未发送。请重新复制该页面,或选择“使用云端”放弃这个副本。其他已保存的内容也需要处理。", + "syncErrorStrategyDeleted": "此战术已被删除,其未发送的更改无法保存。请在资料库中放弃这些更改。", + "syncErrorUnreadableSavedWork": "一项已保存的云端更改无法读取。它仍保留在此设备上;请保持此战术打开,先恢复待发送的更改再继续。", + "syncErrorUnverifiedCloudWork": "Icarus 无法确认此更改已保存在此设备上。没有发送任何内容。请保持此战术打开并重试。", + "syncErrorEditAccessRemoved": "你已没有此战术的编辑权限。这些更改保留在此设备上,未保存到云端。", + "syncErrorForbidden": "此账号没有权限保存这些更改。它们仍保留在此设备上。请向所有者申请编辑权限,然后重试。", + "syncErrorRetryPaused": "一项已保存的云端更改多次发送失败,已暂停。请在网络和账号恢复正常后重试。", + "syncErrorPageTooLargeToCopy": "新页面太大,云端无法复制,因此未添加。“保留我的”会重新尝试;“使用云端”会丢弃它。", + "syncErrorTooLargeForCloudSync": "一项已保存的更改太大,无法云端同步。它仍保存在此设备上。请缩减内容后选择“保留我的”重试,或选择“使用云端”丢弃它。", + "syncErrorLineupOverlap": "此点位与另外单独保存的点位共用同一位置,因此未保存到云端。它仍保留在此设备上。“保留我的”会重新发送,但只要该位置仍被共用就会被拒绝;“使用云端”会将它从这里移除。", + "syncErrorLineupPageMismatch": "此点位与另一个页面上的点位冲突,因此未保存到云端。它仍保留在此设备上;“使用云端”会将它从这里移除。", + "syncErrorTeammateDeletedCannotRestore": "队友删除了此更改所编辑的内容,此更改无法将其恢复。选择“使用云端”放弃此更改。", + "syncErrorTeammateDeleted": "队友删除了你修改的内容。“保留我的”会按你的版本将其恢复;“使用云端”则让它保持删除。", + "syncErrorRetiredLineupOp": "一项由旧版 Icarus 保存的点位更改无法发送到云端,因此不在此战术中。请重新操作一遍,然后选择“使用云端”清除此提示。", + "syncErrorUndrawableWork": "一项已保存的更改与云端当前的页面不再匹配,因此未发送。它仍保留在此设备上。“保留我的”会用你的版本整体替换云端版本;“使用云端”会丢弃你的版本。", + "syncErrorPageDeleted": "这些更改所在的页面已被删除,因此未保存。它们仍保留在此设备上;“使用云端”会丢弃它们。", + "syncErrorConflict": "另一项编辑先到达了云端。你的版本仍保存在此设备上。", + "syncErrorCannotRetryAutomatically": "服务器无法将这项保留的编辑与云端当前版本对应。它仍保存在此设备上。", + "syncErrorSessionExpired": "你的云端会话需要刷新——请重试,或在资料库中重新登录。", + "syncErrorUnreachable": "无法连接到云端。", + "syncErrorStartingUp": "云端同步仍在启动中。", + "syncErrorGeneric": "有些更改尚未同步到云端。", + "syncStatusOfflineNothingWaiting": "离线,更改会保留在此设备上", + "syncStatusOfflineChangesSaved": "离线 · {count, plural, other{{count} 项更改}}已保存在这台电脑上", + "syncStatusStrategyNeedsConnection": "首次打开此战术需要联网。", + "mediaUploadFailedToast": "媒体上传失败。点击“保存”重试云端同步。", + "mediaUploadComplete": "图片上传完成", + "mediaUploadProgress": "{count, plural, other{正在上传 {count} 张图片…}}", + "mediaUploadImageTooLarge": "此图片超过 15 MB。请选择更小的图片。", + "moveToCloudMoved": "已将“{name}”移至云端。", + "moveToCloudMovedToMyLibrary": "已将“{name}”移至云端。它现在位于“我的资料库”。", + "moveToCloudNotMoved": "无法将“{name}”移至云端。它仍在此设备上。", + "moveToCloudChangedMeanwhile": "“{name}”在移动过程中被修改,因此仍保留在此设备上。请再次移动,以将最新版本放到云端。", + "moveToCloudLocalCopyKept": "已将“{name}”移至云端,但无法移除它在此设备上的副本。", + "moveToCloudChangedHere": "已将“{name}”移至云端。它在移动过程中被修改,因此此设备也保留了最新版本。", + "moveToCloudFoldersStayed": "已将“{name}”中的战术移至云端,但其中部分文件夹仍留在此设备上。", + "moveToCloudSomeMoved": "“{name}”:已移动 {moved}/{total} 个战术。其余仍在此设备上。", + "moveToCloudFolderFailed": "未能完成“{name}”的移动。未移动的内容仍在此设备上。", + "moveToCloudSignedOut": "你已退出登录,因此“{name}”仍保留在此设备上。", + "moveToCloudImporting": "正在将“{name}”导入云端…", + "moveToCloudSignedOutRest": "你已退出登录,因此“{name}”的其余内容仍保留在此设备上。", + "moveToCloudOtherAccount": "你已登录另一个账号,因此“{name}”仍保留在此设备上。", + "moveToCloudOffline": "暂时无法连接云端,因此“{name}”仍保留在此设备上。请在联网后再试。", + "opQueueReplayCaptureFolderGone": "“{strategyName}”已放在“我的资料库”的顶层:截取时所选的文件夹已不存在或不属于你。", + "importExportCloudUnreadable": "无法读取你的云端资料库,因此未导出任何内容。请检查网络后再试。", + "importExportCloudNotSynced": "你的部分云端更改尚未同步,因此未导出任何内容。请等待同步完成,或在资料库中查看这些更改,然后再试。", + "importExportCloudWorkUnreadable": "Icarus 无法读取此设备上保存的部分云端更改,因此未导出任何内容。请查看这些更改后再试。", + "importExportCloudChanged": "导出期间你的云端资料库发生了变化,因此未导出任何内容。请再试一次。", + "importExportNewerVersion": "此文件由更新版本的 Icarus 创建。请更新应用后再试。", + "importExportDefaultStrategyFileName": "新战术", + "importExportSaveDialogTitle": "请选择输出文件:", + "unsavedGuardTitle": "保存更改?", + "unsavedGuardDescription": "此战术有未保存的更改。离开前要保存吗?", + "unsavedGuardDontSave": "不保存", + "unsavedGuardCloudPendingTitle": "等待云端同步", + "unsavedGuardStayHere": "留在这里", + "unsavedGuardRetrySignIn": "重试登录", + "unsavedGuardLeaveAnyway": "仍然离开", + "unsavedGuardRetrySync": "重试同步", + "unsavedGuardTextEditSyncFailed": "离开前未能同步正在编辑的文字。", + "unsavedGuardSaveFailed": "离开前未能保存战术。", + "unsavedGuardOffline": "Icarus 处于离线状态,这些更改尚未同步到云端。", + "unsavedGuardImagesPending": "部分图片尚未上传到云端。", + "unsavedGuardStillSending": "Icarus 仍在发送云端编辑和图片。", + "unsavedGuardLeaveKeepsUnreadable": "{reason}离开不会删除此设备上保存的记录。你可以回到此战术重试。", + "unsavedGuardLeaveChooseLater": "{reason}你可以离开;回来后,再通过同步按钮选择要保留的内容。", + "unsavedGuardLeaveRetryFromButton": "{reason}你可以离开;在你通过同步按钮重试之前,不会发送任何内容。", + "unsavedGuardLeaveRetriesLater": "{reason}待发送的内容已保存在此设备上。你可以离开,Icarus 稍后会重试。", + "unsavedGuardStayAndRetry": "{reason}Icarus 无法确认所有待发送的内容都已保存在此设备上,请留在这里重试。", + "signOutGuardCantSignOutTitle": "暂时无法退出登录", + "signOutGuardUnverifiedDescription": "Icarus 无法检查待发送的云端内容。请保持登录并再试一次。", + "signOutGuardUnconfirmedDescription": "Icarus 无法确认所有待发送的云端内容都已保存在此设备上。请保持登录并再试一次。", + "signOutGuardStaySignedIn": "保持登录", + "signOutGuardPendingTitle": "云端内容仍在等待发送", + "signOutGuardConfirmTitle": "退出登录?", + "signOutGuardPendingDescription": "{strategyCount, plural, other{{strategyCount} 个战术}}中共有 {workCount, plural, other{{workCount} 项已保存的更改}}仍在等待发送:{strategies}。这些内容保留在此设备上,只有同一账号再次登录时才会继续发送。", + "signOutGuardUnnamedStrategy": "某个云端战术", + "signOutGuardNoPendingDescription": "云端战术会保留在线上。此设备上待发送的内容仍与此账号绑定。", + "signOutGuardSignOutAnyway": "仍然退出登录", + "signOutGuardFailedTitle": "退出登录失败", + "signOutGuardFailedFallback": "Icarus 无法退出登录。请再试一次。", + "signOutGuardOk": "确定", + "shareLinkCopyDescriptionBrowserStrategy": "任何拥有链接的人都可以查看此战术,无需账号。将它添加到资料库的人会显示在这里,链接在你停用之前一直有效。", + "shareLinkCopyDescription": "通过链接加入的人会显示在这里,链接在你停用之前一直有效。", + "shareLinkCopyEditLinkWarning": "任何拿到此链接的人都能编辑它,包括被转发链接的人。请只发给你的队伍。", + "shareLinkCopyDisableTitle": "停用此链接?", + "shareLinkCopyDisableDescription": "之后不会再有新的人能通过它加入或查看。已加入的人会保留访问权限,除非你将其移除。", + "shareLinkCopyRemoveJoinedOne": "同时移除通过它加入的那个人", + "shareLinkCopyRemoveJoinedMany": "{count, plural, other{同时移除通过它加入的 {count} 人}}", + "shareLinkCopyRemoveJoinedAtLeast": "{count, plural, other{同时移除通过它加入的 {count}+ 人}}", + "shareLinkCopyDisableAction": "停用链接", + "shareLinkCopyDisableFailure": "分享链接停用失败。", + "shareLinkCopyDisabledStatus": "已停用", + "shareLinkCopyDisabledTooltip": "链接已停用", + "shareLinkCopyDisableTooltip": "停用链接", + "shareLinkCopyRemoveMemberTitle": "移除“{name}”?", + "shareLinkCopyRemoveMemberDescription": "对方会立即失去访问权限,内容也会从其“共享”标签页中消失。如果对方仍有有效链接,在你停用之前仍可重新加入。", + "shareLinkCopyRemoveMemberAction": "移除", + "shareLinkCopyRemoveMemberFailure": "无法移除对方。请再试一次。", + "shareLinkCopyRoleChangeFailure": "无法更改对方的权限。请再试一次。", + "cloudActionReconnect": "请重新连接 Icarus 云端,然后再试。", + "cloudActionExportFailed": "无法导出此云端战术。请再试一次。", + "cloudActionNeedsConnection": "需要联网。离线时无法更改你的云端资料库。", + "cloudActionExportRefusedUnreadable": "Icarus 无法读取此设备上保存的部分云端更改,因此未导出任何内容。请查看这些更改后再试。", + "cloudActionExportRefusedNotSynced": "此战术的部分更改尚未同步,因此未导出任何内容。请等待同步完成,或查看这些更改,然后再试。", + "cloudActionDuplicateRefusedUnreadable": "Icarus 无法读取此设备上保存的部分云端更改,因此未创建副本。请查看这些更改后再试。", + "cloudActionDuplicateRefusedNotSynced": "此战术的部分更改尚未同步,因此未创建副本。请等待同步完成,或查看这些更改,然后再试。", + "videoExportCaptionsDontFitThisPage": "此页面的说明放不下。请缩短说明,或关闭“包含说明”。", + "videoExportCaptionsDontFitPage": "第 {page} 页的说明放不下。请缩短说明,或关闭“包含说明”。", + "videoExportNotSynced": "你的最新更改尚未同步。请在战术显示为已同步后再导出。", + "videoExportPagesChanged": "你选择的某个页面已被删除。请检查页面后重新导出。", + "videoExportNoPagesSelected": "未选择页面。", + "videoExportMovementPathsFailed": "移动路径加载失败。请重新导出。", + "videoExportProgressPreparingMap": "正在准备地图", + "videoExportProgressWaitingForPictures": "正在等待图片", + "videoExportProgressRenderingFrames": "正在渲染帧({rendered}/{total})", + "videoExportProgressEncoding": "正在编码视频", + "videoExportWriteFailed": "无法将视频写入所选位置:{error}", + "videoExportBrowserCannotEncode": "此浏览器无法以 {width}x{height}、{fps} fps 编码 H.264 视频。", + "videoExportBrowserCannotScale": "此浏览器无法缩放视频帧。", + "videoExportEncoderStartFailed": "无法启动视频编码器:{error}", + "videoExportEncoderNoVideo": "视频编码器未生成视频。", + "videoExportEncoderOutOfOrder": "视频编码器返回的帧顺序错乱。", + "videoExportEncoderFailed": "视频编码器出错:{error}", + "discordPresenceBrowsingLibrary": "正在浏览战术资料库", + "discordPresenceTagline": "Valorant 战术规划工具", + "discordPresencePlanningAttack": "正在规划{mapName}的进攻", + "discordPresencePlanningDefense": "正在规划{mapName}的防守", + "discordPresenceEmptyBoard": "从空白地图开始", + "discordPresenceBoardAgents": "地图上有 {agentCount, plural, other{{agentCount} 名英雄}}", + "discordPresenceBoardAbilities": "地图上有 {abilityCount, plural, other{{abilityCount} 个技能}}", + "discordPresenceBoardAgentsAndAbilities": "地图上有 {agentCount, plural, other{{agentCount} 名英雄}} · {abilityCount, plural, other{{abilityCount} 个技能}}", + "serviceMsgOpenLogs": "打开日志", + "serviceMsgClipboardBusy": "另一个程序正在使用剪贴板。请稍后再试。", + "serviceMsgAuthEmailConfirmed": "邮箱已确认。请登录以继续。", + "serviceMsgAuthLinkExpired": "此链接已过期或已被使用。请尝试登录。如果邮箱尚未确认,请重新创建账号以获取新链接。", + "serviceMsgAuthResetLinkTimedOut": "此重置链接打开得太晚了。请重新发送,并在 5 分钟内打开。", + "serviceMsgFeatureExport": "导出", + "serviceMsgFeatureImport": "导入", + "serviceMsgFeatureScreenshot": "截图", + "serviceMsgFeatureVideoExport": "导出视频", + "serviceMsgFeatureFileDrop": "拖放文件", + "serviceMsgFeatureAddImages": "添加图片", + "serviceMsgFeatureAddLineups": "添加点位", + "serviceMsgFeatureReplays": "回放", + "serviceMsgFeatureDesktopOnly": "{feature}目前仅支持桌面版。", + "serviceMsgFeatureComingToWeb": "{feature}即将登陆网页测试版。", + "serviceMsgStoreUpdateTitle": "有可用更新", + "serviceMsgStoreUpdateVersionAvailable": "版本 {version} 已发布", + "serviceMsgStoreUpdateDefaultNotes": "Microsoft Store 中有可用更新。", + "serviceMsgStoreUpdateReleaseNotes": "发行说明:", + "serviceMsgStoreUpdateWhatsNew": "更新内容:", + "serviceMsgStoreUpdateLater": "稍后", + "serviceMsgStoreUpdateNow": "立即更新", + "lineupChangeAdded": "已添加", + "lineupChangeDeleted": "已删除", + "lineupChangeRenamed": "已重命名", + "lineupChangeNotesEdited": "备注已编辑", + "lineupChangeVideoChanged": "视频已更改", + "lineupChangeImagesChanged": "图片已更改", + "lineupChangeStandingSpotMoved": "站位已移动", + "lineupChangeLandingMoved": "落点已移动", + "lineupChangeSeparator": "、", + "lineupChangeUnnamed": "未命名点位", + "lineupChangeAgentLineup": "{agent}点位", + "gameSpike": "爆能器", + "weaponCategorySidearms": "手枪", + "weaponCategoryRifles": "步枪", + "weaponCategoryShotguns": "霰弹枪", + "weaponCategorySmgs": "冲锋枪", + "weaponCategorySnipers": "狙击枪", + "weaponCategoryHeavies": "机枪", + "lineupDragPinnedAgent": "请为这个落点拖入{agent}。", + "lineupDragPinnedAgentUnknown": "请为这个落点拖入对应的英雄。", + "lineupOriginPinned": "站位已固定,请拖入技能。", + "lineupLandingPinned": "落点已固定,请拖入英雄。", + "lineupSelectAgentFirst": "请先选择英雄。", + "lineupAbilityAgentMismatch": "该技能不属于所选英雄。", + "lineupPlacementEditClosed": "页面已变更,位置调整未保存并已关闭。", + "startupCloudOutboxUnavailable": "无法打开此设备上尚未发送的云端更改,云端同步已暂时关闭。这些更改会原样保留,此设备上的资料库可照常使用。", + "startupCloudClientsUnavailable": "云端同步无法启动,暂时不能登录。此设备上的资料库可照常使用。请重启 Icarus 后再试。", + "appErrorUnexpected": "应用出现意外错误。", + "appErrorUi": "界面出现错误。", + "appErrorAsync": "出现意外的异步错误。", + "mediaUploadReleaseBytesFailed": "无法释放待上传的媒体数据。", + "mediaUploadPruneBytesFailed": "无法清理待上传的媒体数据。", + "mediaUploadOutboxUpdateFailed": "无法更新待发送的媒体。", + "replayFailureUnsupportedBuild": "暂时无法读取这个版本的回放,后续的 Icarus 更新会支持。", + "replayFailureUnsupportedCompression": "这个回放使用了 Icarus 暂时无法读取的压缩方式。", + "replayFailureTransformCheckFailed": "Icarus 无法解码这个回放,更新 Icarus 后可能会解决。", + "replayFailureUnreadable": "无法读取此文件。", + "replayFailureDesktopOnly": "回放只能在桌面版中打开。", + "replayFailureDecoderMissing": "当前安装缺少回放解码器({detail})。", + "replayMatchStuckStorage": "Icarus 无法在此设备上保存进度。", + "replayMatchStuckDevice": "此设备上的问题导致它停止了。", + "sightlineReportCopy": "复制视线报告", + "sightlineReportCopied": "已复制视线报告", + "sightlineReportBuildFailed": "无法生成视线报告。", + "sightlineReportSaveFailed": "已复制视线报告,但无法保存其文件。", + "sightlineReportCropLabelAttack": "{map} 进攻 · 视线高度 {height} m", + "sightlineReportCropLabelDefense": "{map} 防守 · 视线高度 {height} m", + "shapeToolsCircle": "圆形", + "shapeToolsZone": "区域", + "shapeToolsZoneHint": "拖动或点击以放置,会为范围内的地图地面上色。", + "shapeToolsPlaceZoneButton": "+ 放置区域", + "shapeToolsPlaceCircleButton": "+ 放置圆形" +} diff --git a/lib/l10n/cloud_sync_error_text.dart b/lib/l10n/cloud_sync_error_text.dart new file mode 100644 index 000000000..5986dba52 --- /dev/null +++ b/lib/l10n/cloud_sync_error_text.dart @@ -0,0 +1,127 @@ +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:icarus/collab/cloud_sync_error_message.dart'; +import 'package:icarus/collab/collab_models.dart'; +import 'package:icarus/collab/page_changes.dart'; +import 'package:icarus/l10n/generated/app_localizations.dart'; + +// The words the user reads for a cloud sync reason. The reasons themselves +// (lib/collab/cloud_sync_error_message.dart) are kept in the outbox and +// matched as text, so they stay English and unchanged; only what is drawn +// from them is translated. + +/// What the user is told when the server needs a newer Icarus, with the way +/// out their platform has. Desktop says what it can only once it knows +/// whether an update is there to install. +String clientUpgradeRequiredMessage( + AppLocalizations l10n, { + bool isWeb = kIsWeb, + ClientUpdateAvailability update = ClientUpdateAvailability.available, +}) { + if (isWeb) return l10n.syncErrorClientUpgradeWeb; + return switch (update) { + ClientUpdateAvailability.available => l10n.syncErrorClientUpgradeAvailable, + ClientUpdateAvailability.checking => l10n.syncErrorClientUpgradeChecking, + ClientUpdateAvailability.unavailable => + l10n.syncErrorClientUpgradeUnavailable, + }; +} + +/// What the user is told about a page delete held for them (see +/// heldPageDelete). A page this device could not check is never said to +/// have changed: nothing says it did. +String heldPageDeleteMessage( + AppLocalizations l10n, + ({String page, bool checked}) held, +) => + held.checked + ? l10n.syncErrorHeldPageDeleteChanged(held.page) + : l10n.syncErrorHeldPageDeleteUnchecked(held.page); + +/// What the user is told about an [incompletePageCopyMessage], alone or +/// followed by [otherWorkNeedsAttentionNote]. A reason of another shape is +/// shown as it was saved. +String _incompletePageCopyText(AppLocalizations l10n, String raw) { + final pageName = incompletePageCopyPageName(raw); + if (pageName == null) return raw; + final reason = incompletePageCopyMessage(pageName); + if (raw == reason) return l10n.syncErrorIncompletePageCopy(pageName); + if (raw == withOtherWorkNeedsAttentionNote(reason)) { + return l10n.syncErrorIncompletePageCopyWithOtherWork(pageName); + } + return raw; +} + +String friendlyCloudSyncError(AppLocalizations l10n, String raw) { + if (isClientUpgradeRequiredReason(raw)) { + return clientUpgradeRequiredMessage(l10n); + } + if (heldPageDelete(raw) case final held?) { + return heldPageDeleteMessage(l10n, held); + } + final lower = raw.toLowerCase(); + if (isIncompletePageCopyReason(lower)) { + return _incompletePageCopyText(l10n, raw); + } + if (lower.contains('strategy was deleted')) { + return l10n.syncErrorStrategyDeleted; + } + if (lower.contains('unreadable saved work')) { + return l10n.syncErrorUnreadableSavedWork; + } + if (lower.contains('could not be verified in the durable outbox') || + lower.contains('could not be saved to the durable outbox')) { + return l10n.syncErrorUnverifiedCloudWork; + } + if (lower.contains(editAccessRemovedMessage.toLowerCase())) { + return l10n.syncErrorEditAccessRemoved; + } + if (lower.contains('forbidden')) { + return l10n.syncErrorForbidden; + } + if (lower.contains('retry paused')) { + return l10n.syncErrorRetryPaused; + } + if (lower.contains(pageTooLargeToCopyMessage.toLowerCase())) { + return l10n.syncErrorPageTooLargeToCopy; + } + if (lower.contains('too large for cloud sync')) { + return l10n.syncErrorTooLargeForCloudSync; + } + if (lower.contains(lineupOverlapMessage.toLowerCase())) { + return l10n.syncErrorLineupOverlap; + } + if (lower.contains(lineupPageMismatchMessage.toLowerCase())) { + return l10n.syncErrorLineupPageMismatch; + } + if (lower.contains(teammateDeletedCannotRestoreMessage.toLowerCase())) { + return l10n.syncErrorTeammateDeletedCannotRestore; + } + if (lower.contains(teammateDeletedMessage.toLowerCase())) { + return l10n.syncErrorTeammateDeleted; + } + if (lower.contains(retiredLineupOpMessage.toLowerCase())) { + return l10n.syncErrorRetiredLineupOp; + } + if (lower.contains(undrawableWorkMessage.toLowerCase())) { + return l10n.syncErrorUndrawableWork; + } + if (lower.contains(pageDeletedMessage.toLowerCase())) { + return l10n.syncErrorPageDeleted; + } + if (lower.contains('needs attention')) { + return l10n.syncErrorConflict; + } + if (lower.contains('cannot be retried automatically')) { + return l10n.syncErrorCannotRetryAutomatically; + } + if (lower.contains('auth')) { + return l10n.syncErrorSessionExpired; + } + if (lower.contains('offline') || lower.contains('connection')) { + return l10n.syncErrorUnreachable; + } + if (lower.contains('setup is not ready')) { + return l10n.syncErrorStartingUp; + } + return l10n.syncErrorGeneric; +} diff --git a/lib/l10n/game_names.dart b/lib/l10n/game_names.dart new file mode 100644 index 000000000..f9f7b6a25 --- /dev/null +++ b/lib/l10n/game_names.dart @@ -0,0 +1,24 @@ +import 'package:icarus/const/maps.dart'; +import 'package:icarus/l10n/generated/app_localizations.dart'; +import 'package:icarus/l10n/official_game_names.dart'; + +/// Names lib/const spells its own way, by the game's spelling. +const _gameSpelling = {'kayo': 'kay/o'}; + +/// The game's own names, which Icarus's data (lib/const) holds in English. +extension GameNames on AppLocalizations { + /// [english], an agent, ability, role or weapon, as Valorant names it in + /// this language. A name the game doesn't have stays as it is. + String gameName(String english) { + final name = english.toLowerCase(); + return officialGameNames[_gameSpelling[name] ?? name]?[localeName] ?? + english; + } + + /// [map] as Valorant names it in this language. + String mapName(MapValue map) => mapNameOf(Maps.displayName(map)); + + /// The same for a map known only by its English name, in any case. + String mapNameOf(String english) => + officialMapNames[english.toLowerCase()]?[localeName] ?? english; +} diff --git a/lib/l10n/l10n.dart b/lib/l10n/l10n.dart new file mode 100644 index 000000000..e0f75b1b4 --- /dev/null +++ b/lib/l10n/l10n.dart @@ -0,0 +1,56 @@ +import 'dart:ui'; + +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/l10n/generated/app_localizations.dart'; +import 'package:icarus/providers/user_preferences_provider.dart'; + +export 'package:icarus/l10n/game_names.dart'; +export 'package:icarus/l10n/generated/app_localizations.dart'; + +// Every word Icarus shows comes from lib/l10n/app_en.arb and its translations. +// A widget reads them with `context.l10n`; code with no widget in reach (a +// provider raising a toast, a service naming a file) reads `l10nProvider`. +// Text that is saved, sent to the server or compared in code is not shown +// text and stays out of the .arb files: it is turned into words where it is +// drawn. + +/// English, for a widget drawn with no app above it, as tests pump them. +final _english = lookupAppLocalizations(const Locale('en')); + +extension L10nContext on BuildContext { + /// The app's words in the language it is showing. + AppLocalizations get l10n => + Localizations.of(this, AppLocalizations) ?? _english; +} + +/// The language Icarus is showing: the one picked in settings, otherwise the +/// first of the system's languages Icarus has, otherwise English. The +/// system's languages are read once: changing them takes a restart. +final appLocaleProvider = Provider((ref) { + final picked = ref.watch( + appPreferencesProvider.select((preferences) => preferences.languageCode), + ); + return resolveAppLocale( + picked: picked, + system: PlatformDispatcher.instance.locales, + ); +}); + +/// The app's words for code that has no BuildContext. +final l10nProvider = Provider( + (ref) => lookupAppLocalizations(ref.watch(appLocaleProvider)), +); + +Locale resolveAppLocale({ + required String? picked, + required List system, +}) { + const supported = AppLocalizations.supportedLocales; + for (final wanted in [if (picked != null) Locale(picked), ...system]) { + for (final locale in supported) { + if (locale.languageCode == wanted.languageCode) return locale; + } + } + return const Locale('en'); +} diff --git a/lib/l10n/official_game_names.dart b/lib/l10n/official_game_names.dart new file mode 100644 index 000000000..c96935739 --- /dev/null +++ b/lib/l10n/official_game_names.dart @@ -0,0 +1,223 @@ +// Written by tool/fetch_game_names.dart from the game's own text. Do not edit: +// run the tool again instead. + +/// What Valorant calls an agent, ability, role or weapon, by its English name +/// in lower case, then by language. +const officialGameNames = >{ + 'aftershock': {'ja': 'アフターショック', 'zh': '剧震余波'}, + 'alarmbot': {'ja': 'アラームボット', 'zh': '自动哨兵'}, + 'annihilation': {'ja': 'アナイアレーション', 'zh': '断魂索道'}, + 'arc rose': {'ja': 'アークローズ', 'zh': '弧光玫瑰'}, + 'ares': {'ja': 'アレス', 'zh': '战神'}, + 'armageddon': {'ja': 'アルマゲドン', 'zh': '末日审判'}, + 'astra': {'ja': 'アストラ', 'zh': '星礈'}, + 'astral form': {'ja': 'アストラルフォーム', 'zh': '星界形态'}, + 'bandit': {'ja': 'バンディット', 'zh': '追猎'}, + 'barrier mesh': {'ja': 'バリアメッシュ', 'zh': '阻域屏障'}, + 'barrier orb': {'ja': 'バリアオーブ', 'zh': '玉城'}, + 'bassquake': {'ja': 'ベースクエイク', 'zh': '音脉强袭'}, + 'battery': {'ja': 'バッテリー', 'zh': '蓄能电芯'}, + 'blade storm': {'ja': 'ブレードストーム', 'zh': '飓刃'}, + 'blast pack': {'ja': 'ブラストパック', 'zh': '惊喜翻腾'}, + 'blaze': {'ja': 'ブレイズ', 'zh': '火冒三丈'}, + 'blindside': {'ja': 'ブラインドサイド', 'zh': '攻其不备'}, + 'boom bot': {'ja': 'ブームボット', 'zh': '花车巡游'}, + 'breach': {'ja': 'ブリーチ', 'zh': '铁臂'}, + 'brimstone': {'ja': 'ブリムストーン', 'zh': '炼狱'}, + 'bucky': {'ja': 'バッキー', 'zh': '雄鹿'}, + 'bulldog': {'ja': 'ブルドッグ', 'zh': '獠犬'}, + 'chamber': {'ja': 'チェンバー', 'zh': '尚勃勒'}, + 'chokehold': {'ja': 'チョークホールド', 'zh': '裂变残片'}, + 'classic': {'ja': 'クラシック', 'zh': '标配'}, + 'cloudburst': {'ja': 'クラウドバースト', 'zh': '瞬云'}, + 'clove': {'ja': 'クローヴ', 'zh': '暮蝶'}, + 'contingency': {'ja': 'コンティンジェンシー', 'zh': '绝对屏障'}, + 'controller': {'ja': 'コントローラー', 'zh': '控场'}, + 'convergent paths': {'ja': 'コンバージェントパス', 'zh': '时光修罗场'}, + 'cosmic divide': {'ja': 'コズミックディバイド', 'zh': '宇宙分裂'}, + 'cove': {'ja': 'コーヴ', 'zh': '海盾'}, + 'crosscut': {'ja': 'クロスカット', 'zh': '涡流折跃'}, + 'curveball': {'ja': 'カーブボール', 'zh': '闪光曲球'}, + 'cyber cage': {'ja': 'サイバーケージ', 'zh': '赛博囚笼'}, + 'cypher': {'ja': 'サイファー', 'zh': '零'}, + 'dark cover': {'ja': 'ダークカヴァー', 'zh': '黑瘴'}, + 'deadlock': {'ja': 'デッドロック', 'zh': '钢锁'}, + 'devour': {'ja': 'デバウアー', 'zh': '噬尽'}, + 'dimensional drift': {'ja': 'ディメンショナルドリフト', 'zh': '神鬼不觉'}, + 'dismiss': {'ja': 'ディスミス', 'zh': '逐散'}, + 'displacement': {'ja': 'ディスプレイスメント', 'zh': '念落星移'}, + 'dizzy': {'ja': 'ディジー', 'zh': '炫晕光波'}, + 'double tap': {'ja': 'ダブルタップ', 'zh': '战斗心流'}, + 'drift': {'ja': 'ドリフト', 'zh': '飘移'}, + 'duelist': {'ja': 'デュエリスト', 'zh': '决斗'}, + 'empress': {'ja': 'エンプレス', 'zh': '女皇旨令'}, + 'evolution': {'ja': 'エヴォリューション', 'zh': '完全进化'}, + 'fade': {'ja': 'フェイド', 'zh': '黑梦'}, + 'fakeout': {'ja': 'フェイクアウト', 'zh': '出其不意'}, + 'fast lane': {'ja': 'ファストレーン', 'zh': '高速通道'}, + 'fault line': {'ja': 'フォールトライン', 'zh': '山崩地陷'}, + 'flash/drive': {'ja': 'フラッシュ/ドライブ', 'zh': '闪存过载'}, + 'flashbulb': {'ja': 'フラッシュバルブ', 'zh': '灵光一念'}, + 'flashpoint': {'ja': 'フラッシュポイント', 'zh': '闪点爆破'}, + 'flow': {'ja': 'フロー', 'zh': '身随念动'}, + 'focus': {'ja': 'フォーカス', 'zh': '战斗心流'}, + 'frag/ment': {'ja': 'フラグ/メント', 'zh': '碎片溢出'}, + 'frenzy': {'ja': 'フレンジー', 'zh': '狂怒'}, + 'from the shadows': {'ja': 'フロム・ザ・シャドウズ', 'zh': '离魂'}, + 'gatecrash': {'ja': 'ゲートクラッシュ', 'zh': '不请自来'}, + 'gekko': {'ja': 'ゲッコー', 'zh': '盖可'}, + 'ghost': {'ja': 'ゴースト', 'zh': '鬼魅'}, + 'globule': {'ja': '球体', 'zh': '球体回收'}, + 'gravity well': {'ja': 'グラビティウェル', 'zh': '重力之阱'}, + 'gravnet': {'ja': 'グラヴィネット', 'zh': '重力捕网'}, + 'guardian': {'ja': 'ガーディアン', 'zh': '戍卫'}, + 'guided salvo': {'ja': '誘導サルヴォ', 'zh': '精准投放'}, + 'guiding light': {'ja': 'ガイディングライト', 'zh': '引路之隼'}, + 'harbor': {'ja': 'ハーバー', 'zh': '海神'}, + 'harmonize': {'ja': 'ハーモナイズ', 'zh': '共振谐律'}, + 'haunt': {'ja': 'ホウント', 'zh': '诡眼'}, + 'headhunter': {'ja': 'ヘッドハンター', 'zh': '金牌猎头'}, + 'healing orb': {'ja': 'ヒーリングオーブ', 'zh': '逢春'}, + 'high gear': {'ja': 'ハイギア', 'zh': '充能疾驰'}, + 'high tide': {'ja': 'ハイタイド', 'zh': '狂潮'}, + 'hot hands': {'ja': 'ホットハンド', 'zh': '火热手感'}, + 'hunter\'s fury': {'ja': 'ハンターズフューリー', 'zh': '狂猎之怒'}, + 'hush': {'ja': 'ハッシュ', 'zh': '遐思'}, + 'incendiary': {'ja': 'インセンディアリー', 'zh': '燃烧榴弹'}, + 'initiator': {'ja': 'イニシエーター', 'zh': '先锋'}, + 'inner fire': {'ja': 'インナーファイア', 'zh': '心火不熄'}, + 'interceptor': {'ja': 'インタセプター', 'zh': '噬源体'}, + 'iso': {'ja': 'アイソ', 'zh': '壹决'}, + 'jett': {'ja': 'ジェット', 'zh': '捷风'}, + 'judge': {'ja': 'ジャッジ', 'zh': '判官'}, + 'kay/o': {'ja': 'KAY/O', 'zh': 'K/O'}, + 'kill contract': {'ja': 'キルコントラクト', 'zh': '决斗通牒'}, + 'killjoy': {'ja': 'キルジョイ', 'zh': '奇乐'}, + 'leer': {'ja': 'リーア', 'zh': '睥睨'}, + 'lightspeed': {'ja': 'ライトスピード', 'zh': '光速飞跃'}, + 'lockdown': {'ja': 'ロックダウン', 'zh': '全面封锁'}, + 'm-pulse': {'ja': 'M-パルス', 'zh': '电音脉冲'}, + 'marshal': {'ja': 'マーシャル', 'zh': '飞将'}, + 'meddle': {'ja': 'メドル', 'zh': '整蛊'}, + 'melee': {'ja': '近接武器', 'zh': '近战武器'}, + 'miks': {'ja': 'ミクス', 'zh': '迷核'}, + 'mosh pit': {'ja': 'モッシュピット', 'zh': '嗨爆全场'}, + 'nanoswarm': {'ja': 'ナノスワーム', 'zh': '纳米蜂群'}, + 'nebula': {'ja': 'ネビュラ', 'zh': '星云'}, + 'neon': {'ja': 'ネオン', 'zh': '霓虹'}, + 'neural theft': {'ja': 'ニューラルセフト', 'zh': '神经取析'}, + 'nightfall': {'ja': 'ナイトフォール', 'zh': '夜临'}, + 'not dead yet': {'ja': 'ノット・デッド・イェット', 'zh': '化蝶'}, + 'nova pulse': {'ja': 'ノヴァパルス', 'zh': '新星脉冲'}, + 'null/cmd': {'ja': 'ヌル/コマンド', 'zh': '无效命令'}, + 'odin': {'ja': 'オーディン', 'zh': '奥丁'}, + 'omen': {'ja': 'オーメン', 'zh': '幽影'}, + 'operator': {'ja': 'オペレーター', 'zh': '冥驹'}, + 'orbital strike': {'ja': 'オービタルストライク', 'zh': '天基光束'}, + 'outlaw': {'ja': 'アウトロー', 'zh': '莽侠'}, + 'overdrive': {'ja': 'オーバードライブ', 'zh': '超限暴走'}, + 'owl drone': {'ja': 'オウルドローン', 'zh': '枭型无人机'}, + 'paint shells': {'ja': 'ペイント弾', 'zh': '彩雷飞溅'}, + 'paranoia': {'ja': 'パラノイア', 'zh': '暗魇'}, + 'phantom': {'ja': 'ファントム', 'zh': '幻影'}, + 'phoenix': {'ja': 'フェニックス', 'zh': '不死鸟'}, + 'pick-me-up': {'ja': 'ピック・ミー・アップ', 'zh': '虹吸'}, + 'poison cloud': {'ja': 'ポイズンクラウド', 'zh': '瘴云'}, + 'prowler': {'ja': 'プラウラー', 'zh': '黯兽'}, + 'raze': {'ja': 'レイズ', 'zh': '雷兹'}, + 'razorvine': {'ja': 'レーザーヴァイン', 'zh': '剃刀藤蔓'}, + 'reckoning': {'ja': 'レコニング', 'zh': '怒涛'}, + 'recon bolt': {'ja': 'リコンボルト', 'zh': '寻敌箭'}, + 'refract': {'ja': 'リフラクト', 'zh': '溯流回光'}, + 'regrowth': {'ja': 'リグロウス', 'zh': '愈生之息'}, + 'relay bolt': {'ja': 'リレーボルト', 'zh': '闪电弹球'}, + 'rendezvous': {'ja': 'ランデヴー', 'zh': '闪转自如'}, + 'resurrection': {'ja': 'リザレクション', 'zh': '再起'}, + 'reyna': {'ja': 'レイナ', 'zh': '芮娜'}, + 'rolling thunder': {'ja': 'ローリングサンダー', 'zh': '惊雷卷地'}, + 'run it back': {'ja': 'ラン・イット・バック', 'zh': '再火一回'}, + 'ruse': {'ja': 'ルース', 'zh': '霞染'}, + 'sage': {'ja': 'セージ', 'zh': '贤者'}, + 'saturate': {'ja': 'サチュレート', 'zh': '光棱闪爆'}, + 'seekers': {'ja': 'シーカー', 'zh': '追猎之灵'}, + 'seize': {'ja': 'シーズ', 'zh': '幽爪'}, + 'sentinel': {'ja': 'センチネル', 'zh': '哨卫'}, + 'shear': {'ja': 'シアー', 'zh': '裁断'}, + 'sheriff': {'ja': 'シェリフ', 'zh': '正义'}, + 'shock bolt': {'ja': 'ショックボルト', 'zh': '雷击箭'}, + 'shorty': {'ja': 'ショーティー', 'zh': '短炮'}, + 'showstopper': {'ja': 'ショーストッパー', 'zh': '晚安焰火'}, + 'shrouded step': {'ja': 'シュラウドステップ', 'zh': '践影'}, + 'sky smoke': {'ja': 'スカイスモーク', 'zh': '空投烟幕'}, + 'skye': {'ja': 'スカイ', 'zh': '斯凯'}, + 'slow orb': {'ja': 'スロウオーブ', 'zh': '薄冰'}, + 'snake bite': {'ja': 'スネークバイト', 'zh': '蛇吻'}, + 'sonic sensor': {'ja': 'ソニックセンサー', 'zh': '声感陷阱'}, + 'sova': {'ja': 'ソーヴァ', 'zh': '猎枭'}, + 'special delivery': {'ja': 'スペシャルデリバリー', 'zh': '特快专递'}, + 'spectre': {'ja': 'スペクター', 'zh': '骇灵'}, + 'spycam': {'ja': 'スパイカメラ', 'zh': '战术监控'}, + 'stealth drone': {'ja': 'ステルスドローン', 'zh': '潜袭爬虫'}, + 'steel garden': {'ja': 'スチールガーデン', 'zh': '铁棘禁园'}, + 'stim beacon': {'ja': 'スティムビーコン', 'zh': '振奋信标'}, + 'stinger': {'ja': 'スティンガー', 'zh': '蜂刺'}, + 'storm surge': {'ja': 'ストームサージ', 'zh': '乱涌'}, + 'suspension': {'ja': 'サスペンション', 'zh': '万念皆空'}, + 'tailwind': {'ja': 'テイルウィンド', 'zh': '逐风'}, + 'tejo': {'ja': 'テホ', 'zh': '钛狐'}, + 'telekinesis': {'ja': 'テレキネシス', 'zh': '念力'}, + 'terror trails': {'ja': 'テラートレイル', 'zh': '魇迹'}, + 'thrash': {'ja': 'スラッシュ', 'zh': '无敌超鲨'}, + 'tour de force': {'ja': 'ツール・ド・フォース', 'zh': '孤高火力'}, + 'toxic screen': {'ja': 'トキシックスクリーン', 'zh': '毒幕'}, + 'toxin': {'ja': 'トキシン', 'zh': '虺毒'}, + 'trademark': {'ja': 'トレードマーク', 'zh': '贵宾限行'}, + 'trailblazer': {'ja': 'トレイルブレイザー', 'zh': '辟林之虎'}, + 'trapwire': {'ja': 'トラップワイヤー', 'zh': '震慑绊线'}, + 'turret': {'ja': 'タレット', 'zh': '哨戒炮台'}, + 'undercut': {'ja': 'アンダーカット', 'zh': '稳态剥离'}, + 'updraft': {'ja': 'アップドラフト', 'zh': '凌空'}, + 'vandal': {'ja': 'ヴァンダル', 'zh': '狂徒'}, + 'veto': {'ja': 'ヴィトー', 'zh': '禁灭'}, + 'viper': {'ja': 'ヴァイパー', 'zh': '蝰蛇'}, + 'viper\'s pit': {'ja': 'ヴァイパーズピット', 'zh': '蝰腹'}, + 'vitality': {'ja': 'バイタリティ', 'zh': '魂息'}, + 'vyse': {'ja': 'ヴァイス', 'zh': '维斯'}, + 'warden': {'ja': 'ウォーデン', 'zh': '悍狼'}, + 'waveform': {'ja': 'ウェーブフォーム', 'zh': '声波帷幕'}, + 'waylay': {'ja': 'ウェイレイ', 'zh': '幻棱'}, + 'wingman': {'ja': 'ウィングマン', 'zh': '顽皮搭档'}, + 'yoru': {'ja': 'ヨル', 'zh': '夜露'}, + 'zero/point': {'ja': 'ゼロ/ポイント', 'zh': '零点嗅探'}, +}; + +/// The same for maps. Kept apart because a map and an ability can share an +/// English name. +const officialMapNames = >{ + 'abyss': {'ja': 'アビス', 'zh': '幽邃地窟'}, + 'ascent': {'ja': 'アセント', 'zh': '亚海悬城'}, + 'basic training': {'ja': '基本トレーニング', 'zh': '基础训练'}, + 'bind': {'ja': 'バインド', 'zh': '源工重镇'}, + 'breeze': {'ja': 'ブリーズ', 'zh': '微风岛屿'}, + 'corrode': {'ja': 'カロード', 'zh': '盐海矿镇'}, + 'district': {'ja': 'ディストリクト', 'zh': '商街'}, + 'drift': {'ja': 'ドリフト', 'zh': '渔市'}, + 'fracture': {'ja': 'フラクチャー', 'zh': '裂变峡谷'}, + 'gauntlet': {'ja': 'ガントレット', 'zh': '异变轮战'}, + 'glitch': {'ja': 'グリッチ', 'zh': '乱次元'}, + 'haven': {'ja': 'ヘイヴン', 'zh': '隐世修所'}, + 'icebox': {'ja': 'アイスボックス', 'zh': '森寒冬港'}, + 'kasbah': {'ja': 'カスバ', 'zh': '古城'}, + 'lotus': {'ja': 'ロータス', 'zh': '莲华古城'}, + 'pearl': {'ja': 'パール', 'zh': '深海明珠'}, + 'piazza': {'ja': 'ピアッツァ', 'zh': '小镇'}, + 'skirmish a': {'ja': 'スカーミッシュ A', 'zh': '斗牛 1'}, + 'skirmish b': {'ja': 'スカーミッシュ B', 'zh': '斗牛 2'}, + 'skirmish c': {'ja': 'スカーミッシュ C', 'zh': '斗牛 3'}, + 'skirmish d': {'ja': 'スカーミッシュ D', 'zh': '斗牛 4'}, + 'skirmish e': {'ja': 'スカーミッシュ E', 'zh': '斗牛 5'}, + 'split': {'ja': 'スプリット', 'zh': '霓虹町'}, + 'summit': {'ja': 'サミット', 'zh': '天枢云阙'}, + 'sunset': {'ja': 'サンセット', 'zh': '日落之城'}, + 'the range': {'ja': '射撃場', 'zh': '靶场'}, +}; diff --git a/lib/l10n/page_names.dart b/lib/l10n/page_names.dart new file mode 100644 index 000000000..9f4f1b00e --- /dev/null +++ b/lib/l10n/page_names.dart @@ -0,0 +1,15 @@ +import 'package:icarus/l10n/generated/app_localizations.dart'; + +final _autoName = RegExp(r'^Page (\d{1,9})$'); + +/// A page's name as drawn. A page nobody named is saved as "Page 3" whatever +/// the language, so each person reads it in their own. [name] stays what is +/// saved, sent and compared. +String shownPageName( + AppLocalizations l10n, { + required String name, + required bool isAutoNamed, +}) { + final number = isAutoNamed ? _autoName.firstMatch(name)?.group(1) : null; + return number == null ? name : l10n.pagesBarAutoName(int.parse(number)); +} diff --git a/lib/main.dart b/lib/main.dart index 20e44dd95..63679a458 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -3,6 +3,7 @@ import 'dart:developer' as developer; import 'dart:ui' show PlatformDispatcher; import 'package:app_links/app_links.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/collab/cloud_library_copy.dart'; import 'package:icarus/config/cloud_startup.dart'; import 'package:icarus/config/drive_build.dart'; @@ -316,6 +317,7 @@ Future main(List args) async { error: error, stackTrace: stackTrace, source: 'main.runZonedGuarded', + toastMessage: _crashToastWords().appErrorUnexpected, ); }, ); @@ -368,6 +370,7 @@ void _installGlobalErrorHandlers() { error: details.exception, stackTrace: details.stack, source: 'FlutterError.onError', + toastMessage: _crashToastWords().appErrorUi, ); }; @@ -377,11 +380,20 @@ void _installGlobalErrorHandlers() { error: error, stackTrace: stackTrace, source: 'PlatformDispatcher.onError', + toastMessage: _crashToastWords().appErrorAsync, ); return true; }; } +/// The words for a crash toast. These handlers can run before the provider +/// container exists, so this follows the system's languages and not one +/// picked in settings. +AppLocalizations _crashToastWords() => lookupAppLocalizations( + resolveAppLocale( + picked: null, system: PlatformDispatcher.instance.locales), + ); + Future _initializePersistedDebugLog() async { if (kIsWeb) return; @@ -456,19 +468,20 @@ class _MyAppState extends ConsumerState { Future _loadFromFilePathWithWarning(String filePath) async { try { showImportResult( + ref.read(l10nProvider), await StrategyImportExportService(ref).loadFromFilePath(filePath), source: 'MyApp._loadFromFilePathWithWarning', ); } on NewerVersionImportException catch (error, stackTrace) { AppErrorReporter.reportError( - NewerVersionImportException.userMessage, + NewerVersionImportException.userMessage(ref.read(l10nProvider)), error: error, stackTrace: stackTrace, source: 'MyApp._loadFromFilePathWithWarning', ); } catch (error, stackTrace) { AppErrorReporter.reportError( - 'Failed to import strategy file.', + ref.read(l10nProvider).appImportFailed, error: error, stackTrace: stackTrace, source: 'MyApp._loadFromFilePathWithWarning', @@ -601,10 +614,17 @@ class _MyAppState extends ConsumerState { final cloudUnavailableReason = ref.read(cloudStartupProvider).unavailableReason; if (cloudUnavailableReason != null) { + final l10n = ref.read(l10nProvider); AppErrorReporter.reportWarning( cloudUnavailableReason, source: 'main.cloudStartup', promptUser: true, + toastMessage: switch (cloudUnavailableReason) { + cloudOutboxUnavailableReason => l10n.startupCloudOutboxUnavailable, + cloudClientsUnavailableReason => + l10n.startupCloudClientsUnavailable, + _ => cloudUnavailableReason, + }, ); } @@ -714,6 +734,7 @@ class _MyAppState extends ConsumerState { await _discordPresence.update( DiscordPresenceData.fromAppState( + l10n: ref.read(l10nProvider), strategy: ref.read(strategyProvider), map: ref.read(mapProvider), agentCount: ref.read(agentProvider).length, @@ -786,6 +807,9 @@ class _MyAppState extends ConsumerState { ), child: ShadApp( navigatorKey: appNavigatorKey, + locale: ref.watch(appLocaleProvider), + supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: AppLocalizations.localizationsDelegates, navigatorObservers: [mouseNavigationRouteObserver], themeMode: ThemeMode.dark, darkTheme: ShadThemeData( diff --git a/lib/providers/auth_provider.dart b/lib/providers/auth_provider.dart index a8e00c86c..343917abc 100644 --- a/lib/providers/auth_provider.dart +++ b/lib/providers/auth_provider.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'dart:developer'; import 'package:icarus/collab/cloud_library_copy.dart'; -import 'package:icarus/collab/cloud_sync_error_message.dart'; import 'package:icarus/collab/convex_client.dart'; import 'package:icarus/collab/convex_strategy_repository.dart'; import 'package:flutter/foundation.dart' show kIsWeb; @@ -12,6 +11,8 @@ import 'package:icarus/config/cloud_startup.dart'; import 'package:icarus/config/platform_policy.dart'; import 'package:icarus/const/app_navigator.dart'; import 'package:icarus/const/settings.dart'; +import 'package:icarus/l10n/cloud_sync_error_text.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/collab/client_upgrade_required_provider.dart'; import 'package:icarus/services/app_error_reporter.dart'; import 'package:icarus/services/auth_callback_uri.dart'; @@ -236,7 +237,7 @@ class AppAuthState { bool get hasActiveAuthIncident => activeAuthIncidentId != null; - String get displayName { + String displayName(AppLocalizations l10n) { final metadata = user?.userMetadata ?? const {}; // Metadata values are user/provider controlled — tolerate non-string // entries instead of crashing the widget build. @@ -249,7 +250,9 @@ class AppAuthState { stringEntry('name') ?? stringEntry('user_name') ?? user?.email; - return (name?.isNotEmpty ?? false) ? name! : 'Discord user'; + return (name?.isNotEmpty ?? false) + ? name! + : l10n.authMsgDisplayNameFallback; } String? get avatarUrl { @@ -628,8 +631,10 @@ class AuthProvider extends Notifier { static const _passwordResetCooldown = Duration(seconds: 60); /// A reset request still waiting on Supabase, and its address. - ({String email, Future<({String message, bool sent})> result})? - _pendingPasswordReset; + ({ + String email, + Future<({String message, bool sent})> result + })? _pendingPasswordReset; @visibleForTesting static AuthProviderSupabaseApi? debugSupabaseApi; @@ -876,7 +881,7 @@ class AuthProvider extends Notifier { isLoading: false, isConvexUserReady: false, convexAuthStatus: ConvexAuthStatus.incident, - errorMessage: 'Auth stream error: $error', + errorMessage: ref.read(l10nProvider).authMsgStreamError('$error'), ); } @@ -914,7 +919,7 @@ class AuthProvider extends Notifier { convexAuthStatus: ConvexAuthStatus.incident, errorMessage: error is CloudUnavailableException ? error.message - : 'Discord sign-in failed: $error', + : ref.read(l10nProvider).authMsgDiscordSignInFailed('$error'), ); ProductMetrics.instance.signIn(method: 'discord', succeeded: false); return; @@ -941,7 +946,7 @@ class AuthProvider extends Notifier { ); if (response.session == null) { - const message = "Sign in didn't complete. Please try again."; + final message = ref.read(l10nProvider).authMsgSignInIncomplete; state = state.copyWith( isLoading: false, isConvexUserReady: false, @@ -961,7 +966,7 @@ class AuthProvider extends Notifier { error: error, stackTrace: stackTrace, ); - final message = _friendlySignInError(error); + final message = _friendlySignInError(error, ref.read(l10nProvider)); state = state.copyWith( isLoading: false, isConvexUserReady: false, @@ -973,61 +978,57 @@ class AuthProvider extends Notifier { } } - static String _friendlySignInError(Object error) { + static String _friendlySignInError(Object error, AppLocalizations l10n) { if (error is CloudUnavailableException) return error.message; final message = error.toString().toLowerCase(); if (message.contains('invalid login credentials') || message.contains('invalid_credentials')) { - return 'Incorrect email or password.'; + return l10n.authMsgIncorrectCredentials; } if (message.contains('email not confirmed')) { - return 'Confirm your email first — check your inbox for the ' - 'confirmation link.'; + return l10n.authMsgEmailNotConfirmed; } if (message.contains('rate limit') || message.contains('too many')) { - return 'Too many attempts. Wait a moment and try again.'; + return l10n.authMsgTooManyAttempts; } if (message.contains('socket') || message.contains('network') || message.contains('timed out') || message.contains('failed host lookup')) { - return "Couldn't reach the server. Check your connection and try again."; + return l10n.authMsgServerUnreachable; } - return 'Sign in failed. Please try again.'; + return l10n.authMsgSignInFailed; } - static String _friendlySignUpError(Object error) { + static String _friendlySignUpError(Object error, AppLocalizations l10n) { if (error is CloudUnavailableException) return error.message; final message = error.toString().toLowerCase(); if (message.contains('already registered') || message.contains('already exists')) { - return 'An account with this email already exists. Try signing in ' - 'instead.'; + return l10n.authMsgSignUpAccountExists; } // Supabase lets each address ask once a minute, and this one just did: // the email from that request is on its way or already there. if (message.contains('for security purposes')) { - return 'We already sent a confirmation email to this address. Check ' - 'your inbox and spam, then open the link.'; + return l10n.authMsgSignUpAlreadySent; } // Supabase hit its project-wide email limit or its mail server failed. // No account is created until the confirmation email goes out, and // waiting "a moment" doesn't bring the limit back, so say so. if (message.contains('over_email_send_rate_limit') || message.contains('error sending confirmation email')) { - return "We couldn't send your confirmation email right now. Try again " - 'later, or continue with Discord.'; + return l10n.authMsgConfirmationEmailUnavailable; } if (message.contains('rate limit') || message.contains('too many')) { - return 'Too many attempts. Wait a moment and try again.'; + return l10n.authMsgTooManyAttempts; } if (message.contains('socket') || message.contains('network') || message.contains('timed out') || message.contains('failed host lookup')) { - return "Couldn't reach the server. Check your connection and try again."; + return l10n.authMsgServerUnreachable; } - return 'Sign up failed. Please try again.'; + return l10n.authMsgSignUpFailed; } Future signUpWithEmailPassword({ @@ -1052,8 +1053,8 @@ class AuthProvider extends Notifier { // so strangers can't probe which emails are registered. The account // may have been made with Discord, so name both ways back in. if (response.user?.identities?.isEmpty ?? false) { - const message = 'An account with this email already exists. Sign in ' - 'instead, or continue with Discord if you signed up that way.'; + final message = + ref.read(l10nProvider).authMsgSignUpAccountExistsMaybeDiscord; state = state.copyWith( isLoading: false, isConvexUserReady: false, @@ -1065,9 +1066,7 @@ class AuthProvider extends Notifier { } if (response.session == null) { - const message = - 'Account created! Check your inbox for the confirmation link, ' - 'then sign in.'; + final message = ref.read(l10nProvider).authMsgSignUpCheckInbox; state = state.copyWith( isLoading: false, isConvexUserReady: false, @@ -1088,7 +1087,7 @@ class AuthProvider extends Notifier { error: error, stackTrace: stackTrace, ); - final message = _friendlySignUpError(error); + final message = _friendlySignUpError(error, ref.read(l10nProvider)); state = state.copyWith( isLoading: false, isConvexUserReady: false, @@ -1108,7 +1107,6 @@ class AuthProvider extends Notifier { // that exchanges with the verifier this device saves with the request. // Supabase drops that request after five minutes, however long the email // link itself lasts. - const where = kIsWeb ? 'in this browser' : 'on this computer'; final address = email.toLowerCase(); final lastSent = _lastPasswordReset; // Supabase refuses a second request for an address within a minute, but @@ -1117,9 +1115,11 @@ class AuthProvider extends Notifier { if (lastSent != null && lastSent.email == address && DateTime.now().difference(lastSent.at) < _passwordResetCooldown) { + final l10n = ref.read(l10nProvider); return ( - message: 'A link is already on its way. Check your inbox and spam, ' - 'and open it $where within 5 minutes.', + message: kIsWeb + ? l10n.authMsgPasswordResetAlreadySentBrowser + : l10n.authMsgPasswordResetAlreadySentComputer, sent: true, ); } @@ -1128,7 +1128,7 @@ class AuthProvider extends Notifier { final pending = _pendingPasswordReset; if (pending != null && pending.email == address) return pending.result; - final result = _requestPasswordReset(email, where: where); + final result = _requestPasswordReset(email); _pendingPasswordReset = (email: address, result: result); try { return await result; @@ -1140,18 +1140,19 @@ class AuthProvider extends Notifier { } Future<({String message, bool sent})> _requestPasswordReset( - String email, { - required String where, - }) async { + String email, + ) async { try { await _supabaseApi.resetPasswordForEmail( email, redirectTo: currentAuthRedirectUri().toString(), ); _lastPasswordReset = (email: email.toLowerCase(), at: DateTime.now()); + final l10n = ref.read(l10nProvider); return ( - message: 'If an account uses this email, a link to set a new ' - 'password is on its way. Open it $where within 5 minutes.', + message: kIsWeb + ? l10n.authMsgPasswordResetSentBrowser + : l10n.authMsgPasswordResetSentComputer, sent: true, ); } catch (error, stackTrace) { @@ -1161,26 +1162,32 @@ class AuthProvider extends Notifier { error: error, stackTrace: stackTrace, ); - return (message: _friendlyPasswordResetError(error), sent: false); + return ( + message: _friendlyPasswordResetError(error, ref.read(l10nProvider)), + sent: false, + ); } } - static String _friendlyPasswordResetError(Object error) { + static String _friendlyPasswordResetError( + Object error, + AppLocalizations l10n, + ) { if (error is CloudUnavailableException) return error.message; final message = error.toString().toLowerCase(); // Supabase lets each address ask once a minute. if (message.contains('for security purposes') || message.contains('over_email_send_rate_limit') || message.contains('rate limit')) { - return 'A link was sent a moment ago. Wait a minute, then try again.'; + return l10n.authMsgPasswordResetRateLimited; } if (message.contains('socket') || message.contains('network') || message.contains('timed out') || message.contains('failed host lookup')) { - return "Couldn't reach the server. Check your connection and try again."; + return l10n.authMsgServerUnreachable; } - return "Couldn't send the email. Please try again."; + return l10n.authMsgPasswordResetFailed; } /// Sets the signed-in user's password. Returns an error message, or null @@ -1197,30 +1204,33 @@ class AuthProvider extends Notifier { error: error, stackTrace: stackTrace, ); - return _friendlyNewPasswordError(error); + return _friendlyNewPasswordError(error, ref.read(l10nProvider)); } } - static String _friendlyNewPasswordError(Object error) { + static String _friendlyNewPasswordError( + Object error, + AppLocalizations l10n, + ) { if (error is CloudUnavailableException) return error.message; if (error is AuthSessionMissingException) { - return 'You were signed out. Ask for a new reset link from Sign in.'; + return l10n.authMsgNewPasswordSignedOut; } final message = error.toString().toLowerCase(); if (message.contains('same_password') || message.contains('different from the old password')) { - return "That's already your password. Choose a different one."; + return l10n.authMsgNewPasswordSame; } if (message.contains('weak_password')) { - return 'Choose a longer password.'; + return l10n.authMsgNewPasswordWeak; } if (message.contains('socket') || message.contains('network') || message.contains('timed out') || message.contains('failed host lookup')) { - return "Couldn't reach the server. Check your connection and try again."; + return l10n.authMsgServerUnreachable; } - return "Couldn't save your password. Please try again."; + return l10n.authMsgNewPasswordFailed; } Future signOut() async { @@ -1274,7 +1284,7 @@ class AuthProvider extends Notifier { isLoading: false, isConvexUserReady: false, convexAuthStatus: ConvexAuthStatus.incident, - errorMessage: 'Sign out failed. Please try again.', + errorMessage: ref.read(l10nProvider).authMsgSignOutFailed, ); return; } @@ -1282,8 +1292,8 @@ class AuthProvider extends Notifier { try { Settings.showToast( message: ref.read(platformPolicyProvider).allowsLocalLibrary - ? 'Signed out. Your local strategies stay on this device.' - : 'Signed out.', + ? ref.read(l10nProvider).authMsgSignedOutLocalKept + : ref.read(l10nProvider).authMsgSignedOut, backgroundColor: Settings.tacticalVioletTheme.primary, ); } catch (error, stackTrace) { @@ -1369,8 +1379,7 @@ class AuthProvider extends Notifier { ); try { Settings.showToast( - message: "You're already signed in, so that link wasn't used. To " - 'set a new password, sign out and ask for a new link.', + message: ref.read(l10nProvider).authMsgLinkIgnoredWhileSignedIn, backgroundColor: Settings.tacticalVioletTheme.secondary, autoCloseDuration: const Duration(seconds: 8), ); @@ -1446,7 +1455,7 @@ class AuthProvider extends Notifier { } // The toast is what the user reads, so the link stays in the logs. - const message = 'Failed to complete login. Please try again.'; + final message = ref.read(l10nProvider).authMsgSignInLinkFailed; AppErrorReporter.reportError( message, source: 'auth', @@ -1612,9 +1621,7 @@ class AuthProvider extends Notifier { convexAuthStatus: ConvexAuthStatus.incident, activeAuthIncidentId: incidentId, lastAuthIncidentSource: source, - errorMessage: - 'Your cloud session expired. Reconnect to resume syncing, or ' - 'sign out.', + errorMessage: ref.read(l10nProvider).authMsgCloudSessionExpired, ); log( @@ -1966,8 +1973,8 @@ class AuthProvider extends Notifier { isConvexUserReady: false, convexAuthStatus: ConvexAuthStatus.incident, errorMessage: upgradeRequired - ? clientUpgradeRequiredMessage() - : "Couldn't connect to cloud sync. Please retry.", + ? clientUpgradeRequiredMessage(ref.read(l10nProvider)) + : ref.read(l10nProvider).authMsgCloudSyncConnectFailed, ); } } @@ -2005,10 +2012,8 @@ class AuthProvider extends Notifier { _showingIncidentPrompt = true; state = state.copyWith(isAuthIncidentPromptOpen: true); - final localStrategiesNote = - ref.read(platformPolicyProvider).allowsLocalLibrary - ? ' Local strategies are unaffected.' - : ''; + final allowsLocalLibrary = + ref.read(platformPolicyProvider).allowsLocalLibrary; try { final action = await showShadDialog<_AuthIncidentAction>( @@ -2016,14 +2021,15 @@ class AuthProvider extends Notifier { barrierDismissible: false, builder: (context) { _incidentPromptContext = context; + final l10n = context.l10n; return ShadDialog.alert( - title: const Text('Cloud connection lost'), + title: Text(l10n.authMsgIncidentTitle), description: Padding( padding: const EdgeInsets.symmetric(vertical: 8), child: Text( - 'Your cloud session is no longer valid, so syncing is ' - 'paused. Reconnect to keep your cloud strategies up to ' - 'date, or sign out.$localStrategiesNote', + allowsLocalLibrary + ? l10n.authMsgIncidentDescriptionWithLocal + : l10n.authMsgIncidentDescription, ), ), actions: [ @@ -2031,19 +2037,19 @@ class AuthProvider extends Notifier { onPressed: () { Navigator.of(context).pop(_AuthIncidentAction.dismiss); }, - child: const Text('Not Now'), + child: Text(l10n.authMsgIncidentNotNow), ), ShadButton.secondary( onPressed: () { Navigator.of(context).pop(_AuthIncidentAction.signOut); }, - child: const Text('Sign Out'), + child: Text(l10n.commonSignOut), ), ShadButton( onPressed: () { Navigator.of(context).pop(_AuthIncidentAction.retry); }, - child: const Text('Reconnect'), + child: Text(l10n.commonReconnect), ), ], ); diff --git a/lib/providers/collab/cloud_media_upload_queue_provider.dart b/lib/providers/collab/cloud_media_upload_queue_provider.dart index 890a49c3f..832f94cee 100644 --- a/lib/providers/collab/cloud_media_upload_queue_provider.dart +++ b/lib/providers/collab/cloud_media_upload_queue_provider.dart @@ -13,6 +13,7 @@ import 'package:icarus/collab/pending_media_bytes_store.dart'; import 'package:icarus/const/line_provider.dart'; import 'package:icarus/const/placed_classes.dart'; import 'package:icarus/const/settings.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/auth_provider.dart'; import 'package:icarus/providers/collab/client_upgrade_required_provider.dart'; import 'package:icarus/providers/collab/cloud_collab_provider.dart'; @@ -571,6 +572,7 @@ class CloudMediaUploadQueueNotifier error: error, stackTrace: stackTrace, source: 'cloud_media.upload_queue', + toastMessage: ref.read(l10nProvider).mediaUploadReleaseBytesFailed, ); } } @@ -586,6 +588,7 @@ class CloudMediaUploadQueueNotifier error: error, stackTrace: stackTrace, source: 'cloud_media.upload_queue', + toastMessage: ref.read(l10nProvider).mediaUploadPruneBytesFailed, ); } } @@ -1144,7 +1147,7 @@ class CloudMediaUploadQueueNotifier .noteError(errorMessage); if (showToast && !upgradeRequired) { Settings.showToast( - message: 'Media upload failed. Tap Save to retry cloud sync.', + message: ref.read(l10nProvider).mediaUploadFailedToast, backgroundColor: Settings.tacticalVioletTheme.destructive, ); } @@ -1562,6 +1565,7 @@ class CloudMediaUploadQueueNotifier error: error, stackTrace: stackTrace, source: 'cloud_media.upload_queue', + toastMessage: ref.read(l10nProvider).mediaUploadOutboxUpdateFailed, ); _refreshState(isProcessing: false); } @@ -1625,8 +1629,8 @@ class CloudMediaUploadQueueNotifier final toastState = _uploadProgressToastState; if (toastState != null && _uploadCompletedJobs.isNotEmpty) { - toastState.value = const _UploadProgressToastState( - message: 'Image upload complete', + toastState.value = _UploadProgressToastState( + message: ref.read(l10nProvider).mediaUploadComplete, progress: 1, isComplete: true, ); @@ -1653,9 +1657,7 @@ class CloudMediaUploadQueueNotifier : _uploadProgressTotalJobs; final progress = totalJobs <= 0 ? 0.0 : _currentUploadProgress(totalJobs); return _UploadProgressToastState( - message: activeUploadCount == 1 - ? 'Uploading image...' - : 'Uploading $activeUploadCount images...', + message: ref.read(l10nProvider).mediaUploadProgress(activeUploadCount), progress: progress, isComplete: false, ); diff --git a/lib/providers/collab/cloud_sync_status_provider.dart b/lib/providers/collab/cloud_sync_status_provider.dart index 5d685f0fd..54743f85a 100644 --- a/lib/providers/collab/cloud_sync_status_provider.dart +++ b/lib/providers/collab/cloud_sync_status_provider.dart @@ -1,4 +1,5 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/auth_provider.dart'; import 'package:icarus/providers/collab/client_upgrade_required_provider.dart'; import 'package:icarus/providers/collab/cloud_media_upload_queue_provider.dart'; @@ -130,7 +131,6 @@ final openStrategyUnsentChangesProvider = Provider((ref) { /// The sync status while offline, with [waiting] changes saved on this /// computer that have not reached the server. -String offlineSyncLabel(int waiting) => waiting == 0 - ? 'Offline, changes stay on this device' - : 'Offline · $waiting ${waiting == 1 ? 'change' : 'changes'} saved on ' - 'this computer'; +String offlineSyncLabel(AppLocalizations l10n, int waiting) => waiting == 0 + ? l10n.syncStatusOfflineNothingWaiting + : l10n.syncStatusOfflineChangesSaved(waiting); diff --git a/lib/providers/collab/media_bytes_source.dart b/lib/providers/collab/media_bytes_source.dart index 9734f3aaa..d8953a880 100644 --- a/lib/providers/collab/media_bytes_source.dart +++ b/lib/providers/collab/media_bytes_source.dart @@ -2,6 +2,7 @@ import 'dart:typed_data'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/collab/pending_media_bytes_store.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/services/local_image_file.dart'; /// The bytes one cloud media upload sends. @@ -57,7 +58,7 @@ class MediaTooLargeException implements Exception { const MediaTooLargeException(this.byteSize); final int byteSize; - String get userMessage => 'This image is over 15 MB. Pick a smaller one.'; + String userMessage(AppLocalizations l10n) => l10n.mediaUploadImageTooLarge; @override String toString() => diff --git a/lib/providers/collab/move_to_cloud_provider.dart b/lib/providers/collab/move_to_cloud_provider.dart index 7b9ed7ecd..9ffd53061 100644 --- a/lib/providers/collab/move_to_cloud_provider.dart +++ b/lib/providers/collab/move_to_cloud_provider.dart @@ -15,6 +15,7 @@ import 'package:icarus/const/maps.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/const/sort_index_order.dart'; import 'package:icarus/domain/folder.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/auth_provider.dart'; import 'package:icarus/providers/collab/cloud_copy_scope_provider.dart'; import 'package:icarus/providers/collab/cloud_media_upload_queue_provider.dart'; @@ -190,30 +191,18 @@ class MoveToCloudNotifier extends Notifier { if (!importing) { _showMessage( leavesFolder - ? 'Moved "$name" to the cloud. It\'s in My Library.' - : 'Moved "$name" to the cloud.', + ? _l10n.moveToCloudMovedToMyLibrary(name) + : _l10n.moveToCloudMoved(name), ); } case _Outcome.notMoved: - _showFailure( - "Couldn't move \"$name\" to the cloud. " - "It's still on this device.", - ); + _showFailure(_l10n.moveToCloudNotMoved(name)); case _Outcome.changedMeanwhile: - _showFailure( - '"$name" changed while it was moving, so it stays on this ' - 'device. Move it again to put the latest version in the cloud.', - ); + _showFailure(_l10n.moveToCloudChangedMeanwhile(name)); case _Outcome.movedLocalKept: - _showFailure( - 'Moved "$name" to the cloud, but its copy on this device ' - "couldn't be removed.", - ); + _showFailure(_l10n.moveToCloudLocalCopyKept(name)); case _Outcome.movedChangedHere: - _showFailure( - 'Moved "$name" to the cloud. It changed while moving, so this ' - 'device keeps the latest version too.', - ); + _showFailure(_l10n.moveToCloudChangedHere(name)); } } finally { _unmarkMoving({localId}); @@ -266,10 +255,7 @@ class MoveToCloudNotifier extends Notifier { strategies: strategies.length, outcome: 'not_moved', ); - _showFailure( - "Couldn't move \"${root.name}\" to the cloud. " - "It's still on this device.", - ); + _showFailure(_l10n.moveToCloudNotMoved(root.name)); return; } @@ -301,17 +287,13 @@ class MoveToCloudNotifier extends Notifier { ); if (failed == 0 && foldersWent) { - if (!importing) _showMessage('Moved "${root.name}" to the cloud.'); + if (!importing) _showMessage(_l10n.moveToCloudMoved(root.name)); } else if (failed == 0) { - _showFailure( - 'Moved the strategies in "${root.name}" to the cloud, but some ' - 'of its folders stayed on this device.', - ); + _showFailure(_l10n.moveToCloudFoldersStayed(root.name)); } else { final total = strategies.length; _showFailure( - 'Moved ${total - failed} of $total strategies in ' - '"${root.name}". The rest are still on this device.', + _l10n.moveToCloudSomeMoved(total - failed, total, root.name), ); } } catch (error, stackTrace) { @@ -321,10 +303,7 @@ class MoveToCloudNotifier extends Notifier { strategies: strategies.length, outcome: 'failed', ); - _showFailure( - "Couldn't finish moving \"${root.name}\". What didn't move is " - 'still on this device.', - ); + _showFailure(_l10n.moveToCloudFolderFailed(root.name)); } finally { _unmarkMoving(localIds); } @@ -343,18 +322,16 @@ class MoveToCloudNotifier extends Notifier { if (items.isEmpty) return; final accountId = ref.read(cloudMediaAccountIdProvider); if (accountId == null) { - _showFailure('You signed out, so "$name" stays on this device.'); + _showFailure(_l10n.moveToCloudSignedOut(name)); return; } if (!_canMoveFor(accountId, name)) return; - _showMessage('Importing "$name" to the cloud…'); + _showMessage(_l10n.moveToCloudImporting(name)); for (final item in items) { // Each move alone would stay quiet about a sign-out; the import says // once what didn't go. if (ref.read(cloudMediaAccountIdProvider) != accountId) { - _showFailure( - 'You signed out, so the rest of "$name" stays on this device.', - ); + _showFailure(_l10n.moveToCloudSignedOutRest(name)); return; } await (item.isFolder @@ -431,19 +408,14 @@ class MoveToCloudNotifier extends Notifier { /// Whether a move asked for by [accountId] can start now. Says why not. bool _canMoveFor(String accountId, String name) { if (ref.read(cloudMediaAccountIdProvider) != accountId) { - _showFailure( - 'You signed in to another account, so "$name" stays on this device.', - ); + _showFailure(_l10n.moveToCloudOtherAccount(name)); return false; } if (ref.read(authProvider).isConvexUserReady && ref.read(convexConnectionSnapshotProvider)) { return true; } - _showFailure( - "Can't reach the cloud right now, so \"$name\" stays on this device. " - 'Try again when you are online.', - ); + _showFailure(_l10n.moveToCloudOffline(name)); return false; } @@ -1012,6 +984,8 @@ class MoveToCloudNotifier extends Notifier { ); } + AppLocalizations get _l10n => ref.read(l10nProvider); + void _showMessage(String message) => ref.read(moveToCloudMessageProvider)(message); diff --git a/lib/providers/collab/remote_strategy_snapshot_provider.dart b/lib/providers/collab/remote_strategy_snapshot_provider.dart index 84c8be5cb..8e7e79beb 100644 --- a/lib/providers/collab/remote_strategy_snapshot_provider.dart +++ b/lib/providers/collab/remote_strategy_snapshot_provider.dart @@ -41,11 +41,6 @@ class StrategyAccessLost implements Exception { String toString() => 'StrategyAccessLost($strategyPublicId)'; } -/// What a reader is told when they open a cloud strategy offline that this -/// computer keeps no copy of. -const strategyNeedsConnectionMessage = - 'This strategy needs a connection the first time you open it.'; - /// The error [remoteEditorSnapshotProvider] holds when a cloud strategy is /// opened while the server can't answer and this computer keeps no copy of /// it: it has to be opened online once first. diff --git a/lib/providers/collab/replay_capture_queue_provider.dart b/lib/providers/collab/replay_capture_queue_provider.dart index 1b4e73161..91b59126c 100644 --- a/lib/providers/collab/replay_capture_queue_provider.dart +++ b/lib/providers/collab/replay_capture_queue_provider.dart @@ -9,6 +9,7 @@ import 'package:icarus/collab/convex_strategy_repository.dart'; import 'package:icarus/collab/replay_capture_record.dart'; import 'package:icarus/const/hive_boxes.dart'; import 'package:icarus/const/settings.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/auth_provider.dart'; import 'package:icarus/providers/collab/client_upgrade_required_provider.dart'; import 'package:icarus/providers/collab/cloud_media_upload_queue_provider.dart'; @@ -16,6 +17,15 @@ import 'package:icarus/providers/collab/convex_connection_provider.dart'; import 'package:icarus/services/app_error_reporter.dart'; import 'package:uuid/uuid.dart'; +/// What stopped a capture on this device. +enum ReplayCaptureStuckCause { + /// Its progress couldn't be stored. + storage, + + /// Something else on this device failed. + device, +} + /// Replay captures waiting to be saved to the cloud, and the ones the /// server refused. class ReplayCaptureQueueState { @@ -42,8 +52,8 @@ class ReplayCaptureQueueState { final Set lostStrategyIds; /// Captures an error on this device stopped (their progress couldn't be - /// stored), by id, with what to tell the user. They wait for Retry. - final Map stuck; + /// stored), by id, with what stopped them. They wait for Retry. + final Map stuck; /// Whether every capture is readable and its progress stored. bool get isReliable => unreadable == 0 && stuck.isEmpty; @@ -132,7 +142,7 @@ class ReplayCaptureQueue extends Notifier { final Set _discardedPageIds = {}; final Set _lostStrategyIds = {}; - final Map _stuck = {}; + final Map _stuck = {}; @override ReplayCaptureQueueState build() { @@ -491,8 +501,7 @@ class ReplayCaptureQueue extends Notifier { final moved = await _save(c.copyWith(clearFolder: true)); await create(null); ref.read(replayCaptureMessageProvider)( - '"${c.strategyName}" is at the top level of My Library: the folder ' - 'it was captured into is gone or not yours.', + ref.read(l10nProvider).opQueueReplayCaptureFolderGone(c.strategyName), ); return moved; } @@ -655,8 +664,8 @@ class ReplayCaptureQueue extends Notifier { promptUser: false, ); _stuck[capture.id] = error is _StorageFailed - ? "Icarus couldn't store its progress on this device." - : 'Something on this device stopped it.'; + ? ReplayCaptureStuckCause.storage + : ReplayCaptureStuckCause.device; try { _refresh(); } catch (_) { diff --git a/lib/providers/desktop_update_provider.dart b/lib/providers/desktop_update_provider.dart index fc9cd86fc..f9282094e 100644 --- a/lib/providers/desktop_update_provider.dart +++ b/lib/providers/desktop_update_provider.dart @@ -4,23 +4,25 @@ import 'package:desktop_updater/desktop_updater.dart'; import 'package:flutter/foundation.dart' show debugPrint, kDebugMode, kIsWeb; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/settings.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/update_status_provider.dart'; import 'package:icarus/services/windows_desktop_update_controller.dart'; -const desktopUpdateLocalization = DesktopUpdateLocalization( - updateAvailableText: 'Update Available', - newVersionAvailableText: '{} {} is available', - newVersionLongText: - 'A desktop update is ready. Downloading will fetch {} MB of files.', - downloadText: 'Download Update', - restartText: 'Restart to update', - skipThisVersionText: 'Later', - warningTitleText: 'Restart Required', - restartWarningText: - 'Icarus needs to restart to finish installing the update. Unsaved changes will be lost. Restart now?', - warningCancelText: 'Not now', - warningConfirmText: 'Restart', -); +/// The updater's words. The package fills each `{}` in order (app name, +/// version, download size). +DesktopUpdateLocalization desktopUpdateLocalization(AppLocalizations l10n) => + DesktopUpdateLocalization( + updateAvailableText: l10n.updateMsgUpdateAvailable, + newVersionAvailableText: l10n.updateMsgNewVersionAvailable('{}', '{}'), + newVersionLongText: l10n.updateMsgNewVersionLong('{}'), + downloadText: l10n.updateMsgDownload, + restartText: l10n.updateMsgRestartToUpdate, + skipThisVersionText: l10n.updateMsgLater, + warningTitleText: l10n.updateMsgRestartRequiredTitle, + restartWarningText: l10n.updateMsgRestartWarning, + warningCancelText: l10n.updateMsgRestartCancel, + warningConfirmText: l10n.updateMsgRestartConfirm, + ); /// The in-app updater for direct Windows installs. Null everywhere else: /// the Store, macOS, and web update through their own channels, and the @@ -29,8 +31,9 @@ final desktopUpdateControllerProvider = Provider((ref) { if (kDebugMode && kDebugForceDesktopUpdateDialog) { final controller = WindowsDesktopUpdateController.debugPreview( - localization: desktopUpdateLocalization, + localization: desktopUpdateLocalization(ref.read(l10nProvider)), ); + _followLanguage(ref, controller); ref.onDispose(controller.dispose); return controller; } @@ -47,8 +50,17 @@ final desktopUpdateControllerProvider = ); final controller = WindowsDesktopUpdateController( appArchiveUrl: Settings.desktopUpdaterArchiveUrl, - localization: desktopUpdateLocalization, + localization: desktopUpdateLocalization(ref.read(l10nProvider)), ); + _followLanguage(ref, controller); ref.onDispose(controller.dispose); return controller; }); + +/// Keeps the updater's words in the app's language without replacing the +/// controller, which may be mid-download. +void _followLanguage(Ref ref, WindowsDesktopUpdateController controller) { + ref.listen(l10nProvider, (_, l10n) { + controller.localization = desktopUpdateLocalization(l10n); + }); +} diff --git a/lib/providers/folder_provider.dart b/lib/providers/folder_provider.dart index 809085005..dabb81fde 100644 --- a/lib/providers/folder_provider.dart +++ b/lib/providers/folder_provider.dart @@ -8,6 +8,7 @@ import 'package:icarus/const/folder_icons.dart'; import 'package:icarus/const/hive_boxes.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/domain/folder.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/collab/move_to_cloud_provider.dart'; import 'package:icarus/providers/auth_provider.dart'; import 'package:icarus/providers/collab/remote_library_provider.dart'; @@ -48,6 +49,7 @@ class FolderProvider extends Notifier { if (targetWorkspace == LibraryWorkspace.cloud) { if (refuseCloudChangeWhileOffline( + ref.read(l10nProvider), ref.read(cloudLibraryShowsCopyProvider), )) { throw StateError(cloudNeedsConnectionMessage); @@ -76,8 +78,7 @@ class FolderProvider extends Notifier { // Do NOT fall through to the local write: that silently created a // local folder from the cloud view whenever the server call failed. Settings.showToast( - message: "Couldn't create the cloud folder. Check your connection " - 'and try again.', + message: ref.read(l10nProvider).folderMsgCreateCloudFailed, backgroundColor: Settings.tacticalVioletTheme.destructive, ); rethrow; @@ -208,7 +209,7 @@ class FolderProvider extends Notifier { return true; }, source: 'folder:delete', - failureMessage: "Couldn't delete this cloud folder. Try again.", + failureMessage: ref.read(l10nProvider).folderMsgDeleteCloudFailed, ); // Refused or offline before anything went: the library is as it was. if (!result.didSucceed && deletedStrategies.isEmpty) return result; @@ -251,8 +252,7 @@ class FolderProvider extends Notifier { final moving = ref.read(movingToCloudProvider); if (folderIds.any(moving.contains) || strategyIds.any(moving.contains)) { return CloudLibraryActionResult.failed( - 'Something in this folder is moving to the cloud. Delete it once ' - 'the move ends.', + ref.read(l10nProvider).folderMsgDeleteWhileMoving, ); } @@ -306,7 +306,7 @@ class FolderProvider extends Notifier { return true; }, source: 'folder:update', - failureMessage: "Couldn't update this cloud folder. Try again.", + failureMessage: ref.read(l10nProvider).folderMsgUpdateCloudFailed, ); if (!result.didSucceed) return result; @@ -339,7 +339,7 @@ class FolderProvider extends Notifier { return true; }, source: 'folder:move', - failureMessage: "Couldn't move this cloud folder. Try again.", + failureMessage: ref.read(l10nProvider).folderMsgMoveCloudFailed, showFailureMessage: true, ); if (!result.didSucceed) return result; @@ -365,6 +365,7 @@ class FolderProvider extends Notifier { bool showFailureMessage = false, }) async { if (refuseCloudChangeWhileOffline( + ref.read(l10nProvider), ref.read(cloudLibraryShowsCopyProvider), )) { return CloudLibraryActionResult.cancelled; diff --git a/lib/providers/image_provider.dart b/lib/providers/image_provider.dart index 09b2e1419..eb8fe6884 100644 --- a/lib/providers/image_provider.dart +++ b/lib/providers/image_provider.dart @@ -21,6 +21,7 @@ import 'package:icarus/providers/action_provider.dart'; import 'package:icarus/providers/action_history_models.dart'; import 'package:icarus/providers/placement_center_provider.dart'; import 'package:icarus/const/placed_classes.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/strategy/strategy_page_models.dart'; import 'package:path/path.dart' as path; @@ -184,7 +185,7 @@ class PlacedImageProvider extends Notifier { ); } on MediaTooLargeException catch (error) { Settings.showToast( - message: error.userMessage, + message: error.userMessage(ref.read(l10nProvider)), backgroundColor: Settings.tacticalVioletTheme.destructive, ); return; diff --git a/lib/providers/share_link_provider.dart b/lib/providers/share_link_provider.dart index be7dd9e18..282626201 100644 --- a/lib/providers/share_link_provider.dart +++ b/lib/providers/share_link_provider.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/collab/collab_models.dart'; import 'package:icarus/collab/convex_strategy_repository.dart'; import 'package:icarus/const/settings.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/auth_provider.dart'; import 'package:icarus/providers/folder_provider.dart'; import 'package:icarus/providers/library_workspace_provider.dart'; @@ -55,7 +56,7 @@ class ShareLinkController extends Notifier { ); if (token == null || token.isEmpty) { Settings.showToast( - message: 'That share link is missing a share code.', + message: ref.read(l10nProvider).shareMsgMissingCode, backgroundColor: Settings.tacticalVioletTheme.destructive, ); return true; @@ -130,8 +131,8 @@ class ShareLinkController extends Notifier { .shareJoined(role: response.role, target: response.targetType); Settings.showToast( message: response.targetType == 'folder' - ? 'Shared folder added to your library.' - : 'Shared strategy added to your library.', + ? ref.read(l10nProvider).shareMsgFolderAdded + : ref.read(l10nProvider).shareMsgStrategyAdded, backgroundColor: Settings.tacticalVioletTheme.primary, ); } @@ -155,7 +156,7 @@ class ShareLinkController extends Notifier { _release(generation); if (showFailureToasts) { Settings.showToast( - message: 'Failed to redeem share link.', + message: ref.read(l10nProvider).shareMsgRedeemFailed, backgroundColor: Settings.tacticalVioletTheme.destructive, ); } @@ -180,7 +181,7 @@ class ShareLinkController extends Notifier { if (strategyPublicId == null) { // Browsing a folder needs the cloud library, which needs an account. Settings.showToast( - message: 'Sign in to open this shared folder.', + message: ref.read(l10nProvider).shareMsgSignInForFolder, backgroundColor: Settings.tacticalVioletTheme.primary, ); return false; @@ -196,16 +197,15 @@ class ShareLinkController extends Notifier { _release(generation); Settings.showToast( message: revoked - ? 'This share link was disabled by its owner.' - : 'This share link does not exist.', + ? ref.read(l10nProvider).shareMsgLinkDisabled + : ref.read(l10nProvider).shareMsgLinkNotFound, backgroundColor: Settings.tacticalVioletTheme.destructive, ); return false; } // Most likely offline. The code stays held for a reload to retry. Settings.showToast( - message: 'Could not open the shared strategy. Check your connection ' - 'and reload.', + message: ref.read(l10nProvider).shareMsgOpenStrategyFailed, backgroundColor: Settings.tacticalVioletTheme.destructive, ); return false; @@ -242,8 +242,8 @@ class ShareLinkController extends Notifier { _release(generation); Settings.showToast( message: revoked - ? 'This share link was disabled by its owner.' - : 'This share link does not exist.', + ? ref.read(l10nProvider).shareMsgLinkDisabled + : ref.read(l10nProvider).shareMsgLinkNotFound, backgroundColor: Settings.tacticalVioletTheme.destructive, ); return false; @@ -251,8 +251,7 @@ class ShareLinkController extends Notifier { // Most likely offline. The code stays held, so a reload or the next // sign-in update tries again. Settings.showToast( - message: 'Could not open the shared link. Check your connection and ' - 'try again.', + message: ref.read(l10nProvider).shareMsgOpenLinkFailed, backgroundColor: Settings.tacticalVioletTheme.destructive, ); return false; diff --git a/lib/providers/strategy_filter_provider.dart b/lib/providers/strategy_filter_provider.dart index d0fb23a52..03ebdc532 100644 --- a/lib/providers/strategy_filter_provider.dart +++ b/lib/providers/strategy_filter_provider.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/user_preferences_provider.dart'; enum SortBy { @@ -41,16 +42,18 @@ final strategyFilterProvider = StrategyFilterProvider.new); class StrategyFilterProvider extends Notifier { - static Map sortByLabels = { - SortBy.alphabetical: 'Alphabetical', - SortBy.dateCreated: 'Date Created', - SortBy.dateUpdated: 'Date Updated', - }; + static String sortByLabel(SortBy sortBy, AppLocalizations l10n) => + switch (sortBy) { + SortBy.alphabetical => l10n.prefsMsgSortAlphabetical, + SortBy.dateCreated => l10n.prefsMsgSortDateCreated, + SortBy.dateUpdated => l10n.prefsMsgSortDateUpdated, + }; - static Map sortOrderLabels = { - SortOrder.ascending: 'Ascending', - SortOrder.descending: 'Descending', - }; + static String sortOrderLabel(SortOrder sortOrder, AppLocalizations l10n) => + switch (sortOrder) { + SortOrder.ascending => l10n.prefsMsgSortAscending, + SortOrder.descending => l10n.prefsMsgSortDescending, + }; @override StrategyFilterState build() { @@ -86,7 +89,7 @@ class StrategyFilterProvider extends Notifier { } ({IconData icon, String label}) getCurrentSortBy() { - final label = sortByLabels[state.sortBy]!; + final label = sortByLabel(state.sortBy, ref.read(l10nProvider)); switch (state.sortBy) { case SortBy.alphabetical: diff --git a/lib/providers/strategy_page_session_provider.dart b/lib/providers/strategy_page_session_provider.dart index b6308402d..25046a2c1 100644 --- a/lib/providers/strategy_page_session_provider.dart +++ b/lib/providers/strategy_page_session_provider.dart @@ -15,6 +15,7 @@ import 'package:icarus/const/coordinate_system.dart'; import 'package:icarus/const/maps.dart'; import 'package:icarus/const/placed_classes.dart'; import 'package:icarus/const/settings.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/const/sort_index_order.dart'; import 'package:icarus/page_transition/agent_path.dart'; import 'package:icarus/page_transition/navigation_geometry_map.dart'; @@ -1174,8 +1175,9 @@ class StrategyPageSessionNotifier extends Notifier { final name = ref.read(drawnCloudPagesProvider)?.byId(pageId)?.name; try { Settings.showToast( - message: "Couldn't open ${name ?? 'that page'}. Check your " - 'connection and try again.', + message: name == null + ? ref.read(l10nProvider).strategyMsgPageUnreachableUnnamed + : ref.read(l10nProvider).strategyMsgPageUnreachable(name), backgroundColor: Settings.tacticalVioletTheme.destructive, ); } catch (error) { @@ -1449,7 +1451,11 @@ class StrategyPageSessionNotifier extends Notifier { .where((page) => page.publicId == pageId) .firstOrNull; state = state.copyWith( - deletedPage: (pageId: pageId, name: lastSeen?.name ?? 'This page'), + deletedPage: ( + pageId: pageId, + name: lastSeen?.name ?? + ref.read(l10nProvider).strategyMsgDeletedPageFallbackName, + ), ); return true; } diff --git a/lib/providers/strategy_provider.dart b/lib/providers/strategy_provider.dart index 2e7a05c9f..3f65d8a70 100644 --- a/lib/providers/strategy_provider.dart +++ b/lib/providers/strategy_provider.dart @@ -16,6 +16,7 @@ import 'package:icarus/providers/image_provider.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/hive_boxes.dart'; import 'package:icarus/const/settings.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/ability_provider.dart'; import 'package:icarus/providers/action_provider.dart'; import 'package:icarus/providers/agent_provider.dart'; @@ -298,8 +299,10 @@ class StrategyProvider extends Notifier { /// A change to the cloud library asked for while the library shows this /// computer's copy: refused at once, with the reason on screen. - bool _refuseCloudChangeOffline() => - refuseCloudChangeWhileOffline(ref.read(cloudLibraryShowsCopyProvider)); + bool _refuseCloudChangeOffline() => refuseCloudChangeWhileOffline( + ref.read(l10nProvider), + ref.read(cloudLibraryShowsCopyProvider), + ); bool _selectedWorkspaceIsCloud() { return ref.read(libraryWorkspaceProvider) == LibraryWorkspace.cloud; @@ -2212,8 +2215,7 @@ class StrategyProvider extends Notifier { stackTrace: stackTrace, ); Settings.showToast( - message: 'Strategy created, but it could not be opened. ' - 'Open it from your cloud library.', + message: ref.read(l10nProvider).strategyMsgCreatedNotOpened, backgroundColor: Settings.tacticalVioletTheme.destructive, ); } @@ -2328,7 +2330,11 @@ class StrategyProvider extends Notifier { sameAccount(); } on CloudWorkNotSent catch (error) { reporter.showMessage( - cloudStrategyCopyRefusal(error, "it wasn't duplicated"), + cloudStrategyCopyRefusal( + ref.read(l10nProvider), + error, + CloudStrategyCopy.duplicate, + ), ); return false; } @@ -2344,14 +2350,14 @@ class StrategyProvider extends Notifier { // was made. Say why. if (!isStrategyTooLargeToDuplicateError(error)) rethrow; reporter.showMessage( - "This strategy is too large to duplicate.", + ref.read(l10nProvider).strategyMsgTooLargeToDuplicate, ); return false; } return true; }, source: sourceName, - failureMessage: "Couldn't duplicate this strategy. Try again.", + failureMessage: ref.read(l10nProvider).strategyMsgDuplicateFailed, showFailureMessage: true, reportAuthenticationFailure: (error, stackTrace) => ref.read(authProvider.notifier).reportConvexUnauthenticated( @@ -2409,7 +2415,7 @@ class StrategyProvider extends Notifier { // Only disk space; no strategy points at this folder. } ref.read(cloudLibraryActionReporterProvider).showMessage( - "Couldn't duplicate this strategy: its images could not be copied.", + ref.read(l10nProvider).strategyMsgDuplicateImagesFailed, ); return; } @@ -2429,11 +2435,9 @@ class StrategyProvider extends Notifier { await strategyBox.put(duplicatedStrategy.id, duplicatedStrategy); if (missingPictures > 0) { ref.read(cloudLibraryActionReporterProvider).showMessage( - missingPictures == 1 - ? 'Duplicated. One picture was already missing from the ' - 'original, so the copy is missing it too.' - : 'Duplicated. $missingPictures pictures were already missing ' - 'from the original, so the copy is missing them too.', + ref + .read(l10nProvider) + .strategyMsgDuplicatedMissingPictures(missingPictures), ); } } @@ -2460,7 +2464,7 @@ class StrategyProvider extends Notifier { return true; }, source: sourceName, - failureMessage: "Couldn't delete this cloud strategy. Try again.", + failureMessage: ref.read(l10nProvider).strategyMsgDeleteCloudFailed, reportAuthenticationFailure: (error, stackTrace) => ref.read(authProvider.notifier).reportConvexUnauthenticated( source: sourceName, @@ -2479,7 +2483,7 @@ class StrategyProvider extends Notifier { // starts while this delete waits is never undercut. if (ref.read(movingToCloudProvider).contains(strategyID)) { return CloudLibraryActionResult.failed( - 'This strategy is moving to the cloud. Delete it once the move ends.', + ref.read(l10nProvider).strategyMsgDeleteWhileMoving, ); } await Hive.box(HiveBoxNames.strategiesBox).delete(strategyID); @@ -2846,7 +2850,7 @@ class StrategyProvider extends Notifier { return true; }, source: sourceName, - failureMessage: "Couldn't move this cloud strategy. Try again.", + failureMessage: ref.read(l10nProvider).strategyMsgMoveCloudFailed, showFailureMessage: true, reportAuthenticationFailure: (error, stackTrace) => ref.read(authProvider.notifier).reportConvexUnauthenticated( @@ -2870,7 +2874,7 @@ class StrategyProvider extends Notifier { } else { log("Strategy with ID $strategyID not found."); return CloudLibraryActionResult.failed( - "Couldn't move this strategy. Try again.", + ref.read(l10nProvider).strategyMsgMoveFailed, ); } } diff --git a/lib/providers/user_preferences_provider.dart b/lib/providers/user_preferences_provider.dart index 9b05a2587..8e95fb5d5 100644 --- a/lib/providers/user_preferences_provider.dart +++ b/lib/providers/user_preferences_provider.dart @@ -165,6 +165,10 @@ class AppPreferences extends HiveObject { /// updating users are told once. final bool canvasSelectionHintSeen; + /// The language the user picked in settings ('en', 'ja', 'zh'), or null to + /// follow the computer's or browser's language. + final String? languageCode; + AppPreferences({ required this.defaultThemeProfileIdForNewStrategies, this.autosaveEnabled = true, @@ -193,6 +197,7 @@ class AppPreferences extends HiveObject { this.includeScreenshotCaptions = false, this.includeVideoCaptions = false, this.canvasSelectionHintSeen = false, + this.languageCode, }) : customColorValues = List.unmodifiable(customColorValues ?? const []), customShortcutBindings = Map.unmodifiable(customShortcutBindings ?? const {}); @@ -225,6 +230,8 @@ class AppPreferences extends HiveObject { bool? includeScreenshotCaptions, bool? includeVideoCaptions, bool? canvasSelectionHintSeen, + // A function, so that null (follow the system) can be told from unchanged. + String? Function()? languageCode, }) { return AppPreferences( defaultThemeProfileIdForNewStrategies: @@ -268,6 +275,7 @@ class AppPreferences extends HiveObject { includeVideoCaptions: includeVideoCaptions ?? this.includeVideoCaptions, canvasSelectionHintSeen: canvasSelectionHintSeen ?? this.canvasSelectionHintSeen, + languageCode: languageCode == null ? this.languageCode : languageCode(), ); } } @@ -656,6 +664,13 @@ class AppPreferencesNotifier extends Notifier { ); } + /// [code] is a supported language, or null to follow the system. + Future setLanguageCode(String? code) { + return _updatePreferences( + (current) => current.copyWith(languageCode: () => code), + ); + } + Future setDiscordPresenceEnabled(bool enabled) { return _updatePreferences( (current) => current.copyWith(discordPresenceEnabled: enabled), diff --git a/lib/replay/replay_decoder.dart b/lib/replay/replay_decoder.dart index 5ce0a7fc6..391029b5d 100644 --- a/lib/replay/replay_decoder.dart +++ b/lib/replay/replay_decoder.dart @@ -27,6 +27,15 @@ class ReplayDecodeException implements Exception { String toString() => 'ReplayDecodeException($code): $message'; } +/// The `io` message where replays can't be read at all (the web build). +const replayDesktopOnlyMessage = + 'Replays can only be opened in the desktop app.'; + +/// How the `io` message of an install without the native decoder starts; +/// why it didn't load follows, in brackets. +const replayDecoderMissingPrefix = + 'The replay decoder is missing from this install ('; + /// What a replay's header says, read without decoding the match. class ReplayProbe { const ReplayProbe({ diff --git a/lib/replay/replay_decoder_io.dart b/lib/replay/replay_decoder_io.dart index 1375a693f..a5845946e 100644 --- a/lib/replay/replay_decoder_io.dart +++ b/lib/replay/replay_decoder_io.dart @@ -90,7 +90,7 @@ class _Library { } on ArgumentError catch (error) { throw ReplayDecodeException( 'io', - 'The replay decoder is missing from this install ($error).', + '$replayDecoderMissingPrefix$error).', ); } return _instance = _Library(library); diff --git a/lib/replay/replay_decoder_stub.dart b/lib/replay/replay_decoder_stub.dart index c8244a965..2bff619b8 100644 --- a/lib/replay/replay_decoder_stub.dart +++ b/lib/replay/replay_decoder_stub.dart @@ -5,11 +5,11 @@ const bool replayDecoderAvailable = false; Future probeReplay(String path) => Future.error( const ReplayDecodeException( 'io', - 'Replays can only be opened in the desktop app.', + replayDesktopOnlyMessage, ), ); ReplayDecodeJob decodeReplay(String path) => throw const ReplayDecodeException( 'io', - 'Replays can only be opened in the desktop app.', + replayDesktopOnlyMessage, ); diff --git a/lib/replay_view.dart b/lib/replay_view.dart index f85533126..05f10efe3 100644 --- a/lib/replay_view.dart +++ b/lib/replay_view.dart @@ -14,6 +14,7 @@ import 'package:icarus/providers/strategy_settings_provider.dart'; import 'package:icarus/providers/svg_height_runtime_provider.dart'; import 'package:icarus/providers/user_preferences_provider.dart'; import 'package:icarus/const/utilities.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/replay/replay_cone_cuts.dart'; import 'package:icarus/replay/replay_cone_worker.dart'; import 'package:icarus/replay/replay_decoder.dart'; @@ -27,6 +28,7 @@ import 'package:icarus/replay/replay_playback.dart'; import 'package:icarus/view_cone/svg_height_visibility.dart'; import 'package:icarus/widgets/replay/replay_canvas.dart'; import 'package:icarus/widgets/replay/replay_dock.dart'; +import 'package:icarus/widgets/replay/replay_failure_text.dart'; import 'package:icarus/widgets/replay/replay_match_card.dart'; import 'package:icarus/widgets/replay/replay_roster.dart'; import 'package:icarus/widgets/window_chrome.dart'; @@ -395,7 +397,8 @@ class _ReplayStrip extends StatelessWidget { children: [ const SizedBox(width: 6), ShadTooltip( - builder: (context) => const Text('Replays'), + builder: (context) => + Text(context.l10n.replayBackToLibraryTooltip), child: ShadIconButton.ghost( width: 28, height: 28, @@ -515,6 +518,7 @@ class _ReplayLoading extends StatelessWidget { @override Widget build(BuildContext context) { final theme = ShadTheme.of(context); + final l10n = context.l10n; final failure = error; return Center( child: SizedBox( @@ -524,18 +528,23 @@ class _ReplayLoading extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - failure == null ? 'Reading replay' : "Couldn't open this replay", + failure == null + ? l10n.replayReading + : l10n.replayOpenThisFailed, style: theme.textTheme.p, ), const SizedBox(height: 12), if (failure == null) ShadProgress(value: progress <= 0 ? null : progress) else ...[ - Text(failure.message, style: theme.textTheme.muted), + Text( + replayFailureText(l10n, failure), + style: theme.textTheme.muted, + ), const SizedBox(height: 16), ShadButton.secondary( onPressed: onBack, - child: const Text('Back to replays'), + child: Text(l10n.replayBackToReplays), ), ], ], diff --git a/lib/screenshot/capture_images.dart b/lib/screenshot/capture_images.dart index c6c98cc71..131b17ff7 100644 --- a/lib/screenshot/capture_images.dart +++ b/lib/screenshot/capture_images.dart @@ -3,6 +3,7 @@ import 'dart:typed_data'; import 'package:flutter/painting.dart'; import 'package:http/http.dart' as http; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/strategy_image_source.dart'; /// An image a capture would paint is not ready: its cloud copy is still @@ -15,10 +16,9 @@ class CaptureImagesUnavailable implements Exception { /// Why the image could not be loaded; null while it is still loading. final Object? cause; - String get userMessage => cause == null - ? 'Images on this page are still loading. Try again in a moment.' - : "Couldn't load the images on this page. Check your connection and " - 'try again.'; + String userMessage(AppLocalizations l10n) => cause == null + ? l10n.screenshotImagesStillLoading + : l10n.screenshotImagesLoadFailed; @override String toString() => cause == null diff --git a/lib/screenshot/page_screenshot.dart b/lib/screenshot/page_screenshot.dart index 58b3f3df2..10531bf17 100644 --- a/lib/screenshot/page_screenshot.dart +++ b/lib/screenshot/page_screenshot.dart @@ -4,6 +4,8 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:hive_ce/hive.dart'; +import 'package:icarus/l10n/l10n.dart'; +import 'package:icarus/l10n/page_names.dart'; import 'package:icarus/collab/convex_strategy_repository.dart'; import 'package:icarus/const/coordinate_system.dart'; import 'package:icarus/const/placed_media_dimensions.dart'; @@ -253,7 +255,15 @@ Future> captureStrategyPages( if (includeCaptions && captions[page.id]?.fits != true) { throw CaptionsDontFit(number); } - shots.add((number: number, name: page.name, png: png)); + shots.add(( + number: number, + name: shownPageName( + ref.read(l10nProvider), + name: page.name, + isAutoNamed: page.isAutoNamed == true, + ), + png: png, + )); }, isCancelled: isCancelled, ); @@ -464,6 +474,7 @@ Future _renderPages( final view = ScreenshotView.forPage( strategy, entry.page, + l10n: ref.read(l10nProvider), strategyState: strategyState, mapState: mapState, backgroundDotOpacity: dotOpacity, @@ -543,20 +554,31 @@ String? _activePageName(WidgetRef ref) { final strategy = ref.read(strategyProvider); final pageId = ref.read(strategyPageSessionProvider).activePageId; if (pageId == null) return null; - return switch (strategy.source) { - StrategySource.cloud => ref - .read(remoteEditorSnapshotProvider) - .valueOrNull - ?.pages - .where((page) => page.publicId == pageId) - .firstOrNull - ?.name, - StrategySource.local => Hive.box(HiveBoxNames.strategiesBox) - .get(strategy.strategyId) - ?.pages - .where((page) => page.id == pageId) - .firstOrNull - ?.name, + final (String name, bool? isAutoNamed)? page = switch (strategy.source) { + StrategySource.cloud => switch (ref + .read(remoteEditorSnapshotProvider) + .valueOrNull + ?.pages + .where((page) => page.publicId == pageId) + .firstOrNull) { + final page? => (page.name, page.isAutoNamed), + null => null, + }, + StrategySource.local => switch ( + Hive.box(HiveBoxNames.strategiesBox) + .get(strategy.strategyId) + ?.pages + .where((page) => page.id == pageId) + .firstOrNull) { + final page? => (page.name, page.isAutoNamed), + null => null, + }, null => null, }; + if (page == null) return null; + return shownPageName( + ref.read(l10nProvider), + name: page.$1, + isAutoNamed: page.$2 == true, + ); } diff --git a/lib/screenshot/screenshot_view.dart b/lib/screenshot/screenshot_view.dart index 37f9d6157..6be2d1bba 100644 --- a/lib/screenshot/screenshot_view.dart +++ b/lib/screenshot/screenshot_view.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/l10n/page_names.dart'; import 'package:icarus/widgets/asset_svg.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/const/coordinate_system.dart'; import 'package:icarus/const/drawing_element.dart'; import 'package:icarus/const/line_provider.dart'; @@ -67,6 +69,7 @@ class ScreenshotView extends ConsumerWidget { factory ScreenshotView.forPage( StrategyData strategy, StrategyPage page, { + required AppLocalizations l10n, required StrategyState strategyState, required MapState mapState, required double backgroundDotOpacity, @@ -93,7 +96,11 @@ class ScreenshotView extends ConsumerWidget { utilities: page.utilityData, strategySettings: page.settings, strategyState: strategyState, - pageName: page.name, + pageName: shownPageName( + l10n, + name: page.name, + isAutoNamed: page.isAutoNamed == true, + ), lineUpGraph: page.lineUpGraph, themeProfileId: strategy.themeProfileId, themeOverridePalette: strategy.themeOverridePalette, @@ -271,7 +278,7 @@ class ScreenshotView extends ConsumerWidget { child: AssetSvg( assetName, colorMapper: mapColorMapper, - semanticsLabel: 'Map', + semanticsLabel: context.l10n.strategyViewMapSemanticLabel, fit: BoxFit.contain, ), ), @@ -315,7 +322,8 @@ class ScreenshotView extends ConsumerWidget { flipY: !isAttack, child: AssetSvg( barrierAssetName, - semanticsLabel: 'Barrier', + semanticsLabel: + context.l10n.strategyViewBarrierSemanticLabel, fit: BoxFit.contain, ), ), @@ -328,7 +336,8 @@ class ScreenshotView extends ConsumerWidget { height: CoordinateSystem.screenShotSize.height, child: AssetSvg( calloutsAssetName, - semanticsLabel: 'Callouts', + semanticsLabel: + context.l10n.strategyViewCalloutsSemanticLabel, fit: BoxFit.contain, ), ), @@ -343,7 +352,8 @@ class ScreenshotView extends ConsumerWidget { flipY: !isAttack, child: AssetSvg( ultOrbsAssetName, - semanticsLabel: 'Ult Orbs', + semanticsLabel: + context.l10n.strategyViewUltOrbsSemanticLabel, fit: BoxFit.contain, ), ), diff --git a/lib/services/app_error_reporter.dart b/lib/services/app_error_reporter.dart index 16b98884b..e2e145120 100644 --- a/lib/services/app_error_reporter.dart +++ b/lib/services/app_error_reporter.dart @@ -8,6 +8,7 @@ import 'package:flutter/foundation.dart' import 'package:icarus/const/app_navigator.dart'; import 'package:icarus/const/app_provider_container.dart'; import 'package:icarus/const/settings.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/in_app_debug_provider.dart'; import 'package:icarus/widgets/dialogs/in_app_debug_dialog.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; @@ -109,6 +110,7 @@ class AppErrorReporter { Object? error, StackTrace? stackTrace, bool promptUser = false, + String? toastMessage, }) { final entry = addDebugLog( message, @@ -119,7 +121,7 @@ class AppErrorReporter { ); if (promptUser) { - _showInteractiveNotificationIfNeeded(entry); + _showInteractiveNotificationIfNeeded(entry, toastMessage); } } @@ -129,6 +131,7 @@ class AppErrorReporter { StackTrace? stackTrace, String? source, bool promptUser = true, + String? toastMessage, }) { final entry = addDebugLog( message, @@ -139,7 +142,7 @@ class AppErrorReporter { ); if (promptUser) { - _showInteractiveNotificationIfNeeded(entry); + _showInteractiveNotificationIfNeeded(entry, toastMessage); } } @@ -290,7 +293,11 @@ class AppErrorReporter { appNavigatorKey.currentContext != null || appNavigatorKey.currentState?.overlay?.context != null; - static void _showInteractiveNotificationIfNeeded(DebugLogEntry entry) { + /// Shows [toastMessage] when given, else the logged message. + static void _showInteractiveNotificationIfNeeded( + DebugLogEntry entry, [ + String? toastMessage, + ]) { if (!_canShowInteractiveUi) return; final now = DateTime.now(); @@ -308,9 +315,12 @@ class AppErrorReporter { (existingState ?? _InteractiveNotificationState())..lastSeenAt = now; Settings.showToast( - message: entry.message, + message: toastMessage ?? entry.message, backgroundColor: Settings.tacticalVioletTheme.destructive, - actionLabel: 'Open Logs', + actionLabel: (appNavigatorKey.currentContext ?? + appNavigatorKey.currentState!.overlay!.context) + .l10n + .serviceMsgOpenLogs, onActionPressed: () { unawaited(openDebugLog()); }, diff --git a/lib/services/auth_callback_uri.dart b/lib/services/auth_callback_uri.dart index cc9cfb5ac..f2a237554 100644 --- a/lib/services/auth_callback_uri.dart +++ b/lib/services/auth_callback_uri.dart @@ -1,3 +1,4 @@ +import 'package:icarus/l10n/l10n.dart'; import 'package:supabase_flutter/supabase_flutter.dart' show AuthChangeEvent, AuthException, ErrorCode; @@ -70,21 +71,18 @@ AuthCallback classifyAuthCallbackUri(Uri uri, {required Uri redirectUri}) { /// Why a link from an auth email can't finish here, in the words the sign-in /// dialog opens with. enum AuthLinkNotice { - emailConfirmed('Your email is confirmed. Sign in to continue.', isInfo: true), - linkExpired( - 'This link has expired or was already used. Try signing in. If your email ' - "isn't confirmed yet, create your account again to get a new link.", - isInfo: false, - ), - resetLinkTimedOut( - 'This reset link took too long to open. Send a new one and open it within ' - '5 minutes.', - isInfo: false, - ); + emailConfirmed(isInfo: true), + linkExpired(isInfo: false), + resetLinkTimedOut(isInfo: false); + + const AuthLinkNotice({required this.isInfo}); - const AuthLinkNotice(this.message, {required this.isInfo}); + String message(AppLocalizations l10n) => switch (this) { + emailConfirmed => l10n.serviceMsgAuthEmailConfirmed, + linkExpired => l10n.serviceMsgAuthLinkExpired, + resetLinkTimedOut => l10n.serviceMsgAuthResetLinkTimedOut, + }; - final String message; final bool isInfo; } diff --git a/lib/services/clipboard_service.dart b/lib/services/clipboard_service.dart index 3af82a386..58e1a95f5 100644 --- a/lib/services/clipboard_service.dart +++ b/lib/services/clipboard_service.dart @@ -3,6 +3,7 @@ import 'dart:ui' as ui; import 'package:cross_file/cross_file.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/services/app_error_reporter.dart'; import 'package:icarus/services/browser_paste.dart'; import 'package:image/image.dart' as img; @@ -107,8 +108,8 @@ class ClipboardService { static const _name = 'Clipboard File'; /// What to tell someone when the clipboard stayed busy. - static const busyMessage = - 'Another program is using the clipboard. Try again in a moment.'; + static String busyMessage(AppLocalizations l10n) => + l10n.serviceMsgClipboardBusy; /// Programs open the clipboard for a moment whenever it changes: clipboard /// managers, screen sharing, Deskflow. Windows then refuses everyone else. diff --git a/lib/services/cloud_library_action.dart b/lib/services/cloud_library_action.dart index 5a3bc9c3b..aca7cfa19 100644 --- a/lib/services/cloud_library_action.dart +++ b/lib/services/cloud_library_action.dart @@ -1,5 +1,8 @@ +import 'dart:ui'; + import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/settings.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/auth_provider.dart'; import 'package:icarus/services/app_error_reporter.dart'; @@ -19,10 +22,13 @@ class CloudLibraryActionResult { static const cancelled = CloudLibraryActionResult._( CloudLibraryActionStatus.cancelled, ); - static const authenticationRequired = CloudLibraryActionResult._( - CloudLibraryActionStatus.authenticationRequired, - userMessage: 'Reconnect to Icarus Cloud, then try again.', - ); + factory CloudLibraryActionResult.authenticationRequired( + AppLocalizations l10n, + ) => + CloudLibraryActionResult._( + CloudLibraryActionStatus.authenticationRequired, + userMessage: l10n.cloudActionReconnect, + ); factory CloudLibraryActionResult.failed(String userMessage) => CloudLibraryActionResult._( @@ -38,15 +44,20 @@ class CloudLibraryActionResult { } final cloudLibraryActionReporterProvider = Provider( - (_) => const CloudLibraryActionReporter(), + (ref) => CloudLibraryActionReporter(l10n: ref.watch(l10nProvider)), ); class CloudLibraryActionReporter { const CloudLibraryActionReporter({ + this.l10n, this.showMessage = _showDefaultMessage, this.reportTechnicalFailure = _reportDefaultTechnicalFailure, }); + /// The words for [CloudLibraryActionResult.authenticationRequired]; + /// English when none are given. + final AppLocalizations? l10n; + final void Function(String message) showMessage; final void Function({ required String source, @@ -70,7 +81,9 @@ class CloudLibraryActionReporter { } catch (error, stackTrace) { if (isConvexUnauthenticatedError(error)) { await reportAuthenticationFailure(error, stackTrace); - return CloudLibraryActionResult.authenticationRequired; + return CloudLibraryActionResult.authenticationRequired( + l10n ?? lookupAppLocalizations(const Locale('en')), + ); } reportTechnicalFailure( diff --git a/lib/services/cloud_needs_connection.dart b/lib/services/cloud_needs_connection.dart index 3f7225971..cf2d00567 100644 --- a/lib/services/cloud_needs_connection.dart +++ b/lib/services/cloud_needs_connection.dart @@ -1,4 +1,5 @@ import 'package:icarus/const/settings.dart'; +import 'package:icarus/l10n/l10n.dart'; /// Said, at once, when a change to the cloud library is asked for while /// the library shows this computer's copy of it. @@ -10,11 +11,11 @@ const cloudNeedsConnectionMessage = /// cloudLibraryShowsCopyProvider): the cloud isn't ready, so the change /// could only fail after a wait, under a banner that says changes sync /// later. Shows the reason and returns true when it refuses. -bool refuseCloudChangeWhileOffline(bool showsCopy) { +bool refuseCloudChangeWhileOffline(AppLocalizations l10n, bool showsCopy) { if (!showsCopy) return false; // Not an error: the neutral surface, as for a feature this platform lacks. Settings.showToast( - message: cloudNeedsConnectionMessage, + message: l10n.cloudActionNeedsConnection, backgroundColor: Settings.tacticalVioletTheme.secondary, ); return true; diff --git a/lib/services/cloud_sign_out_coordinator.dart b/lib/services/cloud_sign_out_coordinator.dart index e216d7810..15dd387e6 100644 --- a/lib/services/cloud_sign_out_coordinator.dart +++ b/lib/services/cloud_sign_out_coordinator.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/auth_provider.dart'; import 'package:icarus/providers/collab/cloud_media_upload_queue_provider.dart'; import 'package:icarus/providers/collab/remote_library_provider.dart'; @@ -158,19 +159,16 @@ Future _showPersistenceBlocked(BuildContext context) { context: context, barrierDismissible: false, builder: (context) => ShadDialog.alert( - title: const Text("Can't sign out yet"), - description: const Padding( - padding: EdgeInsets.all(8), - child: Text( - 'Icarus could not confirm that all pending cloud work is saved on ' - 'this device. Stay signed in and try again.', - ), + title: Text(context.l10n.signOutGuardCantSignOutTitle), + description: Padding( + padding: const EdgeInsets.all(8), + child: Text(context.l10n.signOutGuardUnconfirmedDescription), ), actions: [ ShadButton( key: const ValueKey('sign-out-persistence-blocked'), onPressed: () => Navigator.of(context).pop(), - child: const Text('Stay Signed In'), + child: Text(context.l10n.signOutGuardStaySignedIn), ), ], ), @@ -185,6 +183,7 @@ Future _showSignOutConfirmation( required String? currentStrategyName, required Map strategyNames, }) async { + final l10n = context.l10n; final hasPendingWork = workCount > 0; final strategyLabels = strategyIds .map((id) => id == currentStrategyId && @@ -192,38 +191,44 @@ Future _showSignOutConfirmation( ? currentStrategyName!.trim() : (strategyNames[id]?.trim().isNotEmpty == true ? strategyNames[id]!.trim() - : 'a cloud strategy')) + : l10n.signOutGuardUnnamedStrategy)) .toSet() .join(', '); final result = await showShadDialog( context: context, barrierDismissible: false, builder: (context) => ShadDialog.alert( - title: Text(hasPendingWork ? 'Cloud work is still waiting' : 'Sign out?'), + title: Text( + hasPendingWork + ? l10n.signOutGuardPendingTitle + : l10n.signOutGuardConfirmTitle, + ), description: Padding( padding: const EdgeInsets.all(8), child: Text( hasPendingWork - ? '$workCount saved ${workCount == 1 ? 'change' : 'changes'} ' - 'across ${strategyIds.length} ' - '${strategyIds.length == 1 ? 'strategy is' : 'strategies are'} ' - 'still waiting: $strategyLabels. The work remains on this ' - 'device and resumes only when this same account signs in ' - 'again.' - : 'Cloud strategies stay online. Pending work on this device ' - 'will remain tied to this account.', + ? l10n.signOutGuardPendingDescription( + workCount, + strategyIds.length, + strategyLabels, + ) + : l10n.signOutGuardNoPendingDescription, ), ), actions: [ ShadButton.secondary( key: const ValueKey('sign-out-cancel'), onPressed: () => Navigator.of(context).pop(false), - child: const Text('Cancel'), + child: Text(l10n.commonCancel), ), ShadButton.destructive( key: const ValueKey('sign-out-confirm'), onPressed: () => Navigator.of(context).pop(true), - child: Text(hasPendingWork ? 'Sign Out Anyway' : 'Sign Out'), + child: Text( + hasPendingWork + ? l10n.signOutGuardSignOutAnyway + : l10n.commonSignOut, + ), ), ], ), @@ -235,15 +240,15 @@ Future _showSignOutFailed(BuildContext context, String? message) { return showShadDialog( context: context, builder: (context) => ShadDialog.alert( - title: const Text('Sign out failed'), + title: Text(context.l10n.signOutGuardFailedTitle), description: Padding( padding: const EdgeInsets.all(8), - child: Text(message ?? 'Icarus could not sign out. Please try again.'), + child: Text(message ?? context.l10n.signOutGuardFailedFallback), ), actions: [ ShadButton( onPressed: () => Navigator.of(context).pop(), - child: const Text('OK'), + child: Text(context.l10n.signOutGuardOk), ), ], ), diff --git a/lib/services/cloud_strategy_export.dart b/lib/services/cloud_strategy_export.dart index 440569db0..79deb5fe2 100644 --- a/lib/services/cloud_strategy_export.dart +++ b/lib/services/cloud_strategy_export.dart @@ -1,4 +1,5 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/auth_provider.dart'; import 'package:icarus/services/cloud_library_action.dart'; import 'package:icarus/services/cloud_work_sent.dart'; @@ -9,6 +10,7 @@ Future runCloudStrategyExport( String strategyId, ) { const source = 'strategy:export'; + final l10n = ref.read(l10nProvider); final reporter = ref.read(cloudLibraryActionReporterProvider); return reporter.run( action: () async { @@ -17,13 +19,17 @@ Future runCloudStrategyExport( } on CloudWorkNotSent catch (error) { // Not a failure to retry at once: the file would leave out work. reporter.showMessage( - cloudStrategyCopyRefusal(error, 'nothing was exported'), + cloudStrategyCopyRefusal( + l10n, + error, + CloudStrategyCopy.export, + ), ); return false; } }, source: source, - failureMessage: "Couldn't export this cloud strategy. Try again.", + failureMessage: l10n.cloudActionExportFailed, showFailureMessage: true, reportAuthenticationFailure: (error, stackTrace) => ref.read(authProvider.notifier).reportConvexUnauthenticated( diff --git a/lib/services/cloud_work_sent.dart b/lib/services/cloud_work_sent.dart index 9dae4f4a6..92d756771 100644 --- a/lib/services/cloud_work_sent.dart +++ b/lib/services/cloud_work_sent.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/auth_provider.dart'; import 'package:icarus/providers/collab/cloud_media_upload_queue_provider.dart'; import 'package:icarus/providers/collab/strategy_op_queue_provider.dart'; @@ -28,14 +29,25 @@ class CloudWorkNotSent implements Exception { String toString() => 'CloudWorkNotSent(unreadable: $unreadable)'; } -/// What to tell the user when a copy of one cloud strategy was refused; -/// [outcome] says what did not happen ("nothing was exported"). -String cloudStrategyCopyRefusal(CloudWorkNotSent error, String outcome) => - error.unreadable - ? "Icarus can't read some cloud changes saved on this device, so " - '$outcome. Review them, then try again.' - : "Some changes to this strategy haven't synced yet, so $outcome. " - 'Let them sync, or review them, then try again.'; +/// A copy of one cloud strategy that [sendCloudWork] can refuse. +enum CloudStrategyCopy { export, duplicate } + +/// What to tell the user when [copy] of one cloud strategy was refused. +String cloudStrategyCopyRefusal( + AppLocalizations l10n, + CloudWorkNotSent error, + CloudStrategyCopy copy, +) => + switch ((copy, error.unreadable)) { + (CloudStrategyCopy.export, true) => + l10n.cloudActionExportRefusedUnreadable, + (CloudStrategyCopy.export, false) => + l10n.cloudActionExportRefusedNotSynced, + (CloudStrategyCopy.duplicate, true) => + l10n.cloudActionDuplicateRefusedUnreadable, + (CloudStrategyCopy.duplicate, false) => + l10n.cloudActionDuplicateRefusedNotSynced, + }; /// Checks, right before a copy is read or made, that the account whose work /// [sendCloudWork] waited on is still the one signed in. Throws diff --git a/lib/services/discord_presence_service.dart b/lib/services/discord_presence_service.dart index 143ac5c1a..0b7e19b07 100644 --- a/lib/services/discord_presence_service.dart +++ b/lib/services/discord_presence_service.dart @@ -7,6 +7,7 @@ import 'package:discord_rich_presence/discord_rich_presence.dart'; import 'package:flutter/foundation.dart' show kIsWeb, visibleForTesting; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/maps.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/map_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; @@ -30,15 +31,16 @@ class DiscordPresenceData { }); factory DiscordPresenceData.fromAppState({ + required AppLocalizations l10n, required StrategyState strategy, required MapState map, int agentCount = 0, int abilityCount = 0, }) { if (strategy.stratName == null) { - return const DiscordPresenceData( - details: 'Browsing the strategy library', - state: 'Valorant strategy planner', + return DiscordPresenceData( + details: l10n.discordPresenceBrowsingLibrary, + state: l10n.discordPresenceTagline, largeImageKey: 'icarus_logo', largeImageText: 'Icarus', ); @@ -47,13 +49,19 @@ class DiscordPresenceData { final rawMapName = Maps.mapNames[map.currentMap] ?? map.currentMap.name; final mapName = rawMapName.isEmpty ? rawMapName - : '${rawMapName[0].toUpperCase()}${rawMapName.substring(1)}'; + : l10n.mapNameOf( + '${rawMapName[0].toUpperCase()}${rawMapName.substring(1)}', + ); return DiscordPresenceData( details: map.isAttack - ? 'Planning an attack on $mapName' - : 'Planning a defense on $mapName', - state: _boardSummary(agentCount: agentCount, abilityCount: abilityCount), + ? l10n.discordPresencePlanningAttack(mapName) + : l10n.discordPresencePlanningDefense(mapName), + state: _boardSummary( + l10n, + agentCount: agentCount, + abilityCount: abilityCount, + ), largeImageKey: '${map.currentMap.name}_thumbnail', largeImageText: mapName, smallImageKey: 'icarus_logo', @@ -62,17 +70,18 @@ class DiscordPresenceData { } /// A live tally of what is on the board, e.g. "4 agents · 12 abilities". - static String _boardSummary({ + static String _boardSummary( + AppLocalizations l10n, { required int agentCount, required int abilityCount, }) { - final parts = [ - if (agentCount > 0) '$agentCount agent${agentCount == 1 ? '' : 's'}', - if (abilityCount > 0) - '$abilityCount abilit${abilityCount == 1 ? 'y' : 'ies'}', - ]; - if (parts.isEmpty) return 'Starting from an empty board'; - return '${parts.join(' · ')} on the board'; + return switch ((agentCount > 0, abilityCount > 0)) { + (false, false) => l10n.discordPresenceEmptyBoard, + (true, false) => l10n.discordPresenceBoardAgents(agentCount), + (false, true) => l10n.discordPresenceBoardAbilities(abilityCount), + (true, true) => + l10n.discordPresenceBoardAgentsAndAbilities(agentCount, abilityCount), + }; } final String details; diff --git a/lib/services/export_save_path.dart b/lib/services/export_save_path.dart index cb8493289..d2ef57603 100644 --- a/lib/services/export_save_path.dart +++ b/lib/services/export_save_path.dart @@ -3,6 +3,7 @@ import 'dart:typed_data'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:icarus/l10n/l10n.dart'; import 'package:path/path.dart' as p; /// [path], ending in [extension] (with its dot) however the user cased it. @@ -16,14 +17,17 @@ String withFileExtension(String path, String extension) => /// the extension goes with it, and a file without one neither imports nor /// opens. The dialog only asked about replacing the name it returned, so a /// corrected name that is already taken goes back to it to ask. -Future chooseExportPath(String fileName) async { +Future chooseExportPath( + AppLocalizations l10n, + String fileName, +) async { final extension = p.extension(fileName); var suggestedName = fileName; String? directory; while (true) { final chosen = await FilePicker.platform.saveFile( type: FileType.custom, - dialogTitle: 'Please select an output file:', + dialogTitle: l10n.importExportSaveDialogTitle, fileName: suggestedName, initialDirectory: directory, allowedExtensions: [extension.substring(1)], @@ -38,7 +42,11 @@ Future chooseExportPath(String fileName) async { /// Saves [bytes] as an export named [fileName]: desktop asks where (see /// [chooseExportPath]), the browser downloads them. -Future saveExportBytes(String fileName, Uint8List bytes) async { +Future saveExportBytes( + AppLocalizations l10n, + String fileName, + Uint8List bytes, +) async { if (kIsWeb) { await FilePicker.platform.saveFile( type: FileType.custom, @@ -48,6 +56,6 @@ Future saveExportBytes(String fileName, Uint8List bytes) async { ); return; } - final path = await chooseExportPath(fileName); + final path = await chooseExportPath(l10n, fileName); if (path != null) await File(path).writeAsBytes(bytes); } diff --git a/lib/services/guarded_sign_out.dart b/lib/services/guarded_sign_out.dart index 3d5864955..552fcf83a 100644 --- a/lib/services/guarded_sign_out.dart +++ b/lib/services/guarded_sign_out.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; typedef GuardedSignOutRequest = Future Function(BuildContext context); @@ -13,18 +14,15 @@ final guardedSignOutRequestProvider = Provider( await showShadDialog( context: context, builder: (context) => ShadDialog.alert( - title: const Text("Can't sign out yet"), - description: const Padding( - padding: EdgeInsets.all(8), - child: Text( - 'Icarus could not verify pending cloud work. Stay signed in and ' - 'try again.', - ), + title: Text(context.l10n.signOutGuardCantSignOutTitle), + description: Padding( + padding: const EdgeInsets.all(8), + child: Text(context.l10n.signOutGuardUnverifiedDescription), ), actions: [ ShadButton( onPressed: () => Navigator.of(context).pop(), - child: const Text('Stay Signed In'), + child: Text(context.l10n.signOutGuardStaySignedIn), ), ], ), diff --git a/lib/services/unsaved_strategy_guard.dart b/lib/services/unsaved_strategy_guard.dart index 318077e74..a0889d0d0 100644 --- a/lib/services/unsaved_strategy_guard.dart +++ b/lib/services/unsaved_strategy_guard.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:icarus/collab/cloud_sync_error_message.dart'; +import 'package:icarus/l10n/cloud_sync_error_text.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/auth_provider.dart'; import 'package:icarus/providers/collab/cloud_media_upload_queue_provider.dart'; import 'package:icarus/providers/collab/convex_connection_provider.dart'; @@ -31,29 +32,28 @@ Future showUnsavedStrategyDialog( final result = await showShadDialog( context: context, builder: (context) { + final l10n = context.l10n; return ShadDialog.alert( - title: const Text('Save changes?'), - description: const Text( - 'This strategy has unsaved changes. Do you want to save before leaving?', - ), + title: Text(l10n.unsavedGuardTitle), + description: Text(l10n.unsavedGuardDescription), actions: [ ShadButton.ghost( onPressed: () { Navigator.of(context).pop(UnsavedStrategyDecision.cancel); }, - child: const Text('Cancel'), + child: Text(l10n.commonCancel), ), ShadButton.destructive( onPressed: () { Navigator.of(context).pop(UnsavedStrategyDecision.dontSave); }, - child: const Text("Don't save"), + child: Text(l10n.unsavedGuardDontSave), ), ShadButton( onPressed: () { Navigator.of(context).pop(UnsavedStrategyDecision.save); }, - child: const Text('Save'), + child: Text(l10n.commonSave), ), ], ); @@ -72,8 +72,9 @@ Future _showCloudSyncBlockedDialog( final result = await showShadDialog( context: context, builder: (context) { + final l10n = context.l10n; return ShadDialog.alert( - title: const Text('Cloud sync pending'), + title: Text(l10n.unsavedGuardCloudPendingTitle), description: Text(message), // One row that wraps when the labels need more room: the quiet way // out first, the command last. @@ -88,27 +89,27 @@ Future _showCloudSyncBlockedDialog( onPressed: () { Navigator.of(context).pop(CloudExitDecision.stay); }, - child: const Text('Stay here'), + child: Text(l10n.unsavedGuardStayHere), ), if (showRetryAuth) ShadButton.secondary( onPressed: () { Navigator.of(context).pop(CloudExitDecision.retryAuth); }, - child: const Text('Retry sign-in'), + child: Text(l10n.unsavedGuardRetrySignIn), ), if (allowLeaveAnyway) ShadButton.secondary( onPressed: () { Navigator.of(context).pop(CloudExitDecision.leaveAnyway); }, - child: const Text('Leave anyway'), + child: Text(l10n.unsavedGuardLeaveAnyway), ), ShadButton( onPressed: () { Navigator.of(context).pop(CloudExitDecision.retrySync); }, - child: const Text('Retry sync'), + child: Text(l10n.unsavedGuardRetrySync), ), ], ), @@ -153,6 +154,7 @@ Future _guardCloudStrategyExit({ required WidgetRef ref, required Future Function() onContinue, }) async { + final l10n = context.l10n; final openingStrategy = ref.read(strategyProvider); final openingSaveState = ref.read(strategySaveStateProvider); if ((ref.read(textDraftProvider).isNotEmpty || openingSaveState.isDirty) && @@ -164,7 +166,7 @@ Future _guardCloudStrategyExit({ .forceSaveNow(openingStrategy.strategyId!); } catch (error, stackTrace) { AppErrorReporter.reportError( - 'Failed to sync the active text edit before leaving.', + l10n.unsavedGuardTextEditSyncFailed, error: error, stackTrace: stackTrace, source: 'cloud_media.exit_guard', @@ -288,6 +290,7 @@ Future _guardCloudStrategyExit({ final decision = await _showCloudSyncBlockedDialog( context, message: _cloudSyncBlockedMessage( + l10n: context.l10n, isConnected: isConnected, cloudError: cloudError, mediaErrorCount: saveState.mediaSyncErrorCount, @@ -339,6 +342,7 @@ Future _guardCloudStrategyExit({ } String _cloudSyncBlockedMessage({ + required AppLocalizations l10n, required bool isConnected, required String? cloudError, required int mediaErrorCount, @@ -348,32 +352,27 @@ String _cloudSyncBlockedMessage({ required bool hasPausedWork, }) { final base = !isConnected - ? 'Icarus is offline, so these changes have not reached the cloud.' + ? l10n.unsavedGuardOffline : (cloudError != null - ? friendlyCloudSyncError(cloudError) + ? friendlyCloudSyncError(l10n, cloudError) : (mediaErrorCount > 0 - ? 'Some images have not reached the cloud.' - : 'Icarus is still sending cloud edits and images.')); + ? l10n.unsavedGuardImagesPending + : l10n.unsavedGuardStillSending)); if (canLeaveWithDurableWork) { if (hasUnreadableSavedWork) { - return '$base Leaving will not delete the saved device records. You ' - 'can return to this strategy and retry.'; + return l10n.unsavedGuardLeaveKeepsUnreadable(base); } // Refused work waits for the user's choice; nothing retries it. if (hasRefusedWork) { - return '$base You can leave; when you come back, choose what to keep ' - 'from the sync button.'; + return l10n.unsavedGuardLeaveChooseLater(base); } // Paused work is sent again only when the user retries it. if (hasPausedWork) { - return '$base You can leave; nothing is sent until you retry from the ' - 'sync button.'; + return l10n.unsavedGuardLeaveRetryFromButton(base); } - return '$base The pending work is saved on this device. You can leave ' - 'and Icarus will retry it later.'; + return l10n.unsavedGuardLeaveRetriesLater(base); } - return '$base Stay here and retry because Icarus could not confirm that ' - 'all pending work is saved on this device.'; + return l10n.unsavedGuardStayAndRetry(base); } Future guardUnsavedStrategyExit({ @@ -382,6 +381,7 @@ Future guardUnsavedStrategyExit({ required Future Function() onContinue, required String source, }) async { + final l10n = context.l10n; final strategyState = ref.read(strategyProvider); final saveState = ref.read(strategySaveStateProvider); if (strategyState.source == StrategySource.cloud) { @@ -409,7 +409,7 @@ Future guardUnsavedStrategyExit({ } } catch (error, stackTrace) { AppErrorReporter.reportError( - 'Failed to save strategy before leaving.', + l10n.unsavedGuardSaveFailed, error: error, stackTrace: stackTrace, source: source, @@ -432,7 +432,7 @@ Future guardUnsavedStrategyExit({ await ref.read(strategyProvider.notifier).forceSaveNow(strategyId); } catch (error, stackTrace) { AppErrorReporter.reportError( - 'Failed to save strategy before leaving.', + l10n.unsavedGuardSaveFailed, error: error, stackTrace: stackTrace, source: source, diff --git a/lib/services/video_export/browser_video_sink_stub.dart b/lib/services/video_export/browser_video_sink_stub.dart index 9b479cdb3..384fe598d 100644 --- a/lib/services/video_export/browser_video_sink_stub.dart +++ b/lib/services/video_export/browser_video_sink_stub.dart @@ -1,3 +1,4 @@ +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/services/video_export/video_export_quality.dart'; import 'package:icarus/services/video_export/video_frame_sink.dart'; @@ -10,5 +11,8 @@ Future browserCanEncodeVideo( false; /// Encodes in the browser. Off the web there is no browser to encode in. -VideoFrameSink createBrowserVideoSink(VideoExportQuality quality) => +VideoFrameSink createBrowserVideoSink( + VideoExportQuality quality, + AppLocalizations l10n, +) => throw UnsupportedError('Browser video encoding exists only on the web.'); diff --git a/lib/services/video_export/browser_video_sink_web.dart b/lib/services/video_export/browser_video_sink_web.dart index 64832195b..1c9b55e4b 100644 --- a/lib/services/video_export/browser_video_sink_web.dart +++ b/lib/services/video_export/browser_video_sink_web.dart @@ -1,6 +1,7 @@ import 'dart:typed_data'; import 'package:icarus/const/coordinate_system.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/services/video_export/video_export_quality.dart'; import 'package:icarus/services/video_export/video_frame_sink.dart'; import 'package:icarus/services/video_export/web_codecs_video_encoder.dart'; @@ -39,8 +40,11 @@ Future browserCanEncodeVideo( /// Encodes in the browser with WebCodecs; [VideoFrameSink.finish] returns /// the finished .mp4 for the caller to download. -VideoFrameSink createBrowserVideoSink(VideoExportQuality quality) => - _WebCodecsVideoSink(quality); +VideoFrameSink createBrowserVideoSink( + VideoExportQuality quality, + AppLocalizations l10n, +) => + _WebCodecsVideoSink(quality, l10n); /// Sizes and bitrates follow the desktop presets: Potato and Social hold the /// bitrate their size target allows for the video's length (and Potato @@ -48,10 +52,11 @@ VideoFrameSink createBrowserVideoSink(VideoExportQuality quality) => /// bitrate. The browser encodes as frames arrive, so there is no separate /// encode pass and no second attempt at a smaller size. class _WebCodecsVideoSink implements VideoFrameSink { - _WebCodecsVideoSink(this.quality); + _WebCodecsVideoSink(this.quality, AppLocalizations l10n) + : _encoder = WebCodecsMp4Encoder(l10n); final VideoExportQuality quality; - final WebCodecsMp4Encoder _encoder = WebCodecsMp4Encoder(); + final WebCodecsMp4Encoder _encoder; @override Future start({ diff --git a/lib/services/video_export/ffmpeg_video_encoder.dart b/lib/services/video_export/ffmpeg_video_encoder.dart index fff047194..73574ef87 100644 --- a/lib/services/video_export/ffmpeg_video_encoder.dart +++ b/lib/services/video_export/ffmpeg_video_encoder.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'dart:developer'; import 'dart:io'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/services/video_export/video_export_errors.dart'; import 'package:icarus/services/video_export/video_export_quality.dart'; import 'package:path/path.dart' as p; @@ -64,9 +65,13 @@ Future _startFfmpegProcess( /// Encodes a rendered frame sequence into an .mp4 by invoking a bundled (or /// PATH-installed) ffmpeg binary as a child process (ADR 0001). class FfmpegVideoEncoder { - FfmpegVideoEncoder({FfmpegProcessStarter? processStarter}) - : _processStarter = processStarter ?? _startFfmpegProcess; + FfmpegVideoEncoder({ + required AppLocalizations l10n, + FfmpegProcessStarter? processStarter, + }) : _l10n = l10n, + _processStarter = processStarter ?? _startFfmpegProcess; + final AppLocalizations _l10n; final FfmpegProcessStarter _processStarter; Process? _process; bool _cancelled = false; @@ -257,7 +262,7 @@ class FfmpegVideoEncoder { } on Object catch (error) { await removePartialOutput(); throw VideoExportException( - 'Could not write the video to the selected location: $error', + _l10n.videoExportWriteFailed('$error'), ); } onProgress?.call(1.0); diff --git a/lib/services/video_export/ffmpeg_video_sink.dart b/lib/services/video_export/ffmpeg_video_sink.dart index 0da0087d7..5ba7fbd6d 100644 --- a/lib/services/video_export/ffmpeg_video_sink.dart +++ b/lib/services/video_export/ffmpeg_video_sink.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:io'; import 'dart:typed_data'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/services/video_export/ffmpeg_png_sequence_writer.dart'; import 'package:icarus/services/video_export/ffmpeg_video_encoder.dart'; import 'package:icarus/services/video_export/video_export_quality.dart'; @@ -16,13 +17,14 @@ class FfmpegVideoSink implements VideoFrameSink { required this.binary, required this.outputPath, required this.quality, - }); + required AppLocalizations l10n, + }) : _encoder = FfmpegVideoEncoder(l10n: l10n); final String binary; final String outputPath; final VideoExportQuality quality; - final FfmpegVideoEncoder _encoder = FfmpegVideoEncoder(); + final FfmpegVideoEncoder _encoder; final List _concatLines = ['ffconcat version 1.0']; Directory? _tempDir; FfmpegPngSequenceWriter? _frameWriter; diff --git a/lib/services/video_export/quicktime_animation_sink.dart b/lib/services/video_export/quicktime_animation_sink.dart index 49575d37a..494526ef1 100644 --- a/lib/services/video_export/quicktime_animation_sink.dart +++ b/lib/services/video_export/quicktime_animation_sink.dart @@ -2,6 +2,7 @@ import 'dart:io'; import 'dart:math' as math; import 'dart:typed_data'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/services/video_export/mp4_muxer.dart'; import 'package:icarus/services/video_export/quicktime_animation_encoder.dart'; import 'package:icarus/services/video_export/video_export_errors.dart'; @@ -20,10 +21,15 @@ import 'package:icarus/services/video_export/video_frame_sink.dart'; /// With an [outputPath] (desktop) the finished file is written there and /// [finish] returns null; without one (the browser) [finish] returns it. class QuickTimeAnimationSink implements VideoFrameSink { - QuickTimeAnimationSink({required this.fps, this.outputPath}); + QuickTimeAnimationSink({ + required this.fps, + this.outputPath, + required this.l10n, + }); final int fps; final String? outputPath; + final AppLocalizations l10n; static const _maxKeyFrameIntervalSeconds = 2; @@ -117,9 +123,7 @@ class QuickTimeAnimationSink implements VideoFrameSink { await partial.writeAsBytes(video, flush: true); } on Object catch (error) { await removePartial(); - throw VideoExportException( - 'Could not write the video to the selected location: $error', - ); + throw VideoExportException(l10n.videoExportWriteFailed('$error')); } // A cancel that arrived during the write still wins: the rename is the // only step that touches the destination. @@ -131,9 +135,7 @@ class QuickTimeAnimationSink implements VideoFrameSink { await partial.rename(path); } on Object catch (error) { await removePartial(); - throw VideoExportException( - 'Could not write the video to the selected location: $error', - ); + throw VideoExportException(l10n.videoExportWriteFailed('$error')); } onProgress?.call(1); return null; diff --git a/lib/services/video_export/video_export_errors.dart b/lib/services/video_export/video_export_errors.dart index f07a89ba1..02be796fb 100644 --- a/lib/services/video_export/video_export_errors.dart +++ b/lib/services/video_export/video_export_errors.dart @@ -1,3 +1,5 @@ +import 'package:icarus/l10n/l10n.dart'; + /// Thrown when the user cancels a video export. class VideoExportCancelled implements Exception {} @@ -9,12 +11,13 @@ class CaptionsDontFit implements Exception { final int? page; - String get userMessage => captionsDontFitMessage(page); + String userMessage(AppLocalizations l10n) => + captionsDontFitMessage(l10n, page); } -String captionsDontFitMessage(int? page) => - "Captions on ${page == null ? 'this page' : 'page $page'} don't fit. " - 'Shorten them, or turn off Include captions.'; +String captionsDontFitMessage(AppLocalizations l10n, int? page) => page == null + ? l10n.videoExportCaptionsDontFitThisPage + : l10n.videoExportCaptionsDontFitPage(page); /// A video export failure whose [message] can be shown to the user. class VideoExportException implements Exception { diff --git a/lib/services/video_export/video_export_source.dart b/lib/services/video_export/video_export_source.dart index 3adc9868f..d04f98bd9 100644 --- a/lib/services/video_export/video_export_source.dart +++ b/lib/services/video_export/video_export_source.dart @@ -2,11 +2,13 @@ import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:hive_ce/hive.dart'; +import 'package:icarus/l10n/page_names.dart'; import 'package:icarus/collab/collab_models.dart'; import 'package:icarus/collab/convex_strategy_repository.dart'; import 'package:icarus/const/hive_boxes.dart'; import 'package:icarus/const/placed_media_dimensions.dart'; import 'package:icarus/const/sort_index_order.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/action_history_models.dart'; import 'package:icarus/providers/collab/cloud_media_cache_provider.dart'; import 'package:icarus/providers/drawing_provider.dart'; @@ -25,15 +27,19 @@ import 'package:icarus/strategy/strategy_page_models.dart'; /// A page the export dialog offers. typedef VideoExportPageChoice = ({String id, String name}); -/// The open strategy's pages in order, from wherever the strategy lives. +/// The open strategy's pages in order, from wherever the strategy lives, +/// named as the pages bar names them. List videoExportPageChoices(WidgetRef ref) { final strategy = ref.read(strategyProvider); + final l10n = ref.read(l10nProvider); + String shown(String name, bool? isAutoNamed) => + shownPageName(l10n, name: name, isAutoNamed: isAutoNamed == true); return switch (strategy.source) { StrategySource.cloud => [ for (final page in [ ...?ref.read(remoteEditorSnapshotProvider).valueOrNull?.pages, ]..sortBySortIndex((page) => page.sortIndex)) - (id: page.publicId, name: page.name), + (id: page.publicId, name: shown(page.name, page.isAutoNamed)), ], StrategySource.local => [ for (final page in [ @@ -41,7 +47,7 @@ List videoExportPageChoices(WidgetRef ref) { .get(strategy.strategyId) ?.pages, ]..sortBySortIndex((page) => page.sortIndex)) - (id: page.id, name: page.name), + (id: page.id, name: shown(page.name, page.isAutoNamed)), ], null => const [], }; @@ -52,9 +58,7 @@ List videoExportPageChoices(WidgetRef ref) { class VideoExportNotSynced implements Exception { const VideoExportNotSynced(); - String get userMessage => - "Your latest changes haven't synced yet. Export once the strategy " - 'shows as synced.'; + String userMessage(AppLocalizations l10n) => l10n.videoExportNotSynced; @override String toString() => 'VideoExportNotSynced'; @@ -65,8 +69,7 @@ class VideoExportNotSynced implements Exception { class VideoExportPagesChanged implements Exception { const VideoExportPagesChanged(); - String get userMessage => - 'A page you selected was deleted. Check the pages and export again.'; + String userMessage(AppLocalizations l10n) => l10n.videoExportPagesChanged; @override String toString() => 'VideoExportPagesChanged'; @@ -177,8 +180,8 @@ Future readSavedStrategy( } checkCancelled(); return ( - strategy: - StrategyImportExportService.strategyDataFromRemoteSnapshot(snapshot), + strategy: StrategyImportExportService.strategyDataFromRemoteSnapshot( + snapshot), assets: snapshot.assetsById, ); case StrategySource.local || null: diff --git a/lib/services/video_export/video_exporter.dart b/lib/services/video_export/video_exporter.dart index ee063b009..957472389 100644 --- a/lib/services/video_export/video_exporter.dart +++ b/lib/services/video_export/video_exporter.dart @@ -8,6 +8,7 @@ import 'package:icarus/const/map_doors.dart'; import 'package:icarus/const/placed_classes.dart'; import 'package:icarus/const/sort_index_order.dart'; import 'package:icarus/const/transition_data.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/page_transition/agent_path.dart'; import 'package:icarus/page_transition/navigation_geometry_map.dart'; import 'package:icarus/page_transition/transition_planner.dart'; @@ -44,6 +45,7 @@ class VideoExporter { this.imageSources = const {}, this.navigation, this.requireNavigation = false, + required this.l10n, }); final StrategyData strategy; @@ -69,6 +71,7 @@ class VideoExporter { final Map imageSources; final NavigationGeometryMap? navigation; final bool requireNavigation; + final AppLocalizations l10n; static int transitionFrameCountFor(int fps) { if (fps <= 0) throw ArgumentError.value(fps, 'fps'); @@ -111,7 +114,7 @@ class VideoExporter { void Function(double fraction, String label)? onProgress, }) async { if (pages.isEmpty) { - throw VideoExportException('No pages selected.'); + throw VideoExportException(l10n.videoExportNoPagesSelected); } if (requireNavigation && navigation == null && @@ -127,8 +130,7 @@ class VideoExporter { if (entries.any((entry) => entry.kind == TransitionKind.move && entry.visualWidget is PlacedAgentNode)) { - throw VideoExportException( - 'Movement paths could not load. Please try the export again.'); + throw VideoExportException(l10n.videoExportMovementPathsFailed); } } } @@ -172,7 +174,7 @@ class VideoExporter { try { final offscreenContainer = createCaptureContainer(images: imageSources); captureContainer = offscreenContainer; - onProgress?.call(0, 'Preparing map'); + onProgress?.call(0, l10n.videoExportProgressPreparingMap); captureGeometry = await prepareCaptureGeometry( offscreenContainer, strategy.mapData, @@ -185,7 +187,7 @@ class VideoExporter { isCancelled: () => _cancelled, onWaitingForPictures: () => onProgress?.call( renderedFrames / totalFrames * _renderWeight, - 'Waiting for pictures', + l10n.videoExportProgressWaitingForPictures, ), wrapWidget: (child) => wrapForOffscreenCapture(child, container: offscreenContainer), @@ -211,7 +213,7 @@ class VideoExporter { renderedFrames++; onProgress?.call( renderedFrames / totalFrames * _renderWeight, - 'Rendering frames ($renderedFrames/$totalFrames)', + l10n.videoExportProgressRenderingFrames(renderedFrames, totalFrames), ); } @@ -274,11 +276,11 @@ class VideoExporter { } if (_cancelled) throw VideoExportCancelled(); - onProgress?.call(_renderWeight, 'Encoding video'); + onProgress?.call(_renderWeight, l10n.videoExportProgressEncoding); return await sink.finish( onProgress: (fraction) => onProgress?.call( _renderWeight + fraction * (1 - _renderWeight), - 'Encoding video', + l10n.videoExportProgressEncoding, ), ); } finally { @@ -355,6 +357,7 @@ class VideoExporter { ScreenshotView.forPage( strategy, page, + l10n: l10n, strategyState: strategyState, mapState: mapState, backgroundDotOpacity: backgroundDotOpacity, diff --git a/lib/services/video_export/web_codecs_video_encoder.dart b/lib/services/video_export/web_codecs_video_encoder.dart index 4f1adfd55..eb1b8a6cf 100644 --- a/lib/services/video_export/web_codecs_video_encoder.dart +++ b/lib/services/video_export/web_codecs_video_encoder.dart @@ -4,6 +4,7 @@ import 'dart:js_interop_unsafe'; import 'dart:math' as math; import 'dart:typed_data'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/services/video_export/mp4_muxer.dart'; import 'package:icarus/services/video_export/video_export_errors.dart'; @@ -15,6 +16,10 @@ import 'package:icarus/services/video_export/video_export_errors.dart'; /// for its duration. A repeat reuses the frame's pixels, so a held page costs /// one RGBA upload however long it is shown. class WebCodecsMp4Encoder { + WebCodecsMp4Encoder(this._l10n); + + final AppLocalizations _l10n; + /// Keyframe spacing ceiling, so players can seek within long holds. static const _maxKeyFrameIntervalSeconds = 2; @@ -93,8 +98,7 @@ class WebCodecsMp4Encoder { if (_cancelled) throw VideoExportCancelled(); if (config == null) { throw VideoExportException( - 'This browser cannot encode H.264 video at ' - '${outputWidth}x$outputHeight, $fps fps.', + _l10n.videoExportBrowserCannotEncode(outputWidth, outputHeight, fps), ); } @@ -102,7 +106,7 @@ class WebCodecsMp4Encoder { final canvas = _OffscreenCanvas(outputWidth, outputHeight); final context = canvas.getContext('2d'); if (context == null) { - throw VideoExportException('This browser cannot scale video frames.'); + throw VideoExportException(_l10n.videoExportBrowserCannotScale); } context.imageSmoothingQuality = 'high'; _scaleCanvas = canvas; @@ -121,7 +125,9 @@ class WebCodecsMp4Encoder { encoder.configure(config); } on Object catch (error) { _closeEncoder(); - throw VideoExportException('Could not start the video encoder: $error'); + throw VideoExportException( + _l10n.videoExportEncoderStartFailed('$error'), + ); } } @@ -201,7 +207,7 @@ class WebCodecsMp4Encoder { final config = _avcDecoderConfig; if (_samples.isEmpty || config == null) { - throw VideoExportException('The video encoder produced no video.'); + throw VideoExportException(_l10n.videoExportEncoderNoVideo); } final ticksPerFrame = _timescale ~/ _fps; final muxer = Mp4H264Muxer( @@ -217,9 +223,7 @@ class WebCodecsMp4Encoder { // presentation order. Encoders that reorder (B-frames) fail loudly // rather than produce a video that plays frames out of order. if (nextFrame <= sample.frame) { - throw VideoExportException( - 'The video encoder returned frames out of order.', - ); + throw VideoExportException(_l10n.videoExportEncoderOutOfOrder); } muxer.addSample( sample.bytes, @@ -271,7 +275,7 @@ class WebCodecsMp4Encoder { final failure = _failure; if (failure != null) { _closeEncoder(); - throw VideoExportException('The video encoder failed: $failure'); + throw VideoExportException(_l10n.videoExportEncoderFailed(failure)); } final encoder = _encoder; if (encoder == null || _finished) { diff --git a/lib/share/share_link_copy.dart b/lib/share/share_link_copy.dart index 0ef936f96..91f1a13dd 100644 --- a/lib/share/share_link_copy.dart +++ b/lib/share/share_link_copy.dart @@ -1,45 +1,57 @@ +import 'package:icarus/l10n/l10n.dart'; + abstract final class ShareLinkCopy { /// A strategy link opens read-only for anyone, account or not, where the /// link opens in a browser: links made in the web app point at it. Links /// made in the desktop app hand off to the app, which a recipient may not /// have, and a folder link needs an account; neither promises a view. static String dialogDescription( + AppLocalizations l10n, String targetType, { required bool opensInBrowser, }) => targetType == 'strategy' && opensInBrowser - ? 'Anyone with a link can view this strategy, no account needed. ' - 'People who add it to their library show up here, and links ' - 'stay active until you disable them.' - : 'People who join through a link show up here, and links stay ' - 'active until you disable them.'; + ? l10n.shareLinkCopyDescriptionBrowserStrategy + : l10n.shareLinkCopyDescription; /// Shown under the link kind while "Can edit" is picked. - static const editLinkWarning = - 'Anyone who gets this link can change it, including people it is ' - 'forwarded to. Send it only to your team.'; + static String editLinkWarning(AppLocalizations l10n) => + l10n.shareLinkCopyEditLinkWarning; - static const disableTitle = 'Disable this link?'; - static const disableDescription = - 'No one new can join or view through it. People who already joined ' - 'keep their access unless you remove them.'; - static String removeJoinedLabel(int count, {bool more = false}) => + static String disableTitle(AppLocalizations l10n) => + l10n.shareLinkCopyDisableTitle; + static String disableDescription(AppLocalizations l10n) => + l10n.shareLinkCopyDisableDescription; + static String removeJoinedLabel( + AppLocalizations l10n, + int count, { + bool more = false, + }) => count == 1 && !more - ? 'Also remove the person who joined through it' - : 'Also remove the $count${more ? '+' : ''} people who joined ' - 'through it'; - static const disableAction = 'Disable link'; - static const disableFailure = 'Failed to disable share link.'; + ? l10n.shareLinkCopyRemoveJoinedOne + : more + ? l10n.shareLinkCopyRemoveJoinedAtLeast(count) + : l10n.shareLinkCopyRemoveJoinedMany(count); + static String disableAction(AppLocalizations l10n) => + l10n.shareLinkCopyDisableAction; + static String disableFailure(AppLocalizations l10n) => + l10n.shareLinkCopyDisableFailure; - static const disabledStatus = 'Disabled'; - static const disabledTooltip = 'Link disabled'; - static const disableTooltip = 'Disable link'; + static String disabledStatus(AppLocalizations l10n) => + l10n.shareLinkCopyDisabledStatus; + static String disabledTooltip(AppLocalizations l10n) => + l10n.shareLinkCopyDisabledTooltip; + static String disableTooltip(AppLocalizations l10n) => + l10n.shareLinkCopyDisableTooltip; - static String removeMemberTitle(String name) => 'Remove $name?'; - static const removeMemberDescription = - 'They lose access right away, and it leaves their Shared tab. If they ' - 'still have an active link, they can join again until you disable it.'; - static const removeMemberAction = 'Remove'; - static const removeMemberFailure = "Couldn't remove them. Try again."; - static const roleChangeFailure = "Couldn't change their access. Try again."; + static String removeMemberTitle(AppLocalizations l10n, String name) => + l10n.shareLinkCopyRemoveMemberTitle(name); + static String removeMemberDescription(AppLocalizations l10n) => + l10n.shareLinkCopyRemoveMemberDescription; + static String removeMemberAction(AppLocalizations l10n) => + l10n.shareLinkCopyRemoveMemberAction; + static String removeMemberFailure(AppLocalizations l10n) => + l10n.shareLinkCopyRemoveMemberFailure; + static String roleChangeFailure(AppLocalizations l10n) => + l10n.shareLinkCopyRoleChangeFailure; } diff --git a/lib/sidebar.dart b/lib/sidebar.dart index d3e597252..1f1cfa463 100644 --- a/lib/sidebar.dart +++ b/lib/sidebar.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/agent_filter_provider.dart'; import 'package:icarus/widgets/selectable_icon_button.dart'; import 'package:icarus/widgets/sidebar_widgets/agent_dragable.dart'; @@ -66,13 +67,13 @@ class _SideBarUIState extends ConsumerState { const SizedBox( height: 16, ), - const Align( + Align( alignment: Alignment.centerLeft, child: Padding( - padding: EdgeInsets.symmetric(horizontal: 16), + padding: const EdgeInsets.symmetric(horizontal: 16), child: Text( - "Agents", - style: TextStyle(fontSize: 20), + context.l10n.sidebarAgentsTitle, + style: const TextStyle(fontSize: 20), ), ), ), @@ -92,8 +93,10 @@ class _SideBarUIState extends ConsumerState { ShadTooltip( builder: (context) => Text( filterState.favoritesOnly - ? "Show all agents" - : "Show only favorite agents", + ? context + .l10n.sidebarShowAllAgentsTooltip + : context.l10n + .sidebarShowFavoriteAgentsTooltip, ), child: SelectableIconButton( isSelected: filterState.favoritesOnly, @@ -129,20 +132,23 @@ class _SideBarUIState extends ConsumerState { switchInCurve: Settings.easeOut, switchOutCurve: Settings.easeOut.flipped, child: filterState.favoritesOnly - ? const Padding( - key: ValueKey("favorites-only-indicator"), - padding: EdgeInsets.only(top: 6, left: 2), + ? Padding( + key: const ValueKey( + "favorites-only-indicator"), + padding: + const EdgeInsets.only(top: 6, left: 2), child: Row( spacing: 6, children: [ - Icon( + const Icon( LucideIcons.star, size: 12, color: Color(0xFFFF9800), ), Text( - "Filtering: Favorites only", - style: TextStyle( + context.l10n + .sidebarFavoritesOnlyIndicator, + style: const TextStyle( fontSize: 12, color: Color(0xFFFF9800), fontWeight: FontWeight.w600, @@ -163,10 +169,11 @@ class _SideBarUIState extends ConsumerState { ), Expanded( child: agentList.isEmpty - ? const Center( + ? Center( child: Text( - "No agent available", - style: TextStyle(fontWeight: FontWeight.w500), + context.l10n.sidebarNoAgentAvailable, + style: + const TextStyle(fontWeight: FontWeight.w500), ), ) : RawScrollbar( diff --git a/lib/strategy/lineup_group_changes.dart b/lib/strategy/lineup_group_changes.dart index 12f8ddc84..eb911fcd4 100644 --- a/lib/strategy/lineup_group_changes.dart +++ b/lib/strategy/lineup_group_changes.dart @@ -3,17 +3,57 @@ import 'dart:convert'; import 'package:icarus/collab/canonical_json.dart'; import 'package:icarus/const/agents.dart'; import 'package:icarus/const/line_provider.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:uuid/uuid.dart'; /// What happened to one lineup between two versions of its group. class LineupChange { - const LineupChange({required this.label, required this.description}); + const LineupChange({this.name, this.agent, required this.kinds}); + + /// What the user called the lineup, or null if they left it unnamed. + final String? name; + + /// The lineup's agent, in English as lib/const names it, if it has one. + final String? agent; + + /// Everything that changed. [LineupChangeKind.added] and + /// [LineupChangeKind.deleted] stand alone. + final List kinds; /// The lineup as the user knows it: its name, or its agent's. - final String label; + String label(AppLocalizations l10n) => + name ?? + switch (agent) { + null => l10n.lineupChangeUnnamed, + final agent => l10n.lineupChangeAgentLineup(l10n.gameName(agent)), + }; /// What changed, e.g. "added" or "notes edited, landing moved". - final String description; + String description(AppLocalizations l10n) => [ + for (final kind in kinds) + switch (kind) { + LineupChangeKind.added => l10n.lineupChangeAdded, + LineupChangeKind.deleted => l10n.lineupChangeDeleted, + LineupChangeKind.renamed => l10n.lineupChangeRenamed, + LineupChangeKind.notesEdited => l10n.lineupChangeNotesEdited, + LineupChangeKind.videoChanged => l10n.lineupChangeVideoChanged, + LineupChangeKind.imagesChanged => l10n.lineupChangeImagesChanged, + LineupChangeKind.standingSpotMoved => + l10n.lineupChangeStandingSpotMoved, + LineupChangeKind.landingMoved => l10n.lineupChangeLandingMoved, + }, + ].join(l10n.lineupChangeSeparator); +} + +enum LineupChangeKind { + added, + deleted, + renamed, + notesEdited, + videoChanged, + imagesChanged, + standingSpotMoved, + landingMoved, } /// The lineups that differ between [from] and [to], one entry each, in @@ -28,40 +68,36 @@ List lineupChanges({ for (final link in to.links) { final old = before.links[link.id]; if (old == null) { - changes.add(LineupChange(label: after.label(link), description: 'added')); + changes.add(after.change(link, const [LineupChangeKind.added])); continue; } final what = [ - if (old.name != link.name) 'renamed', - if (old.notes != link.notes) 'notes edited', - if (old.youtubeLink != link.youtubeLink) 'video changed', + if (old.name != link.name) LineupChangeKind.renamed, + if (old.notes != link.notes) LineupChangeKind.notesEdited, + if (old.youtubeLink != link.youtubeLink) LineupChangeKind.videoChanged, if (!_sameJson( [for (final image in old.images) image.toJson()], [for (final image in link.images) image.toJson()], )) - 'images changed', + LineupChangeKind.imagesChanged, if (!_sameJson( before.origins[old.originId]?.toJson(), after.origins[link.originId]?.toJson(), )) - 'standing spot moved', + LineupChangeKind.standingSpotMoved, if (!_sameJson( before.landings[old.landingId]?.toJson(), after.landings[link.landingId]?.toJson(), )) - 'landing moved', + LineupChangeKind.landingMoved, ]; if (what.isNotEmpty) { - changes.add( - LineupChange(label: after.label(link), description: what.join(', ')), - ); + changes.add(after.change(link, what)); } } for (final link in from.links) { if (!after.links.containsKey(link.id)) { - changes.add( - LineupChange(label: before.label(link), description: 'deleted'), - ); + changes.add(before.change(link, const [LineupChangeKind.deleted])); } } return changes; @@ -127,10 +163,12 @@ class _Lookup { final Map landings; final Map links; - String label(LineUpLink link) { + LineupChange change(LineUpLink link, List kinds) { final name = link.name.trim(); - if (name.isNotEmpty) return name; - final agent = AgentData.agents[origins[link.originId]?.agent.type]?.name; - return agent == null ? 'Unnamed lineup' : '$agent lineup'; + return LineupChange( + name: name.isEmpty ? null : name, + agent: AgentData.agents[origins[link.originId]?.agent.type]?.name, + kinds: kinds, + ); } } diff --git a/lib/strategy/strategy_import_export.dart b/lib/strategy/strategy_import_export.dart index 6e6d77cce..aaec0efe6 100644 --- a/lib/strategy/strategy_import_export.dart +++ b/lib/strategy/strategy_import_export.dart @@ -21,6 +21,7 @@ import 'package:icarus/const/maps.dart'; import 'package:icarus/const/placed_classes.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/const/sort_index_order.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/ability_provider.dart'; import 'package:icarus/providers/agent_provider.dart'; import 'package:icarus/providers/drawing_provider.dart'; @@ -72,26 +73,21 @@ final cloudStrategyExporterProvider = Provider( /// An export that holds cloud work stopped before writing anything: a /// backup missing part of the library must never look complete. class LibraryExportIncomplete implements Exception { - const LibraryExportIncomplete.cloudUnreadable({this.cause}) - : userMessage = "Couldn't read your cloud library, so nothing was " - 'exported. Check your connection and try again.'; - - const LibraryExportIncomplete.cloudNotSynced() - : userMessage = "Some of your cloud changes haven't synced yet, so " - 'nothing was exported. Let them sync, or review them in your ' - 'library, then try again.', + LibraryExportIncomplete.cloudUnreadable(AppLocalizations l10n, {this.cause}) + : userMessage = l10n.importExportCloudUnreadable; + + LibraryExportIncomplete.cloudNotSynced(AppLocalizations l10n) + : userMessage = l10n.importExportCloudNotSynced, cause = null; /// Saved cloud work on this device could not be read, so no strategy can /// be shown to have none waiting. - const LibraryExportIncomplete.cloudWorkUnreadable() - : userMessage = "Icarus can't read some cloud changes saved on this " - 'device, so nothing was exported. Review them, then try again.', + LibraryExportIncomplete.cloudWorkUnreadable(AppLocalizations l10n) + : userMessage = l10n.importExportCloudWorkUnreadable, cause = null; - const LibraryExportIncomplete.cloudChanged({this.cause}) - : userMessage = 'Your cloud library changed while exporting, so ' - 'nothing was exported. Try again.'; + LibraryExportIncomplete.cloudChanged(AppLocalizations l10n, {this.cause}) + : userMessage = l10n.importExportCloudChanged; final String userMessage; final Object? cause; @@ -109,9 +105,8 @@ class NewerVersionImportException implements Exception { final int importedVersion; final int currentVersion; - static const String userMessage = - 'This file was created in a newer version of Icarus. ' - 'Please update the app and try again.'; + static String userMessage(AppLocalizations l10n) => + l10n.importExportNewerVersion; @override String toString() { @@ -2300,6 +2295,7 @@ class StrategyImportExportService { // The staging directory holds one directory, named for the folder. final folderDirectory = stagingDirectory.listSync().single; final outputFile = await chooseExportPath( + ref.read(l10nProvider), '${path.basename(folderDirectory.path)}.zip', ); @@ -2324,6 +2320,7 @@ class StrategyImportExportService { try { final outputFile = await chooseExportPath( + ref.read(l10nProvider), buildLibraryBackupFileName(DateTime.now()), ); @@ -2559,7 +2556,7 @@ class StrategyImportExportService { for (final id in folderIds) { final folder = tree[id]; if (folder == null || folder.parentID != inventory.folders[id]) { - throw const LibraryExportIncomplete.cloudChanged(); + throw LibraryExportIncomplete.cloudChanged(ref.read(l10nProvider)); } folders.add(folder); } @@ -2599,7 +2596,10 @@ class StrategyImportExportService { ); } catch (error, stackTrace) { Error.throwWithStackTrace( - LibraryExportIncomplete.cloudUnreadable(cause: error), + LibraryExportIncomplete.cloudUnreadable( + ref.read(l10nProvider), + cause: error, + ), stackTrace, ); } @@ -2631,7 +2631,7 @@ class StrategyImportExportService { if (!auth.isConvexUserReady || auth.hasActiveAuthIncident || !ref.read(convexConnectionSnapshotProvider)) { - throw const LibraryExportIncomplete.cloudUnreadable(); + throw LibraryExportIncomplete.cloudUnreadable(ref.read(l10nProvider)); } await _waitUntilCloudWorkSent(); return ref.read(convexStrategyRepositoryProvider); @@ -2646,8 +2646,14 @@ class StrategyImportExportService { } catch (error, stackTrace) { Error.throwWithStackTrace( isTypedConvexNotFoundError(error) - ? LibraryExportIncomplete.cloudChanged(cause: error) - : LibraryExportIncomplete.cloudUnreadable(cause: error), + ? LibraryExportIncomplete.cloudChanged( + ref.read(l10nProvider), + cause: error, + ) + : LibraryExportIncomplete.cloudUnreadable( + ref.read(l10nProvider), + cause: error, + ), stackTrace, ); } @@ -2664,8 +2670,8 @@ class StrategyImportExportService { _cloudAccountCheck = await sendCloudWork(ref.read); } on CloudWorkNotSent catch (error) { throw error.unreadable - ? const LibraryExportIncomplete.cloudWorkUnreadable() - : const LibraryExportIncomplete.cloudNotSynced(); + ? LibraryExportIncomplete.cloudWorkUnreadable(ref.read(l10nProvider)) + : LibraryExportIncomplete.cloudNotSynced(ref.read(l10nProvider)); } } @@ -2674,7 +2680,7 @@ class StrategyImportExportService { try { _cloudAccountCheck?.call(); } on CloudWorkNotSent { - throw const LibraryExportIncomplete.cloudChanged(); + throw LibraryExportIncomplete.cloudChanged(ref.read(l10nProvider)); } } @@ -2960,6 +2966,7 @@ class StrategyImportExportService { await _ensureRemoteAssetsCached(snapshot); final strategy = strategyDataFromRemoteSnapshot(snapshot); final outputFile = await chooseExportPath( + ref.read(l10nProvider), '${sanitizeStrategyFileName(strategy.name)}.ica', ); if (outputFile == null) return false; @@ -2970,8 +2977,10 @@ class StrategyImportExportService { Future exportFile(String id) async { await ref.read(strategyProvider.notifier).forceSaveNow(id); + final AppLocalizations l10n = ref.read(l10nProvider); final outputFile = await chooseExportPath( - '${sanitizeStrategyFileName(ref.read(strategyProvider).strategyName ?? "new strategy")}.ica', + l10n, + '${sanitizeStrategyFileName(ref.read(strategyProvider).strategyName ?? l10n.importExportDefaultStrategyFileName)}.ica', ); if (outputFile == null) return; diff --git a/lib/strategy_view.dart b/lib/strategy_view.dart index 48ea2d87b..f8fe632c7 100644 --- a/lib/strategy_view.dart +++ b/lib/strategy_view.dart @@ -12,6 +12,7 @@ import 'package:icarus/const/maps.dart'; import 'package:icarus/const/routes.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/interactive_map.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/agent_filter_provider.dart'; import 'package:icarus/providers/collab/remote_strategy_snapshot_provider.dart'; import 'package:icarus/providers/collab/strategy_capabilities_provider.dart'; @@ -210,8 +211,8 @@ class _StrategyViewState extends ConsumerState if (mounted) { Settings.showToast( message: notKept - ? strategyNeedsConnectionMessage - : 'Could not load strategy.', + ? context.l10n.syncStatusStrategyNeedsConnection + : context.l10n.strategyViewLoadFailed, backgroundColor: notKept ? Settings.tacticalVioletTheme.secondary : Settings.tacticalVioletTheme.destructive, @@ -275,7 +276,7 @@ class _StrategyViewState extends ConsumerState } ref.read(shareLinkControllerProvider.notifier).forgetViewedLink(); Settings.showToast( - message: 'This share link was disabled by its owner.', + message: context.l10n.strategyViewShareLinkDisabled, backgroundColor: Settings.tacticalVioletTheme.destructive, ); unawaited(_leaveToLibrary()); @@ -334,7 +335,8 @@ class _StrategyViewState extends ConsumerState const SizedBox(width: 6), ShadTooltip( builder: (context) => - Text(widget.backTooltip ?? 'Library'), + Text(widget.backTooltip ?? + context.l10n.strategyViewBackToLibraryTooltip), child: ShadIconButton.ghost( width: 28, height: 28, @@ -487,22 +489,22 @@ class _DiscordLink extends StatelessWidget { }, leading: showLabel ? null : const Icon(CustomIcons.discord, size: 16), child: showLabel - ? const Row( + ? Row( mainAxisSize: MainAxisSize.min, children: [ Text( - 'Have any bugs? Join the Discord', - style: TextStyle(fontSize: 13), + context.l10n.strategyViewDiscordPrompt, + style: const TextStyle(fontSize: 13), ), - SizedBox(width: 8), - Icon(CustomIcons.discord, size: 16), + const SizedBox(width: 8), + const Icon(CustomIcons.discord, size: 16), ], ) : const SizedBox.shrink(), ); if (showLabel) return button; return ShadTooltip( - builder: (context) => const Text('Have any bugs? Join the Discord'), + builder: (context) => Text(context.l10n.strategyViewDiscordPrompt), child: button, ); } diff --git a/lib/widgets/asset_svg.dart b/lib/widgets/asset_svg.dart index c92adadf8..c27978e52 100644 --- a/lib/widgets/asset_svg.dart +++ b/lib/widgets/asset_svg.dart @@ -1,5 +1,6 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/services/app_assets.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; @@ -99,10 +100,11 @@ class MissingMapNotice extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - Text("Map didn't load", style: theme.textTheme.small), + Text(context.l10n.mapArtworkLoadFailedTitle, + style: theme.textTheme.small), const SizedBox(height: 8), Text( - "Trying again. Your strategy isn't affected.", + context.l10n.mapArtworkLoadFailedRetrying, style: theme.textTheme.muted, ), ], diff --git a/lib/widgets/better_color_picker.dart b/lib/widgets/better_color_picker.dart index 611e6e854..0f008b683 100644 --- a/lib/widgets/better_color_picker.dart +++ b/lib/widgets/better_color_picker.dart @@ -3,6 +3,7 @@ import 'dart:ui' show FontFeature; import 'package:flutter/material.dart' as material; import 'package:flutter/services.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/widgets/text_editing_shortcut_scope.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; @@ -403,7 +404,7 @@ class BetterColorPickerField extends material.StatelessWidget { this.orientation = material.Axis.vertical, this.label, this.enabled = true, - this.dialogTitle = 'Select color', + this.dialogTitle, this.style = const BetterColorPickerStyle(), }); @@ -415,7 +416,7 @@ class BetterColorPickerField extends material.StatelessWidget { final material.Axis orientation; final String? label; final bool enabled; - final String dialogTitle; + final String? dialogTitle; final BetterColorPickerStyle style; @override @@ -494,7 +495,7 @@ class BetterColorPickerField extends material.StatelessWidget { Future showBetterColorPickerDialog( material.BuildContext context, { required material.Color initialColor, - String title = 'Select color', + String? title, BetterColorPickerMode initialMode = BetterColorPickerMode.rgb, bool showAlpha = false, material.Axis orientation = material.Axis.vertical, @@ -506,7 +507,7 @@ Future showBetterColorPickerDialog( builder: (context) { return _BetterColorPickerDialog( initialColor: initialColor, - title: title, + title: title ?? context.l10n.colorPickerTitle, initialMode: initialMode, showAlpha: showAlpha, orientation: orientation, @@ -621,11 +622,11 @@ class _BetterColorPickerDialogState actions: [ material.TextButton( onPressed: () => material.Navigator.of(context).pop(), - child: const material.Text('Cancel'), + child: material.Text(context.l10n.commonCancel), ), material.FilledButton( onPressed: () => material.Navigator.of(context).pop(_value), - child: const material.Text('Apply'), + child: material.Text(context.l10n.commonApply), ), ], ); @@ -658,6 +659,7 @@ class _PickerControls extends material.StatelessWidget { @override material.Widget build(material.BuildContext context) { final palette = style.paletteFor(material.Theme.of(context).brightness); + final l10n = context.l10n; final fields = <_GroupedFieldData>[ _GroupedFieldData( width: 96, @@ -674,10 +676,10 @@ class _PickerControls extends material.StatelessWidget { ), ), ...switch (mode) { - BetterColorPickerMode.rgb => _buildRgbFields(), - BetterColorPickerMode.hsl => _buildHslFields(), - BetterColorPickerMode.hsv => _buildHsvFields(), - BetterColorPickerMode.hex => _buildHexFields(), + BetterColorPickerMode.rgb => _buildRgbFields(l10n), + BetterColorPickerMode.hsl => _buildHslFields(l10n), + BetterColorPickerMode.hsv => _buildHsvFields(l10n), + BetterColorPickerMode.hex => _buildHexFields(l10n), }, ]; final preferredWidth = @@ -710,12 +712,12 @@ class _PickerControls extends material.StatelessWidget { ); } - List<_GroupedFieldData> _buildRgbFields() { + List<_GroupedFieldData> _buildRgbFields(AppLocalizations l10n) { return [ _numberField( width: 64, value: value.red.toString(), - placeholder: 'Red', + placeholder: l10n.colorPickerRed, min: 0, max: 255, onChanged: (next) => onChanged(value.changeToColorRed(next.toDouble())), @@ -723,7 +725,7 @@ class _PickerControls extends material.StatelessWidget { _numberField( width: 64, value: value.green.toString(), - placeholder: 'Green', + placeholder: l10n.colorPickerGreen, min: 0, max: 255, onChanged: (next) => @@ -732,7 +734,7 @@ class _PickerControls extends material.StatelessWidget { _numberField( width: 64, value: value.blue.toString(), - placeholder: 'Blue', + placeholder: l10n.colorPickerBlue, min: 0, max: 255, onChanged: (next) => @@ -742,7 +744,7 @@ class _PickerControls extends material.StatelessWidget { _numberField( width: 64, value: (value.opacity * 255).round().toString(), - placeholder: 'Alpha', + placeholder: l10n.colorPickerAlpha, min: 0, max: 255, onChanged: (next) => onChanged(value.changeToOpacity(next / 255)), @@ -750,12 +752,12 @@ class _PickerControls extends material.StatelessWidget { ]; } - List<_GroupedFieldData> _buildHslFields() { + List<_GroupedFieldData> _buildHslFields(AppLocalizations l10n) { return [ _numberField( width: 64, value: value.hslHue.round().toString(), - placeholder: 'Hue', + placeholder: l10n.colorPickerHue, min: 0, max: 360, onChanged: (next) => onChanged(value.changeToHSLHue(next.toDouble())), @@ -763,7 +765,7 @@ class _PickerControls extends material.StatelessWidget { _numberField( width: 64, value: (value.hslSat * 100).round().toString(), - placeholder: 'Sat', + placeholder: l10n.colorPickerSaturation, min: 0, max: 100, onChanged: (next) => onChanged(value.changeToHSLSaturation(next / 100)), @@ -771,7 +773,7 @@ class _PickerControls extends material.StatelessWidget { _numberField( width: 64, value: (value.hslLightness * 100).round().toString(), - placeholder: 'Lum', + placeholder: l10n.colorPickerLightness, min: 0, max: 100, onChanged: (next) => onChanged(value.changeToHSLLightness(next / 100)), @@ -780,7 +782,7 @@ class _PickerControls extends material.StatelessWidget { _numberField( width: 64, value: (value.opacity * 100).round().toString(), - placeholder: 'Alpha', + placeholder: l10n.colorPickerAlpha, min: 0, max: 100, onChanged: (next) => onChanged(value.changeToOpacity(next / 100)), @@ -788,12 +790,12 @@ class _PickerControls extends material.StatelessWidget { ]; } - List<_GroupedFieldData> _buildHsvFields() { + List<_GroupedFieldData> _buildHsvFields(AppLocalizations l10n) { return [ _numberField( width: 64, value: value.hsvHue.round().toString(), - placeholder: 'Hue', + placeholder: l10n.colorPickerHue, min: 0, max: 360, onChanged: (next) => onChanged(value.changeToHSVHue(next.toDouble())), @@ -801,7 +803,7 @@ class _PickerControls extends material.StatelessWidget { _numberField( width: 64, value: (value.hsvSat * 100).round().toString(), - placeholder: 'Sat', + placeholder: l10n.colorPickerSaturation, min: 0, max: 100, onChanged: (next) => onChanged(value.changeToHSVSaturation(next / 100)), @@ -809,7 +811,7 @@ class _PickerControls extends material.StatelessWidget { _numberField( width: 64, value: (value.hsvValue * 100).round().toString(), - placeholder: 'Val', + placeholder: l10n.colorPickerValue, min: 0, max: 100, onChanged: (next) => onChanged(value.changeToHSVValue(next / 100)), @@ -818,7 +820,7 @@ class _PickerControls extends material.StatelessWidget { _numberField( width: 64, value: (value.opacity * 100).round().toString(), - placeholder: 'Alpha', + placeholder: l10n.colorPickerAlpha, min: 0, max: 100, onChanged: (next) => onChanged(value.changeToOpacity(next / 100)), @@ -826,7 +828,7 @@ class _PickerControls extends material.StatelessWidget { ]; } - List<_GroupedFieldData> _buildHexFields() { + List<_GroupedFieldData> _buildHexFields(AppLocalizations l10n) { return [ _GroupedFieldData( width: 104, @@ -861,7 +863,7 @@ class _PickerControls extends material.StatelessWidget { _numberField( width: 64, value: (value.opacity * 100).round().toString(), - placeholder: 'Alpha', + placeholder: l10n.colorPickerAlpha, min: 0, max: 100, onChanged: (next) => onChanged(value.changeToOpacity(next / 100)), diff --git a/lib/widgets/canvas_paste.dart b/lib/widgets/canvas_paste.dart index 26f1f3b68..5867aa1c8 100644 --- a/lib/widgets/canvas_paste.dart +++ b/lib/widgets/canvas_paste.dart @@ -5,8 +5,8 @@ import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/config/platform_policy.dart'; -import 'package:icarus/const/maps.dart'; import 'package:icarus/const/settings.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/const/shortcut_info.dart'; import 'package:icarus/providers/canvas_clipboard_provider.dart'; import 'package:icarus/providers/caption_card_provider.dart'; @@ -71,14 +71,14 @@ class _CanvasPasteState extends ConsumerState { if (picture != null) { await _placePicture(picture, stillThere); } else if (ref.read(canvasClipboardProvider) == null) { - _refuse('No image found in clipboard'); + _refuse(context.l10n.canvasPasteNoImage); } else if (!stillThere()) { _refuse(_pageChanged); } else if (ref.read(interactionStateProvider).itemsInReach) { await _pasteItems(); } } on ClipboardBusyException { - if (mounted) _refuse(ClipboardService.busyMessage); + if (mounted) _refuse(ClipboardService.busyMessage(context.l10n)); } catch (error, stackTrace) { AppErrorReporter.reportError( 'Paste failed: $error', @@ -87,7 +87,7 @@ class _CanvasPasteState extends ConsumerState { source: 'canvas_paste.paste', promptUser: false, ); - if (mounted) _refuse("Couldn't paste what you copied."); + if (mounted) _refuse(context.l10n.canvasPasteFailed); } finally { _pasting = false; } @@ -100,12 +100,13 @@ class _CanvasPasteState extends ConsumerState { bool Function() stillThere, ) async { if (!ensureFeatureAvailable(ref, PlatformFeature.addImages)) return; + final l10n = context.l10n; // The map shows nothing until the picture is ready, half a second for // a 1080p Windows screenshot and a few at 4K: see pngOfBitmap. ToastificationItem? notice; final slow = Timer(_slowPicture, () { notice = Settings.showToast( - message: 'Adding picture…', + message: l10n.canvasPasteAddingPicture, backgroundColor: Settings.tacticalVioletTheme.secondary, // Past pngOfBitmap's limit, so no notice outlasts what it describes. autoCloseDuration: const Duration(seconds: 20), @@ -123,7 +124,7 @@ class _CanvasPasteState extends ConsumerState { .update(InteractionState.navigation); final added = await ref.read(placedImageProvider.notifier).addImageOnScreen(bytes); - if (!added && mounted) _refuse('Clipboard image type not supported'); + if (!added && mounted) _refuse(l10n.canvasPasteUnsupportedImage); } finally { slow.cancel(); if (notice case final item?) _dismissAfterItShows(item); @@ -142,28 +143,22 @@ class _CanvasPasteState extends ConsumerState { final refusal = await ref.read(canvasClipboardProvider.notifier).paste(); if (refusal == null || !mounted) return; final copiedOn = ref.read(canvasClipboardProvider)?.map; + final l10n = context.l10n; _refuse( switch (refusal) { PasteRefusal.otherMap => copiedOn == null - ? 'What you copied is from another map.' - : 'What you copied is from ${Maps.displayName(copiedOn)}. Paste ' - 'it into a strategy on ${Maps.displayName(copiedOn)}.', - PasteRefusal.picturesMissing => - "A picture in what you copied couldn't be read when you copied " - 'it, so nothing was pasted. Check your connection and copy ' - 'it again.', + ? l10n.canvasPasteOtherMap + : l10n.canvasPasteFromMap(l10n.mapName(copiedOn)), + PasteRefusal.picturesMissing => l10n.canvasPastePicturesMissing, PasteRefusal.pageChanged => _pageChanged, - PasteRefusal.pictureTooLarge => - 'A picture in what you copied is over 15 MB, more than a cloud ' - 'strategy takes, so nothing was pasted.', + PasteRefusal.pictureTooLarge => l10n.canvasPastePictureTooLarge, }, ); } static const _slowPicture = Duration(milliseconds: 200); - static const _pageChanged = - 'The page changed before the paste finished, so nothing was pasted.'; + String get _pageChanged => context.l10n.canvasPastePageChanged; void _refuse(String message) => Settings.showToast( message: message, diff --git a/lib/widgets/canvas_selection_layer.dart b/lib/widgets/canvas_selection_layer.dart index a1d655b31..66b41524a 100644 --- a/lib/widgets/canvas_selection_layer.dart +++ b/lib/widgets/canvas_selection_layer.dart @@ -8,6 +8,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/coordinate_system.dart'; import 'package:icarus/const/line_provider.dart'; import 'package:icarus/const/settings.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/const/shortcut_info.dart'; import 'package:icarus/providers/canvas_clipboard_provider.dart'; import 'package:icarus/providers/canvas_selection_provider.dart'; @@ -262,8 +263,7 @@ class _CanvasSelectionLayerState extends ConsumerState if (preferences.canvasSelectionHintSeen) return; ref.read(appPreferencesProvider.notifier).markCanvasSelectionHintSeen(); Settings.showToast( - message: 'Dragging on the map now selects. Hold Space and drag to ' - 'move the map.', + message: context.l10n.canvasSelectionHint, backgroundColor: Settings.tacticalVioletTheme.primary, autoCloseDuration: const Duration(seconds: 8), ); diff --git a/lib/widgets/caption_card.dart b/lib/widgets/caption_card.dart index ae4729d25..bb0072d6f 100644 --- a/lib/widgets/caption_card.dart +++ b/lib/widgets/caption_card.dart @@ -13,6 +13,7 @@ import 'package:icarus/const/line_provider.dart'; import 'package:icarus/const/placed_classes.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/const/utilities.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/ability_provider.dart'; import 'package:icarus/providers/agent_provider.dart'; import 'package:icarus/providers/caption_card_provider.dart'; @@ -31,14 +32,20 @@ import 'package:icarus/widgets/text_editing_shortcut_scope.dart'; import 'package:url_launcher/url_launcher.dart'; /// The name a caption card shows for [item]. -String captionItemName(PlacedWidget item) => switch (item) { - PlacedAgentNode() => AgentData.agents[item.type]?.name ?? 'Agent', - PlacedAbility() => item.data.name, - PlacedText() => 'Text', - PlacedImage() => 'Image', - PlacedUtility() when item.type == UtilityType.spike => 'Spike', - PlacedUtility() when UtilityData.isViewCone(item.type) => 'Vision cone', - _ => 'Shape', +String captionItemName(AppLocalizations l10n, PlacedWidget item) => + switch (item) { + PlacedAgentNode() => + switch (AgentData.agents[item.type]?.name) { + final name? => l10n.gameName(name), + null => l10n.captionCardAgentName, + }, + PlacedAbility() => l10n.gameName(item.data.name), + PlacedText() => l10n.captionCardTextName, + PlacedImage() => l10n.captionCardImageName, + PlacedUtility() when item.type == UtilityType.spike => l10n.gameSpike, + PlacedUtility() when UtilityData.isViewCone(item.type) => + l10n.captionCardVisionConeName, + _ => l10n.captionCardShapeName, }; /// The caption card of the item that's open, above that item, or below it @@ -300,7 +307,7 @@ class _CaptionCardLayerState extends ConsumerState } final draft = open.writing ? _draftFor(open, caption) : null; - final name = captionItemName(item); + final name = captionItemName(context.l10n, item); final placement = _placement ??= _Placement(); return CustomSingleChildLayout( delegate: _CaptionCardLayout( @@ -356,8 +363,7 @@ void _save(ProviderContainer container, _Draft draft) { // written. unawaited(Clipboard.setData(ClipboardData(text: text))); Settings.showToast( - message: "That item is gone, so your note wasn't saved. " - "It's on your clipboard.", + message: container.read(l10nProvider).captionCardItemGone, backgroundColor: Settings.tacticalVioletTheme.destructive, ); } @@ -741,7 +747,7 @@ class _CaptionEditor extends StatelessWidget { textInputAction: TextInputAction.newline, inputFormatters: [LengthLimitingTextInputFormatter(captionMaxLength)], decoration: InputDecoration.collapsed( - hintText: 'Write a caption', + hintText: context.l10n.captionCardHint, hintStyle: style.copyWith( color: Settings.tacticalVioletTheme.mutedForeground, ), @@ -764,7 +770,11 @@ class _CaptionEditor extends StatelessWidget { onTap: draft.focus.requestFocus, child: _CardFrame( name: name, - trailing: Text(_mac ? '⌘Enter to save' : 'Ctrl+Enter to save'), + trailing: Text( + context.l10n.captionCardSaveShortcut( + _mac ? '⌘Enter' : 'Ctrl+Enter', + ), + ), // Keys bound to the app's shortcuts stop at the field, so a letter // typed into a note never deletes or turns what's under the // pointer. diff --git a/lib/widgets/client_upgrade_button.dart b/lib/widgets/client_upgrade_button.dart index c6cf7cb1b..baa868626 100644 --- a/lib/widgets/client_upgrade_button.dart +++ b/lib/widgets/client_upgrade_button.dart @@ -2,6 +2,8 @@ import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/collab/cloud_sync_error_message.dart'; +import 'package:icarus/l10n/cloud_sync_error_text.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/collab/client_upgrade_required_provider.dart'; import 'package:icarus/providers/update_status_provider.dart'; import 'package:icarus/services/browser_url.dart'; @@ -36,7 +38,7 @@ class ClientUpgradeNotice extends StatelessWidget { if (isWeb) { return builder( context, - clientUpgradeRequiredMessage(isWeb: true), + clientUpgradeRequiredMessage(context.l10n, isWeb: true), _ReloadButton(size: buttonSize, onReload: onReload), ); } @@ -45,10 +47,10 @@ class ClientUpgradeNotice extends StatelessWidget { if (openUpdate != null) { return builder( context, - clientUpgradeRequiredMessage(isWeb: false), + clientUpgradeRequiredMessage(context.l10n, isWeb: false), _button( icon: LucideIcons.download, - label: 'Update', + label: context.l10n.clientUpgradeUpdate, size: buttonSize, onPressed: openUpdate, ), @@ -57,6 +59,7 @@ class ClientUpgradeNotice extends StatelessWidget { return builder( context, clientUpgradeRequiredMessage( + context.l10n, isWeb: false, update: isChecking ? ClientUpdateAvailability.checking @@ -100,7 +103,7 @@ class _ReloadButtonState extends ConsumerState<_ReloadButton> { Widget build(BuildContext context) { return _button( icon: LucideIcons.refreshCw, - label: 'Reload', + label: context.l10n.clientUpgradeReload, size: widget.size, onPressed: _leaving ? null : _reload, ); @@ -116,7 +119,7 @@ class _CheckAgainButton extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { return _button( icon: LucideIcons.refreshCw, - label: 'Check again', + label: context.l10n.clientUpgradeCheckAgain, size: size, onPressed: () { ref.invalidate(appUpdateStatusProvider); diff --git a/lib/widgets/cloud_outbox_summary_banner.dart b/lib/widgets/cloud_outbox_summary_banner.dart index c7070e828..af0ff754e 100644 --- a/lib/widgets/cloud_outbox_summary_banner.dart +++ b/lib/widgets/cloud_outbox_summary_banner.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/providers/collab/move_to_cloud_provider.dart'; import 'package:icarus/collab/replay_capture_record.dart'; import 'package:icarus/const/settings.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/auth_provider.dart'; import 'package:icarus/providers/collab/client_upgrade_required_provider.dart'; import 'package:icarus/providers/collab/cloud_media_upload_queue_provider.dart'; @@ -92,6 +93,7 @@ class CloudOutboxSummaryBanner extends ConsumerWidget { if (workCount == 0 && !hasDurabilityProblem && deletedStrategies.isEmpty) { return const SizedBox.shrink(); } + final l10n = context.l10n; if (heldForUpgrade) { return ClientUpgradeNotice( buttonSize: ShadButtonSize.sm, @@ -99,10 +101,8 @@ class CloudOutboxSummaryBanner extends ConsumerWidget { context, needsAttention: true, connected: connected, - title: 'Cloud work is waiting for a newer Icarus', - detail: '$workCount saved ' - '${workCount == 1 ? 'change is' : 'changes are'} kept on this ' - 'device. $message', + title: l10n.outboxBannerUpgradeTitle, + detail: l10n.outboxBannerUpgradeDetail(workCount, message), actions: [if (action != null) action], ), ); @@ -114,31 +114,27 @@ class CloudOutboxSummaryBanner extends ConsumerWidget { failedCaptures.isNotEmpty || deletedStrategies.isNotEmpty; final title = needsAttention - ? 'Cloud work needs attention' + ? l10n.outboxBannerAttentionTitle : connected - ? 'Syncing cloud work' - : 'Working offline'; + ? l10n.outboxBannerSyncingTitle + : l10n.outboxBannerOfflineTitle; final detail = hasDurabilityProblem - ? 'Icarus cannot read some saved cloud work on this device. Stay ' - 'signed in and review it.' + ? l10n.outboxBannerUnreadableDetail : authBlocked - ? 'Reconnect this account before Icarus can send its saved work.' + ? l10n.outboxBannerReconnectDetail : workCount == 0 && deletedStrategies.isNotEmpty - ? '$deletedChangeCount unsent ' - '${deletedChangeCount == 1 ? 'change' : 'changes'} to a ' - 'strategy that was deleted cannot be saved. Discard ' - '${deletedChangeCount == 1 ? 'it' : 'them'} to clear this.' + ? l10n.outboxBannerDeletedStrategyDetail(deletedChangeCount) : needsAttention - ? '$workCount saved ${workCount == 1 ? 'change needs' : 'changes need'} ' - 'review across ${strategyIds.length} ' - '${strategyIds.length == 1 ? 'strategy' : 'strategies'}.' + ? l10n.outboxBannerAttentionDetail( + workCount, + strategyIds.length, + ) : connected - ? '$workCount saved ${workCount == 1 ? 'change is' : 'changes are'} ' - 'being sent from ${strategyIds.length} ' - '${strategyIds.length == 1 ? 'strategy' : 'strategies'}.' - : '$workCount saved ${workCount == 1 ? 'change is' : 'changes are'} ' - 'waiting on this device and will resume when the ' - 'connection returns.'; + ? l10n.outboxBannerSendingDetail( + workCount, + strategyIds.length, + ) + : l10n.outboxBannerOfflineDetail(workCount); return _card( context, needsAttention: needsAttention, @@ -153,6 +149,7 @@ class CloudOutboxSummaryBanner extends ConsumerWidget { child: Flexible( child: Text( _attentionLabel( + l10n, strategyId, strategyNames[strategyId], opQueue.accountOutbox.strategies[strategyId]?.reason, @@ -172,9 +169,7 @@ class CloudOutboxSummaryBanner extends ConsumerWidget { ), child: Flexible( child: Text( - failedCaptures.length == 1 - ? 'Retry replay capture' - : 'Retry replay captures', + l10n.outboxBannerRetryCaptures(failedCaptures.length), maxLines: 1, overflow: TextOverflow.ellipsis, ), @@ -186,9 +181,7 @@ class CloudOutboxSummaryBanner extends ConsumerWidget { onPressed: () => _discardCaptures(ref, failedCaptures), child: Flexible( child: Text( - failedCaptures.length == 1 - ? 'Discard replay capture' - : 'Discard ${failedCaptures.length} replay captures', + l10n.outboxBannerDiscardCaptures(failedCaptures.length), maxLines: 1, overflow: TextOverflow.ellipsis, ), @@ -200,8 +193,11 @@ class CloudOutboxSummaryBanner extends ConsumerWidget { onPressed: () => _discardDeleted(ref, strategyId), child: Flexible( child: Text( - 'Discard changes to ' - '${strategyNames[strategyId]?.trim().isNotEmpty == true ? strategyNames[strategyId]!.trim() : 'the deleted strategy'}', + strategyNames[strategyId]?.trim().isNotEmpty == true + ? l10n.outboxBannerDiscardChangesTo( + strategyNames[strategyId]!.trim(), + ) + : l10n.outboxBannerDiscardDeletedStrategyChanges, maxLines: 1, overflow: TextOverflow.ellipsis, ), @@ -289,12 +285,13 @@ class CloudOutboxSummaryBanner extends ConsumerWidget { WidgetRef ref, List captures, ) async { + final l10n = ref.read(l10nProvider); final mayHaveLanded = await ref .read(replayCaptureQueueProvider.notifier) .discard([for (final capture in captures) capture.id]); if (mayHaveLanded) { Settings.showToast( - message: 'Discarded. It may still appear in your library.', + message: l10n.outboxBannerCaptureDiscardedToast, backgroundColor: Settings.tacticalVioletTheme.primary, ); } @@ -324,6 +321,7 @@ class CloudOutboxSummaryBanner extends ConsumerWidget { } String _attentionLabel( + AppLocalizations l10n, String id, String? strategyName, String? reason, @@ -331,14 +329,13 @@ class CloudOutboxSummaryBanner extends ConsumerWidget { ) { final label = strategyName?.trim().isNotEmpty == true ? strategyName!.trim() - : 'A cloud strategy'; + : l10n.outboxBannerUnnamedStrategy; if (reason != null && reason.isNotEmpty) { - return '$label: review sync'; + return l10n.outboxBannerReviewSync(label); } if (failedMediaCount > 0) { - return '$label: $failedMediaCount ' - '${failedMediaCount == 1 ? 'image failed' : 'images failed'}'; + return l10n.outboxBannerImagesFailed(label, failedMediaCount); } - return '$label: review'; + return l10n.outboxBannerReview(label); } } diff --git a/lib/widgets/cloud_sync_button.dart b/lib/widgets/cloud_sync_button.dart index 0866162ef..53f74347b 100644 --- a/lib/widgets/cloud_sync_button.dart +++ b/lib/widgets/cloud_sync_button.dart @@ -7,6 +7,8 @@ import 'package:icarus/collab/cloud_sync_error_message.dart'; import 'package:icarus/collab/collab_models.dart'; import 'package:icarus/collab/page_changes.dart'; import 'package:icarus/const/settings.dart'; +import 'package:icarus/l10n/cloud_sync_error_text.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/collab/active_page_live_sync_models.dart'; import 'package:icarus/providers/collab/client_upgrade_required_provider.dart'; import 'package:icarus/providers/collab/cloud_media_upload_queue_provider.dart'; @@ -128,9 +130,8 @@ class _CloudSyncButtonState extends ConsumerState { .firstOrNull; Settings.showToast( message: specificReason != null - ? friendlyCloudSyncError(specificReason) - : 'Another edit reached the cloud first. Your version is still on ' - 'this device and needs attention.', + ? friendlyCloudSyncError(context.l10n, specificReason) + : context.l10n.syncButtonConflictToast, backgroundColor: Settings.tacticalVioletTheme.destructive, ); ref.read(strategyConflictProvider.notifier).clearAll(); @@ -172,6 +173,7 @@ class _CloudSyncButtonState extends ConsumerState { Future _useCloudVersions() async { if (_isResolving) return; + final l10n = context.l10n; setState(() { _isResolving = true; _resolutionError = null; @@ -185,8 +187,7 @@ class _CloudSyncButtonState extends ConsumerState { ProductMetrics.instance.conflictResolved('use_cloud'); _popoverController.hide(); } else { - resolutionError = 'Could not load the cloud version. ' - 'Your saved version was not changed.'; + resolutionError = l10n.syncButtonUseCloudFailed; } } catch (error, stackTrace) { log( @@ -195,8 +196,7 @@ class _CloudSyncButtonState extends ConsumerState { error: error, stackTrace: stackTrace, ); - resolutionError = 'Could not load the cloud version. ' - 'Your saved version was not changed.'; + resolutionError = l10n.syncButtonUseCloudFailed; } finally { if (mounted) { setState(() { @@ -212,21 +212,25 @@ class _CloudSyncButtonState extends ConsumerState { Future _keepHeldPages( Map held, ) => - _resolveHeldDeletes('keep_page', () => ref - .read(strategyOpQueueProvider.notifier) - .discardRejected(held.keys.toSet(), onlyIf: { - for (final entry in held.entries) - entry.key: (entry.value.opId, null), - })); + _resolveHeldDeletes( + 'keep_page', + () => ref + .read(strategyOpQueueProvider.notifier) + .discardRejected(held.keys.toSet(), onlyIf: { + for (final entry in held.entries) + entry.key: (entry.value.opId, null), + })); /// Delete anyway: the held deletes the user saw are sent, unread. Future _deleteHeldPagesAnyway( Map held, ) => - _resolveHeldDeletes('delete_anyway', () => - ref.read(strategyOpQueueProvider.notifier).deleteHeldPagesAnyway({ - for (final entry in held.entries) entry.key: entry.value.opId, - })); + _resolveHeldDeletes( + 'delete_anyway', + () => + ref.read(strategyOpQueueProvider.notifier).deleteHeldPagesAnyway({ + for (final entry in held.entries) entry.key: entry.value.opId, + })); Future _resolveHeldDeletes( String choice, @@ -248,6 +252,7 @@ class _CloudSyncButtonState extends ConsumerState { Future _keepBoth() async { if (_isResolving) return; + final l10n = context.l10n; setState(() { _isResolving = true; _resolutionError = null; @@ -261,11 +266,9 @@ class _CloudSyncButtonState extends ConsumerState { ProductMetrics.instance.conflictResolved('keep_both'); _popoverController.hide(); case KeepBothOutcome.unchanged: - resolutionError = 'Could not load the cloud version. ' - 'Nothing was changed.'; + resolutionError = l10n.syncButtonKeepBothUnchanged; case KeepBothOutcome.copiesOnly: - resolutionError = 'Your version was added as a copy, but the ' - 'conflict is still open.'; + resolutionError = l10n.syncButtonKeepBothCopiesOnly; } } catch (error, stackTrace) { log( @@ -274,8 +277,7 @@ class _CloudSyncButtonState extends ConsumerState { error: error, stackTrace: stackTrace, ); - resolutionError = 'Could not keep both versions. ' - 'Your version is still saved on this device.'; + resolutionError = l10n.syncButtonKeepBothFailed; } finally { if (mounted) { setState(() { @@ -341,11 +343,16 @@ class _CloudSyncButtonState extends ConsumerState { final waiting = ref.watch(openStrategyUnsentChangesProvider); final syncTooltip = status == _SyncStatus.offline && shownCopy != null ? _copyTooltip(context, shownCopy.copiedAt, waiting: waiting) - : _tooltip(status, saveState.lastPersistedAt, waiting: waiting); + : _tooltip( + context.l10n, + status, + saveState.lastPersistedAt, + waiting: waiting, + ); // The editor carries no status chips, so a viewer learns here why their // edits do not stick. final tooltip = ref.watch(lastKnownCloudRoleProvider) == 'viewer' - ? 'View only · $syncTooltip' + ? context.l10n.syncButtonViewOnlyTooltip(syncTooltip) : syncTooltip; final foreground = status == _SyncStatus.attention ? Settings.tacticalVioletTheme.destructive @@ -455,11 +462,8 @@ class _CloudSyncButtonState extends ConsumerState { DateTime? copiedAt, { required int waiting, }) { - final saved = waiting == 0 - ? '; changes stay on this device' - : ' · $waiting ${waiting == 1 ? 'change' : 'changes'} saved on this ' - 'computer'; - if (copiedAt == null) return "Offline. Showing this computer's copy$saved"; + final l10n = context.l10n; + if (copiedAt == null) return l10n.syncButtonOfflineCopyTooltip(waiting); final localizations = MaterialLocalizations.of(context); final local = copiedAt.toLocal(); final now = DateTime.now(); @@ -470,13 +474,18 @@ class _CloudSyncButtonState extends ConsumerState { final sameDay = local.year == now.year && local.month == now.month && local.day == now.day; - final when = - sameDay ? time : '${localizations.formatShortMonthDay(local)}, $time'; - return "Offline. Showing this computer's copy from $when$saved"; + final when = sameDay + ? time + : l10n.syncButtonCopyDateTime( + localizations.formatShortMonthDay(local), + time, + ); + return l10n.syncButtonOfflineCopyFromTooltip(when, waiting); } /// One line, what the glyph means and what a press will do. static String _tooltip( + AppLocalizations l10n, _SyncStatus status, DateTime? lastSynced, { required int waiting, @@ -484,16 +493,18 @@ class _CloudSyncButtonState extends ConsumerState { switch (status) { case _SyncStatus.synced: return lastSynced == null - ? 'Synced' - : 'Synced at ${_SyncStatusPopover._formatTime(lastSynced)}'; + ? l10n.syncButtonSyncedTooltip + : l10n.syncButtonSyncedAtTooltip( + _SyncStatusPopover._formatTime(lastSynced), + ); case _SyncStatus.editing: - return 'Edit not synced yet'; + return l10n.syncButtonEditNotSynced; case _SyncStatus.syncing: - return 'Syncing…'; + return l10n.syncButtonSyncingTooltip; case _SyncStatus.offline: - return offlineSyncLabel(waiting); + return offlineSyncLabel(l10n, waiting); case _SyncStatus.attention: - return 'Sync needs attention'; + return l10n.syncButtonNeedsAttention; } } } @@ -589,8 +600,9 @@ class _SyncStatusPopover extends StatelessWidget { bool get _holdsIncompleteCopy => isIncompletePageCopyReason(saveState.cloudSyncError); - String get _heldExplanation => - [for (final held in heldPages) heldPageDeleteMessage(held)].join(' '); + String _heldExplanation(AppLocalizations l10n) => [ + for (final held in heldPages) heldPageDeleteMessage(l10n, held), + ].join(l10n.commonSentenceSpace); @override Widget build(BuildContext context) { @@ -606,6 +618,7 @@ class _SyncStatusPopover extends StatelessWidget { ), ); } + final l10n = context.l10n; final acts = status == _SyncStatus.attention && (!hasOtherStrategyAttention || hasActiveStrategyAttention); final holds = acts && heldPages.isNotEmpty; @@ -619,22 +632,21 @@ class _SyncStatusPopover extends StatelessWidget { size: ShadButtonSize.sm, expands: false, onPressed: isResolving ? null : onDeleteHeldPagesAnyway, - child: const Text('Delete anyway'), + child: Text(l10n.syncButtonDeleteAnyway), ), ShadButton( size: ShadButtonSize.sm, expands: false, onPressed: isResolving ? null : onKeepHeldPages, - child: - Text(heldPages.length == 1 ? 'Keep the page' : 'Keep the pages'), + child: Text(l10n.syncButtonKeepHeldPages(heldPages.length)), ), ], ); return _layout( context, explanation: [ - if (holds) _heldExplanation, - if (!holds || others) _explanation, + if (holds) _heldExplanation(l10n), + if (!holds || others) _explanation(l10n), ].join('\n\n'), actions: holds && !others ? heldActions @@ -644,16 +656,16 @@ class _SyncStatusPopover extends StatelessWidget { children: [ heldActions, const SizedBox(height: 8), - _otherActions, + _otherActions(l10n), ], ) : others - ? _otherActions + ? _otherActions(l10n) : null, ); } - Widget get _otherActions => Wrap( + Widget _otherActions(AppLocalizations l10n) => Wrap( alignment: WrapAlignment.end, spacing: 8, runSpacing: 8, @@ -663,14 +675,14 @@ class _SyncStatusPopover extends StatelessWidget { size: ShadButtonSize.sm, expands: false, onPressed: isResolving ? null : onUseCloudVersions, - child: const Text('Use cloud'), + child: Text(l10n.syncButtonUseCloud), ), if (_shownLineupConflicts != null) ShadButton.secondary( size: ShadButtonSize.sm, expands: false, onPressed: isResolving ? null : onKeepBoth, - child: const Text('Keep both'), + child: Text(l10n.syncButtonKeepBoth), ), // A copy that didn't finish saving can't be kept: there is // nothing whole to send. Copying the page again, or Use cloud, @@ -683,8 +695,8 @@ class _SyncStatusPopover extends StatelessWidget { onPressed: isResolving ? null : onRetry, child: Text( hasRejectedWork && !_holdsIncompleteCopy - ? 'Keep mine' - : 'Retry sync', + ? l10n.syncButtonKeepMine + : l10n.syncButtonRetrySync, ), ), ], @@ -696,6 +708,7 @@ class _SyncStatusPopover extends StatelessWidget { required Widget? actions, }) { final theme = ShadTheme.of(context); + final l10n = context.l10n; final lastSynced = saveState.lastPersistedAt; // Text alignment is inherited from the editor, which centers, so pin it @@ -709,7 +722,7 @@ class _SyncStatusPopover extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - _title, + _title(l10n), style: theme.textTheme.small.copyWith( color: theme.colorScheme.foreground, ), @@ -737,7 +750,7 @@ class _SyncStatusPopover extends StatelessWidget { if (lastSynced != null) ...[ const SizedBox(height: 8), Text( - 'Last synced at ${_formatTime(lastSynced)}', + l10n.syncButtonLastSyncedAt(_formatTime(lastSynced)), style: theme.textTheme.small.copyWith( color: theme.colorScheme.mutedForeground, fontSize: 11, @@ -754,50 +767,44 @@ class _SyncStatusPopover extends StatelessWidget { ); } - String get _title { + String _title(AppLocalizations l10n) { switch (status) { case _SyncStatus.synced: - return 'All changes synced'; + return l10n.syncButtonSyncedTitle; case _SyncStatus.editing: - return 'Edit not synced yet'; + return l10n.syncButtonEditNotSynced; case _SyncStatus.syncing: - return 'Syncing changes'; + return l10n.syncButtonSyncingTitle; case _SyncStatus.offline: - return 'Working offline'; + return l10n.syncButtonOfflineTitle; case _SyncStatus.attention: - return 'Sync needs attention'; + return l10n.syncButtonNeedsAttention; } } - String get _explanation { + String _explanation(AppLocalizations l10n) { switch (status) { case _SyncStatus.synced: - return 'Your strategy is safely stored in the cloud.'; + return l10n.syncButtonSyncedExplanation; case _SyncStatus.editing: - return 'Finish editing or switch pages to send this change to the ' - 'cloud.'; + return l10n.syncButtonEditingExplanation; case _SyncStatus.syncing: return hasOtherStrategyWork - ? 'Saved changes from your cloud library are being sent in the ' - 'background.' - : 'Your edits are being sent to the cloud. You can keep ' - 'working — this happens in the background.'; + ? l10n.syncButtonSyncingOtherExplanation + : l10n.syncButtonSyncingExplanation; case _SyncStatus.offline: - return 'Changes are kept on this device and will sync automatically ' - 'when your connection returns.'; + return l10n.syncButtonOfflineExplanation; case _SyncStatus.attention: - const otherStrategyExplanation = - 'Saved work in another strategy also needs attention. Open it ' - 'from the Cloud library to review the reason.'; - if (!hasOtherStrategyAttention) return _attentionExplanation; + if (!hasOtherStrategyAttention) return _attentionExplanation(l10n); if (hasActiveStrategyAttention) { - return '$_attentionExplanation $otherStrategyExplanation'; + return '${_attentionExplanation(l10n)}${l10n.commonSentenceSpace}' + '${l10n.syncButtonOtherStrategyAlsoNeedsAttention}'; } - return otherStrategyExplanation.replaceFirst(' also', ''); + return l10n.syncButtonOtherStrategyNeedsAttention; } } - String get _attentionExplanation { + String _attentionExplanation(AppLocalizations l10n) { final mediaErrors = saveState.mediaSyncErrorCount; final parts = []; final saved = saveState.cloudSyncError; @@ -806,35 +813,14 @@ class _SyncStatusPopover extends StatelessWidget { final hasSpecificReason = error != null && isSpecificAttentionReason(error); final listsWholeGroups = _shownLineupConflicts != null && _noLongerFits; if (listsWholeGroups) { - parts.add( - 'A teammate changed these lineups while you were working on them, ' - 'so your change was not sent. The page shows your version, still ' - 'saved on this device.', - ); - parts.add( - 'Keep mine saves these lineups exactly as shown, replacing all of ' - 'their changes, even to lineups you did not edit. Use cloud replaces ' - 'yours, and Keep both adds yours beside theirs as a copy.', - ); + parts.add(l10n.syncButtonLineupsNoLongerFit); + parts.add(l10n.syncButtonLineupsNoLongerFitChoices); } else if (_shownLineupConflicts != null) { - parts.add( - 'A teammate changed these lineups while you were working on them. ' - 'Your version remains saved on this device.', - ); - parts.add( - 'Keep mine replaces their changes, Use cloud replaces yours, and ' - 'Keep both adds yours beside theirs as a copy.', - ); + parts.add(l10n.syncButtonLineupConflict); + parts.add(l10n.syncButtonLineupConflictChoices); } else if (hasRejectedWork && !hasSpecificReason) { - parts.add( - 'Another edit reached the cloud first. Your version remains saved ' - 'on this device.', - ); - parts.add( - rejectedCount == 1 - ? 'Choose which version to keep for this conflicting change.' - : 'Your choice applies to all $rejectedCount conflicting changes.', - ); + parts.add(l10n.syncButtonRejected); + parts.add(l10n.syncButtonRejectedChoice(rejectedCount)); } final retryUnavailable = error?.toLowerCase().contains('cannot be retried automatically') ?? @@ -842,32 +828,22 @@ class _SyncStatusPopover extends StatelessWidget { if (error != null && !listsWholeGroups && (!hasRejectedWork || retryUnavailable || hasSpecificReason)) { - parts.add(friendlyCloudSyncError(error)); + parts.add(friendlyCloudSyncError(l10n, error)); } if (error?.contains(otherWorkNeedsAttentionNote) ?? false) { - parts.add( - 'Other changes here were not saved either. They remain on this ' - 'device.', - ); + parts.add(l10n.syncButtonOtherChangesNotSaved); } if (hasRejectedWork && hasSpecificReason && rejectedCount > 1) { - parts.add( - 'Your choice applies to all $rejectedCount changes that need ' - 'attention.', - ); + parts.add(l10n.syncButtonChoiceAppliesToAll(rejectedCount)); } if (mediaErrors > 0) { - parts.add( - mediaErrors == 1 - ? 'One image failed to upload.' - : '$mediaErrors images failed to upload.', - ); + parts.add(l10n.syncButtonImagesFailed(mediaErrors)); } if (parts.isEmpty) { - parts.add("Some changes haven't reached the cloud yet."); + parts.add(l10n.syncButtonChangesNotSent); } - if (!hasRejectedWork) parts.add('Retry to send them now.'); - return parts.join(' '); + if (!hasRejectedWork) parts.add(l10n.syncButtonRetryHint); + return parts.join(l10n.commonSentenceSpace); } static String _formatTime(DateTime time) { @@ -890,6 +866,7 @@ class _LineupConflictList extends StatelessWidget { @override Widget build(BuildContext context) { + final l10n = context.l10n; final yours = [for (final conflict in conflicts) ...conflict.yours]; // Without the version both sides started from, only the difference // between them can be shown, not who made it. @@ -898,7 +875,7 @@ class _LineupConflictList extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ const SizedBox(height: 10), - _section(context, 'How your version differs from the cloud', yours), + _section(context, l10n.syncButtonLineupDiffTitle, yours), ], ); } @@ -907,9 +884,9 @@ class _LineupConflictList extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ const SizedBox(height: 10), - _section(context, 'Your changes', yours), + _section(context, l10n.syncButtonYourChangesTitle, yours), const SizedBox(height: 8), - _section(context, 'Cloud changes', cloud), + _section(context, l10n.syncButtonCloudChangesTitle, cloud), ], ); } @@ -920,6 +897,7 @@ class _LineupConflictList extends StatelessWidget { List changes, ) { final theme = ShadTheme.of(context); + final l10n = context.l10n; final lineStyle = theme.textTheme.small.copyWith( color: theme.colorScheme.foreground, fontSize: 12, @@ -942,13 +920,17 @@ class _LineupConflictList extends StatelessWidget { ), ), const SizedBox(height: 4), - if (changes.isEmpty) Text('No lineup changes', style: mutedStyle), + if (changes.isEmpty) + Text(l10n.syncButtonNoLineupChanges, style: mutedStyle), for (final change in shown) Text.rich( TextSpan( children: [ - TextSpan(text: change.label), - TextSpan(text: ' · ${change.description}', style: mutedStyle), + TextSpan(text: change.label(l10n)), + TextSpan( + text: ' · ${change.description(l10n)}', + style: mutedStyle, + ), ], ), style: lineStyle, @@ -957,7 +939,7 @@ class _LineupConflictList extends StatelessWidget { ), if (hidden > 0) Text( - hidden == 1 ? 'and 1 more lineup' : 'and $hidden more lineups', + l10n.syncButtonMoreLineups(hidden), style: mutedStyle, ), ], diff --git a/lib/widgets/custom_search_field.dart b/lib/widgets/custom_search_field.dart index 9bac2f2e3..a1da29c7d 100644 --- a/lib/widgets/custom_search_field.dart +++ b/lib/widgets/custom_search_field.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/settings.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/widgets/text_editing_shortcut_scope.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; @@ -21,7 +22,7 @@ class SearchTextField extends ConsumerStatefulWidget { const SearchTextField({ super.key, this.controller, - this.hintText = 'Search', + this.hintText, this.onSubmitted, this.collapsedWidth = 44, this.expandedWidth = 260, @@ -33,7 +34,7 @@ class SearchTextField extends ConsumerStatefulWidget { }); final TextEditingController? controller; - final String hintText; + final String? hintText; final ValueChanged? onSubmitted; final double collapsedWidth; final double expandedWidth; @@ -175,7 +176,8 @@ class _SearchTextFieldState extends ConsumerState { borderRadius: BorderRadius.circular(8.0), borderSide: BorderSide.none, ), - hintText: _expanded ? widget.hintText : null, + hintText: + _expanded ? widget.hintText ?? context.l10n.searchFieldHint : null, hintStyle: TextStyle( color: Colors.white54, fontSize: compact ? 14 : 16, @@ -198,7 +200,7 @@ class _SearchTextFieldState extends ConsumerState { ), suffixIcon: _hasText ? IconButton( - tooltip: 'Clear', + tooltip: context.l10n.commonClear, icon: Icon( LucideIcons.x, size: compact ? 18 : 20, diff --git a/lib/widgets/desktop_update_dialog.dart b/lib/widgets/desktop_update_dialog.dart index 1ef9ff6c6..69d5c4e2e 100644 --- a/lib/widgets/desktop_update_dialog.dart +++ b/lib/widgets/desktop_update_dialog.dart @@ -2,6 +2,7 @@ import 'package:desktop_updater/desktop_updater.dart'; import 'package:flutter/material.dart'; import 'package:icarus/const/release_notes.dart'; import 'package:icarus/const/settings.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/services/app_error_reporter.dart'; import 'package:icarus/services/windows_desktop_update_controller.dart'; import 'package:icarus/widgets/dialogs/confirm_alert_dialog.dart'; @@ -170,11 +171,13 @@ class DesktopUpdateDialog extends StatelessWidget { ), ), if (controller.isMandatory) - const Positioned( + Positioned( top: 10, left: 10, child: ShadBadge.destructive( - child: Text('Required'), + child: Text( + context.l10n.desktopUpdateRequiredBadge, + ), ), ), if (canDismiss) @@ -306,7 +309,8 @@ class _UpdateButton extends StatelessWidget { onPressed: () => _confirmRestart(context), leading: const Icon(LucideIcons.rotateCcw), child: Text( - controller.getLocalization?.restartText ?? 'Restart to update', + controller.getLocalization?.restartText ?? + context.l10n.desktopUpdateRestartToUpdate, ), ); } @@ -316,17 +320,19 @@ class _UpdateButton extends StatelessWidget { onPressed: () => _startDownload(context), leading: const Icon(LucideIcons.download), child: Text( - controller.getLocalization?.downloadText ?? 'Download Update', + controller.getLocalization?.downloadText ?? + context.l10n.desktopUpdateDownload, ), ); } Future _startDownload(BuildContext context) async { + final l10n = context.l10n; try { await controller.downloadUpdate(); } catch (error, stackTrace) { AppErrorReporter.reportError( - 'Failed to download the desktop update. Please try again.', + l10n.desktopUpdateDownloadFailed, error: error, stackTrace: stackTrace, source: 'DesktopUpdateDialog.downloadUpdate', @@ -335,13 +341,17 @@ class _UpdateButton extends StatelessWidget { } Future _confirmRestart(BuildContext context) async { + final l10n = context.l10n; final bool confirmed = await ConfirmAlertDialog.show( context: context, - title: controller.getLocalization?.warningTitleText ?? 'Restart Required', + title: controller.getLocalization?.warningTitleText ?? + l10n.desktopUpdateRestartRequiredTitle, content: controller.getLocalization?.restartWarningText ?? - 'A restart is required to complete the update installation.\nAny unsaved changes will be lost. Would you like to restart now?', - confirmText: controller.getLocalization?.warningConfirmText ?? 'Restart', - cancelText: controller.getLocalization?.warningCancelText ?? 'Not now', + l10n.desktopUpdateRestartWarning, + confirmText: controller.getLocalization?.warningConfirmText ?? + l10n.desktopUpdateRestartConfirm, + cancelText: controller.getLocalization?.warningCancelText ?? + l10n.desktopUpdateRestartCancel, ); if (confirmed) { @@ -349,7 +359,7 @@ class _UpdateButton extends StatelessWidget { await controller.restartApp(); } catch (error, stackTrace) { AppErrorReporter.reportError( - 'Failed to apply the downloaded desktop update. Please try downloading it again.', + l10n.desktopUpdateApplyFailed, error: error, stackTrace: stackTrace, source: 'DesktopUpdateDialog.restartToUpdate', @@ -398,7 +408,7 @@ class _DownloadFillButton extends StatelessWidget { ), Center( child: Text( - 'Downloading… $percent%', + context.l10n.desktopUpdateDownloading(percent), style: theme.textTheme.small.copyWith( color: theme.colorScheme.foreground, fontWeight: FontWeight.w500, diff --git a/lib/widgets/dialogs/auth/auth_dialog.dart b/lib/widgets/dialogs/auth/auth_dialog.dart index 8ccdd232b..3083ddb98 100644 --- a/lib/widgets/dialogs/auth/auth_dialog.dart +++ b/lib/widgets/dialogs/auth/auth_dialog.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/providers/auth_provider.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/services/auth_callback_uri.dart'; import 'package:icarus/widgets/custom_text_field.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; @@ -72,7 +73,7 @@ class _AuthDialogState extends ConsumerState { _view = notice == AuthLinkNotice.resetLinkTimedOut ? _AuthView.resetPassword : _AuthView.signIn; - _message = notice.message; + _message = notice.message(ref.read(l10nProvider)); _messageIsInfo = notice.isInfo; _errorField = null; } @@ -111,12 +112,13 @@ class _AuthDialogState extends ConsumerState { Future _submit() async { // Enter in a field still submits while a request is out. if (_submitting || ref.read(authProvider).isLoading) return; + final l10n = context.l10n; final email = _emailController.text.trim(); final password = _passwordController.text; final confirm = _confirmController.text; if (!_emailShape.hasMatch(email)) { - _setValidationError('Enter a valid email address.', _AuthField.email); + _setValidationError(l10n.authInvalidEmail, _AuthField.email); return; } @@ -127,14 +129,14 @@ class _AuthDialogState extends ConsumerState { if (password.length < 6) { _setValidationError( - 'Password must be at least 6 characters.', + l10n.authPasswordTooShort, _AuthField.password, ); return; } if (_isSignUp && password != confirm) { - _setValidationError('Passwords do not match.', _AuthField.confirm); + _setValidationError(l10n.authPasswordsDoNotMatch, _AuthField.confirm); return; } @@ -161,9 +163,8 @@ class _AuthDialogState extends ConsumerState { _message = error; // These outcomes come back through the error channel but are good // news — present them as info, not failure. - _messageIsInfo = error != null && - (error.startsWith('Account created') || - error.startsWith('We already sent')); + _messageIsInfo = error == l10n.authMsgSignUpCheckInbox || + error == l10n.authMsgSignUpAlreadySent; }); } @@ -231,7 +232,7 @@ class _AuthDialogState extends ConsumerState { setState(() { if (_waitingForDiscord) { _waitingForDiscord = false; - _message = 'Failed to complete login. Please try again.'; + _message = context.l10n.authDiscordLoginFailed; _messageIsInfo = false; } else { _applyNotice(notice); @@ -241,19 +242,18 @@ class _AuthDialogState extends ConsumerState { final resetting = _view == _AuthView.resetPassword; final theme = ShadTheme.of(context); + final l10n = context.l10n; return ShadDialog( title: Text(switch (_view) { - _AuthView.signIn => 'Sign in', - _AuthView.signUp => 'Create account', - _AuthView.resetPassword => 'Reset password', + _AuthView.signIn => l10n.authSignInTitle, + _AuthView.signUp => l10n.authSignUpTitle, + _AuthView.resetPassword => l10n.authResetPasswordTitle, }), description: Text(switch (_view) { - _AuthView.signIn => 'Sign in with email and password.', - _AuthView.signUp => 'Use email and password to create an account.', - _AuthView.resetPassword => - "Enter your account's email and we'll send a link to set a new " - 'password. It works for accounts made with Discord too.', + _AuthView.signIn => l10n.authSignInDescription, + _AuthView.signUp => l10n.authSignUpDescription, + _AuthView.resetPassword => l10n.authResetPasswordDescription, }), child: SizedBox( width: 420, @@ -267,7 +267,7 @@ class _AuthDialogState extends ConsumerState { focusNode: _emailFocusNode, keyboardType: TextInputType.emailAddress, autofillHints: const [AutofillHints.email], - hintText: 'Email', + hintText: l10n.authEmailHint, textInputAction: resetting ? TextInputAction.done : TextInputAction.next, onSubmitted: resetting ? (_) => _submit() : null, @@ -281,7 +281,7 @@ class _AuthDialogState extends ConsumerState { focusNode: _passwordFocusNode, obscureText: true, autofillHints: const [AutofillHints.password], - hintText: 'Password', + hintText: l10n.authPasswordHint, textInputAction: _isSignUp ? TextInputAction.next : TextInputAction.done, onSubmitted: _isSignUp ? null : (_) => _submit(), @@ -297,10 +297,9 @@ class _AuthDialogState extends ConsumerState { padding: EdgeInsets.zero, foregroundColor: theme.colorScheme.mutedForeground, hoverForegroundColor: theme.colorScheme.foreground, - onPressed: busy - ? null - : () => _showView(_AuthView.resetPassword), - child: const Text('Forgot password?'), + onPressed: + busy ? null : () => _showView(_AuthView.resetPassword), + child: Text(l10n.authForgotPassword), ), ), if (_isSignUp) ...[ @@ -311,7 +310,7 @@ class _AuthDialogState extends ConsumerState { focusNode: _confirmFocusNode, obscureText: true, autofillHints: const [AutofillHints.password], - hintText: 'Confirm password', + hintText: l10n.authConfirmPasswordHint, textInputAction: TextInputAction.done, onSubmitted: (_) => _submit(), hasError: _errorField == _AuthField.confirm, @@ -360,9 +359,9 @@ class _AuthDialogState extends ConsumerState { : _AuthView.signIn, ), child: Text(switch (_view) { - _AuthView.signIn => 'Create an account', - _AuthView.signUp => 'Sign in instead', - _AuthView.resetPassword => 'Back to sign in', + _AuthView.signIn => l10n.authSwitchToSignUp, + _AuthView.signUp => l10n.authSwitchToSignIn, + _AuthView.resetPassword => l10n.authBackToSignIn, }), ), ), @@ -374,7 +373,7 @@ class _AuthDialogState extends ConsumerState { key: const ValueKey('auth-discord-button'), onPressed: busy || _waitingForDiscord ? null : _startDiscordSignIn, - child: const Text('Continue with Discord'), + child: Text(l10n.authContinueWithDiscord), ), const SizedBox(height: 10), ], @@ -389,12 +388,12 @@ class _AuthDialogState extends ConsumerState { ) : null, child: Text(switch ((_view, busy)) { - (_AuthView.signIn, false) => 'Sign in', - (_AuthView.signIn, true) => 'Signing in…', - (_AuthView.signUp, false) => 'Create account', - (_AuthView.signUp, true) => 'Creating…', - (_AuthView.resetPassword, false) => 'Send reset link', - (_AuthView.resetPassword, true) => 'Sending…', + (_AuthView.signIn, false) => l10n.authSignInButton, + (_AuthView.signIn, true) => l10n.authSigningIn, + (_AuthView.signUp, false) => l10n.authSignUpButton, + (_AuthView.signUp, true) => l10n.authCreating, + (_AuthView.resetPassword, false) => l10n.authSendResetLink, + (_AuthView.resetPassword, true) => l10n.authSending, }), ), ], @@ -479,8 +478,7 @@ class _DiscordPendingBanner extends StatelessWidget { const SizedBox(width: 8), Expanded( child: Text( - 'Finish signing in with Discord in your browser — this dialog ' - 'will close automatically.', + context.l10n.authDiscordPending, style: theme.textTheme.small.copyWith( color: theme.colorScheme.mutedForeground, fontSize: 12, @@ -492,7 +490,7 @@ class _DiscordPendingBanner extends StatelessWidget { ShadButton.ghost( size: ShadButtonSize.sm, onPressed: onCancel, - child: const Text('Cancel'), + child: Text(context.l10n.commonCancel), ), ], ), diff --git a/lib/widgets/dialogs/auth/set_password_dialog.dart b/lib/widgets/dialogs/auth/set_password_dialog.dart index d4cd15d57..5ffa6a6c9 100644 --- a/lib/widgets/dialogs/auth/set_password_dialog.dart +++ b/lib/widgets/dialogs/auth/set_password_dialog.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/settings.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/auth_provider.dart'; import 'package:icarus/widgets/custom_text_field.dart'; import 'package:icarus/widgets/dialogs/auth/auth_dialog.dart'; @@ -37,16 +38,17 @@ class _SetPasswordDialogState extends ConsumerState { Future _save() async { final password = _passwordController.text; + final l10n = context.l10n; if (password.length < 6) { setState(() { - _error = 'Password must be at least 6 characters.'; + _error = l10n.authPasswordTooShort; _confirmHasError = false; }); return; } if (password != _confirmController.text) { setState(() { - _error = 'Passwords do not match.'; + _error = l10n.authPasswordsDoNotMatch; _confirmHasError = true; }); return; @@ -70,8 +72,7 @@ class _SetPasswordDialogState extends ConsumerState { Navigator.of(context).pop(); Settings.showToast( - message: 'Password saved. Sign in with your email and this password ' - 'from now on.', + message: l10n.setPasswordSaved, backgroundColor: Settings.tacticalVioletTheme.primary, ); } @@ -79,14 +80,14 @@ class _SetPasswordDialogState extends ConsumerState { @override Widget build(BuildContext context) { final email = ref.watch(authProvider.select((state) => state.user?.email)); + final l10n = context.l10n; return ShadDialog( - title: const Text('Set a new password'), + title: Text(l10n.setPasswordTitle), description: Text( email == null - ? 'Choose the password you will sign in with.' - : "You're signed in as $email. Choose the password you will " - 'sign in with.', + ? l10n.setPasswordDescription + : l10n.setPasswordDescriptionSignedIn(email), ), child: SizedBox( width: 420, @@ -100,7 +101,7 @@ class _SetPasswordDialogState extends ConsumerState { focusNode: _passwordFocusNode, obscureText: true, autofillHints: const [AutofillHints.newPassword], - hintText: 'New password', + hintText: l10n.setPasswordNewHint, textInputAction: TextInputAction.next, hasError: _error != null && !_confirmHasError, ), @@ -111,7 +112,7 @@ class _SetPasswordDialogState extends ConsumerState { focusNode: _confirmFocusNode, obscureText: true, autofillHints: const [AutofillHints.newPassword], - hintText: 'Confirm new password', + hintText: l10n.setPasswordConfirmHint, textInputAction: TextInputAction.done, onSubmitted: (_) => _save(), hasError: _confirmHasError, @@ -129,7 +130,7 @@ class _SetPasswordDialogState extends ConsumerState { key: const ValueKey('set-password-not-now'), onPressed: _saving ? null : () => Navigator.of(context).pop(), - child: const Text('Not now'), + child: Text(l10n.setPasswordNotNow), ), ), const SizedBox(width: 10), @@ -144,7 +145,9 @@ class _SetPasswordDialogState extends ConsumerState { child: LoadingSpinner(strokeWidth: 2), ) : null, - child: Text(_saving ? 'Saving…' : 'Save password'), + child: Text(_saving + ? l10n.setPasswordSaving + : l10n.setPasswordSave), ), ), ], diff --git a/lib/widgets/dialogs/confirm_alert_dialog.dart b/lib/widgets/dialogs/confirm_alert_dialog.dart index 8fd2023d7..59ad4de89 100644 --- a/lib/widgets/dialogs/confirm_alert_dialog.dart +++ b/lib/widgets/dialogs/confirm_alert_dialog.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; class ConfirmAlertDialog extends ConsumerWidget { @@ -7,8 +8,8 @@ class ConfirmAlertDialog extends ConsumerWidget { super.key, required this.title, required this.content, - this.confirmText = "Confirm", - this.cancelText = "Cancel", + this.confirmText, + this.cancelText, this.isDestructive = false, this.body, }); @@ -18,12 +19,14 @@ class ConfirmAlertDialog extends ConsumerWidget { /// Shown instead of [content] when the message needs styling. final Widget? body; - final String confirmText; - final String cancelText; + final String? confirmText; + final String? cancelText; final bool isDestructive; // For dangerous actions like delete @override Widget build(BuildContext context, WidgetRef ref) { + final confirmText = this.confirmText ?? context.l10n.confirmDialogConfirm; + final cancelText = this.cancelText ?? context.l10n.commonCancel; void cancel() => Navigator.of(context).pop(false); void confirm() => Navigator.of(context).pop(true); @@ -78,8 +81,8 @@ class ConfirmAlertDialog extends ConsumerWidget { required BuildContext context, required String title, required String content, - String confirmText = "Confirm", - String cancelText = "Cancel", + String? confirmText, + String? cancelText, bool isDestructive = false, Widget? body, }) async { diff --git a/lib/widgets/dialogs/create_lineup_dialog.dart b/lib/widgets/dialogs/create_lineup_dialog.dart index b70893813..2d32caa6c 100644 --- a/lib/widgets/dialogs/create_lineup_dialog.dart +++ b/lib/widgets/dialogs/create_lineup_dialog.dart @@ -6,6 +6,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/line_provider.dart'; import 'package:icarus/const/settings.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/collab/cloud_media_upload_queue_provider.dart'; import 'package:icarus/providers/collab/media_bytes_source.dart'; import 'package:icarus/providers/collab/strategy_capabilities_provider.dart'; @@ -124,10 +125,11 @@ class _CreateLineupDialogState extends ConsumerState { void _startPaste() { if (_saving) return; + final l10n = context.l10n; late final Future paste; paste = _pasteImage().catchError((Object error, StackTrace stackTrace) { AppErrorReporter.reportError( - 'Could not add a pasted image: $error', + l10n.createLineupPasteFailed('$error'), error: error, stackTrace: stackTrace, source: 'create_lineup_dialog.paste', @@ -139,13 +141,14 @@ class _CreateLineupDialogState extends ConsumerState { } Future _pasteImage() async { + final l10n = context.l10n; final ClipboardImage? image; try { image = await ClipboardService.trySelectImageFromClipboard(); } on ClipboardBusyException { if (mounted) { Settings.showToast( - message: ClipboardService.busyMessage, + message: ClipboardService.busyMessage(l10n), backgroundColor: Settings.tacticalVioletTheme.destructive, ); } @@ -153,7 +156,7 @@ class _CreateLineupDialogState extends ConsumerState { } if (image == null) { Settings.showToast( - message: 'No image found in clipboard', + message: l10n.createLineupNoClipboardImage, backgroundColor: Settings.tacticalVioletTheme.destructive, ); return; @@ -163,7 +166,7 @@ class _CreateLineupDialogState extends ConsumerState { final fileExtension = PlacedImageSerializer.detectImageFormat(bytes); if (fileExtension == null) { Settings.showToast( - message: 'Clipboard image type not supported', + message: l10n.createLineupClipboardTypeUnsupported, backgroundColor: Settings.tacticalVioletTheme.destructive, ); return; @@ -173,12 +176,13 @@ class _CreateLineupDialogState extends ConsumerState { } Future _addDraftImage(Uint8List bytes, String fileExtension) async { + final l10n = context.l10n; final SimpleImageData? image; try { image = await _drafts.add(bytes, fileExtension); } on MediaTooLargeException catch (error) { Settings.showToast( - message: error.userMessage, + message: error.userMessage(l10n), backgroundColor: Settings.tacticalVioletTheme.destructive, ); return; @@ -284,8 +288,7 @@ class _CreateLineupDialogState extends ConsumerState { return false; } Settings.showToast( - message: 'You can no longer edit this strategy, so this lineup was not ' - 'saved.', + message: context.l10n.createLineupLostEditAccess, backgroundColor: Settings.tacticalVioletTheme.destructive, ); // A lineup being placed is let go with the dialog. @@ -309,6 +312,7 @@ class _CreateLineupDialogState extends ConsumerState { /// opens placement editing for it. Future _save({bool thenEditPlacement = false}) async { if (_closedForLostEditAccess()) return; + final l10n = context.l10n; final notifier = ref.read(lineUpProvider.notifier); final name = _nameController.text.trim(); final existing = _isEditing ? notifier.linkById(widget.linkId!) : null; @@ -335,8 +339,7 @@ class _CreateLineupDialogState extends ConsumerState { await _drafts.takeBack(handedOver); if (mounted) setState(() => _saving = false); Settings.showToast( - message: 'Could not queue these images for cloud sync. ' - 'They remain on this device. Try again before closing.', + message: l10n.createLineupQueueImagesFailed, backgroundColor: Settings.tacticalVioletTheme.destructive, ); return; @@ -383,8 +386,7 @@ class _CreateLineupDialogState extends ConsumerState { await _commitLineupMediaJobs(images: imagesNeedingUpload); } catch (_) { Settings.showToast( - message: 'The lineup is kept in the editor, but its cloud work is ' - 'still pending. Use Save before leaving.', + message: l10n.createLineupCommitPending, backgroundColor: Settings.tacticalVioletTheme.destructive, ); } @@ -416,7 +418,9 @@ class _CreateLineupDialogState extends ConsumerState { } }, child: ShadDialog( - title: Text(_isEditing ? "Edit Lineup" : "Create Lineup"), + title: Text(_isEditing + ? context.l10n.createLineupEditTitle + : context.l10n.createLineupCreateTitle), description: _isEditing ? LineUpEditorsNotice(itemId: widget.linkId!) : null, // The close button pops directly, past PopScope, so it steps aside @@ -427,11 +431,11 @@ class _CreateLineupDialogState extends ConsumerState { ShadButton.secondary( leading: const Icon(LucideIcons.move), onPressed: _saving ? null : () => _save(thenEditPlacement: true), - child: const Text("Edit placement"), + child: Text(context.l10n.createLineupEditPlacement), ), ShadButton( onPressed: _saving ? null : _save, - child: const Text("Done"), + child: Text(context.l10n.commonDone), ), ], child: SizedBox( diff --git a/lib/widgets/dialogs/delete_folder_alert_dialog.dart b/lib/widgets/dialogs/delete_folder_alert_dialog.dart index 81f8ff661..2af1952ad 100644 --- a/lib/widgets/dialogs/delete_folder_alert_dialog.dart +++ b/lib/widgets/dialogs/delete_folder_alert_dialog.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/settings.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/folder_provider.dart'; import 'package:icarus/providers/library_workspace_provider.dart'; import 'package:icarus/services/app_error_reporter.dart'; @@ -29,6 +30,7 @@ class _DeleteFolderAlertDialogState Future _delete() async { if (_isDeleting) return; + final l10n = context.l10n; setState(() { _isDeleting = true; _failureMessage = null; @@ -50,7 +52,7 @@ class _DeleteFolderAlertDialogState ); if (mounted) { setState(() { - _failureMessage = "Couldn't delete this folder. Try again."; + _failureMessage = l10n.deleteFolderFailed; }); } } finally { @@ -62,21 +64,22 @@ class _DeleteFolderAlertDialogState return; } if (result != null) { - setState(() => _failureMessage = - result!.userMessage ?? "Couldn't delete this folder. Try again."); + setState(() => + _failureMessage = result!.userMessage ?? l10n.deleteFolderFailed); } } @override Widget build(BuildContext context) { + final l10n = context.l10n; return ShadDialog.alert( - title: Text("Delete '${widget.folder.name}'?"), + title: Text(l10n.deleteFolderTitle(widget.folder.name)), description: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text( - 'This also removes every strategy and subfolder inside it.', + Text( + l10n.deleteFolderDescription, ), if (_failureMessage != null) ...[ const SizedBox(height: 10), @@ -93,12 +96,13 @@ class _DeleteFolderAlertDialogState actions: [ ShadButton.secondary( onPressed: _isDeleting ? null : () => Navigator.of(context).pop(), - child: const Text('Cancel'), + child: Text(l10n.commonCancel), ), ShadButton.destructive( key: const ValueKey('delete-folder-confirm'), onPressed: _isDeleting ? null : _delete, - child: Text(_isDeleting ? 'Deleting...' : 'Delete'), + child: + Text(_isDeleting ? l10n.deleteFolderDeleting : l10n.commonDelete), ), ], ); diff --git a/lib/widgets/dialogs/delete_page_dialog.dart b/lib/widgets/dialogs/delete_page_dialog.dart index d912be38a..25abcb41b 100644 --- a/lib/widgets/dialogs/delete_page_dialog.dart +++ b/lib/widgets/dialogs/delete_page_dialog.dart @@ -1,6 +1,7 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/collab/collab_models.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/strategy/strategy_page_models.dart'; import 'package:icarus/providers/collab/strategy_presence_provider.dart'; @@ -19,27 +20,25 @@ Future confirmDeletePage( required String pageId, required String pageName, }) { + final l10n = context.l10n; final peers = ref.read(strategyPresenceProvider).peopleOn(pageId).toList(); final afterwards = ref.read(strategyProvider).source == StrategySource.cloud - ? "You can restore it from Recently deleted for " - "$pageTrashRetentionDays days." - : "This action cannot be undone."; - final warning = " on this page right now. Delete it anyway? $afterwards"; + ? l10n.deletePageRestorableFor(pageTrashRetentionDays) + : l10n.deletePageCannotUndo; return ConfirmAlertDialog.show( context: context, - title: "Delete '$pageName'?", + title: l10n.deletePageTitle(pageName), content: peers.isEmpty - ? "Are you sure you want to delete this page? $afterwards" - : "${_peopleHere([for (final peer in peers) peer.name])}$warning", + ? l10n.deletePageConfirm(afterwards) + : _peopleHere(l10n, [for (final peer in peers) peer.name], afterwards), // Each name in bold, in the colour their cursor has on the map. body: peers.isEmpty ? null - : Text.rich(TextSpan(children: [ - ..._peopleHereSpans(peers), - TextSpan(text: warning), - ])), - confirmText: peers.isEmpty ? "Delete" : "Delete anyway", - cancelText: "Cancel", + : Text.rich( + TextSpan(children: _peopleHereSpans(l10n, peers, afterwards))), + confirmText: + peers.isEmpty ? l10n.commonDelete : l10n.deletePageDeleteAnyway, + cancelText: l10n.commonCancel, isDestructive: true, ); } @@ -52,33 +51,48 @@ TextSpan _name(PresencePeer peer) => TextSpan( ), ); -/// [_peopleHere] with each named teammate styled by [_name]. -List _peopleHereSpans(List peers) => switch (peers) { - [final only] => [_name(only), const TextSpan(text: ' is')], - [final first, final second] => [ - _name(first), - const TextSpan(text: ' and '), - _name(second), - const TextSpan(text: ' are'), - ], - [final first, final second, ...final rest] => [ - _name(first), - const TextSpan(text: ', '), - _name(second), - TextSpan( - text: ' and ${rest.length} ' - '${rest.length == 1 ? 'other' : 'others'} are', - ), - ], - [] => const [], - }; +/// [_peopleHere] with each named teammate styled by [_name]: the warning is +/// built with a marker in place of each name, then cut at the markers. +List _peopleHereSpans( + AppLocalizations l10n, + List peers, + String afterwards, +) { + final named = peers.take(2).toList(); + final markers = [for (var i = 0; i < named.length; i++) '\u{E000}$i\u{E000}']; + final warning = _peopleHere( + l10n, + [...markers, for (final peer in peers.skip(2)) peer.name], + afterwards, + ); + final spans = []; + var rest = warning; + while (rest.isNotEmpty) { + final at = rest.indexOf('\u{E000}'); + if (at < 0) { + spans.add(TextSpan(text: rest)); + break; + } + if (at > 0) spans.add(TextSpan(text: rest.substring(0, at))); + final end = rest.indexOf('\u{E000}', at + 1); + spans.add(_name(named[int.parse(rest.substring(at + 1, end))])); + rest = rest.substring(end + 1); + } + return spans; +} -/// "Alex is", "Alex and Sam are", "Alex, Sam and 2 others are". -String _peopleHere(List names) => switch (names) { - [final only] => '$only is', - [final first, final second] => '$first and $second are', +/// "Alex is on this page right now…", "Alex and Sam are…", "Alex, Sam and 2 +/// others are…", followed by [afterwards]. +String _peopleHere( + AppLocalizations l10n, + List names, + String afterwards, +) => + switch (names) { + [final only] => l10n.deletePagePeerOne(only, afterwards), + [final first, final second] => + l10n.deletePagePeersTwo(first, second, afterwards), [final first, final second, ...final rest] => - '$first, $second and ${rest.length} ' - '${rest.length == 1 ? 'other' : 'others'} are', + l10n.deletePagePeersMany(first, second, rest.length, afterwards), [] => '', }; diff --git a/lib/widgets/dialogs/deleted_page_dialog.dart b/lib/widgets/dialogs/deleted_page_dialog.dart index 1cca5b0fb..1967aedc9 100644 --- a/lib/widgets/dialogs/deleted_page_dialog.dart +++ b/lib/widgets/dialogs/deleted_page_dialog.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/strategy_page_session_provider.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; @@ -36,6 +37,7 @@ class _DeletedPageDialogState extends ConsumerState { bool get _isBusy => _isRestoring || _isLeaving; Future _restore() async { + final l10n = context.l10n; setState(() { _isRestoring = true; _error = null; @@ -51,14 +53,12 @@ class _DeletedPageDialogState extends ConsumerState { setState(() { _isRestoring = false; _isGone = outcome == DeletedPageRestore.gone; - _error = _isGone - ? null - : 'Could not confirm the page was restored. Check your ' - 'connection and try again, or discard your changes.'; + _error = _isGone ? null : l10n.deletedPageRestoreFailed; }); } Future _discard() async { + final l10n = context.l10n; setState(() { _isLeaving = true; _error = null; @@ -72,8 +72,7 @@ class _DeletedPageDialogState extends ConsumerState { } setState(() { _isLeaving = false; - _error = 'Could not leave this page yet: changes to it are still being ' - 'sent, or another page could not load. Try again in a moment.'; + _error = l10n.deletedPageLeaveFailed; }); } @@ -86,19 +85,18 @@ class _DeletedPageDialogState extends ConsumerState { }); final theme = ShadTheme.of(context); final name = widget.page.name; + final l10n = context.l10n; return ShadDialog.alert( - title: const Text('This page was deleted'), + title: Text(l10n.deletedPageTitle), description: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( _isGone - ? '“$name” can no longer be restored, so your latest changes ' - 'to it could not be saved.' - : '“$name” was deleted while you were editing it. Restore it ' - 'to keep your latest changes, or discard them.', + ? l10n.deletedPageGone(name) + : l10n.deletedPageDescription(name), ), if (_error != null) ...[ const SizedBox(height: 8), @@ -113,16 +111,18 @@ class _DeletedPageDialogState extends ConsumerState { if (_isGone) ShadButton( onPressed: _isBusy ? null : _discard, - child: const Text('Discard changes'), + child: Text(l10n.deletedPageDiscardChanges), ) else ...[ ShadButton.secondary( onPressed: _isBusy ? null : _discard, - child: const Text('Discard changes'), + child: Text(l10n.deletedPageDiscardChanges), ), ShadButton( onPressed: _isBusy ? null : _restore, - child: Text(_isRestoring ? 'Restoring…' : 'Restore page'), + child: Text(_isRestoring + ? l10n.deletedPageRestoring + : l10n.deletedPageRestore), ), ], ], diff --git a/lib/widgets/dialogs/export_video_dialog.dart b/lib/widgets/dialogs/export_video_dialog.dart index b855b6e1b..78dce1b6b 100644 --- a/lib/widgets/dialogs/export_video_dialog.dart +++ b/lib/widgets/dialogs/export_video_dialog.dart @@ -6,6 +6,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/const/sort_index_order.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/map_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/providers/user_preferences_provider.dart'; @@ -158,7 +159,7 @@ class _ExportVideoDialogState extends ConsumerState { for (final (index, page) in _pages.indexed) { if (_selectedPageIds.contains(page.id) && _captions[page.id]?.fits == false) { - return captionsDontFitMessage(index + 1); + return captionsDontFitMessage(context.l10n, index + 1); } } return null; @@ -166,7 +167,7 @@ class _ExportVideoDialogState extends ConsumerState { /// What the Include captions box says: the selected page that takes longest /// to read, when that is longer than a step holds. - String get _captionHint { + String _captionHint(AppLocalizations l10n) { var slowestPage = 0; var slowestSeconds = 0; for (final (index, page) in _pages.indexed) { @@ -180,10 +181,9 @@ class _ExportVideoDialogState extends ConsumerState { } final step = _stepDurationSeconds.round(); if (slowestSeconds <= step) { - return 'Numbers on the map, notes in the side margins.'; + return l10n.exportVideoIncludeCaptionsDescription; } - return 'Page $slowestPage needs about $slowestSeconds s to read. ' - 'Each step holds for $step s.'; + return l10n.exportVideoCaptionsTooSlow(slowestPage, slowestSeconds, step); } void _setTransparent(bool value) { @@ -208,6 +208,7 @@ class _ExportVideoDialogState extends ConsumerState { } Future _runExport() async { + final l10n = context.l10n; final transparent = _transparent; final includeCaptions = _includeCaptions; final quality = _exportQuality; @@ -222,7 +223,7 @@ class _ExportVideoDialogState extends ConsumerState { .setVideoExportStepDurationSeconds(_stepDurationSeconds), ); final fileName = - "${sanitizeStrategyFileName(ref.read(strategyProvider).stratName ?? "new video")}.$extension"; + "${sanitizeStrategyFileName(ref.read(strategyProvider).stratName ?? l10n.exportVideoDefaultFileName)}.$extension"; // Where the video goes is settled first, so a dismissed save dialog or // a browser that cannot encode video costs nothing. @@ -232,10 +233,14 @@ class _ExportVideoDialogState extends ConsumerState { // encoder. The browser downloads the finished file. String? outputPath; if (!kIsWeb) { - outputPath = await chooseExportPath(fileName); + outputPath = await chooseExportPath(l10n, fileName); if (outputPath == null) return; } - sink = QuickTimeAnimationSink(fps: quality.fps, outputPath: outputPath); + sink = QuickTimeAnimationSink( + fps: quality.fps, + outputPath: outputPath, + l10n: l10n, + ); } else if (kIsWeb) { final totalSeconds = VideoExporter.plannedDurationSeconds( pageCount: pageIds.length, @@ -247,29 +252,28 @@ class _ExportVideoDialogState extends ConsumerState { totalSeconds: totalSeconds, )) { Settings.showToast( - message: 'This browser cannot export video. Try the latest Chrome ' - 'or Edge, or the desktop app.', + message: l10n.exportVideoBrowserUnsupported, backgroundColor: Settings.tacticalVioletTheme.destructive, ); return; } - sink = createBrowserVideoSink(quality); + sink = createBrowserVideoSink(quality, l10n); } else { final ffmpegBinary = await FfmpegVideoEncoder.resolveBinary(); if (ffmpegBinary == null) { Settings.showToast( - message: - 'Video encoder not found. Reinstalling Icarus should fix this.', + message: l10n.exportVideoEncoderNotFound, backgroundColor: Settings.tacticalVioletTheme.destructive, ); return; } - final outputPath = await chooseExportPath(fileName); + final outputPath = await chooseExportPath(l10n, fileName); if (outputPath == null) return; sink = FfmpegVideoSink( binary: ffmpegBinary, outputPath: outputPath, quality: quality, + l10n: l10n, ); } if (!mounted) return; @@ -278,7 +282,7 @@ class _ExportVideoDialogState extends ConsumerState { _preparing = true; _cancelRequested = false; _progress = 0; - _progressLabel = 'Saving your changes'; + _progressLabel = l10n.exportVideoSavingChanges; }); CaptureImages? images; try { @@ -327,10 +331,11 @@ class _ExportVideoDialogState extends ConsumerState { imageSources: images.sources, navigation: navigation, requireNavigation: requireNavigation, + l10n: l10n, ); setState(() { _exporter = exporter; - _progressLabel = 'Preparing'; + _progressLabel = l10n.exportVideoPreparing; }); final video = await exporter.export( @@ -375,22 +380,22 @@ class _ExportVideoDialogState extends ConsumerState { Navigator.of(context).pop(); } Settings.showToast( - message: 'Video exported.', + message: l10n.exportVideoExported, backgroundColor: Settings.tacticalVioletTheme.primary, ); } on VideoExportCancelled { Settings.showToast( - message: 'Video export cancelled.', + message: l10n.exportVideoCancelled, backgroundColor: Settings.tacticalVioletTheme.primary, ); } on VideoExportNotSynced catch (error) { Settings.showToast( - message: error.userMessage, + message: error.userMessage(l10n), backgroundColor: Settings.tacticalVioletTheme.destructive, ); } on VideoExportPagesChanged catch (error) { Settings.showToast( - message: error.userMessage, + message: error.userMessage(l10n), backgroundColor: Settings.tacticalVioletTheme.destructive, ); if (mounted) { @@ -401,12 +406,12 @@ class _ExportVideoDialogState extends ConsumerState { } } on CaptureImagesUnavailable catch (error) { Settings.showToast( - message: error.userMessage, + message: error.userMessage(l10n), backgroundColor: Settings.tacticalVioletTheme.destructive, ); } on CaptionsDontFit catch (error) { Settings.showToast( - message: error.userMessage, + message: error.userMessage(l10n), backgroundColor: Settings.tacticalVioletTheme.destructive, ); } on Object catch (error) { @@ -414,8 +419,8 @@ class _ExportVideoDialogState extends ConsumerState { // Closing the dialog mid-preparation can fail the work it cut // short; the user asked to stop, so that is what they hear. message: _cancelRequested - ? 'Video export cancelled.' - : 'Video export failed: $error', + ? l10n.exportVideoCancelled + : l10n.exportVideoFailed('$error'), backgroundColor: _cancelRequested ? Settings.tacticalVioletTheme.primary : Settings.tacticalVioletTheme.destructive, @@ -452,10 +457,13 @@ class _ExportVideoDialogState extends ConsumerState { bool get _allPagesSelected => _selectedPageIds.length == _pages.length; - static String _formatSeconds(double seconds) { + static String _formatSeconds(AppLocalizations l10n, double seconds) { final total = seconds.round(); - if (total < 60) return '${total}s'; - return '${total ~/ 60}m ${(total % 60).toString().padLeft(2, '0')}s'; + if (total < 60) return l10n.exportVideoSeconds(total); + return l10n.exportVideoMinutesSeconds( + total ~/ 60, + (total % 60).toString().padLeft(2, '0'), + ); } void _togglePage(String pageId) { @@ -476,13 +484,14 @@ class _ExportVideoDialogState extends ConsumerState { Widget _buildProgressDialog(BuildContext context) { final colors = ShadTheme.of(context).colorScheme; + final l10n = context.l10n; return ShadDialog( constraints: const BoxConstraints(maxWidth: _dialogWidth), - title: const Text('Exporting video'), + title: Text(l10n.exportVideoExportingTitle), actions: [ ShadButton.destructive( onPressed: _cancel, - child: const Text('Cancel export'), + child: Text(l10n.exportVideoCancelExport), ), ], child: Material( @@ -514,7 +523,7 @@ class _ExportVideoDialogState extends ConsumerState { if (kIsWeb) ...[ const SizedBox(height: 12), Text( - 'Keep this tab open until the video downloads.', + l10n.exportVideoKeepTabOpen, style: TextStyle( fontSize: 12, color: colors.mutedForeground, @@ -534,13 +543,13 @@ class _ExportVideoDialogState extends ConsumerState { final selectedCount = _selectedPageIds.length; final estimatedSeconds = selectedCount == 0 ? null : _estimatedVideoSeconds; final captionRefusal = _captionRefusal; + final l10n = context.l10n; return ShadDialog( constraints: const BoxConstraints(maxWidth: _dialogWidth), - title: const Text('Export video'), - description: const Text( - 'Plays the selected pages in order. Each step holds for the step ' - 'duration, then transitions into the next.', + title: Text(l10n.exportVideoTitle), + description: Text( + l10n.exportVideoDescription, ), child: Material( color: Colors.transparent, @@ -554,9 +563,13 @@ class _ExportVideoDialogState extends ConsumerState { children: [ Row( children: [ - const _SectionLabel(text: 'Step duration'), + _SectionLabel(text: l10n.exportVideoStepDuration), const Spacer(), - _ValueChip(text: '${_stepDurationSeconds.round()}s'), + _ValueChip( + text: l10n.exportVideoSeconds( + _stepDurationSeconds.round(), + ), + ), ], ), const SizedBox(height: 12), @@ -571,8 +584,7 @@ class _ExportVideoDialogState extends ConsumerState { ), const SizedBox(height: 8), Text( - 'How long each step holds on screen before its transition ' - 'plays.', + l10n.exportVideoStepDurationDescription, style: TextStyle( fontSize: 14, color: colors.mutedForeground, @@ -582,26 +594,25 @@ class _ExportVideoDialogState extends ConsumerState { ShadCheckbox( value: _transparent, onChanged: _setTransparent, - label: const Text('Transparent background'), - sublabel: const Text( - 'Leave out the background and dots. Saves a lossless .mov ' - 'that keeps the transparency, for video editors.', + label: Text(l10n.exportVideoTransparentBackground), + sublabel: Text( + l10n.exportVideoTransparentBackgroundDescription, ), ), const SizedBox(height: 12), ShadCheckbox( value: _includeCaptions, onChanged: _setIncludeCaptions, - label: const Text('Include captions'), + label: Text(l10n.exportVideoIncludeCaptions), sublabel: captionRefusal == null - ? Text(_captionHint) + ? Text(_captionHint(l10n)) : Text( captionRefusal, style: TextStyle(color: colors.destructive), ), ), const SizedBox(height: 24), - const _SectionLabel(text: 'Quality'), + _SectionLabel(text: l10n.exportVideoQuality), const SizedBox(height: 10), // A transparent video is lossless, so the presets sit out: // dimmed, and without a tap they take no clicks or focus. @@ -637,8 +648,7 @@ class _ExportVideoDialogState extends ConsumerState { if (_transparent) ...[ const SizedBox(height: 8), Text( - 'A transparent video is lossless at 30 fps, so these ' - "don't apply.", + l10n.exportVideoTransparentQualityNote, style: TextStyle( fontSize: 14, color: colors.mutedForeground, @@ -648,10 +658,13 @@ class _ExportVideoDialogState extends ConsumerState { const SizedBox(height: 24), Row( children: [ - const _SectionLabel(text: 'Pages'), + _SectionLabel(text: l10n.exportVideoPages), const SizedBox(width: 8), Text( - '$selectedCount of ${_pages.length}', + l10n.exportVideoPagesSelected( + selectedCount, + _pages.length, + ), style: TextStyle( fontSize: 12, color: colors.mutedForeground, @@ -670,8 +683,9 @@ class _ExportVideoDialogState extends ConsumerState { } }); }, - child: - Text(_allPagesSelected ? 'Clear all' : 'Select all'), + child: Text(_allPagesSelected + ? l10n.exportVideoClearAll + : l10n.exportVideoSelectAll), ), ], ), @@ -697,19 +711,20 @@ class _ExportVideoDialogState extends ConsumerState { estimatedSeconds, ), extension: _transparent ? 'mov' : 'mp4', - formatSeconds: _formatSeconds, + formatSeconds: (seconds) => + _formatSeconds(l10n, seconds), ), ), const SizedBox(width: 16), ShadButton.secondary( onPressed: () => Navigator.of(context).pop(), - child: const Text('Cancel'), + child: Text(l10n.commonCancel), ), const SizedBox(width: 8), ShadButton( enabled: _canExport, onPressed: _export, - child: const Text('Export'), + child: Text(l10n.commonExport), ), ], ), @@ -782,15 +797,17 @@ class _QualityCard extends StatelessWidget { VideoExportQuality.max: LucideIcons.gem, }; - static const _titles = { - VideoExportQuality.potato: 'Potato', - VideoExportQuality.social: 'Social', - VideoExportQuality.max: 'Max', - }; + static String _title(AppLocalizations l10n, VideoExportQuality quality) => + switch (quality) { + VideoExportQuality.potato => l10n.exportVideoQualityPotato, + VideoExportQuality.social => l10n.exportVideoQualitySocial, + VideoExportQuality.max => l10n.exportVideoQualityMax, + }; @override Widget build(BuildContext context) { final colors = ShadTheme.of(context).colorScheme; + final l10n = context.l10n; return InkWell( mouseCursor: onTap == null ? SystemMouseCursors.basic : SystemMouseCursors.click, @@ -819,14 +836,14 @@ class _QualityCard extends StatelessWidget { ), const SizedBox(width: 7), Text( - _titles[quality]!, + _title(l10n, quality), style: const TextStyle(fontSize: 15), ), ], ), const SizedBox(height: 6), Text( - videoExportQualityCaption(quality), + videoExportQualityCaption(l10n, quality), style: TextStyle( fontSize: 12, color: colors.mutedForeground, @@ -866,20 +883,24 @@ class _ExportSummary extends StatelessWidget { const SizedBox(width: 10), Expanded( child: estimatedSeconds == null - ? Text('Select at least one page to export.', style: muted) + ? Text(context.l10n.exportVideoSelectAPage, style: muted) : Text.rich( TextSpan( children: [ TextSpan( - text: '~${formatSeconds(estimatedSeconds!)} video', + text: context.l10n.exportVideoSummaryLength( + formatSeconds(estimatedSeconds!), + ), style: TextStyle( fontSize: 13, color: colors.foreground, ), ), TextSpan( - text: - ' · ${outputHeight}p · ${extension.toUpperCase()}', + text: context.l10n.exportVideoSummaryFormat( + outputHeight, + extension.toUpperCase(), + ), style: muted, ), ], @@ -893,10 +914,13 @@ class _ExportSummary extends StatelessWidget { /// The one fact that distinguishes each quality option; the summary line /// carries the live resolution, including Potato's adaptive 720p drop. -String videoExportQualityCaption(VideoExportQuality quality) { +String videoExportQualityCaption( + AppLocalizations l10n, + VideoExportQuality quality, +) { return switch (quality) { - VideoExportQuality.potato => '~10 MB', - VideoExportQuality.social => '~20 MB', - VideoExportQuality.max => '60 fps, no size cap', + VideoExportQuality.potato => l10n.exportVideoQualityPotatoCaption, + VideoExportQuality.social => l10n.exportVideoQualitySocialCaption, + VideoExportQuality.max => l10n.exportVideoQualityMaxCaption, }; } diff --git a/lib/widgets/dialogs/lineup_panel_dialog.dart b/lib/widgets/dialogs/lineup_panel_dialog.dart index a4bc07afb..9344d7acb 100644 --- a/lib/widgets/dialogs/lineup_panel_dialog.dart +++ b/lib/widgets/dialogs/lineup_panel_dialog.dart @@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/agents.dart'; import 'package:icarus/const/line_provider.dart'; import 'package:icarus/const/settings.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/collab/editing_presence_provider.dart'; import 'package:icarus/providers/collab/strategy_capabilities_provider.dart'; import 'package:icarus/providers/strategy_image_source.dart'; @@ -51,10 +52,14 @@ Future openLandingLineUps( return showLineUpPanel(context, landingId: landingId); } -String lineUpDisplayName(LineUpLink link, List siblings) { +String lineUpDisplayName( + AppLocalizations l10n, + LineUpLink link, + List siblings, +) { if (link.name.isNotEmpty) return link.name; final index = siblings.indexWhere((entry) => entry.id == link.id); - return 'Lineup ${index < 0 ? siblings.length : index + 1}'; + return l10n.lineupPanelUnnamed(index < 0 ? siblings.length : index + 1); } class LineUpPanelDialog extends ConsumerStatefulWidget { @@ -134,19 +139,20 @@ class _LineUpPanelDialogState extends ConsumerState { } Future _rename(LineUpLink link) async { + final l10n = context.l10n; final controller = TextEditingController(text: link.name); final name = await showShadDialog( context: context, builder: (context) => ShadDialog( - title: const Text('Rename lineup'), + title: Text(l10n.lineupPanelRenameTitle), actions: [ ShadButton.outline( onPressed: () => Navigator.of(context).pop(), - child: const Text('Cancel'), + child: Text(l10n.commonCancel), ), ShadButton( onPressed: () => Navigator.of(context).pop(controller.text.trim()), - child: const Text('Save'), + child: Text(l10n.commonSave), ), ], child: SizedBox( @@ -155,8 +161,7 @@ class _LineUpPanelDialogState extends ConsumerState { color: Colors.transparent, child: CustomTextField( controller: controller, - hintText: - 'Optional, for telling this apart from other lineups here', + hintText: l10n.lineupMediaNameHint, ), ), ), @@ -167,8 +172,7 @@ class _LineUpPanelDialogState extends ConsumerState { // The reader's access changed while the dialog was open. if (!ref.read(currentStrategyCapabilitiesProvider).canEditPages) { Settings.showToast( - message: 'You can no longer edit this strategy, so the name was not ' - 'saved.', + message: l10n.lineupPanelRenameLostEditAccess, backgroundColor: Settings.tacticalVioletTheme.destructive, ); return; @@ -205,12 +209,16 @@ class _LineUpPanelDialogState extends ConsumerState { widget.landingId == null ? null : state.landingById(widget.landingId!); final origin = widget.originId == null ? null : state.originById(widget.originId!); + final l10n = context.l10n; final title = landing != null - ? landing.ability.data.name - : AgentData.agents[origin?.agent.type]?.name ?? 'Origin'; + ? l10n.gameName(landing.ability.data.name) + : switch (AgentData.agents[origin?.agent.type]?.name) { + final name? => l10n.gameName(name), + null => l10n.lineupPanelOriginFallback, + }; final subtitle = landing != null - ? '${links.length} lineups land here' - : '${links.length} lineups from here'; + ? l10n.lineupPanelLandHere(links.length) + : l10n.lineupPanelFromHere(links.length); // The media is the point of this dialog, so it takes most of the window // and gives the pane whatever the list doesn't need. @@ -254,7 +262,7 @@ class _LineUpPanelDialogState extends ConsumerState { final link = links[index]; return _LineUpRow( link: link, - label: lineUpDisplayName(link, links), + label: lineUpDisplayName(l10n, link, links), agentName: AgentData .agents[state .originById(link.originId) @@ -357,6 +365,7 @@ class _LineUpRowState extends State<_LineUpRow> { @override Widget build(BuildContext context) { const theme = Settings.tacticalVioletTheme; + final l10n = context.l10n; final firstImage = widget.link.images.isEmpty ? null : widget.link.images.first; @@ -431,17 +440,17 @@ class _LineUpRowState extends State<_LineUpRow> { ShadContextMenuItem( leading: const Icon(LucideIcons.pencil), onPressed: widget.onEditMedia, - child: const Text('Edit media'), + child: Text(l10n.lineupPanelEditMedia), ), ShadContextMenuItem( leading: const Icon(LucideIcons.textCursorInput), onPressed: widget.onRename, - child: const Text('Rename'), + child: Text(l10n.commonRename), ), ShadContextMenuItem( leading: Icon(LucideIcons.trash2, color: theme.destructive), onPressed: widget.onDelete, - child: const Text('Delete lineup'), + child: Text(l10n.lineupPanelDeleteLineup), ), ], child: row, diff --git a/lib/widgets/dialogs/map_theme_editor_dialog.dart b/lib/widgets/dialogs/map_theme_editor_dialog.dart index 7325772e7..b65fc6573 100644 --- a/lib/widgets/dialogs/map_theme_editor_dialog.dart +++ b/lib/widgets/dialogs/map_theme_editor_dialog.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/widgets/asset_svg.dart'; import 'package:icarus/const/maps.dart'; import 'package:icarus/const/settings.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/map_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/providers/user_preferences_provider.dart'; @@ -161,7 +162,7 @@ class _MapThemeEditorDialogState extends ConsumerState { mapAsset, colorMapper: MapSvgColorMapper.forPalette(_palette), fit: BoxFit.contain, - semanticsLabel: 'Map theme preview', + semanticsLabel: context.l10n.mapThemeEditorPreviewLabel, ), ), ), @@ -172,10 +173,11 @@ class _MapThemeEditorDialogState extends ConsumerState { } Widget _buildControls(BuildContext context) { + final l10n = context.l10n; final title = switch (widget.mode) { - MapThemeEditorMode.createProfile => "New profile", - MapThemeEditorMode.editProfile => "Edit profile", - MapThemeEditorMode.customizeStrategy => "Customize map theme", + MapThemeEditorMode.createProfile => l10n.mapThemeEditorNewProfileTitle, + MapThemeEditorMode.editProfile => l10n.mapThemeEditorEditProfileTitle, + MapThemeEditorMode.customizeStrategy => l10n.mapThemeEditorCustomizeTitle, }; final showNameField = widget.mode != MapThemeEditorMode.customizeStrategy; @@ -198,7 +200,7 @@ class _MapThemeEditorDialogState extends ConsumerState { if (widget.mode == MapThemeEditorMode.customizeStrategy) ...[ const SizedBox(height: 4), Text( - "Only this strategy is affected.", + l10n.mapThemeEditorCustomizeDescription, style: ShadTheme.of(context).textTheme.small.copyWith( color: Settings.tacticalVioletTheme.mutedForeground, ), @@ -213,7 +215,7 @@ class _MapThemeEditorDialogState extends ConsumerState { if (showNameField) ...[ CustomTextField( controller: _nameController, - hintText: "Profile name", + hintText: l10n.mapThemeEditorProfileNameHint, ), const SizedBox(height: 14), ], @@ -240,6 +242,7 @@ class _MapThemeEditorDialogState extends ConsumerState { Widget _buildSlotTabs(BuildContext context) { const theme = Settings.tacticalVioletTheme; + final l10n = context.l10n; return FittedBox( fit: BoxFit.scaleDown, @@ -253,11 +256,13 @@ class _MapThemeEditorDialogState extends ConsumerState { child: Row( mainAxisSize: MainAxisSize.min, children: [ - _buildSlotTab(context, _PaletteSlot.base, "Base"), + _buildSlotTab(context, _PaletteSlot.base, l10n.mapThemeEditorBase), const SizedBox(width: 2), - _buildSlotTab(context, _PaletteSlot.detail, "Detail"), + _buildSlotTab( + context, _PaletteSlot.detail, l10n.mapThemeEditorDetail), const SizedBox(width: 2), - _buildSlotTab(context, _PaletteSlot.highlight, "Highlight"), + _buildSlotTab( + context, _PaletteSlot.highlight, l10n.mapThemeEditorHighlight), ], ), ), @@ -338,6 +343,7 @@ class _MapThemeEditorDialogState extends ConsumerState { } Widget _buildFooter(BuildContext context) { + final l10n = context.l10n; return FittedBox( fit: BoxFit.scaleDown, alignment: Alignment.centerRight, @@ -347,24 +353,24 @@ class _MapThemeEditorDialogState extends ConsumerState { ShadButton.ghost( size: ShadButtonSize.sm, onPressed: () => Navigator.of(context).pop(), - child: const Text("Cancel"), + child: Text(l10n.commonCancel), ), const SizedBox(width: 6), switch (widget.mode) { MapThemeEditorMode.createProfile => ShadButton( size: ShadButtonSize.sm, onPressed: _saveNewProfile, - child: const Text("Save profile"), + child: Text(l10n.mapThemeEditorSaveProfile), ), MapThemeEditorMode.editProfile => ShadButton( size: ShadButtonSize.sm, onPressed: _saveProfileEdits, - child: const Text("Save changes"), + child: Text(l10n.mapThemeEditorSaveChanges), ), MapThemeEditorMode.customizeStrategy => ShadButton( size: ShadButtonSize.sm, onPressed: _applyToStrategy, - child: const Text("Apply"), + child: Text(l10n.commonApply), ), }, ], @@ -373,12 +379,13 @@ class _MapThemeEditorDialogState extends ConsumerState { } Future _saveNewProfile() async { + final l10n = context.l10n; final created = await ref .read(mapThemeProfilesProvider.notifier) .createProfile(name: _nameController.text.trim(), palette: _palette); if (created == null) { Settings.showToast( - message: "Profile limit reached or invalid name.", + message: l10n.mapThemeEditorCreateFailed, backgroundColor: Settings.tacticalVioletTheme.destructive, ); return; @@ -392,8 +399,9 @@ class _MapThemeEditorDialogState extends ConsumerState { if (!mounted) return; Navigator.of(context).pop(); Settings.showToast( - message: - hasActiveStrategy ? "Profile saved and applied." : "Profile saved.", + message: hasActiveStrategy + ? l10n.mapThemeEditorSavedAndApplied + : l10n.mapThemeEditorSaved, backgroundColor: Settings.tacticalVioletTheme.primary, ); } @@ -401,9 +409,10 @@ class _MapThemeEditorDialogState extends ConsumerState { Future _saveProfileEdits() async { final profile = widget.profile!; final trimmedName = _nameController.text.trim(); + final l10n = context.l10n; if (trimmedName.isEmpty) { Settings.showToast( - message: "Enter a profile name.", + message: l10n.mapThemeEditorEnterName, backgroundColor: Settings.tacticalVioletTheme.destructive, ); return; @@ -415,7 +424,7 @@ class _MapThemeEditorDialogState extends ConsumerState { ); if (!paletteSaved) { Settings.showToast( - message: "Couldn't update this profile.", + message: l10n.mapThemeEditorUpdateFailed, backgroundColor: Settings.tacticalVioletTheme.destructive, ); return; @@ -431,8 +440,8 @@ class _MapThemeEditorDialogState extends ConsumerState { Navigator.of(context).pop(); Settings.showToast( message: renamed - ? "Profile updated." - : "Colors saved, but the name couldn't be changed.", + ? l10n.mapThemeEditorUpdated + : l10n.mapThemeEditorRenameFailed, backgroundColor: renamed ? Settings.tacticalVioletTheme.primary : Settings.tacticalVioletTheme.destructive, diff --git a/lib/widgets/dialogs/recently_deleted_dialog.dart b/lib/widgets/dialogs/recently_deleted_dialog.dart index 118447165..00ea7c32b 100644 --- a/lib/widgets/dialogs/recently_deleted_dialog.dart +++ b/lib/widgets/dialogs/recently_deleted_dialog.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/collab/collab_models.dart'; import 'package:icarus/collab/convex_strategy_repository.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/strategy_page_session_provider.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; @@ -94,20 +95,20 @@ class _RecentlyDeletedDialogState extends ConsumerState { @override Widget build(BuildContext context) { + final l10n = context.l10n; return ShadDialog( - title: const Text('Recently deleted'), - description: const Text( - 'Deleted pages stay here for $pageTrashRetentionDays days, then they ' - 'are gone for good.', + title: Text(l10n.recentlyDeletedTitle), + description: Text( + l10n.recentlyDeletedDescription(pageTrashRetentionDays), ), child: SizedBox( width: 440, - child: _body(ShadTheme.of(context)), + child: _body(ShadTheme.of(context), l10n), ), ); } - Widget _body(ShadThemeData theme) { + Widget _body(ShadThemeData theme, AppLocalizations l10n) { final muted = theme.textTheme.small.copyWith( color: theme.colorScheme.mutedForeground, fontWeight: FontWeight.w400, @@ -119,8 +120,7 @@ class _RecentlyDeletedDialogState extends ConsumerState { children: [ Expanded( child: Text( - 'Could not load deleted pages. Check your connection and ' - 'try again.', + l10n.recentlyDeletedLoadFailed, style: muted, ), ), @@ -128,7 +128,7 @@ class _RecentlyDeletedDialogState extends ConsumerState { ShadButton.secondary( size: ShadButtonSize.sm, onPressed: _load, - child: const Text('Try again'), + child: Text(l10n.commonTryAgain), ), ], ), @@ -138,7 +138,7 @@ class _RecentlyDeletedDialogState extends ConsumerState { if (pages == null) { return Padding( padding: const EdgeInsets.symmetric(vertical: 12), - child: Text('Loading…', style: muted), + child: Text(l10n.recentlyDeletedLoading, style: muted), ); } if (pages.isEmpty) { @@ -154,7 +154,7 @@ class _RecentlyDeletedDialogState extends ConsumerState { color: theme.colorScheme.mutedForeground, ), const SizedBox(height: 8), - Text('No deleted pages.', style: muted), + Text(l10n.recentlyDeletedEmpty, style: muted), ], ), ), @@ -195,13 +195,13 @@ class _TrashedPageRow extends StatelessWidget { Widget build(BuildContext context) { final theme = ShadTheme.of(context); final now = clock.now(); + final l10n = context.l10n; // Past its time here too, as when the server says so. final gone = state == _RowState.gone || !page.restorableUntil.isAfter(now); final error = gone - ? 'It can no longer be restored.' + ? l10n.recentlyDeletedGone : state == _RowState.failed - ? 'Could not confirm the restore. Check your connection and try ' - 'again.' + ? l10n.recentlyDeletedRestoreFailed : null; // Type roles from DESIGN.md: title for the name, label for the rest. final label = theme.textTheme.small.copyWith( @@ -233,9 +233,11 @@ class _TrashedPageRow extends StatelessWidget { ), Text( gone - ? deletedLabel(page, now) - : '${deletedLabel(page, now)} · ' - '${timeLeftLabel(page, now)}', + ? deletedLabel(l10n, page, now) + : l10n.recentlyDeletedRowDetail( + deletedLabel(l10n, page, now), + timeLeftLabel(l10n, page, now), + ), style: label.copyWith( color: theme.colorScheme.mutedForeground, ), @@ -258,7 +260,9 @@ class _TrashedPageRow extends StatelessWidget { size: ShadButtonSize.sm, onPressed: state == _RowState.restoring ? null : onRestore, child: Text( - state == _RowState.restoring ? 'Restoring…' : 'Restore', + state == _RowState.restoring + ? l10n.recentlyDeletedRestoring + : l10n.recentlyDeletedRestore, ), ), ], @@ -271,24 +275,27 @@ class _TrashedPageRow extends StatelessWidget { /// "Deleted by Sam, 2 days ago", "Deleted by you, 5 minutes ago", or /// "Deleted 2 days ago" when who deleted it is not known. @visibleForTesting -String deletedLabel(TrashedPage page, DateTime now) { - final by = page.deletedByYou ? 'you' : page.deletedByName; - final when = _ago(now.difference(page.deletedAt)); - return by == null ? 'Deleted $when' : 'Deleted by $by, $when'; +String deletedLabel(AppLocalizations l10n, TrashedPage page, DateTime now) { + final when = _ago(l10n, now.difference(page.deletedAt)); + if (page.deletedByYou) return l10n.recentlyDeletedByYou(when); + final by = page.deletedByName; + return by == null + ? l10n.recentlyDeletedWhen(when) + : l10n.recentlyDeletedBy(by, when); } /// "28 days left", "1 day left", "Less than a day left". @visibleForTesting -String timeLeftLabel(TrashedPage page, DateTime now) { +String timeLeftLabel(AppLocalizations l10n, TrashedPage page, DateTime now) { final days = page.restorableUntil.difference(now).inDays; - if (days < 1) return 'Less than a day left'; - return days == 1 ? '1 day left' : '$days days left'; + if (days < 1) return l10n.recentlyDeletedLessThanADayLeft; + return l10n.recentlyDeletedDaysLeft(days); } -String _ago(Duration elapsed) { - String count(int n, String unit) => n == 1 ? '1 $unit' : '$n ${unit}s'; - if (elapsed.inMinutes < 1) return 'just now'; - if (elapsed.inHours < 1) return '${count(elapsed.inMinutes, 'minute')} ago'; - if (elapsed.inDays < 1) return '${count(elapsed.inHours, 'hour')} ago'; - return '${count(elapsed.inDays, 'day')} ago'; +String _ago(AppLocalizations l10n, Duration elapsed) { + if (elapsed.inMinutes < 1) return l10n.recentlyDeletedJustNow; + if (elapsed.inHours < 1) + return l10n.recentlyDeletedMinutesAgo(elapsed.inMinutes); + if (elapsed.inDays < 1) return l10n.recentlyDeletedHoursAgo(elapsed.inHours); + return l10n.recentlyDeletedDaysAgo(elapsed.inDays); } diff --git a/lib/widgets/dialogs/release_notes_dialog.dart b/lib/widgets/dialogs/release_notes_dialog.dart index 8f4b9da6b..d1bc900e8 100644 --- a/lib/widgets/dialogs/release_notes_dialog.dart +++ b/lib/widgets/dialogs/release_notes_dialog.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/config/platform_policy.dart'; import 'package:icarus/const/release_notes.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/release_notes_provider.dart'; import 'package:icarus/widgets/desktop_update_dialog.dart'; import 'package:icarus/widgets/dialogs/welcome_tour/welcome_tour_dialog.dart'; @@ -32,9 +33,10 @@ class ReleaseNotesDialog extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final notes = ref.watch(releaseNotesProvider); final theme = ShadTheme.of(context); + final l10n = context.l10n; return ShadDialog( - title: const Text("What's new"), + title: Text(l10n.releaseNotesTitle), // The web beta never shows the tour, so it has nothing to go back to. description: ref.watch(platformPolicyProvider).isWebBeta ? null @@ -47,7 +49,7 @@ class ReleaseNotesDialog extends ConsumerWidget { foregroundColor: theme.colorScheme.mutedForeground, hoverForegroundColor: theme.colorScheme.foreground, onPressed: () => Navigator.of(context).pop(true), - child: const Text('Show the welcome tour'), + child: Text(l10n.releaseNotesShowWelcomeTour), ), ), constraints: const BoxConstraints(maxWidth: _width), @@ -118,10 +120,11 @@ class _ReleaseCard extends StatelessWidget { ), if (entry.isInstalled) ...[ const SizedBox(width: 10), - const ShadBadge.outline(child: Text('Installed')), + ShadBadge.outline( + child: Text(context.l10n.releaseNotesInstalled)), ] else if (entry.isNewerThanInstalled) ...[ const SizedBox(width: 10), - const ShadBadge(child: Text('Available')), + ShadBadge(child: Text(context.l10n.releaseNotesAvailable)), ], const Spacer(), if (entry.date != null) @@ -162,7 +165,7 @@ class _Unavailable extends StatelessWidget { ), const SizedBox(height: 12), Text( - "Couldn't load patch notes. Check your connection and try again.", + context.l10n.releaseNotesLoadFailed, textAlign: TextAlign.center, style: theme.textTheme.small.copyWith( color: theme.colorScheme.mutedForeground, @@ -173,7 +176,7 @@ class _Unavailable extends StatelessWidget { ShadButton.secondary( onPressed: onRetry, leading: const Icon(LucideIcons.refreshCw, size: 16), - child: const Text('Try again'), + child: Text(context.l10n.commonTryAgain), ), ], ), diff --git a/lib/widgets/dialogs/screenshot_dialog.dart b/lib/widgets/dialogs/screenshot_dialog.dart index 5830e2940..374e375b7 100644 --- a/lib/widgets/dialogs/screenshot_dialog.dart +++ b/lib/widgets/dialogs/screenshot_dialog.dart @@ -7,6 +7,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/coordinate_system.dart'; import 'package:icarus/const/settings.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/strategy_page_session_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/providers/user_preferences_provider.dart'; @@ -176,7 +177,7 @@ class _ScreenshotDialogState extends ConsumerState { String? get _captionRefusal { for (final (index, page) in _pages.indexed) { if (_selected.contains(page.id) && _captionMisfits.contains(page.id)) { - return captionsDontFitMessage(_single ? null : index + 1); + return captionsDontFitMessage(context.l10n, _single ? null : index + 1); } } return null; @@ -223,7 +224,9 @@ class _ScreenshotDialogState extends ConsumerState { if (_busy || _selected.isEmpty || _captionRefusal != null) return; final strategy = ref.read(strategyProvider); if (strategy.strategyId == null) return; - final strategyName = strategy.strategyName ?? 'new image'; + final l10n = context.l10n; + final strategyName = + strategy.strategyName ?? l10n.screenshotDialogDefaultFileName; final pageIds = {..._selected}; // The page on the canvas now, which a teammate's delete can change while // the dialog is up: a ticked page that is no longer it is read as saved. @@ -237,12 +240,12 @@ class _ScreenshotDialogState extends ConsumerState { if (pageIds.length > 1 && !kIsWeb) { try { folder = await FilePicker.platform.getDirectoryPath( - dialogTitle: 'Choose a folder for the screenshots', + dialogTitle: l10n.screenshotDialogChooseFolder, ); } catch (error, stackTrace) { _busy = false; AppErrorReporter.reportError( - "Couldn't open the folder picker. Please try again.", + l10n.screenshotDialogFolderPickerFailed, error: error, stackTrace: stackTrace, source: 'ScreenshotDialog.save', @@ -272,6 +275,7 @@ class _ScreenshotDialogState extends ConsumerState { // Desktop asks where to save and writes the bytes there; the // browser downloads them. await saveExportBytes( + l10n, '${sanitizeStrategyFileName(strategyName)}.png', png, ); @@ -295,14 +299,17 @@ class _ScreenshotDialogState extends ConsumerState { if (mounted) setState(() => _setCaptured(shots.length)); if (shots.length == 1) { await saveExportBytes( + l10n, pageScreenshotFileName(strategyName, shots.single), shots.single.png, ); } else if (folder != null) { await writeScreenshotsToFolder(folder, strategyName, shots); Settings.showToast( - message: 'Saved ${shots.length} screenshots to ' - '${p.basename(folder)}.', + message: l10n.screenshotDialogSavedToFolder( + shots.length, + p.basename(folder), + ), backgroundColor: Settings.tacticalVioletTheme.primary, ); } else { @@ -315,22 +322,22 @@ class _ScreenshotDialogState extends ConsumerState { } } on VideoExportCancelled { Settings.showToast( - message: 'Screenshots cancelled.', + message: l10n.screenshotDialogCancelled, backgroundColor: Settings.tacticalVioletTheme.primary, ); } on VideoExportNotSynced catch (error, stackTrace) { - _reportWarning(error.userMessage, error, stackTrace); + _reportWarning(error.userMessage(l10n), error, stackTrace); } on VideoExportPagesChanged catch (error, stackTrace) { - _reportWarning(error.userMessage, error, stackTrace); + _reportWarning(error.userMessage(l10n), error, stackTrace); } on CaptureImagesUnavailable catch (error, stackTrace) { - _reportWarning(error.userMessage, error, stackTrace); + _reportWarning(error.userMessage(l10n), error, stackTrace); } on CaptionsDontFit catch (error, stackTrace) { - _reportWarning(error.userMessage, error, stackTrace); + _reportWarning(error.userMessage(l10n), error, stackTrace); } catch (error, stackTrace) { AppErrorReporter.reportError( pageIds.length > 1 - ? 'Could not export the screenshots. Please try again.' - : 'Could not export the screenshot. Please try again.', + ? l10n.screenshotDialogExportFailedMany + : l10n.screenshotDialogExportFailedOne, error: error, stackTrace: stackTrace, source: 'ScreenshotDialog.save', @@ -385,12 +392,13 @@ class _ScreenshotDialogState extends ConsumerState { final refusal = _captionRefusal; final saving = _saving != null; final count = _selected.length; + final l10n = context.l10n; final title = switch ((saving, count > 1)) { - (true, true) => 'Saving screenshots', - (true, false) => 'Saving screenshot', - (false, _) when _single => 'Save screenshot', - _ => 'Save screenshots', + (true, true) => l10n.screenshotDialogSavingManyTitle, + (true, false) => l10n.screenshotDialogSavingOneTitle, + (false, _) when _single => l10n.screenshotDialogSaveOneTitle, + _ => l10n.screenshotDialogSaveManyTitle, }; final columns = _pages.length <= 4 ? 2 : 3; @@ -409,7 +417,8 @@ class _ScreenshotDialogState extends ConsumerState { dotOpacity: dotOpacity, selected: _single || _selected.contains(page.id), showCheckbox: !_single && !saving, - status: _statusOf(page.id), + status: _statusOf(l10n, page.id), + captured: _isCaptured(page.id), onTap: _single || saving ? null : () => _toggle(page.id), ), ), @@ -421,15 +430,15 @@ class _ScreenshotDialogState extends ConsumerState { onChanged: (value) => ref .read(appPreferencesProvider.notifier) .setTransparentScreenshotBackground(value), - label: const Text('Transparent background'), + label: Text(l10n.screenshotDialogTransparentBackground), ); final captionsToggle = ShadCheckbox( value: includeCaptions, enabled: !saving, onChanged: _setIncludeCaptions, - label: const Text('Include captions'), + label: Text(l10n.screenshotDialogIncludeCaptions), sublabel: refusal == null - ? const Text('Numbers on the map, notes in the side margins.') + ? Text(l10n.screenshotDialogIncludeCaptionsDescription) : Text( refusal, style: TextStyle(color: theme.colorScheme.destructive), @@ -446,14 +455,16 @@ class _ScreenshotDialogState extends ConsumerState { ); final cancel = ShadButton.secondary( onPressed: () => Navigator.of(context).maybePop(), - child: const Text('Cancel'), + child: Text(l10n.commonCancel), ); final save = ShadButton( autofocus: true, enabled: count > 0 && refusal == null, onPressed: _save, leading: const Icon(LucideIcons.camera, size: 16), - child: Text(count > 1 ? 'Save $count screenshots' : 'Save screenshot'), + child: Text(count > 1 + ? l10n.screenshotDialogSaveManyButton(count) + : l10n.screenshotDialogSaveOneButton), ); final Widget footer; @@ -473,7 +484,9 @@ class _ScreenshotDialogState extends ConsumerState { ShadButton.destructive( onPressed: _stopping ? null : () => setState(() => _stopping = true), - child: Text(_stopping ? 'Stopping…' : 'Stop'), + child: Text(_stopping + ? l10n.screenshotDialogStoppingButton + : l10n.screenshotDialogStop), ), ], ], @@ -521,7 +534,9 @@ class _ScreenshotDialogState extends ConsumerState { ShadButton.ghost( size: ShadButtonSize.sm, onPressed: _toggleAll, - child: Text(_allSelected ? 'Clear all' : 'Select all'), + child: Text(_allSelected + ? l10n.screenshotDialogClearAll + : l10n.screenshotDialogSelectAll), ), // Clear of the close button in the corner. const SizedBox(width: 16), @@ -564,18 +579,25 @@ class _ScreenshotDialogState extends ConsumerState { /// What a tile says while the save runs: captured, capturing, or nothing /// for a page waiting its turn or left out. - String? _statusOf(String pageId) { + String? _statusOf(AppLocalizations l10n, String pageId) { final order = _saving; if (order == null) return null; final index = order.indexOf(pageId); if (index < 0) return null; - if (index < _captured) return 'Captured'; + if (index < _captured) return l10n.screenshotDialogCaptured; if (index == _captured) { - if (_stopping) return 'Stopping'; - return _waitingForPictures ? 'Waiting for pictures' : 'Capturing'; + if (_stopping) return l10n.screenshotDialogStoppingStatus; + return _waitingForPictures + ? l10n.screenshotDialogWaitingForPictures + : l10n.screenshotDialogCapturing; } return null; } + + bool _isCaptured(String pageId) { + final index = _saving?.indexOf(pageId) ?? -1; + return index >= 0 && index < _captured; + } } /// One page in the screenshot dialog: its thumbnail over the background it @@ -591,6 +613,7 @@ class _PageTile extends StatefulWidget { required this.selected, required this.showCheckbox, required this.status, + required this.captured, required this.onTap, }); @@ -608,6 +631,9 @@ class _PageTile extends StatefulWidget { final bool selected; final bool showCheckbox; final String? status; + + /// Whether [status] says the page is captured. + final bool captured; final VoidCallback? onTap; @override @@ -703,7 +729,7 @@ class _PageTileState extends State<_PageTile> { top: 10, child: _StatusChip( text: status, - done: status == 'Captured', + done: widget.captured, ), ), ], diff --git a/lib/widgets/dialogs/share_links_dialog.dart b/lib/widgets/dialogs/share_links_dialog.dart index d3255d460..602ebf470 100644 --- a/lib/widgets/dialogs/share_links_dialog.dart +++ b/lib/widgets/dialogs/share_links_dialog.dart @@ -7,6 +7,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/collab/collab_models.dart'; import 'package:icarus/collab/convex_strategy_repository.dart'; import 'package:icarus/const/settings.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/auth_provider.dart'; import 'package:icarus/providers/folder_provider.dart'; import 'package:icarus/providers/library_workspace_provider.dart'; @@ -22,26 +23,27 @@ import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:icarus/widgets/loading_spinner.dart'; /// Headline like: Share "My Strategy Name" (`"` in [name] become `'`). -String _shareDialogHeadline(String name) { +String _shareDialogHeadline(AppLocalizations l10n, String name) { final safe = name.replaceAll('"', "'"); - return 'Share "$safe"'; + return l10n.shareLinksTitle(safe); } const _stateSwitchDuration = Duration(milliseconds: 180); const _hoverDuration = Duration(milliseconds: 120); const _copiedFlashDuration = Duration(milliseconds: 1200); -String _relativeTime(DateTime time) { +String _relativeTime(AppLocalizations l10n, DateTime time) { final diff = DateTime.now().difference(time); - if (diff.inMinutes < 1) return 'just now'; - if (diff.inHours < 1) return '${diff.inMinutes}m ago'; - if (diff.inDays < 1) return '${diff.inHours}h ago'; - if (diff.inDays < 30) return '${diff.inDays}d ago'; + if (diff.inMinutes < 1) return l10n.shareLinksJustNow; + if (diff.inHours < 1) return l10n.shareLinksMinutesAgo(diff.inMinutes); + if (diff.inDays < 1) return l10n.shareLinksHoursAgo(diff.inHours); + if (diff.inDays < 30) return l10n.shareLinksDaysAgo(diff.inDays); return '${time.year}-${time.month.toString().padLeft(2, '0')}' '-${time.day.toString().padLeft(2, '0')}'; } -String _memberName(ShareMember member) => member.displayName ?? 'Teammate'; +String _memberName(AppLocalizations l10n, ShareMember member) => + member.displayName ?? l10n.shareLinksTeammateFallback; /// The owner's own name as teammates see it: an email sign-in shows the /// part before the @, as the server stores it. @@ -204,7 +206,7 @@ class _ShareLinksDialogState extends ConsumerState { } catch (_) { if (!mounted || generation != _peopleGeneration) return; Settings.showToast( - message: "Couldn't load more people.", + message: context.l10n.shareLinksLoadMoreFailed, backgroundColor: Settings.tacticalVioletTheme.destructive, ); // The cursor itself may be what the server refused; start over. @@ -245,6 +247,7 @@ class _ShareLinksDialogState extends ConsumerState { } Future _createLink() async { + final l10n = context.l10n; setState(() => _isCreating = true); final token = generateIcarusShareCode(); try { @@ -259,13 +262,13 @@ class _ShareLinksDialogState extends ConsumerState { await Clipboard.setData(ClipboardData( text: buildIcarusShareLink(token, origin: currentShareOrigin()))); Settings.showToast( - message: 'Share link copied to clipboard.', + message: l10n.shareLinksLinkCopied, backgroundColor: Settings.tacticalVioletTheme.primary, ); await _load(showLoading: false); } catch (_) { Settings.showToast( - message: 'Failed to create share link.', + message: l10n.shareLinksCreateFailed, backgroundColor: Settings.tacticalVioletTheme.destructive, ); } finally { @@ -296,7 +299,7 @@ class _ShareLinksDialogState extends ConsumerState { disabled = true; } catch (_) { Settings.showToast( - message: ShareLinkCopy.disableFailure, + message: ShareLinkCopy.disableFailure(ref.read(l10nProvider)), backgroundColor: Settings.tacticalVioletTheme.destructive, ); } @@ -322,7 +325,7 @@ class _ShareLinksDialogState extends ConsumerState { ProductMetrics.instance.shareMembership('role_changed'); } catch (_) { Settings.showToast( - message: ShareLinkCopy.roleChangeFailure, + message: ShareLinkCopy.roleChangeFailure(ref.read(l10nProvider)), backgroundColor: Settings.tacticalVioletTheme.destructive, ); } @@ -337,9 +340,12 @@ class _ShareLinksDialogState extends ConsumerState { Future _remove(ShareMember member) async { final confirmed = await ConfirmAlertDialog.show( context: context, - title: ShareLinkCopy.removeMemberTitle(_memberName(member)), - content: ShareLinkCopy.removeMemberDescription, - confirmText: ShareLinkCopy.removeMemberAction, + title: ShareLinkCopy.removeMemberTitle( + context.l10n, + _memberName(context.l10n, member), + ), + content: ShareLinkCopy.removeMemberDescription(context.l10n), + confirmText: ShareLinkCopy.removeMemberAction(context.l10n), isDestructive: true, ); if (!confirmed || !mounted) { @@ -356,7 +362,7 @@ class _ShareLinksDialogState extends ConsumerState { await _load(showLoading: false); } catch (_) { Settings.showToast( - message: ShareLinkCopy.removeMemberFailure, + message: ShareLinkCopy.removeMemberFailure(ref.read(l10nProvider)), backgroundColor: Settings.tacticalVioletTheme.destructive, ); } finally { @@ -370,14 +376,16 @@ class _ShareLinksDialogState extends ConsumerState { Widget build(BuildContext context) { final theme = ShadTheme.of(context); final maxBodyHeight = MediaQuery.sizeOf(context).height - 280; + final l10n = context.l10n; return ShadDialog( title: Text( - _shareDialogHeadline(widget.title), + _shareDialogHeadline(l10n, widget.title), softWrap: true, ), description: Text( ShareLinkCopy.dialogDescription( + context.l10n, widget.targetType, // Links made on web point at the web app (see currentShareOrigin). opensInBrowser: kIsWeb, @@ -386,7 +394,7 @@ class _ShareLinksDialogState extends ConsumerState { actions: [ ShadButton.secondary( onPressed: () => Navigator.of(context).pop(), - child: const Text('Done'), + child: Text(l10n.commonDone), ), ], child: SizedBox( @@ -421,12 +429,12 @@ class _ShareLinksDialogState extends ConsumerState { return _LinkListMessage( key: const ValueKey('error'), icon: LucideIcons.circleAlert, - message: "Couldn't load the share links.", + message: context.l10n.shareLinksLoadFailed, action: ShadButton.ghost( size: ShadButtonSize.sm, onPressed: _load, leading: const Icon(LucideIcons.refreshCw, size: 14), - child: const Text('Retry'), + child: Text(context.l10n.commonRetry), ), ); case _LoadStatus.ready: @@ -450,13 +458,14 @@ class _ShareLinksDialogState extends ConsumerState { Widget _buildPeople() { final auth = ref.watch(authProvider); final theme = ShadTheme.of(context); + final l10n = context.l10n; return Column( key: const ValueKey('share-people'), mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _SectionLabel( - label: 'People with access', + label: l10n.shareLinksPeopleWithAccess, // Counted only once everyone is here. count: _membersFailed || _nextCursor != null ? null @@ -464,7 +473,7 @@ class _ShareLinksDialogState extends ConsumerState { ), const SizedBox(height: 6), _PersonRow( - name: _ownerName(auth.displayName), + name: _ownerName(auth.displayName(context.l10n)), isYou: true, avatarUrl: auth.avatarUrl, subtitle: null, @@ -474,20 +483,20 @@ class _ShareLinksDialogState extends ConsumerState { _LinkListMessage( key: const ValueKey('share-people-error'), icon: LucideIcons.circleAlert, - message: "Couldn't load who else has access.", + message: l10n.shareLinksLoadPeopleFailed, action: ShadButton.ghost( size: ShadButtonSize.sm, onPressed: () => _load(showLoading: false), leading: const Icon(LucideIcons.refreshCw, size: 14), - child: const Text('Retry'), + child: Text(l10n.commonRetry), ), ), for (final member in _members) _PersonRow( key: ValueKey('share-member-${member.memberId}'), - name: _memberName(member), + name: _memberName(l10n, member), avatarUrl: member.avatarUrl, - subtitle: _joinedSubtitle(member), + subtitle: _joinedSubtitle(l10n, member), trailing: _MemberControls( member: member, revision: _roleRevisions[member.memberId] ?? 0, @@ -503,7 +512,9 @@ class _ShareLinksDialogState extends ConsumerState { key: const ValueKey('share-people-more'), size: ShadButtonSize.sm, onPressed: _loadingMore ? null : _loadMore, - child: Text(_loadingMore ? 'Loading…' : 'Show more'), + child: Text(_loadingMore + ? l10n.shareLinksLoadingMore + : l10n.shareLinksShowMore), ), ), // A folder's people are managed from the folder; here they are one @@ -521,8 +532,7 @@ class _ShareLinksDialogState extends ConsumerState { const SizedBox(width: 8), Expanded( child: Text( - 'People with access to the folder "$folder" can open ' - 'this too.', + l10n.shareLinksFolderAccess(folder), style: theme.textTheme.small.copyWith( color: theme.colorScheme.mutedForeground, fontSize: 12, @@ -537,27 +547,27 @@ class _ShareLinksDialogState extends ConsumerState { ); } - String _joinedSubtitle(ShareMember member) { - final when = _relativeTime(member.joinedAt); + String _joinedSubtitle(AppLocalizations l10n, ShareMember member) { + final when = _relativeTime(l10n, member.joinedAt); final token = member.shareLinkToken; if (token == null) { - return 'Joined $when'; + return l10n.shareLinksJoined(when); } final link = _links.where((link) => link.token == token).firstOrNull; - final through = switch (link?.role) { - 'editor' => 'an edit link', - 'viewer' => 'a view link', - _ => 'a link', + return switch (link?.role) { + 'editor' => l10n.shareLinksJoinedThroughEditLink(when), + 'viewer' => l10n.shareLinksJoinedThroughViewLink(when), + _ => l10n.shareLinksJoinedThroughLink(when), }; - return 'Joined through $through · $when'; } Widget _buildNewLink(ShadThemeData theme) { + final l10n = context.l10n; return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - const _SectionLabel(label: 'New link'), + _SectionLabel(label: l10n.shareLinksNewLink), const SizedBox(height: 8), Row( crossAxisAlignment: CrossAxisAlignment.start, @@ -567,23 +577,23 @@ class _ShareLinksDialogState extends ConsumerState { key: const ValueKey('share-link-role-select'), initialValue: _selectedRole, selectedOptionBuilder: (context, value) => Text( - value == 'editor' ? 'Can edit' : 'View only', + value == 'editor' + ? l10n.shareLinksCanEdit + : l10n.shareLinksViewOnly, ), - options: const [ + options: [ ShadOption( value: 'viewer', child: _RoleOptionLabel( - label: 'View only', - description: 'Anyone with the link can open every page', + label: l10n.shareLinksViewOnly, + description: l10n.shareLinksViewOnlyLinkDescription, ), ), ShadOption( value: 'editor', child: _RoleOptionLabel( - label: 'Can edit', - description: - 'Anyone with the link can change pages, drawings, ' - 'and media', + label: l10n.shareLinksCanEdit, + description: l10n.shareLinksCanEditLinkDescription, ), ), ], @@ -608,7 +618,9 @@ class _ShareLinksDialogState extends ConsumerState { ), ) : const Icon(LucideIcons.link, size: 16), - child: Text(_isCreating ? 'Creating…' : 'Create & copy'), + child: Text(_isCreating + ? l10n.shareLinksCreating + : l10n.shareLinksCreateAndCopy), ), ], ), @@ -634,7 +646,7 @@ class _ShareLinksDialogState extends ConsumerState { const SizedBox(width: 6), Expanded( child: Text( - ShareLinkCopy.editLinkWarning, + ShareLinkCopy.editLinkWarning(context.l10n), style: theme.textTheme.small.copyWith( color: theme.colorScheme.mutedForeground, fontSize: 12, @@ -652,20 +664,21 @@ class _ShareLinksDialogState extends ConsumerState { } Widget _buildLinks() { + final l10n = context.l10n; return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _SectionLabel( - label: 'Active links', + label: l10n.shareLinksActiveLinks, count: _activeLinkCount > 0 ? _activeLinkCount : null, ), const SizedBox(height: 10), if (_links.isEmpty) - const _LinkListMessage( - key: ValueKey('empty'), + _LinkListMessage( + key: const ValueKey('empty'), icon: LucideIcons.link2, - message: 'No links yet. Create one above to share it.', + message: l10n.shareLinksNoLinks, ) else for (final (index, link) in _links.indexed) ...[ @@ -791,7 +804,7 @@ class _PersonRow extends StatelessWidget { children: [ if (isYou) TextSpan( - text: ' (you)', + text: context.l10n.shareLinksYouSuffix, style: TextStyle( color: theme.colorScheme.mutedForeground, fontWeight: FontWeight.w400, @@ -839,7 +852,7 @@ class _OwnerLabel extends StatelessWidget { // Lines up with the role switches, which sit left of a 32px X. padding: const EdgeInsets.only(right: 40), child: Text( - 'Owner', + context.l10n.shareLinksOwner, style: theme.textTheme.small.copyWith( color: theme.colorScheme.mutedForeground, fontWeight: FontWeight.w400, @@ -869,7 +882,8 @@ class _MemberControls extends StatelessWidget { @override Widget build(BuildContext context) { final theme = ShadTheme.of(context); - final name = _memberName(member); + final l10n = context.l10n; + final name = _memberName(l10n, member); return Row( mainAxisSize: MainAxisSize.min, children: [ @@ -883,21 +897,21 @@ class _MemberControls extends StatelessWidget { initialValue: member.role, minWidth: 112, selectedOptionBuilder: (context, value) => Text( - value == 'editor' ? 'Can edit' : 'Can view', + value == 'editor' ? l10n.shareLinksCanEdit : l10n.shareLinksCanView, ), - options: const [ + options: [ ShadOption( value: 'viewer', child: _RoleOptionLabel( - label: 'Can view', - description: 'Opens every page, changes nothing', + label: l10n.shareLinksCanView, + description: l10n.shareLinksCanViewMemberDescription, ), ), ShadOption( value: 'editor', child: _RoleOptionLabel( - label: 'Can edit', - description: 'Changes pages, drawings, and media', + label: l10n.shareLinksCanEdit, + description: l10n.shareLinksCanEditMemberDescription, ), ), ], @@ -906,13 +920,13 @@ class _MemberControls extends StatelessWidget { }, ), Semantics( - label: 'Remove $name', + label: l10n.shareLinksRemoveMember(name), button: true, enabled: !busy, onTap: busy ? null : onRemove, excludeSemantics: true, child: Tooltip( - message: 'Remove $name', + message: l10n.shareLinksRemoveMember(name), child: ShadButton.ghost( key: ValueKey('share-member-remove-${member.memberId}'), size: ShadButtonSize.sm, @@ -962,14 +976,14 @@ class _DisableLinkDialogState extends State<_DisableLinkDialog> { final anyone = joins.joined > 0; final others = joins.more || joins.joined > joins.names.length; return ShadDialog.alert( - title: const Text(ShareLinkCopy.disableTitle), + title: Text(ShareLinkCopy.disableTitle(context.l10n)), description: Padding( padding: const EdgeInsets.all(8), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text(ShareLinkCopy.disableDescription), + Text(ShareLinkCopy.disableDescription(context.l10n)), if (anyone) ...[ const SizedBox(height: 14), ShadCheckbox( @@ -978,6 +992,7 @@ class _DisableLinkDialogState extends State<_DisableLinkDialog> { onChanged: (value) => setState(() => _removeJoined = value), label: Text( ShareLinkCopy.removeJoinedLabel( + context.l10n, joins.joined, more: joins.more, ), @@ -986,7 +1001,12 @@ class _DisableLinkDialogState extends State<_DisableLinkDialog> { ), ), sublabel: Text( - '${joins.names.join(', ')}${others ? ' and others' : ''}', + others + ? context.l10n.shareLinksJoinedNamesAndOthers( + joins.names + .join(context.l10n.shareLinksNameSeparator), + ) + : joins.names.join(context.l10n.shareLinksNameSeparator), style: theme.textTheme.small.copyWith( color: theme.colorScheme.mutedForeground, fontSize: 12, @@ -1002,12 +1022,12 @@ class _DisableLinkDialogState extends State<_DisableLinkDialog> { ShadButton.secondary( key: const ValueKey('share-link-disable-cancel'), onPressed: () => Navigator.of(context).pop(), - child: const Text('Cancel'), + child: Text(context.l10n.commonCancel), ), ShadButton.destructive( key: const ValueKey('share-link-disable-confirm'), onPressed: () => Navigator.of(context).pop(anyone && _removeJoined), - child: const Text(ShareLinkCopy.disableAction), + child: Text(ShareLinkCopy.disableAction(context.l10n)), ), ], ); @@ -1031,13 +1051,15 @@ class _LinkKindBadge extends StatelessWidget { background = theme.colorScheme.destructive.withValues(alpha: 0.14); foreground = theme.colorScheme.destructive; icon = null; - label = ShareLinkCopy.disabledStatus; + label = ShareLinkCopy.disabledStatus(context.l10n); } else { // Neutral for both kinds: violet marks actions, and a badge is not one. background = theme.colorScheme.muted; foreground = theme.colorScheme.mutedForeground; icon = role == 'editor' ? LucideIcons.pencil : LucideIcons.eye; - label = role == 'editor' ? 'Edit' : 'View'; + label = role == 'editor' + ? context.l10n.shareLinksEditBadge + : context.l10n.shareLinksViewBadge; } return Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3), @@ -1096,11 +1118,15 @@ class _ShareLinkTileState extends State<_ShareLinkTile> { final link = widget.link; final url = buildIcarusShareLink(link.token, origin: currentShareOrigin()); final joins = widget.joins; - final joined = widget.removing - ? ' · Removing people…' + final l10n = context.l10n; + final created = _relativeTime(l10n, link.createdAt); + final createdLine = widget.removing + ? l10n.shareLinksCreatedRemoving(created) : joins.joined == 0 - ? '' - : ' · ${joins.joined}${joins.more ? '+' : ''} joined'; + ? l10n.shareLinksCreated(created) + : joins.more + ? l10n.shareLinksCreatedJoinedMore(created, joins.joined) + : l10n.shareLinksCreatedJoined(created, joins.joined); return MouseRegion( onEnter: (_) => setState(() => _hovered = true), @@ -1150,7 +1176,7 @@ class _ShareLinkTileState extends State<_ShareLinkTile> { ), const SizedBox(height: 2), Text( - 'Created ${_relativeTime(link.createdAt)}$joined', + createdLine, style: theme.textTheme.small.copyWith( color: theme.colorScheme.mutedForeground, fontSize: 11, @@ -1163,24 +1189,24 @@ class _ShareLinkTileState extends State<_ShareLinkTile> { const SizedBox(width: 8), _CopyIconButton( buttonKey: ValueKey('share-link-copy-link-${link.token}'), - tooltip: 'Copy link', + tooltip: l10n.shareLinksCopyLinkTooltip, icon: LucideIcons.link, enabled: !link.isRevoked, textToCopy: url, - toastMessage: 'Share link copied to clipboard.', + toastMessage: l10n.shareLinksLinkCopied, ), _CopyIconButton( buttonKey: ValueKey('share-link-copy-code-${link.token}'), - tooltip: 'Copy code', + tooltip: l10n.shareLinksCopyCodeTooltip, icon: LucideIcons.hash, enabled: !link.isRevoked, textToCopy: link.token, - toastMessage: 'Share code copied to clipboard.', + toastMessage: l10n.shareLinksCodeCopied, ), Semantics( label: link.isRevoked - ? ShareLinkCopy.disabledTooltip - : ShareLinkCopy.disableTooltip, + ? ShareLinkCopy.disabledTooltip(l10n) + : ShareLinkCopy.disableTooltip(l10n), button: true, enabled: !link.isRevoked && !widget.isDisabling, onTap: link.isRevoked || widget.isDisabling @@ -1189,8 +1215,8 @@ class _ShareLinkTileState extends State<_ShareLinkTile> { excludeSemantics: true, child: Tooltip( message: link.isRevoked - ? ShareLinkCopy.disabledTooltip - : ShareLinkCopy.disableTooltip, + ? ShareLinkCopy.disabledTooltip(l10n) + : ShareLinkCopy.disableTooltip(l10n), child: ShadButton.ghost( key: ValueKey('share-link-disable-${link.token}'), size: ShadButtonSize.sm, @@ -1442,11 +1468,12 @@ class _AddSharedItemDialogState extends ConsumerState { _controller.text, currentOrigin: currentShareOrigin(), ); + final l10n = context.l10n; if (token == null || token.isEmpty) { setState(() { _errorText = _controller.text.trim().isEmpty - ? 'Paste a share link or code first.' - : "That doesn't look like an Icarus share link or code."; + ? l10n.addSharedItemEmpty + : l10n.addSharedItemInvalid; }); return; } @@ -1462,8 +1489,7 @@ class _AddSharedItemDialogState extends ConsumerState { Navigator.of(context).pop(); } else { setState(() { - _errorText = "Couldn't add that item. The link may be revoked, " - 'or you may need to sign in.'; + _errorText = l10n.addSharedItemFailed; }); } } @@ -1472,17 +1498,17 @@ class _AddSharedItemDialogState extends ConsumerState { Widget build(BuildContext context) { final theme = ShadTheme.of(context); final hasError = _errorText != null; + final l10n = context.l10n; return ShadDialog( - title: const Text('Add Shared Item'), - description: const Text( - 'Paste an Icarus share link or enter a share code to add it to ' - 'Shared with Me.', + title: Text(l10n.addSharedItemTitle), + description: Text( + l10n.addSharedItemDescription, ), actions: [ ShadButton.secondary( onPressed: () => Navigator.of(context).pop(), - child: const Text('Cancel'), + child: Text(l10n.commonCancel), ), ShadButton( onPressed: _isSubmitting ? null : _submit, @@ -1496,7 +1522,8 @@ class _AddSharedItemDialogState extends ConsumerState { ), ) : null, - child: Text(_isSubmitting ? 'Adding…' : 'Add'), + child: Text( + _isSubmitting ? l10n.addSharedItemAdding : l10n.addSharedItemAdd), ), ], child: Column( @@ -1508,7 +1535,7 @@ class _AddSharedItemDialogState extends ConsumerState { controller: _controller, autofocus: true, placeholder: Text( - '${currentShareOrigin()}/share/… or ICR-XXXX-XXXX-XXXX-XXXX', + l10n.addSharedItemPlaceholder(currentShareOrigin().toString()), ), onSubmitted: (_) => _submit(), onChanged: (_) { @@ -1579,12 +1606,12 @@ Future leaveSharedItem({ required String targetPublicId, required String name, }) async { + final l10n = context.l10n; final confirmed = await ConfirmAlertDialog.show( context: context, - title: 'Leave "${name.replaceAll('"', "'")}"?', - content: 'You lose access, and it leaves your Shared tab. You can only ' - 'get it back through a link from its owner.', - confirmText: 'Leave', + title: l10n.leaveSharedTitle(name.replaceAll('"', "'")), + content: l10n.leaveSharedDescription, + confirmText: l10n.commonLeave, isDestructive: true, ); if (!confirmed) return; @@ -1596,7 +1623,7 @@ Future leaveSharedItem({ ProductMetrics.instance.shareMembership('left'); } catch (_) { Settings.showToast( - message: "Couldn't leave it. Check your connection and try again.", + message: l10n.leaveSharedFailed, backgroundColor: Settings.tacticalVioletTheme.destructive, ); } diff --git a/lib/widgets/dialogs/strategy/create_strategy_dialog.dart b/lib/widgets/dialogs/strategy/create_strategy_dialog.dart index 31d19a83e..5fabf0e11 100644 --- a/lib/widgets/dialogs/strategy/create_strategy_dialog.dart +++ b/lib/widgets/dialogs/strategy/create_strategy_dialog.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/maps.dart'; import 'package:icarus/const/settings.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; const double _tileWidth = 220; @@ -57,7 +58,7 @@ class _CreateStrategyDialogState extends ConsumerState { Widget build(BuildContext context) { final theme = ShadTheme.of(context); return ShadDialog( - title: const Text('Pick a map'), + title: Text(context.l10n.createStrategyPickMap), // Shad caps dialogs at 512; the grid plus padding needs more. constraints: const BoxConstraints(maxWidth: _gridWidth + 48), child: Material( @@ -85,7 +86,7 @@ class _CreateStrategyDialogState extends ConsumerState { child: const Icon(LucideIcons.chevronDown, size: 14), ), child: Text( - 'Out of rotation', + context.l10n.createStrategyOutOfRotation, style: theme.textTheme.small .copyWith(color: theme.colorScheme.mutedForeground), ), @@ -178,7 +179,7 @@ class _MapCard extends StatelessWidget { opacity: quiet ? .5 : 1, child: Center( child: Text( - Maps.displayName(map).toUpperCase(), + context.l10n.mapName(map).toUpperCase(), style: const TextStyle( fontWeight: FontWeight.bold, fontSize: 16, diff --git a/lib/widgets/dialogs/strategy/delete_strategy_alert_dialog.dart b/lib/widgets/dialogs/strategy/delete_strategy_alert_dialog.dart index d8407aa99..e215f5de0 100644 --- a/lib/widgets/dialogs/strategy/delete_strategy_alert_dialog.dart +++ b/lib/widgets/dialogs/strategy/delete_strategy_alert_dialog.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/settings.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/services/app_error_reporter.dart'; import 'package:icarus/services/cloud_library_action.dart'; @@ -30,6 +31,7 @@ class _DeleteStrategyAlertDialogState Future _delete() async { if (_isDeleting) return; + final l10n = context.l10n; setState(() { _isDeleting = true; _failureMessage = null; @@ -51,7 +53,7 @@ class _DeleteStrategyAlertDialogState ); if (mounted) { setState(() { - _failureMessage = "Couldn't delete this strategy. Try again."; + _failureMessage = l10n.deleteStrategyFailed; }); } } finally { @@ -63,23 +65,18 @@ class _DeleteStrategyAlertDialogState return; } if (result != null) { - setState(() => _failureMessage = - result!.userMessage ?? "Couldn't delete this strategy. Try again."); + setState(() => + _failureMessage = result!.userMessage ?? l10n.deleteStrategyFailed); } } @override Widget build(BuildContext context) { + final l10n = context.l10n; return ShadDialog.alert( title: Text.rich( TextSpan( - children: [ - const TextSpan(text: "Delete "), - TextSpan( - text: widget.name, - style: const TextStyle(fontWeight: FontWeight.bold), - ), - ], + children: _withBoldName(l10n.deleteStrategyTitle(_nameMarker)), ), ), description: Column( @@ -88,14 +85,7 @@ class _DeleteStrategyAlertDialogState children: [ Text.rich( TextSpan( - children: [ - const TextSpan(text: "Are you sure you want to delete "), - TextSpan( - text: widget.name, - style: const TextStyle(fontWeight: FontWeight.bold), - ), - const TextSpan(text: "? This action cannot be undone."), - ], + children: _withBoldName(l10n.deleteStrategyConfirm(_nameMarker)), ), ), if (_failureMessage != null) ...[ @@ -113,8 +103,8 @@ class _DeleteStrategyAlertDialogState actions: [ ShadButton.secondary( onPressed: _isDeleting ? null : () => Navigator.of(context).pop(), - child: const Text( - "Cancel", + child: Text( + l10n.commonCancel, ), ), ShadButton.destructive( @@ -129,7 +119,7 @@ class _DeleteStrategyAlertDialogState ), const SizedBox(width: 5), Text( - _isDeleting ? 'Deleting...' : 'Delete', + _isDeleting ? l10n.deleteStrategyDeleting : l10n.commonDelete, style: TextStyle( color: Settings.tacticalVioletTheme.destructiveForeground, ), @@ -140,4 +130,21 @@ class _DeleteStrategyAlertDialogState ], ); } + + static const _nameMarker = '\u{E000}'; + + /// [message] with the strategy's name, in bold, where [_nameMarker] is. + List _withBoldName(String message) { + final parts = message.split(_nameMarker); + return [ + for (var i = 0; i < parts.length; i++) ...[ + if (i > 0) + TextSpan( + text: widget.name, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + if (parts[i].isNotEmpty) TextSpan(text: parts[i]), + ], + ]; + } } diff --git a/lib/widgets/dialogs/strategy/line_up_media_page.dart b/lib/widgets/dialogs/strategy/line_up_media_page.dart index 9aa3ff57a..49944fcc4 100644 --- a/lib/widgets/dialogs/strategy/line_up_media_page.dart +++ b/lib/widgets/dialogs/strategy/line_up_media_page.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/line_provider.dart'; import 'package:icarus/const/settings.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/strategy_image_source.dart'; import 'package:icarus/widgets/custom_text_field.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; @@ -51,35 +52,35 @@ class _LineupMediaPageState extends ConsumerState { @override Widget build(BuildContext context) { + final l10n = context.l10n; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (widget.header != null) widget.header!, if (widget.nameController != null) ...[ Text( - "Name", + l10n.lineupMediaName, style: TextStyle(color: Settings.tacticalVioletTheme.foreground), ), const SizedBox(height: 8), CustomTextField( controller: widget.nameController!, - hintText: - "Optional, for telling this apart from other lineups here", + hintText: l10n.lineupMediaNameHint, ), const SizedBox(height: 24), ], Text( - "Youtube link", + l10n.lineupMediaYoutubeLink, style: TextStyle(color: Settings.tacticalVioletTheme.foreground), ), const SizedBox(height: 8), CustomTextField( controller: widget.youtubeLinkController, - hintText: "Paste YouTube link here...", + hintText: l10n.lineupMediaYoutubeLinkHint, ), const SizedBox(height: 24), Text( - "Images", + l10n.lineupMediaImages, style: TextStyle(color: Settings.tacticalVioletTheme.foreground), ), const SizedBox(height: 8), @@ -98,12 +99,12 @@ class _LineupMediaPageState extends ConsumerState { Padding( padding: const EdgeInsets.symmetric(vertical: 8.0), child: Text( - "Notes", + l10n.lineupMediaNotes, style: TextStyle(color: Settings.tacticalVioletTheme.foreground), ), ), CustomTextField( - hintText: "Add any notes here...", + hintText: l10n.lineupMediaNotesHint, controller: widget.notesController, // onEnterPressed: (intent) { // // Handle enter pressed if needed @@ -131,7 +132,7 @@ class _LineupMediaPageState extends ConsumerState { ), const SizedBox(height: 8), Text( - "Click here to add images", + context.l10n.lineupMediaAddImages, style: TextStyle( color: Settings.tacticalVioletTheme.cardForeground), ), @@ -216,7 +217,7 @@ class _LineupMediaPageState extends ConsumerState { _pasteGlyph(), const SizedBox(height: 4), Text( - "Paste", + context.l10n.lineupMediaPaste, style: TextStyle( color: Settings.tacticalVioletTheme.secondaryForeground, fontSize: 12, @@ -247,7 +248,7 @@ class _LineupMediaPageState extends ConsumerState { _pasteGlyph(size: 16), const SizedBox(width: 6), Text( - "Paste from clipboard", + context.l10n.lineupMediaPasteFromClipboard, style: TextStyle( color: Settings.tacticalVioletTheme.secondaryForeground, ), diff --git a/lib/widgets/dialogs/strategy/rename_strategy_dialog.dart b/lib/widgets/dialogs/strategy/rename_strategy_dialog.dart index 6db6cf035..074dac1a1 100644 --- a/lib/widgets/dialogs/strategy/rename_strategy_dialog.dart +++ b/lib/widgets/dialogs/strategy/rename_strategy_dialog.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/settings.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/strategy/strategy_page_models.dart'; import 'package:icarus/widgets/custom_text_field.dart'; @@ -40,22 +41,21 @@ class _RenameStrategyDialogState extends ConsumerState { @override Widget build(BuildContext context) { + final l10n = context.l10n; return ShadDialog( - title: const Text("Rename Strategy"), + title: Text(l10n.renameStrategyTitle), actions: [ ShadButton.secondary( onPressed: () { Navigator.of(context).pop(); // Close the dialog }, - child: const Text("Cancel"), + child: Text(l10n.commonCancel), ), ShadButton( onPressed: () async { final strategyName = _textController.text; if (strategyName.isNotEmpty) { - await ref - .read(strategyProvider.notifier) - .renameStrategy( + await ref.read(strategyProvider.notifier).renameStrategy( widget.strategyId, strategyName, source: widget.source, @@ -65,14 +65,14 @@ class _RenameStrategyDialogState extends ConsumerState { } else { // Optionally, show an error message if the name is empty Settings.showToast( - message: "Strategy name cannot be empty.", + message: l10n.renameStrategyEmptyName, backgroundColor: Settings.tacticalVioletTheme.destructive, ); } }, height: 35, leading: const Icon(LucideIcons.pencil), - child: const Text("Rename"), + child: Text(l10n.commonRename), ), ], child: Padding( @@ -84,9 +84,7 @@ class _RenameStrategyDialogState extends ConsumerState { textAlign: TextAlign.start, onSubmitted: (value) async { if (value.isNotEmpty) { - await ref - .read(strategyProvider.notifier) - .renameStrategy( + await ref.read(strategyProvider.notifier).renameStrategy( widget.strategyId, value, source: widget.source, @@ -98,7 +96,7 @@ class _RenameStrategyDialogState extends ConsumerState { // Optionally, show an error message if the name is empty Settings.showToast( - message: "Strategy name cannot be empty.", + message: l10n.renameStrategyEmptyName, backgroundColor: Settings.tacticalVioletTheme.destructive, ); } diff --git a/lib/widgets/dialogs/upload_image_dialog.dart b/lib/widgets/dialogs/upload_image_dialog.dart index a6b00110e..f95e02052 100644 --- a/lib/widgets/dialogs/upload_image_dialog.dart +++ b/lib/widgets/dialogs/upload_image_dialog.dart @@ -6,6 +6,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/config/platform_policy.dart'; import 'package:icarus/const/settings.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/image_provider.dart'; import 'package:icarus/services/clipboard_service.dart'; import 'package:icarus/services/halved_image.dart'; @@ -73,7 +74,7 @@ class _UploadImageDialogState extends ConsumerState { } on ClipboardBusyException { if (mounted) { Settings.showToast( - message: ClipboardService.busyMessage, + message: ClipboardService.busyMessage(context.l10n), backgroundColor: Settings.tacticalVioletTheme.destructive, ); } @@ -140,7 +141,7 @@ class _UploadImageDialogState extends ConsumerState { if (!mounted) return; setState(() => _isDragging = false); Settings.showToast( - message: 'Unsupported image format', + message: context.l10n.uploadImageUnsupportedFormat, backgroundColor: Settings.tacticalVioletTheme.destructive, ); return; @@ -153,7 +154,7 @@ class _UploadImageDialogState extends ConsumerState { if (detectedExtension == null) { setState(() => _isDragging = false); Settings.showToast( - message: 'Unsupported image format', + message: context.l10n.uploadImageUnsupportedFormat, backgroundColor: Settings.tacticalVioletTheme.destructive, ); return; @@ -173,6 +174,7 @@ class _UploadImageDialogState extends ConsumerState { final bool hasSelection = _selectedBytes != null; final policy = ref.watch(platformPolicyProvider); final canDropFiles = policy.supports(PlatformFeature.fileDrop); + final l10n = context.l10n; Widget content = _UploadDropSquare( isDragging: _isDragging, @@ -204,16 +206,16 @@ class _UploadImageDialogState extends ConsumerState { } return ShadDialog( - title: const Text('Upload image'), + title: Text(l10n.uploadImageTitle), description: Text( canDropFiles - ? 'Drop an image here or click to choose a file.' - : 'Click to choose a file.', + ? l10n.uploadImageDescriptionDrop + : l10n.uploadImageDescriptionClick, ), actions: [ ShadButton.secondary( onPressed: () => Navigator.of(context).pop(null), - child: const Text('Cancel'), + child: Text(l10n.commonCancel), ), ShadIconButton.secondary( icon: const Icon(LucideIcons.clipboard), @@ -230,7 +232,7 @@ class _UploadImageDialogState extends ConsumerState { ), ) : null, - child: const Text('Use image'), + child: Text(l10n.uploadImageUseImage), ), ], child: ConstrainedBox( @@ -245,14 +247,15 @@ class _UploadImageDialogState extends ConsumerState { const SizedBox(height: 12), Text( canDropFiles - ? 'Tip: You can also drag & drop an image from your desktop.' - : policy.unavailableMessage(PlatformFeature.fileDrop)!, + ? l10n.uploadImageDragTip + : policy.unavailableMessage( + l10n, PlatformFeature.fileDrop)!, style: Theme.of(context).textTheme.bodySmall?.copyWith( color: cs.onSurfaceVariant, ), ), const SizedBox(height: 12), - const Text('Tag color'), + Text(l10n.uploadImageTagColor), const SizedBox(height: 6), ColorLibrary( includeEmpty: true, @@ -393,7 +396,9 @@ class _EmptyState extends StatelessWidget { ), const SizedBox(height: 10), Text( - isDragging ? 'Drop to upload' : 'Drop or click to upload', + isDragging + ? context.l10n.uploadImageDropToUpload + : context.l10n.uploadImageDropOrClick, textAlign: TextAlign.center, style: headline?.copyWith( color: isDragging ? cs.primary : cs.onSurface, @@ -418,7 +423,7 @@ class _EmptyState extends StatelessWidget { ), const SizedBox(height: 6), Text( - 'Checking clipboard…', + context.l10n.uploadImageCheckingClipboard, style: body?.copyWith(color: cs.onSurfaceVariant), ), ], @@ -460,7 +465,7 @@ class _SelectionFooter extends StatelessWidget { const SizedBox(width: 8), Expanded( child: Text( - fileName ?? 'Selected image', + fileName ?? context.l10n.uploadImageSelectedImage, maxLines: 1, overflow: TextOverflow.ellipsis, style: textStyle, @@ -470,7 +475,7 @@ class _SelectionFooter extends StatelessWidget { ShadButton.outline( size: ShadButtonSize.sm, onPressed: onClear, - child: const Text('Clear'), + child: Text(context.l10n.commonClear), ), ], ), diff --git a/lib/widgets/dialogs/web_beta_dialog.dart b/lib/widgets/dialogs/web_beta_dialog.dart index 5af2ffbc3..453f84004 100644 --- a/lib/widgets/dialogs/web_beta_dialog.dart +++ b/lib/widgets/dialogs/web_beta_dialog.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/config/platform_policy.dart'; import 'package:icarus/const/settings.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:url_launcher/url_launcher.dart' show launchUrl; @@ -14,23 +15,23 @@ class WebBetaDialog extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final policy = ref.watch(platformPolicyProvider); + final l10n = context.l10n; return ShadDialog( key: const ValueKey('web-beta-dialog'), - title: const Text('Icarus web beta'), - description: const Text( - 'Strategies you make here are saved to your Icarus account and open ' - 'in the desktop app too.', + title: Text(l10n.webBetaTitle), + description: Text( + l10n.webBetaDescription, ), actions: [ ShadButton.secondary( onPressed: () => Navigator.of(context).pop(), - child: const Text('Close'), + child: Text(l10n.commonClose), ), ShadButton( key: const ValueKey('web-beta-download'), leading: const Icon(LucideIcons.download, size: 16), onPressed: () => launchUrl(Settings.stableWindowsInstallerLink), - child: const Text('Download for Windows'), + child: Text(l10n.webBetaDownloadForWindows), ), ], child: SizedBox( @@ -43,7 +44,7 @@ class WebBetaDialog extends ConsumerWidget { children: [ if (policy.desktopOnly.isNotEmpty) _FeatureList( - heading: 'Desktop-only for now', + heading: l10n.webBetaDesktopOnly, features: policy.desktopOnly, ), if (policy.desktopOnly.isNotEmpty && @@ -51,7 +52,7 @@ class WebBetaDialog extends ConsumerWidget { const SizedBox(height: 12), if (policy.comingToWebBeta.isNotEmpty) _FeatureList( - heading: 'Coming to the web beta', + heading: l10n.webBetaComing, features: policy.comingToWebBeta, ), ], @@ -84,7 +85,8 @@ class _FeatureList extends StatelessWidget { ), const SizedBox(height: 4), Text( - [for (final feature in features) feature.label].join(' · '), + [for (final feature in features) feature.label(context.l10n)] + .join(' · '), style: TextStyle(fontSize: 14, color: theme.foreground), ), ], diff --git a/lib/widgets/dialogs/web_view_dialog.dart b/lib/widgets/dialogs/web_view_dialog.dart index c1eca1416..47664965c 100644 --- a/lib/widgets/dialogs/web_view_dialog.dart +++ b/lib/widgets/dialogs/web_view_dialog.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -7,19 +8,19 @@ class WebViewDialog extends StatelessWidget { @override Widget build(BuildContext context) { + final l10n = context.l10n; return ShadDialog.alert( - title: const Text('WebView'), - description: const Text( - 'WebView is not installed on your system. Please install it to use youtube videos.'), + title: Text(l10n.webViewTitle), + description: Text(l10n.webViewNotInstalled), actions: [ ShadButton.secondary( - child: const Text('Close'), + child: Text(l10n.commonClose), onPressed: () { Navigator.of(context).pop(); }, ), ShadButton( - child: const Text('Install'), + child: Text(l10n.webViewInstall), onPressed: () { launchUrl(Uri.parse( 'https://developer.microsoft.com/en-us/microsoft-edge/webview2/?form=MA13LH')); diff --git a/lib/widgets/dialogs/welcome_tour/welcome_tour_dialog.dart b/lib/widgets/dialogs/welcome_tour/welcome_tour_dialog.dart index 2d3274f6b..50272aee0 100644 --- a/lib/widgets/dialogs/welcome_tour/welcome_tour_dialog.dart +++ b/lib/widgets/dialogs/welcome_tour/welcome_tour_dialog.dart @@ -7,6 +7,7 @@ import 'package:icarus/config/platform_policy.dart'; import 'package:icarus/const/hive_boxes.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/domain/folder.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/auth_provider.dart'; import 'package:icarus/providers/desktop_update_provider.dart'; import 'package:icarus/providers/library_navigation_provider.dart'; @@ -18,31 +19,6 @@ import 'package:icarus/widgets/dialogs/auth/auth_dialog.dart'; import 'package:icarus/widgets/dialogs/welcome_tour/welcome_tour_pictures.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; -/// Everything the welcome tour says. `**word**` marks a word drawn in the -/// foreground color, the name of something the user will see on screen. -abstract final class WelcomeTourCopy { - static const kicker = 'New in this update'; - - static const libraryTitle = 'Your library is still here'; - static const libraryBody = - 'Everything you made before this update is saved on this computer, ' - 'exactly as it was. Nothing moves unless you move it.'; - - static const cloudTitle = 'Sign in to sync and share'; - static const cloudBody = - 'Strategies you make while signed in are saved to your account, so ' - 'they open on any computer and you can share them with your team. To ' - 'bring an older one along, right-click it and choose **Move to cloud**.'; - - // What the release promises about replays. Icarus reads replays through - // VALORANT patch 13.08 except 13.07, and each new patch needs an update, - // so this is the copy most likely to change before the release ships. - static const replaysTitle = 'Turn your matches into strategies'; - static const replaysBody = - 'The **Replays** tab lists matches you downloaded in VALORANT. Pause on ' - 'any moment and press **Capture** to save it as a strategy page.'; -} - /// Shows the welcome tour once per install, the first time the library is /// on screen. Desktop only: the web beta has no local library to reassure /// about and no replays. It waits its turn: a launch that opens a file or a @@ -183,7 +159,7 @@ class _WelcomeTourDialogState extends ConsumerState { hoverForegroundColor: Settings.tacticalVioletTheme.foreground, onPressed: () => _goTo(_index - 1), - child: const Text('Back'), + child: Text(context.l10n.welcomeTourBack), ), const SizedBox(width: 8), ], @@ -197,6 +173,7 @@ class _WelcomeTourDialogState extends ConsumerState { } List _actions(_Step step, {required bool signedIn}) { + final l10n = context.l10n; Widget secondary(String label, VoidCallback onPressed) => Padding( padding: const EdgeInsets.only(right: 8), child: ShadButton.secondary( @@ -210,15 +187,15 @@ class _WelcomeTourDialogState extends ConsumerState { ); return switch (step) { - _Step.library => [primary('Next', _next)], - _Step.cloud when signedIn => [primary('Next', _next)], + _Step.library => [primary(l10n.welcomeTourNext, _next)], + _Step.cloud when signedIn => [primary(l10n.welcomeTourNext, _next)], _Step.cloud => [ - secondary('Not now', _next), - primary('Sign in', _signIn), + secondary(l10n.welcomeTourNotNow, _next), + primary(l10n.welcomeTourSignIn, _signIn), ], _Step.replays => [ - secondary('Done', () => Navigator.of(context).pop()), - primary('Open Replays', _openReplays), + secondary(l10n.commonDone, () => Navigator.of(context).pop()), + primary(l10n.welcomeTourOpenReplays, _openReplays), ], }; } @@ -231,21 +208,22 @@ class _StepPage extends StatelessWidget { @override Widget build(BuildContext context) { + final l10n = context.l10n; final (picture, title, body) = switch (step) { _Step.library => ( const WelcomeLibraryPicture(), - WelcomeTourCopy.libraryTitle, - WelcomeTourCopy.libraryBody, + l10n.welcomeTourLibraryTitle, + l10n.welcomeTourLibraryBody, ), _Step.cloud => ( const WelcomeCloudPicture(), - WelcomeTourCopy.cloudTitle, - WelcomeTourCopy.cloudBody, + l10n.welcomeTourCloudTitle, + l10n.welcomeTourCloudBody, ), _Step.replays => ( const WelcomeReplayPicture(), - WelcomeTourCopy.replaysTitle, - WelcomeTourCopy.replaysBody, + l10n.welcomeTourReplaysTitle, + l10n.welcomeTourReplaysBody, ), }; const theme = Settings.tacticalVioletTheme; @@ -265,7 +243,7 @@ class _StepPage extends StatelessWidget { picture, const SizedBox(height: 18), Text( - WelcomeTourCopy.kicker, + l10n.welcomeTourKicker, style: TextStyle( fontSize: 12, fontWeight: FontWeight.w600, @@ -319,7 +297,7 @@ class _StepDots extends StatelessWidget { Semantics( button: true, selected: i == current, - label: 'Step ${i + 1} of $count', + label: context.l10n.welcomeTourStepLabel(i + 1, count), child: MouseRegion( cursor: SystemMouseCursors.click, child: GestureDetector( diff --git a/lib/widgets/dialogs/welcome_tour/welcome_tour_pictures.dart b/lib/widgets/dialogs/welcome_tour/welcome_tour_pictures.dart index 6abd4f3a1..893104c4e 100644 --- a/lib/widgets/dialogs/welcome_tour/welcome_tour_pictures.dart +++ b/lib/widgets/dialogs/welcome_tour/welcome_tour_pictures.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:icarus/const/agents.dart'; import 'package:icarus/const/maps.dart'; import 'package:icarus/const/settings.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/widgets/dot_painter.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; @@ -18,30 +19,31 @@ class WelcomeLibraryPicture extends StatelessWidget { @override Widget build(BuildContext context) { - return const _Stage( + final l10n = context.l10n; + return _Stage( child: Center( child: Row( mainAxisSize: MainAxisSize.min, children: [ _MiniTile( - name: 'A site execute', + name: l10n.welcomeTourPictureASiteExecute, map: MapValue.ascent, icon: LucideIcons.monitor, - meta: 'On this device', + meta: l10n.welcomeTourPictureOnThisDevice, ), - SizedBox(width: 10), + const SizedBox(width: 10), _MiniTile( - name: 'B retake vs Viper', + name: l10n.welcomeTourPictureBRetake, map: MapValue.lotus, icon: LucideIcons.monitor, - meta: 'On this device', + meta: l10n.welcomeTourPictureOnThisDevice, ), - SizedBox(width: 10), + const SizedBox(width: 10), _MiniTile( - name: 'Pistol defaults', + name: l10n.welcomeTourPicturePistolDefaults, map: MapValue.haven, icon: LucideIcons.monitor, - meta: 'On this device', + meta: l10n.welcomeTourPictureOnThisDevice, ), ], ), @@ -57,7 +59,8 @@ class WelcomeCloudPicture extends StatelessWidget { @override Widget build(BuildContext context) { - return const _Stage( + final l10n = context.l10n; + return _Stage( child: Stack( children: [ Center( @@ -68,24 +71,24 @@ class WelcomeCloudPicture extends StatelessWidget { Opacity( opacity: 0.45, child: _MiniTile( - name: 'A site execute', + name: l10n.welcomeTourPictureASiteExecute, map: MapValue.ascent, icon: LucideIcons.cloudUpload, - meta: 'Moving…', + meta: l10n.welcomeTourPictureMoving, ), ), - SizedBox(width: 10), + const SizedBox(width: 10), _MiniTile( - name: 'Retake drills', + name: l10n.welcomeTourPictureRetakeDrills, map: MapValue.pearl, icon: LucideIcons.cloud, - meta: 'In your account', + meta: l10n.welcomeTourPictureInYourAccount, ), ], ), ), - Positioned(left: 150, top: 126, child: _MiniMenu()), - Positioned( + const Positioned(left: 150, top: 126, child: _MiniMenu()), + const Positioned( right: 26, top: 20, child: _Teammates( @@ -132,12 +135,14 @@ class WelcomeReplayPicture extends StatelessWidget { children: [ Row( children: [ - const Expanded(child: _TileName('Lotus')), + Expanded( + child: _TileName(context.l10n.mapName(MapValue.lotus)), + ), Text('31:11', style: muted), ], ), const SizedBox(height: 2), - Text('Tue, Oct 13', style: muted), + Text(context.l10n.welcomeTourPictureReplayDate, style: muted), const SizedBox(height: 6), // Ten players spread across the tile, about 3px apart. Row( @@ -323,14 +328,17 @@ class _MiniMenu extends StatelessWidget { boxShadow: const [Settings.floatingMenuShadow], ), // As wide as its longest item, so the highlight spans the menu. - child: const IntrinsicWidth( + child: IntrinsicWidth( child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - _MiniMenuItem(icon: LucideIcons.link, label: 'Share'), + _MiniMenuItem( + icon: LucideIcons.link, + label: context.l10n.commonShare, + ), _MiniMenuItem( icon: LucideIcons.cloudUpload, - label: 'Move to cloud', + label: context.l10n.welcomeTourPictureMoveToCloud, highlighted: true, ), ], @@ -453,7 +461,7 @@ class _CaptureButton extends StatelessWidget { Icon(LucideIcons.camera, size: 13, color: ink), const SizedBox(width: 6), Text( - 'Capture', + context.l10n.welcomeTourPictureCapture, style: TextStyle( fontSize: 12, fontWeight: FontWeight.w600, diff --git a/lib/widgets/draggable_widgets/ability/ability_visibility_context_menu.dart b/lib/widgets/draggable_widgets/ability/ability_visibility_context_menu.dart index 3f9772fa0..d2d722e80 100644 --- a/lib/widgets/draggable_widgets/ability/ability_visibility_context_menu.dart +++ b/lib/widgets/draggable_widgets/ability/ability_visibility_context_menu.dart @@ -7,6 +7,7 @@ import 'package:icarus/const/line_provider.dart'; import 'package:icarus/const/maps.dart'; import 'package:icarus/const/placed_classes.dart'; import 'package:icarus/const/settings.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/ability_bar_provider.dart'; import 'package:icarus/providers/ability_provider.dart'; import 'package:icarus/providers/interaction_state_provider.dart'; @@ -92,13 +93,15 @@ List _buildWallItems(WidgetRef ref, PlacedAbility ability) final data = ability.data.abilityData; if (data is! GuidedWallAbility) return const []; final hasWall = ability.wallPath?.isNotEmpty ?? false; + final l10n = ref.read(l10nProvider); return [ ShadContextMenuItem( leading: Icon( hasWall ? LucideIcons.eraser : LucideIcons.waves, size: 16, ), - child: Text(hasWall ? 'Remove wall' : 'Draw wall'), + child: Text( + hasWall ? l10n.abilityMenuRemoveWall : l10n.abilityMenuDrawWall), onPressed: () { final map = ref.read(mapProvider); ref.read(abilityProvider.notifier).setWallPath( @@ -126,11 +129,12 @@ List buildLandingLineUpMenuItems( final state = ref.read(lineUpProvider); final links = state.linksToLanding(landingId); final landing = state.landingById(landingId); + final l10n = ref.read(l10nProvider); return [ ShadContextMenuItem( leading: const Icon(LucideIcons.plus, size: 16), - child: const Text('Add lineup here'), + child: Text(l10n.abilityMenuAddLineupHere), onPressed: () { if (landing == null) return; if (!ensureFeatureAvailable(ref, PlatformFeature.addLineups)) return; @@ -145,7 +149,9 @@ List buildLandingLineUpMenuItems( ), ShadContextMenuItem( leading: const Icon(LucideIcons.pencil, size: 16), - child: Text(links.length > 1 ? 'Show lineups' : 'Edit media'), + child: Text(links.length > 1 + ? l10n.abilityMenuShowLineups + : l10n.abilityMenuEditMedia), onPressed: () { if (context == null) return; if (links.length == 1) { @@ -160,7 +166,7 @@ List buildLandingLineUpMenuItems( ), ShadContextMenuItem( leading: const Icon(LucideIcons.move, size: 16), - child: const Text('Edit placement'), + child: Text(l10n.abilityMenuEditPlacement), onPressed: () { if (links.isEmpty) return; ref @@ -174,7 +180,9 @@ List buildLandingLineUpMenuItems( size: 16, color: Settings.tacticalVioletTheme.destructive, ), - child: Text(links.length > 1 ? 'Delete spot' : 'Delete lineup'), + child: Text(links.length > 1 + ? l10n.abilityMenuDeleteSpot + : l10n.abilityMenuDeleteLineup), onPressed: () { ref.read(lineUpProvider.notifier).deleteLanding(landingId); }, @@ -188,12 +196,13 @@ List _buildVisibilityItems( Ability? abilityData, { String? landingId, }) { + final l10n = ref.read(l10nProvider); final controls = [ - ..._buildVisibilityControls(abilityData, ability.visualState), + ..._buildVisibilityControls(l10n, abilityData, ability.visualState), if (landingId == null && AbilityVisionConeSpec.forAbility(ability.data) != null) _AbilityVisibilityControl( - label: 'Vision cone', + label: l10n.abilityMenuVisionCone, isEnabled: ability.visualState.showVisionCone, toggle: (state) => state.copyWith( showVisionCone: !state.showVisionCone, @@ -217,13 +226,14 @@ List _buildVisibilityItems( } List<_AbilityVisibilityControl> _buildVisibilityControls( + AppLocalizations l10n, Ability? abilityData, AbilityVisualState visualState, ) { if (supportsAbilityInactiveState(abilityData)) { return [ _AbilityVisibilityControl( - label: 'Active', + label: l10n.abilityMenuActive, isEnabled: visualState.showRangeFill, toggle: (state) => state.copyWith( showRangeFill: !state.showRangeFill, @@ -236,21 +246,21 @@ List<_AbilityVisibilityControl> _buildVisibilityControls( if (_hasInnerRange(abilityData)) { return [ _AbilityVisibilityControl( - label: 'Range outline', + label: l10n.abilityMenuRangeOutline, isEnabled: visualState.showRangeOutline, toggle: (state) => state.copyWith( showRangeOutline: !state.showRangeOutline, ), ), _AbilityVisibilityControl( - label: 'Inner outline', + label: l10n.abilityMenuInnerOutline, isEnabled: visualState.showInnerOutline, toggle: (state) => state.copyWith( showInnerOutline: !state.showInnerOutline, ), ), _AbilityVisibilityControl( - label: 'Inner fill', + label: l10n.abilityMenuInnerFill, isEnabled: visualState.showInnerFill, toggle: (state) => state.copyWith( showInnerFill: !state.showInnerFill, @@ -261,7 +271,7 @@ List<_AbilityVisibilityControl> _buildVisibilityControls( return [ _AbilityVisibilityControl( - label: 'Range outline', + label: l10n.abilityMenuRangeOutline, isEnabled: visualState.showRangeOutline, toggle: (state) => state.copyWith( showRangeOutline: !state.showRangeOutline, @@ -269,7 +279,7 @@ List<_AbilityVisibilityControl> _buildVisibilityControls( ), if (_hasVisibleRangeFill(abilityData)) _AbilityVisibilityControl( - label: 'Range fill', + label: l10n.abilityMenuRangeFill, isEnabled: visualState.showRangeFill, toggle: (state) => state.copyWith( showRangeFill: !state.showRangeFill, @@ -281,7 +291,7 @@ List<_AbilityVisibilityControl> _buildVisibilityControls( if (abilityData is DeadlockBarrierMeshAbility) { return [ _AbilityVisibilityControl( - label: 'Mesh', + label: l10n.abilityMenuMesh, isEnabled: visualState.showRangeFill, toggle: (state) => state.copyWith( showRangeFill: !state.showRangeFill, @@ -293,7 +303,7 @@ List<_AbilityVisibilityControl> _buildVisibilityControls( if (abilityData is SquareAbility || abilityData is CenterSquareAbility) { return [ _AbilityVisibilityControl( - label: 'Range', + label: l10n.abilityMenuRange, isEnabled: visualState.showRangeFill, toggle: (state) => state.copyWith( showRangeFill: !state.showRangeFill, diff --git a/lib/widgets/draggable_widgets/ability/lineup_ability_stack_selector.dart b/lib/widgets/draggable_widgets/ability/lineup_ability_stack_selector.dart index 209f4622d..95ca13cfc 100644 --- a/lib/widgets/draggable_widgets/ability/lineup_ability_stack_selector.dart +++ b/lib/widgets/draggable_widgets/ability/lineup_ability_stack_selector.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/line_provider.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/strategy_settings_provider.dart'; import 'package:icarus/widgets/context_menu_region.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; @@ -14,7 +15,7 @@ Future showLineUpAbilityStackSelector({ return showGeneralDialog( context: context, barrierDismissible: true, - barrierLabel: 'Close lineup ability stack selector', + barrierLabel: context.l10n.abilityMenuCloseStackSelectorLabel, barrierColor: Colors.transparent, pageBuilder: (context, animation, secondaryAnimation) { return _LineUpAbilityStackSelectorDialog( @@ -33,7 +34,7 @@ Future showLineUpAbilityContextMenu({ return showGeneralDialog( context: context, barrierDismissible: true, - barrierLabel: 'Close lineup ability context menu', + barrierLabel: context.l10n.abilityMenuCloseContextMenuLabel, barrierColor: Colors.transparent, pageBuilder: (context, animation, secondaryAnimation) { return _LineUpAbilityContextMenuDialog( diff --git a/lib/widgets/draggable_widgets/adjacent_page_copy_menu.dart b/lib/widgets/draggable_widgets/adjacent_page_copy_menu.dart index f4b10dd4e..77ec3419a 100644 --- a/lib/widgets/draggable_widgets/adjacent_page_copy_menu.dart +++ b/lib/widgets/draggable_widgets/adjacent_page_copy_menu.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/const/transition_data.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/strategy_provider.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; @@ -12,6 +13,7 @@ List buildAdjacentPageCopyMenuItems( if (widgetId.isEmpty) return const []; final notifier = ref.read(strategyProvider.notifier); return _copyMenuItems( + ref.read(l10nProvider), notifier.copyDirectionsForPlacedWidget(widgetId), (direction) => notifier.copyPlacedWidgetToAdjacentPage( widgetId: widgetId, @@ -29,6 +31,7 @@ List buildLineUpAdjacentPageCopyMenuItems( if (linkIds.isEmpty) return const []; final notifier = ref.read(strategyProvider.notifier); return _copyMenuItems( + ref.read(l10nProvider), notifier.copyDirectionsForLineUps(linkIds), (direction) => notifier.copyLineUpsToAdjacentPage( linkIds: linkIds, @@ -38,29 +41,28 @@ List buildLineUpAdjacentPageCopyMenuItems( } List _copyMenuItems( + AppLocalizations l10n, List directions, Future Function(PageTransitionDirection direction) copyTo, ) { Future copy(PageTransitionDirection direction) async { final result = await copyTo(direction); - final page = direction == PageTransitionDirection.forward - ? 'next page' - : 'previous page'; switch (result) { case PageCopyResult.alreadyThere: Settings.showToast( - message: 'The $page already has it.', + message: direction == PageTransitionDirection.forward + ? l10n.canvasItemNextPageAlreadyHasIt + : l10n.canvasItemPreviousPageAlreadyHasIt, backgroundColor: Settings.tacticalVioletTheme.primary, ); case PageCopyResult.unreachable: Settings.showToast( - message: "Couldn't reach the cloud, so nothing was copied.", + message: l10n.canvasItemCopyUnreachable, backgroundColor: Settings.tacticalVioletTheme.destructive, ); case PageCopyResult.notSaved: Settings.showToast( - message: "Couldn't save the copy on this device, so nothing was " - 'copied.', + message: l10n.canvasItemCopyNotSaved, backgroundColor: Settings.tacticalVioletTheme.destructive, ); case PageCopyResult.copied || PageCopyResult.unavailable: @@ -72,13 +74,13 @@ List _copyMenuItems( if (directions.contains(PageTransitionDirection.backward)) ShadContextMenuItem( leading: const Icon(LucideIcons.arrowUp, size: 16), - child: const Text('Copy to previous page'), + child: Text(l10n.canvasItemCopyToPreviousPage), onPressed: () => copy(PageTransitionDirection.backward), ), if (directions.contains(PageTransitionDirection.forward)) ShadContextMenuItem( leading: const Icon(LucideIcons.arrowDown, size: 16), - child: const Text('Copy to next page'), + child: Text(l10n.canvasItemCopyToNextPage), onPressed: () => copy(PageTransitionDirection.forward), ), ]; diff --git a/lib/widgets/draggable_widgets/agents/agent_weapon_menu.dart b/lib/widgets/draggable_widgets/agents/agent_weapon_menu.dart index 08f23270f..7a2d7082d 100644 --- a/lib/widgets/draggable_widgets/agents/agent_weapon_menu.dart +++ b/lib/widgets/draggable_widgets/agents/agent_weapon_menu.dart @@ -1,11 +1,13 @@ import 'package:flutter/material.dart'; import 'package:icarus/const/weapons.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/widgets/draggable_widgets/agents/weapon_icon.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; /// One "Weapon" entry whose submenu holds the categories. "None" leads the /// submenu only while a weapon is equipped, since it is a no-op otherwise. List buildAgentWeaponMenu({ + required AppLocalizations l10n, required WeaponType selectedWeapon, required ValueChanged onSelected, }) { @@ -27,7 +29,7 @@ List buildAgentWeaponMenu({ if (equipped) ShadContextMenuItem( onPressed: () => onSelected(WeaponType.none), - child: const Text('None'), + child: Text(l10n.agentMenuNoWeapon), ), for (final category in WeaponCategory.values) ShadContextMenuItem( @@ -38,13 +40,20 @@ List buildAgentWeaponMenu({ leading: WeaponIcon(weapon: weapon, width: 36, height: 20), trailing: selectionMark(weapon), onPressed: () => onSelected(weapon), - child: Text(weapon.displayName), + child: Text(l10n.gameName(weapon.displayName)), ), ], - child: Text(category.label), + child: Text(switch (category) { + WeaponCategory.sidearms => l10n.weaponCategorySidearms, + WeaponCategory.rifles => l10n.weaponCategoryRifles, + WeaponCategory.shotguns => l10n.weaponCategoryShotguns, + WeaponCategory.smgs => l10n.weaponCategorySmgs, + WeaponCategory.snipers => l10n.weaponCategorySnipers, + WeaponCategory.heavies => l10n.weaponCategoryHeavies, + }), ), ], - child: const Text('Weapon'), + child: Text(l10n.agentMenuWeapon), ), ]; } diff --git a/lib/widgets/draggable_widgets/agents/agent_widget.dart b/lib/widgets/draggable_widgets/agents/agent_widget.dart index afd075dc8..c580d6174 100644 --- a/lib/widgets/draggable_widgets/agents/agent_widget.dart +++ b/lib/widgets/draggable_widgets/agents/agent_widget.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/agents.dart'; import 'package:icarus/const/weapons.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/widgets/draggable_widgets/agents/agent_weapon_menu.dart'; import 'package:icarus/widgets/draggable_widgets/agents/agent_weapon_badge.dart'; import 'package:icarus/const/coordinate_system.dart'; @@ -283,6 +284,7 @@ class AgentWidget extends ConsumerWidget { : const []; final hasContextMenuItemsBelow = canInteract && (lineUpId != null || isLineUpDraft || placedAgentNode != null); + final l10n = context.l10n; final contextMenuItems = [ if (canInteract) ShadContextMenuItem.raw( @@ -304,7 +306,7 @@ class AgentWidget extends ConsumerWidget { if (canInteract && lineUpId != null) ShadContextMenuItem( leading: const Icon(LucideIcons.plus, size: 16), - child: const Text('Add lineup'), + child: Text(l10n.agentMenuAddLineup), onPressed: () { if (!ensureFeatureAvailable(ref, PlatformFeature.addLineups)) { return; @@ -324,7 +326,7 @@ class AgentWidget extends ConsumerWidget { if (canInteract && lineUpId != null) ShadContextMenuItem( leading: const Icon(LucideIcons.move, size: 16), - child: const Text('Edit placement'), + child: Text(l10n.agentMenuEditPlacement), onPressed: () { final links = ref.read(lineUpProvider.notifier).linksFromOrigin(lineUpId!); @@ -341,7 +343,7 @@ class AgentWidget extends ConsumerWidget { size: 16, color: Settings.tacticalVioletTheme.destructive, ), - child: const Text('Delete origin'), + child: Text(l10n.agentMenuDeleteOrigin), onPressed: () { ref.read(lineUpProvider.notifier).deleteOrigin(lineUpId!); }, @@ -349,6 +351,7 @@ class AgentWidget extends ConsumerWidget { if (canInteract && viewConeAgent != null) if (svgHeightModel != null && svgHeightOrigin != null) buildSvgHeightElevationMenuItem( + l10n: l10n, model: svgHeightModel, origin: svgHeightOrigin, selectedElevationCm: viewConeAgent.visionElevation, @@ -360,6 +363,7 @@ class AgentWidget extends ConsumerWidget { ), if (canInteract && viewConeAgent != null && mapState != null) buildSightlineReportMenuItem( + l10n: l10n, map: mapState.currentMap, isAttack: mapState.isAttack, model: svgHeightModel, @@ -379,13 +383,13 @@ class AgentWidget extends ConsumerWidget { if (canInteract && viewConeAgent != null) ShadContextMenuItem( leading: const Icon(LucideIcons.unlink, size: 16), - child: const Text('Detach view cone'), + child: Text(l10n.agentMenuDetachViewCone), onPressed: () => detachFromAgent(ref.read, viewConeAgent), ), if (canInteract && viewConeAgent != null) ShadContextMenuItem( leading: const Icon(LucideIcons.eyeOff, size: 16), - child: const Text('Remove view cone'), + child: Text(l10n.agentMenuRemoveViewCone), onPressed: () { ref.read(actionProvider.notifier).performTransaction( groups: const [ActionGroup.agent], @@ -400,13 +404,13 @@ class AgentWidget extends ConsumerWidget { if (canInteract && circleAgent != null) ShadContextMenuItem( leading: const Icon(LucideIcons.unlink, size: 16), - child: const Text('Detach circle'), + child: Text(l10n.agentMenuDetachCircle), onPressed: () => detachFromAgent(ref.read, circleAgent), ), if (canInteract && circleAgent != null) ShadContextMenuItem( leading: const Icon(LucideIcons.circleOff, size: 16), - child: const Text('Remove circle'), + child: Text(l10n.agentMenuRemoveCircle), onPressed: () { ref.read(actionProvider.notifier).performTransaction( groups: const [ActionGroup.agent], @@ -424,7 +428,7 @@ class AgentWidget extends ConsumerWidget { plainAgent.id.isNotEmpty) ShadContextMenuItem( leading: const Icon(LucideIcons.plus, size: 16), - child: const Text('Create lineup'), + child: Text(l10n.agentMenuCreateLineup), onPressed: () { ref.read(abilityBarProvider.notifier).updateData(agent); final lineUps = ref.read(lineUpProvider.notifier); @@ -440,6 +444,7 @@ class AgentWidget extends ConsumerWidget { lineUpId != null || onWeaponSelected != null)) ...buildAgentWeaponMenu( + l10n: l10n, selectedWeapon: weapon, onSelected: (selectedWeapon) { if (onWeaponSelected != null) { @@ -463,7 +468,7 @@ class AgentWidget extends ConsumerWidget { size: 16, color: Settings.tacticalVioletTheme.destructive, ), - child: const Text('Delete'), + child: Text(l10n.commonDelete), onPressed: () => deleteAgentFromMenu(ref, placedAgentNode.id), ), ]; diff --git a/lib/widgets/draggable_widgets/agents/placed_circle_agent_widget.dart b/lib/widgets/draggable_widgets/agents/placed_circle_agent_widget.dart index b05dd2591..0a7ff6b68 100644 --- a/lib/widgets/draggable_widgets/agents/placed_circle_agent_widget.dart +++ b/lib/widgets/draggable_widgets/agents/placed_circle_agent_widget.dart @@ -3,6 +3,7 @@ import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/agents.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/widgets/canvas_draggable.dart'; import 'package:icarus/widgets/editor_operation_scope.dart'; import 'package:icarus/const/weapons.dart'; @@ -322,7 +323,7 @@ class _PlacedCircleAgentWidgetState child: FractionalTranslation( translation: const Offset(-0.5, -1.0), child: CustomShapeResizeTooltip( - label: 'D', + label: context.l10n.shapeToolsDiameterShortLabel, valueMeters: diameterMeters, ), ), diff --git a/lib/widgets/draggable_widgets/caption_menu.dart b/lib/widgets/draggable_widgets/caption_menu.dart index 81a1feb00..dc9d09912 100644 --- a/lib/widgets/draggable_widgets/caption_menu.dart +++ b/lib/widgets/draggable_widgets/caption_menu.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/placed_classes.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/ability_provider.dart'; import 'package:icarus/providers/agent_provider.dart'; import 'package:icarus/providers/caption_card_provider.dart'; @@ -89,7 +90,9 @@ List buildCaptionMenuItems( return [ ShadContextMenuItem( leading: const Icon(LucideIcons.messageSquareText, size: 16), - child: Text(current.isEmpty ? 'Create caption' : 'Edit caption'), + child: Text(current.isEmpty + ? context.l10n.canvasItemCreateCaption + : context.l10n.canvasItemEditCaption), onPressed: () => container .read(captionCardProvider.notifier) .startWriting(id, currentCaptionPlace(container)), diff --git a/lib/widgets/draggable_widgets/image/image_widget.dart b/lib/widgets/draggable_widgets/image/image_widget.dart index b36141877..7c4653047 100644 --- a/lib/widgets/draggable_widgets/image/image_widget.dart +++ b/lib/widgets/draggable_widgets/image/image_widget.dart @@ -6,6 +6,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter/services.dart'; import 'package:icarus/const/coordinate_system.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/const/placed_media_dimensions.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/providers/strategy_image_source.dart'; @@ -244,14 +245,14 @@ class _ImageWidgetState extends ConsumerState { fit: BoxFit.contain, gaplessPlayback: true, ), - ImageLoading() => const _ImageStatePlaceholder( + ImageLoading() => _ImageStatePlaceholder( icon: LucideIcons.loaderCircle, - label: 'Syncing image', + label: context.l10n.canvasItemImageSyncing, showSpinner: true, ), - ImageFailed() => const _ImageStatePlaceholder( + ImageFailed() => _ImageStatePlaceholder( icon: LucideIcons.imageOff, - label: 'Image unavailable', + label: context.l10n.canvasItemImageUnavailable, ), }; diff --git a/lib/widgets/draggable_widgets/image/placed_image_builder.dart b/lib/widgets/draggable_widgets/image/placed_image_builder.dart index 679ea3348..97bf98117 100644 --- a/lib/widgets/draggable_widgets/image/placed_image_builder.dart +++ b/lib/widgets/draggable_widgets/image/placed_image_builder.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/coordinate_system.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/const/image_scale_policy.dart'; import 'package:icarus/const/placed_classes.dart'; import 'package:icarus/const/placed_media_dimensions.dart'; @@ -253,7 +254,7 @@ class _PlacedImageBuilderState extends State { List _buildTagColorItems(WidgetRef ref) { return [ ShadContextMenuItem( - child: const Text('Reset tag to gray'), + child: Text(context.l10n.canvasItemResetTagToGray), onPressed: () { ref .read(placedImageProvider.notifier) @@ -285,11 +286,12 @@ class _PlacedImageBuilderState extends State { } String _labelForColor(ColorLibraryEntry entry) { - final kind = entry.isCustom ? 'custom' : 'default'; final hex = (entry.color.toARGB32() & 0x00FFFFFF) .toRadixString(16) .padLeft(6, '0') .toUpperCase(); - return '#$hex $kind tag'; + return entry.isCustom + ? context.l10n.canvasItemCustomColorTag(hex) + : context.l10n.canvasItemDefaultColorTag(hex); } } diff --git a/lib/widgets/draggable_widgets/text/placed_text_builder.dart b/lib/widgets/draggable_widgets/text/placed_text_builder.dart index 9df1b5314..21cf16ab5 100644 --- a/lib/widgets/draggable_widgets/text/placed_text_builder.dart +++ b/lib/widgets/draggable_widgets/text/placed_text_builder.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/coordinate_system.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/const/placed_classes.dart'; import 'package:icarus/providers/hovered_delete_target_provider.dart'; import 'package:icarus/providers/screen_zoom_provider.dart'; @@ -262,7 +263,7 @@ class _PlacedTextBuilderState extends ConsumerState { List _buildTagColorItems() { return [ ShadContextMenuItem( - child: const Text('Reset tag to gray'), + child: Text(context.l10n.canvasItemResetTagToGray), onPressed: () { ref .read(textProvider.notifier) @@ -296,17 +297,17 @@ class _PlacedTextBuilderState extends ConsumerState { String _labelForColor(Color color) { if (color.toARGB32() == const Color(0xFF22C55E).toARGB32()) { - return 'Green tag'; + return context.l10n.canvasItemGreenTag; } if (color.toARGB32() == const Color(0xFF3B82F6).toARGB32()) { - return 'Blue tag'; + return context.l10n.canvasItemBlueTag; } if (color.toARGB32() == const Color(0xFFF59E0B).toARGB32()) { - return 'Amber tag'; + return context.l10n.canvasItemAmberTag; } if (color.toARGB32() == const Color(0xFFEF4444).toARGB32()) { - return 'Red tag'; + return context.l10n.canvasItemRedTag; } - return 'Purple tag'; + return context.l10n.canvasItemPurpleTag; } } diff --git a/lib/widgets/draggable_widgets/text/text_format_bar.dart b/lib/widgets/draggable_widgets/text/text_format_bar.dart index b2b152608..2a5b94bae 100644 --- a/lib/widgets/draggable_widgets/text/text_format_bar.dart +++ b/lib/widgets/draggable_widgets/text/text_format_bar.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:icarus/const/settings.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/widgets/editor_toolbar.dart'; import 'package:icarus/widgets/draggable_widgets/text/text_markup.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; @@ -65,7 +66,7 @@ class TextFormatBar extends StatelessWidget { EditorToolbarButton( style: style, showTooltip: false, - tooltip: 'Bold (Ctrl+B)', + tooltip: context.l10n.canvasItemBoldTooltip('Ctrl+B'), active: MarkupEditing.isInlineActive(value, '**'), onPressed: () => onApply( MarkupEditing.toggleInline(value, '**'), @@ -75,7 +76,7 @@ class TextFormatBar extends StatelessWidget { EditorToolbarButton( style: style, showTooltip: false, - tooltip: 'Italic (Ctrl+I)', + tooltip: context.l10n.canvasItemItalicTooltip('Ctrl+I'), active: MarkupEditing.isInlineActive(value, '*'), onPressed: () => onApply( MarkupEditing.toggleInline(value, '*'), @@ -85,7 +86,7 @@ class TextFormatBar extends StatelessWidget { EditorToolbarButton( style: style, showTooltip: false, - tooltip: 'Bullet list', + tooltip: context.l10n.canvasItemBulletListTooltip, active: MarkupEditing.lineKindAt(value) == MarkupLineKind.bullet, onPressed: () => onApply( @@ -97,7 +98,7 @@ class TextFormatBar extends StatelessWidget { EditorToolbarButton( style: style, showTooltip: false, - tooltip: 'Numbered list', + tooltip: context.l10n.canvasItemNumberedListTooltip, active: MarkupEditing.lineKindAt(value) == MarkupLineKind.numbered, onPressed: () => onApply( @@ -111,7 +112,7 @@ class TextFormatBar extends StatelessWidget { EditorToolbarButton( style: style, showTooltip: false, - tooltip: 'Heading', + tooltip: context.l10n.canvasItemHeadingTooltip, active: MarkupEditing.lineKindAt(value) == MarkupLineKind.heading, onPressed: () => onApply( diff --git a/lib/widgets/draggable_widgets/text/text_widget.dart b/lib/widgets/draggable_widgets/text/text_widget.dart index 31fa1b784..cd911ebe6 100644 --- a/lib/widgets/draggable_widgets/text/text_widget.dart +++ b/lib/widgets/draggable_widgets/text/text_widget.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/coordinate_system.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/screenshot_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/providers/text_draft_provider.dart'; @@ -213,12 +214,12 @@ class _EditableTextWidgetState extends ConsumerState<_EditableTextWidget> { children: [ if (_controller.text.isEmpty) Text( - 'Write here...', + context.l10n.canvasItemTextPlaceholder, style: bodyStyle.copyWith(color: Colors.grey), ), MergeSemantics( child: Semantics( - label: 'Placed text', + label: context.l10n.canvasItemPlacedTextSemanticLabel, child: TextField( focusNode: _focusNode, controller: _controller, @@ -244,7 +245,7 @@ class _EditableTextWidgetState extends ConsumerState<_EditableTextWidget> { builder: (context, _) => FormattedTextView( text: _controller.text, style: bodyStyle, - hintText: 'Write here...', + hintText: context.l10n.canvasItemTextPlaceholder, ), ), ); @@ -365,7 +366,7 @@ class _FeedbackTextWidget extends StatelessWidget { child: FormattedTextView( text: text, style: style, - hintText: 'Write here...', + hintText: context.l10n.canvasItemTextPlaceholder, ), ); } diff --git a/lib/widgets/draggable_widgets/utilities/custom_shape_context_menu.dart b/lib/widgets/draggable_widgets/utilities/custom_shape_context_menu.dart index 973d0ac49..9b0ad862c 100644 --- a/lib/widgets/draggable_widgets/utilities/custom_shape_context_menu.dart +++ b/lib/widgets/draggable_widgets/utilities/custom_shape_context_menu.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/placed_classes.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/color_library_provider.dart'; import 'package:icarus/providers/utility_provider.dart'; import 'package:icarus/widgets/draggable_widgets/adjacent_page_copy_menu.dart'; @@ -23,7 +24,7 @@ List buildCustomShapeContextMenuItems( : Color(currentColorValue), ), items: [_CustomShapeColorItems(utility: utility)], - child: const Text('Color'), + child: Text(context.l10n.shapeToolsColorMenu), ), ...buildCaptionMenuItems(context, ref, utility.id), ...buildAdjacentPageCopyMenuItems(ref, utility.id), @@ -55,7 +56,7 @@ class _CustomShapeColorItems extends ConsumerWidget { colorValue: entry.color.toARGB32(), ); }, - child: Text(_colorLabel(entry)), + child: Text(_colorLabel(context.l10n, entry)), ), ShadContextMenuItem( leading: const Icon(LucideIcons.palette, size: 14), @@ -66,7 +67,7 @@ class _CustomShapeColorItems extends ConsumerWidget { initialColor: currentColorValue == null ? Colors.white : Color(currentColorValue), - title: 'Custom color', + title: context.l10n.shapeToolsCustomColorTitle, ); if (picked == null) return; utilityNotifier.updateCustomShapeColor( @@ -74,27 +75,28 @@ class _CustomShapeColorItems extends ConsumerWidget { colorValue: picked.toARGB32(), ); }, - child: const Text('Custom color…'), + child: Text(context.l10n.shapeToolsCustomColorMenu), ), ], ); } } -String _colorLabel(ColorLibraryEntry entry) { +String _colorLabel(AppLocalizations l10n, ColorLibraryEntry entry) { if (!entry.isCustom) { - if (entry.color.toARGB32() == Colors.white.toARGB32()) return 'White'; - if (entry.color.toARGB32() == Colors.red.toARGB32()) return 'Red'; - if (entry.color.toARGB32() == Colors.blue.toARGB32()) return 'Blue'; - if (entry.color.toARGB32() == Colors.yellow.toARGB32()) return 'Yellow'; - if (entry.color.toARGB32() == Colors.green.toARGB32()) return 'Green'; + final color = entry.color.toARGB32(); + if (color == Colors.white.toARGB32()) return l10n.shapeToolsColorWhite; + if (color == Colors.red.toARGB32()) return l10n.shapeToolsColorRed; + if (color == Colors.blue.toARGB32()) return l10n.shapeToolsColorBlue; + if (color == Colors.yellow.toARGB32()) return l10n.shapeToolsColorYellow; + if (color == Colors.green.toARGB32()) return l10n.shapeToolsColorGreen; } final hex = (entry.color.toARGB32() & 0x00FFFFFF) .toRadixString(16) .padLeft(6, '0') .toUpperCase(); - return entry.isCustom ? 'Saved #$hex' : '#$hex'; + return entry.isCustom ? l10n.shapeToolsSavedColor(hex) : '#$hex'; } class _ColorSwatch extends StatelessWidget { diff --git a/lib/widgets/draggable_widgets/utilities/placed_custom_circle_widget.dart b/lib/widgets/draggable_widgets/utilities/placed_custom_circle_widget.dart index 1051ff50f..a5a921b8c 100644 --- a/lib/widgets/draggable_widgets/utilities/placed_custom_circle_widget.dart +++ b/lib/widgets/draggable_widgets/utilities/placed_custom_circle_widget.dart @@ -3,6 +3,7 @@ import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/agents.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/const/coordinate_system.dart'; import 'package:icarus/const/maps.dart'; import 'package:icarus/const/transition_data.dart'; @@ -464,7 +465,7 @@ class _PlacedCustomCircleWidgetState child: FractionalTranslation( translation: const Offset(-0.5, -1.0), child: CustomShapeResizeTooltip( - label: 'D', + label: context.l10n.shapeToolsDiameterShortLabel, valueMeters: diameterMeters, ), ), diff --git a/lib/widgets/draggable_widgets/utilities/placed_custom_rectangle_widget.dart b/lib/widgets/draggable_widgets/utilities/placed_custom_rectangle_widget.dart index 7e65d4dba..ece8ff839 100644 --- a/lib/widgets/draggable_widgets/utilities/placed_custom_rectangle_widget.dart +++ b/lib/widgets/draggable_widgets/utilities/placed_custom_rectangle_widget.dart @@ -4,6 +4,7 @@ import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/agents.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/const/app_cursors.dart'; import 'package:icarus/const/coordinate_system.dart'; import 'package:icarus/const/maps.dart'; @@ -841,7 +842,9 @@ class _PlacedCustomRectangleWidgetState outerSize: outerSize, ); final isLengthAxis = _isLengthHandle(_activeHandle); - final label = isLengthAxis ? 'L' : 'W'; + final label = isLengthAxis + ? context.l10n.shapeToolsLengthShortLabel + : context.l10n.shapeToolsWidthShortLabel; final valueMeters = isLengthAxis ? lengthMeters : widthMeters; final gap = coordinateSystem.scale(16); @@ -884,7 +887,7 @@ class _PlacedCustomRectangleWidgetState child: FractionalTranslation( translation: const Offset(-0.5, -1.0), child: CustomShapeResizeTooltip( - label: 'R', + label: context.l10n.shapeToolsRotationShortLabel, valueDegrees: degrees, ), ), diff --git a/lib/widgets/draggable_widgets/utilities/sightline_report_menu.dart b/lib/widgets/draggable_widgets/utilities/sightline_report_menu.dart index cdface607..aeaaa1769 100644 --- a/lib/widgets/draggable_widgets/utilities/sightline_report_menu.dart +++ b/lib/widgets/draggable_widgets/utilities/sightline_report_menu.dart @@ -8,6 +8,7 @@ import 'package:icarus/const/app_provider_container.dart'; import 'package:icarus/const/coordinate_system.dart'; import 'package:icarus/const/maps.dart'; import 'package:icarus/const/settings.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/map_doors_provider.dart'; import 'package:icarus/services/app_error_reporter.dart'; import 'package:icarus/view_cone/sightline_report.dart'; @@ -20,6 +21,7 @@ import 'package:shadcn_ui/shadcn_ui.dart'; /// One click turns "this cone looks wrong" into everything we need to fix it: /// the report on the clipboard, and the same report plus a crop on disk. ShadContextMenuItem buildSightlineReportMenuItem({ + required AppLocalizations l10n, required MapValue map, required bool isAttack, required SvgHeightVisibility? model, @@ -41,7 +43,7 @@ ShadContextMenuItem buildSightlineReportMenuItem({ lengthVirtual: lengthVirtual, visionElevationCm: visionElevationCm, ), - child: const Text('Copy sightline report'), + child: Text(l10n.sightlineReportCopy), ); } @@ -66,6 +68,7 @@ void copySightlineReport({ final svgOrigin = transform.sourceFromSideWorld(sideOrigin, isAttack: isAttack); final mapName = Maps.mapNames[map] ?? map.name; + final l10n = appProviderContainer.read(l10nProvider); final SightlineReport report; final String encoded; try { @@ -92,13 +95,14 @@ void copySightlineReport({ error: error, stackTrace: stack, source: 'copySightlineReport.$mapName', + toastMessage: l10n.sightlineReportBuildFailed, ); return; } unawaited(Clipboard.setData(ClipboardData(text: encoded))); Settings.showToast( - message: 'Sightline report copied', + message: l10n.sightlineReportCopied, backgroundColor: Settings.tacticalVioletTheme.primary, ); @@ -108,8 +112,13 @@ void copySightlineReport({ model: model, svgOrigin: svgOrigin, stem: sightlineReportFileStem(mapName, isAttack, DateTime.now()), - label: '$mapName ${isAttack ? 'attack' : 'defense'} · eye ' - '${report.eyeElevationMeters?.toStringAsFixed(2) ?? '—'} m', + label: (isAttack + ? l10n.sightlineReportCropLabelAttack + : l10n.sightlineReportCropLabelDefense)( + mapName, + report.eyeElevationMeters?.toStringAsFixed(2) ?? '—', + ), + saveFailedMessage: l10n.sightlineReportSaveFailed, )); } @@ -120,6 +129,7 @@ Future _saveSightlineReport({ required Offset svgOrigin, required String stem, required String label, + required String saveFailedMessage, }) async { try { final directory = Directory( @@ -144,6 +154,7 @@ Future _saveSightlineReport({ error: error, stackTrace: stack, source: 'copySightlineReport.save', + toastMessage: saveFailedMessage, ); } } diff --git a/lib/widgets/draggable_widgets/utilities/view_cone_elevation_menu.dart b/lib/widgets/draggable_widgets/utilities/view_cone_elevation_menu.dart index 4efb2e9d9..76b0cd68d 100644 --- a/lib/widgets/draggable_widgets/utilities/view_cone_elevation_menu.dart +++ b/lib/widgets/draggable_widgets/utilities/view_cone_elevation_menu.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/view_cone/vision_geometry.dart'; import 'package:icarus/view_cone/svg_height_visibility.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; @@ -35,6 +36,7 @@ ShadContextMenuItem buildViewConeElevationMenuItem({ } ShadContextMenuItem buildSvgHeightElevationMenuItem({ + required AppLocalizations l10n, required SvgHeightVisibility model, required Offset origin, required double? selectedElevationCm, @@ -53,29 +55,37 @@ ShadContextMenuItem buildSvgHeightElevationMenuItem({ final unmatched = selectedElevationCm != null && support == null && !groundSelected; final automaticSelected = selectedElevationCm == null || unmatched; + final automaticSurface = + automatic?.label ?? l10n.canvasItemViewElevationGround; return ShadContextMenuItem( leading: const Icon(Icons.layers_outlined), trailing: Text(automaticSelected - ? '${automatic?.label ?? 'Ground'} (auto${unmatched ? '*' : ''})' - : support?.label ?? 'Ground'), + ? (unmatched + ? l10n.canvasItemViewElevationAutoUnmatchedTrailing( + automaticSurface) + : l10n.canvasItemViewElevationAutoTrailing(automaticSurface)) + : support?.label ?? l10n.canvasItemViewElevationGround), items: [ _elevationItem( label: unmatched - ? 'Automatic (saved height unavailable)' - : 'Automatic (${automatic?.label ?? 'Ground'})', + ? l10n.canvasItemViewElevationAutomaticUnavailable + : l10n.canvasItemViewElevationAutomatic(automaticSurface), selected: automaticSelected, onPressed: () => onChanged(null), ), if (groundEye != null && automatic != null) _elevationItem( - label: 'Ground (${formatVisionElevation(groundEye * 100)})', + label: l10n.canvasItemViewElevationGroundHeight( + formatVisionElevation(groundEye * 100)), selected: groundSelected && !automaticSelected, onPressed: () => onChanged(groundEye * 100), ), for (final choice in choices) _elevationItem( - label: '${choice.label ?? choice.id} ' - '(${formatVisionElevation(choice.surfaceElevationAt(origin)! * 100 + model.defaultCameraHeightMeters * 100)})', + label: l10n.canvasItemViewElevationSurfaceHeight( + choice.label ?? choice.id, + formatVisionElevation(choice.surfaceElevationAt(origin)! * 100 + + model.defaultCameraHeightMeters * 100)), selected: !automaticSelected && identical(choice, support), onPressed: () => onChanged( choice.surfaceElevationAt(origin)! * 100 + @@ -83,7 +93,7 @@ ShadContextMenuItem buildSvgHeightElevationMenuItem({ ), ), ], - child: const Text('View elevation'), + child: Text(l10n.canvasItemViewElevationMenu), ); } diff --git a/lib/widgets/draggable_widgets/utilities/view_cone_widget.dart b/lib/widgets/draggable_widgets/utilities/view_cone_widget.dart index 3d3d9a347..09d7483c1 100644 --- a/lib/widgets/draggable_widgets/utilities/view_cone_widget.dart +++ b/lib/widgets/draggable_widgets/utilities/view_cone_widget.dart @@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/coordinate_system.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/const/placed_classes.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/const/utilities.dart'; @@ -278,6 +279,7 @@ class ViewConeWidget extends ConsumerWidget { // Only a placed cone carries a context menu; a drag preview has none. if (placedUtility != null) { sightlineReportItem = buildSightlineReportMenuItem( + l10n: context.l10n, map: mapState.currentMap, isAttack: mapState.isAttack, model: svgHeightModel, @@ -297,6 +299,7 @@ class ViewConeWidget extends ConsumerWidget { : [ if (svgHeightModel != null && svgHeightOrigin != null) buildSvgHeightElevationMenuItem( + l10n: context.l10n, model: svgHeightModel, origin: svgHeightOrigin, selectedElevationCm: placedUtility.visionElevation, diff --git a/lib/widgets/editor_toolbar.dart b/lib/widgets/editor_toolbar.dart index c2d7f13ef..02b496660 100644 --- a/lib/widgets/editor_toolbar.dart +++ b/lib/widgets/editor_toolbar.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/config/platform_policy.dart'; import 'package:icarus/const/settings.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/auth_provider.dart'; import 'package:icarus/providers/share_link_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; @@ -62,6 +63,8 @@ class _EditorToolbarState extends ConsumerState { final allowsLocalSave = ref.watch(platformPolicyProvider).allowsLocalLibrary; + final l10n = context.l10n; + // The strategy view owns the spacing around this card, so it aligns // with the map card above it. return Row( @@ -80,7 +83,7 @@ class _EditorToolbarState extends ConsumerState { if (viewingThroughLink && !signedIn) EditorToolbarButton( style: style, - tooltip: 'View only · Sign in to add it to your library', + tooltip: l10n.editorToolbarViewOnlySignIn, onPressed: () => showDialog( context: context, builder: (_) => const AuthDialog(), @@ -91,7 +94,7 @@ class _EditorToolbarState extends ConsumerState { EditorToolbarButton( key: const ValueKey('editor-add-to-library'), style: style, - tooltip: 'View only · Add to my library', + tooltip: l10n.editorToolbarViewOnlyAddToLibrary, onPressed: () => ref .read(shareLinkControllerProvider.notifier) .addViewedLinkToLibrary(), @@ -103,26 +106,26 @@ class _EditorToolbarState extends ConsumerState { const AutoSaveButton(style: style), EditorToolbarButton( style: style, - tooltip: 'Export .ica', + tooltip: l10n.editorToolbarExportIca, onPressed: _exportStrategy, icon: const Icon(LucideIcons.upload200), ), EditorToolbarButton( style: style, - tooltip: 'Export video', + tooltip: l10n.editorToolbarExportVideo, onPressed: _exportVideo, icon: const Icon(LucideIcons.clapperboard200), ), EditorToolbarButton( style: style, - tooltip: 'Screenshot', + tooltip: l10n.editorToolbarScreenshot, onPressed: _openScreenshotDialog, icon: const Icon(LucideIcons.camera200), ), const EditorToolbarDivider(), EditorToolbarButton( style: style, - tooltip: 'Settings', + tooltip: l10n.editorToolbarSettings, onPressed: () { showShadDialog( context: context, diff --git a/lib/widgets/folder_card.dart b/lib/widgets/folder_card.dart index 680218e0a..727de797b 100644 --- a/lib/widgets/folder_card.dart +++ b/lib/widgets/folder_card.dart @@ -5,6 +5,7 @@ import 'package:icarus/const/folder_icons.dart'; import 'package:icarus/const/maps.dart'; import 'package:icarus/const/form_factor.dart'; import 'package:icarus/const/settings.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/collab/move_to_cloud_provider.dart'; import 'package:icarus/providers/folder_provider.dart'; import 'package:icarus/widgets/context_menu_region.dart'; @@ -664,7 +665,7 @@ class _FolderCardState extends ConsumerState ), ), const SizedBox(width: 6), - Text('Moving to cloud', style: footerStyle), + Text(context.l10n.folderCardMovingToCloud, style: footerStyle), ], ); } @@ -676,8 +677,8 @@ class _FolderCardState extends ConsumerState ? Text( switch (widget.data.strategyCount) { null => '', - 0 => 'Empty', - final count => '$count strateg${count == 1 ? 'y' : 'ies'}', + 0 => context.l10n.folderCardEmpty, + final count => context.l10n.folderCardStrategyCount(count), }, style: footerStyle, ) @@ -690,7 +691,7 @@ class _FolderCardState extends ConsumerState ref.watch(isCloudWorkspaceAvailableProvider)) ...[ const SizedBox(width: 6), Tooltip( - message: 'Saved only on this computer', + message: context.l10n.folderCardLocalOnlyTooltip, child: Icon( LucideIcons.monitor, size: 13, @@ -768,14 +769,18 @@ class _FolderCardState extends ConsumerState /// copy: refused at once, with the reason. bool _refusedOffline() => _isCloud && - refuseCloudChangeWhileOffline(ref.read(cloudLibraryShowsCopyProvider)); + refuseCloudChangeWhileOffline( + ref.read(l10nProvider), + ref.read(cloudLibraryShowsCopyProvider), + ); List _buildMenuItems({required bool isPinned}) { final id = _folder.id; + final l10n = context.l10n; return [ ShadContextMenuItem( leading: Icon(isPinned ? LucideIcons.pinOff : LucideIcons.pin), - child: Text(isPinned ? 'Unpin' : 'Pin'), + child: Text(isPinned ? l10n.folderCardUnpin : l10n.folderCardPin), onPressed: () { _closeMenus(); if (widget.isDemo) return; @@ -800,12 +805,12 @@ class _FolderCardState extends ConsumerState }, ); }, - child: const Text('Edit'), + child: Text(l10n.commonEdit), ), if (_isCloud && widget.cloudRole == 'owner') ShadContextMenuItem( leading: const Icon(LucideIcons.link2), - child: const Text('Share'), + child: Text(l10n.commonShare), onPressed: () async { _closeMenus(); if (_refusedOffline()) return; @@ -826,7 +831,7 @@ class _FolderCardState extends ConsumerState ShadContextMenuItem( key: const ValueKey('folder-card-leave'), leading: const Icon(LucideIcons.logOut), - child: const Text('Leave'), + child: Text(l10n.commonLeave), onPressed: () { _closeMenus(); if (_refusedOffline()) return; @@ -845,7 +850,7 @@ class _FolderCardState extends ConsumerState ref.watch(isCloudWorkspaceAvailableProvider)) ShadContextMenuItem( leading: const Icon(LucideIcons.cloudUpload), - child: const Text('Move to cloud'), + child: Text(l10n.folderCardMoveToCloud), onPressed: () { _closeMenus(); ref.read(moveToCloudProvider.notifier).moveFolder(id); @@ -853,7 +858,7 @@ class _FolderCardState extends ConsumerState ), ShadContextMenuItem( leading: const Icon(LucideIcons.upload), - child: const Text('Export'), + child: Text(l10n.commonExport), onPressed: () async { _closeMenus(); if (!ensureFeatureAvailable(ref, PlatformFeature.exportFiles)) { @@ -870,7 +875,7 @@ class _FolderCardState extends ConsumerState AppErrorReporter.reportError( error is LibraryExportIncomplete ? error.userMessage - : 'Failed to export folder.', + : l10n.folderCardExportFailed, error: error, stackTrace: stackTrace, source: 'FolderCard.export', @@ -895,7 +900,7 @@ class _FolderCardState extends ConsumerState ), ); }, - child: Text('Delete', + child: Text(l10n.commonDelete, style: TextStyle(color: Settings.tacticalVioletTheme.destructive)), ), ]; diff --git a/lib/widgets/folder_content.dart b/lib/widgets/folder_content.dart index 30cb58b10..eb1a6ca21 100644 --- a/lib/widgets/folder_content.dart +++ b/lib/widgets/folder_content.dart @@ -8,6 +8,7 @@ import 'package:icarus/collab/cloud_library_models.dart'; import 'package:icarus/config/platform_policy.dart'; import 'package:icarus/const/hive_boxes.dart'; import 'package:icarus/const/settings.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/auth_provider.dart'; import 'package:icarus/providers/collab/client_upgrade_required_provider.dart'; import 'package:icarus/providers/collab/remote_library_provider.dart'; @@ -243,14 +244,13 @@ class FolderContent extends ConsumerWidget { folders: _filterFolders(ref, folders), strategies: _filterStrategies(ref, strategies), acceptsIcaDrops: _acceptsIcaDrops(ref), - emptyStateTitle: 'No strategies in this folder', - emptyStateSubtitle: - 'Create a new strategy or drop strategies, folders, or .zip archives', + emptyStateTitle: context.l10n.folderEmptyTitle, + emptyStateSubtitle: context.l10n.folderEmptyLocalSubtitle, emptyStateAction: ShadButton( key: const ValueKey('folder-empty-create-strategy'), onPressed: onCreateStrategy, leading: const Icon(LucideIcons.plus), - child: const Text('Create Strategy'), + child: Text(context.l10n.folderCreateStrategy), ), ), ); @@ -363,16 +363,15 @@ class FolderContent extends ConsumerWidget { : null, emptyStateKey: const ValueKey('library-empty-state'), emptyStateIcon: LucideIcons.folder, - emptyStateTitle: 'Your library is empty', + emptyStateTitle: context.l10n.folderLibraryEmptyTitle, emptyStateSubtitle: cloudAvailable - ? 'Strategies you make are saved to your account.' - : 'Create a new strategy or drop strategies, folders, ' - 'or .zip archives here.', + ? context.l10n.folderLibraryEmptyCloudSubtitle + : context.l10n.folderLibraryEmptyLocalSubtitle, emptyStateAction: ShadButton( key: const ValueKey('library-empty-create-strategy'), onPressed: onCreateStrategy, leading: const Icon(LucideIcons.plus), - child: const Text('Create Strategy'), + child: Text(context.l10n.folderCreateStrategy), ), ), ), @@ -499,6 +498,7 @@ class FolderContent extends ConsumerWidget { LibraryStrategyRow.cloud(entry), ]; final isSharedWithMe = cloudSection == CloudLibrarySection.sharedWithMe; + final l10n = context.l10n; return KeyedSubtree( key: const ValueKey('cloud-content'), child: _buildScaffold( @@ -514,19 +514,20 @@ class FolderContent extends ConsumerWidget { emptyStateIcon: isSharedWithMe && folder == null ? LucideIcons.users : null, emptyStateTitle: isSharedWithMe && folder == null - ? 'Nothing shared with you yet' - : 'No strategies in this folder', + ? l10n.folderSharedEmptyTitle + : l10n.folderEmptyTitle, emptyStateSubtitle: isSharedWithMe && folder == null - ? 'Add a share link or code from a teammate to keep it here.' + ? l10n.folderSharedEmptySubtitle : isSharedWithMe - ? 'Strategies shared into this folder will show up here.' - : 'Create a new strategy to fill it.', + ? l10n.folderSharedFolderEmptySubtitle + : l10n.folderEmptyCloudSubtitle, emptyStateAction: isSharedWithMe ? (folder == null ? ShadButton( key: const ValueKey('shared-empty-add-item'), onPressed: () { if (refuseCloudChangeWhileOffline( + ref.read(l10nProvider), ref.read(cloudLibraryShowsCopyProvider), )) { return; @@ -534,14 +535,14 @@ class FolderContent extends ConsumerWidget { showAddSharedItemDialog(context); }, leading: const Icon(LucideIcons.link), - child: const Text('Add by Link or Code'), + child: Text(l10n.libraryAddByLinkOrCode), ) : null) : ShadButton( key: const ValueKey('cloud-empty-create-strategy'), onPressed: onCreateStrategy, leading: const Icon(LucideIcons.plus), - child: const Text('Create Strategy'), + child: Text(l10n.folderCreateStrategy), ), ), ); @@ -601,8 +602,10 @@ class FolderContent extends ConsumerWidget { // and offer nothing to create. final searching = ref.watch(strategySearchQueryProvider).trim().isNotEmpty; final icon = searching ? null : emptyStateIcon; - final title = searching ? 'No matches' : emptyStateTitle; - final subtitle = searching ? 'Try a different search' : emptyStateSubtitle; + final title = + searching ? context.l10n.folderNoMatchesTitle : emptyStateTitle; + final subtitle = + searching ? context.l10n.folderNoMatchesSubtitle : emptyStateSubtitle; final action = searching ? null : emptyStateAction; final Widget emptyState = Center( key: emptyStateKey, @@ -774,15 +777,14 @@ class FolderContent extends ConsumerWidget { // A live session that cannot reach the cloud retries; only a signed-out // user is asked to log in. final signedIn = ref.watch(authProvider).isAuthenticated; + final l10n = context.l10n; return _LibraryMessageState( icon: LucideIcons.cloudOff, iconColor: Settings.tacticalVioletTheme.mutedForeground, - title: 'Cloud unavailable', + title: l10n.folderCloudUnavailableTitle, subtitle: signedIn - ? 'Retry to reach your online strategies, or go back to your ' - 'library to keep working.' - : 'Sign in again to reach your online strategies, or go back ' - 'to your library to keep working.', + ? l10n.folderCloudUnavailableRetrySubtitle + : l10n.folderCloudUnavailableSignInSubtitle, actions: [ if (signedIn) ShadButton( @@ -790,7 +792,7 @@ class FolderContent extends ConsumerWidget { onPressed: () => ref .read(authProvider.notifier) .reinitializeConvexAuth(source: 'cloud_unavailable_retry'), - child: const Text('Retry'), + child: Text(l10n.commonRetry), ) else ShadButton( @@ -800,11 +802,11 @@ class FolderContent extends ConsumerWidget { builder: (_) => const AuthDialog(), ); }, - child: const Text('Log In'), + child: Text(l10n.commonLogIn), ), ShadButton.secondary( onPressed: ref.read(libraryNavigationProvider).showLibrary, - child: const Text('Back to My Library'), + child: Text(l10n.folderBackToMyLibrary), ), ], ); @@ -817,21 +819,22 @@ class FolderContent extends ConsumerWidget { WidgetRef ref, { bool offerLibrary = true, }) { + final l10n = context.l10n; return _LibraryMessageState( icon: LucideIcons.cloudOff, iconColor: Settings.tacticalVioletTheme.destructive, - title: "Couldn't load your cloud library", - subtitle: 'Check your connection and try again.', + title: l10n.folderCloudLoadFailedTitle, + subtitle: l10n.folderCheckConnectionSubtitle, actions: [ ShadButton( leading: const Icon(LucideIcons.refreshCw, size: 14), onPressed: () => _retryCloud(ref), - child: const Text('Retry'), + child: Text(l10n.commonRetry), ), if (offerLibrary) ShadButton.secondary( onPressed: ref.read(libraryNavigationProvider).showLibrary, - child: const Text('Back to My Library'), + child: Text(l10n.folderBackToMyLibrary), ), ], ); @@ -842,18 +845,18 @@ class FolderContent extends ConsumerWidget { Widget _buildNotKeptState(BuildContext context, WidgetRef ref) { final isShared = ref.watch(cloudLibrarySectionProvider) == CloudLibrarySection.sharedWithMe; + final l10n = context.l10n; return _LibraryMessageState( icon: LucideIcons.cloudOff, iconColor: Settings.tacticalVioletTheme.mutedForeground, title: isShared && folder == null - ? "Shared isn't on this computer yet" - : "This folder isn't on this computer yet", - subtitle: 'Icarus keeps what you open while online. This loads when ' - "you're back online.", + ? l10n.folderSharedNotKeptTitle + : l10n.folderNotKeptTitle, + subtitle: l10n.folderNotKeptSubtitle, actions: [ ShadButton.secondary( onPressed: ref.read(libraryNavigationProvider).showLibrary, - child: const Text('Back to My Library'), + child: Text(l10n.folderBackToMyLibrary), ), ], ); @@ -865,6 +868,7 @@ class FolderContent extends ConsumerWidget { /// cloud gets a retry, never a second login over the one it has. Widget _buildSignInState(BuildContext context, WidgetRef ref) { final auth = ref.watch(authProvider); + final l10n = context.l10n; final restoring = auth.isLoading || auth.convexAuthStatus == ConvexAuthStatus.configuring; if (restoring) { @@ -880,7 +884,7 @@ class FolderContent extends ConsumerWidget { builder: (context, message, action) => _LibraryMessageState( icon: LucideIcons.cloudOff, iconColor: Settings.tacticalVioletTheme.destructive, - title: 'Your cloud library needs a newer Icarus', + title: l10n.folderCloudNeedsUpgradeTitle, subtitle: message, actions: [if (action != null) action], ), @@ -892,8 +896,8 @@ class FolderContent extends ConsumerWidget { child: _LibraryMessageState( icon: LucideIcons.cloudOff, iconColor: Settings.tacticalVioletTheme.destructive, - title: "Couldn't reach your cloud library", - subtitle: auth.errorMessage ?? 'Check your connection and try again.', + title: l10n.folderCloudUnreachableTitle, + subtitle: auth.errorMessage ?? l10n.folderCheckConnectionSubtitle, actions: [ ShadButton( key: const ValueKey('library-cloud-retry'), @@ -901,7 +905,7 @@ class FolderContent extends ConsumerWidget { onPressed: () => ref .read(authProvider.notifier) .reinitializeConvexAuth(source: 'library_retry'), - child: const Text('Retry'), + child: Text(l10n.commonRetry), ), ], ), @@ -912,9 +916,8 @@ class FolderContent extends ConsumerWidget { child: _LibraryMessageState( icon: LucideIcons.logIn, iconColor: Settings.tacticalVioletTheme.mutedForeground, - title: 'Log in to use the web beta', - subtitle: 'Strategies you make here are saved to your Icarus ' - 'account.', + title: l10n.folderSignInTitle, + subtitle: l10n.folderSignInSubtitle, actions: [ ShadButton( key: const ValueKey('library-sign-in-action'), @@ -924,7 +927,7 @@ class FolderContent extends ConsumerWidget { builder: (_) => const AuthDialog(), ); }, - child: const Text('Log In'), + child: Text(l10n.commonLogIn), ), ], ), @@ -935,13 +938,12 @@ class FolderContent extends ConsumerWidget { return _LibraryMessageState( icon: LucideIcons.globe, iconColor: Settings.tacticalVioletTheme.primary, - title: 'Community strats are coming soon', - subtitle: - 'This space is reserved for public lineups, team executes, and discoverable strategy packs.', + title: context.l10n.folderCommunityTitle, + subtitle: context.l10n.folderCommunitySubtitle, actions: [ ShadButton.secondary( onPressed: ref.read(libraryNavigationProvider).showLibrary, - child: const Text('Back to My Library'), + child: Text(context.l10n.folderBackToMyLibrary), ), ], ); @@ -964,8 +966,7 @@ class _CloudLoadingBanner extends StatelessWidget { color: Settings.tacticalVioletTheme.mutedForeground, ), ), - message: "Loading your cloud library. Showing what's on this computer " - 'for now.', + message: context.l10n.folderCloudLoadingBanner, ); } } @@ -987,9 +988,9 @@ class _CloudOfflineBanner extends StatelessWidget { size: 16, color: Settings.tacticalVioletTheme.mutedForeground, ), - message: 'Offline. Showing your cloud library as of ' - '${cloudCopyTimeLabel(context, matchedAt, now: DateTime.now())}. ' - "Changes sync when you're back online.", + message: context.l10n.folderCloudOfflineBanner( + cloudCopyTimeLabel(context, matchedAt, now: DateTime.now()), + ), ); } } @@ -1012,7 +1013,12 @@ String cloudCopyTimeLabel( final sameDay = local.year == today.year && local.month == today.month && local.day == today.day; - return sameDay ? time : '${localizations.formatShortMonthDay(local)}, $time'; + return sameDay + ? time + : context.l10n.folderCopyDateTime( + localizations.formatShortMonthDay(local), + time, + ); } /// Shown above the My Library root when the cloud half failed to load. The @@ -1031,8 +1037,7 @@ class _CloudErrorBanner extends StatelessWidget { size: 16, color: Settings.tacticalVioletTheme.destructive, ), - message: "Couldn't load your cloud library. Showing what's on this " - 'computer.', + message: context.l10n.folderCloudErrorBanner, action: ShadButton.ghost( // The button's usual vertical padding leaves too little of 28 for a // line of text, and clips the y of Retry. @@ -1040,7 +1045,7 @@ class _CloudErrorBanner extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 12), leading: const Icon(LucideIcons.refreshCw, size: 14), onPressed: onRetry, - child: const Text('Retry'), + child: Text(context.l10n.commonRetry), ), ); } diff --git a/lib/widgets/folder_edit_dialog.dart b/lib/widgets/folder_edit_dialog.dart index 8fd1c5609..eed9a45ea 100644 --- a/lib/widgets/folder_edit_dialog.dart +++ b/lib/widgets/folder_edit_dialog.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/folder_icons.dart'; import 'package:icarus/const/settings.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/folder_provider.dart'; import 'package:icarus/providers/library_workspace_provider.dart'; import 'package:icarus/services/app_error_reporter.dart'; @@ -53,6 +54,7 @@ class _FolderEditDialogState extends ConsumerState { /// is ignored. A failed cloud write keeps the dialog open with the reason. Future _submit() async { if (_isSubmitting) return; + final l10n = context.l10n; setState(() { _isSubmitting = true; _failureMessage = null; @@ -92,7 +94,7 @@ class _FolderEditDialogState extends ConsumerState { ); if (mounted) { setState(() { - _failureMessage = "Couldn't save this folder. Try again."; + _failureMessage = l10n.folderEditSaveFailed; }); } } finally { @@ -102,8 +104,8 @@ class _FolderEditDialogState extends ConsumerState { if (result?.didSucceed == true) { Navigator.of(context).pop(); } else if (result != null) { - setState(() => _failureMessage = - result!.userMessage ?? "Couldn't save this folder. Try again."); + setState(() => + _failureMessage = result!.userMessage ?? l10n.folderEditSaveFailed); } } @@ -130,13 +132,16 @@ class _FolderEditDialogState extends ConsumerState { @override Widget build(BuildContext context) { + final l10n = context.l10n; return ShadDialog( - title: Text(widget.folder != null ? "Edit Folder" : "Add Folder"), + title: Text(widget.folder != null + ? l10n.folderEditTitleEdit + : l10n.folderEditTitleAdd), actions: [ SizedBox( width: 250, child: CustomTextField( - hintText: "Folder Name", + hintText: l10n.folderEditNameHint, controller: _folderNameController, autofocus: true, onSubmitted: (_) { @@ -151,7 +156,8 @@ class _FolderEditDialogState extends ConsumerState { key: const ValueKey('folder-edit-submit'), leading: _isSubmitting ? null : const Icon(LucideIcons.check), onPressed: _isSubmitting ? null : _submit, - child: Text(_isSubmitting ? 'Saving...' : 'Done'), + child: + Text(_isSubmitting ? l10n.folderEditSaving : l10n.commonDone), ), ), ], @@ -256,10 +262,10 @@ class _FolderEditDialogState extends ConsumerState { context: context, builder: (context) { return ShadDialog( - title: const Text("Pick a custom color"), + title: Text(l10n.folderEditCustomColorTitle), actions: [ ShadButton( - child: const Text('Done'), + child: Text(l10n.commonDone), onPressed: () { Navigator.of(context).pop(); }, @@ -306,22 +312,22 @@ class _FolderEditDialogState extends ConsumerState { child: CustomSegmentedTabs<_FolderIconFilter>( compactness: 0.8, value: _iconFilter, - items: const [ + items: [ SegmentedTabItem<_FolderIconFilter>( value: _FolderIconFilter.all, - child: Text("All"), + child: Text(l10n.folderEditIconFilterAll), ), SegmentedTabItem<_FolderIconFilter>( value: _FolderIconFilter.symbols, - child: Text("Symbols"), + child: Text(l10n.folderEditIconFilterSymbols), ), SegmentedTabItem<_FolderIconFilter>( value: _FolderIconFilter.roles, - child: Text("Roles"), + child: Text(l10n.folderEditIconFilterRoles), ), SegmentedTabItem<_FolderIconFilter>( value: _FolderIconFilter.modes, - child: Text("Modes"), + child: Text(l10n.folderEditIconFilterModes), ), ], onChanged: (value) { @@ -358,7 +364,9 @@ class _FolderEditDialogState extends ConsumerState { final iconId = entry.id; const iconSize = 24.0; return IconButton( - tooltip: entry.label.isEmpty ? null : entry.label, + tooltip: entry.label.isEmpty + ? null + : context.l10n.gameName(entry.label), onPressed: () { setState(() { _selectedIconId = iconId; diff --git a/lib/widgets/folder_navigator.dart b/lib/widgets/folder_navigator.dart index 8decfa9a2..0a85faabb 100644 --- a/lib/widgets/folder_navigator.dart +++ b/lib/widgets/folder_navigator.dart @@ -8,6 +8,7 @@ import 'package:icarus/config/platform_policy.dart'; import 'package:icarus/const/maps.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/const/update_checker.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/main.dart'; import 'package:icarus/providers/collab/remote_library_provider.dart'; import 'package:icarus/providers/desktop_update_provider.dart'; @@ -91,13 +92,13 @@ class _FolderNavigatorState extends ConsumerState { Future handleImportIca() => _importPicked( StrategyImportExportService(ref).importFromFilePicker, - failureMessage: 'Failed to import strategy file.', + failureMessage: context.l10n.libraryImportIcaFailed, source: 'FolderNavigator.handleImportIca', ); Future handleImportBackup() => _importPicked( StrategyImportExportService(ref).importFromFilePicker, - failureMessage: 'Failed to import backup archive.', + failureMessage: context.l10n.libraryImportBackupFailed, source: 'FolderNavigator.handleImportBackup', ); @@ -107,11 +108,12 @@ class _FolderNavigatorState extends ConsumerState { required String source, }) async { if (!ensureFeatureAvailable(ref, PlatformFeature.importFiles)) return; + final l10n = context.l10n; try { - showImportResult(await import(), source: source); + showImportResult(l10n, await import(), source: source); } on NewerVersionImportException catch (error, stackTrace) { AppErrorReporter.reportError( - NewerVersionImportException.userMessage, + NewerVersionImportException.userMessage(l10n), error: error, stackTrace: stackTrace, source: source, @@ -128,6 +130,7 @@ class _FolderNavigatorState extends ConsumerState { Future handleExportLibrary() async { if (!ensureFeatureAvailable(ref, PlatformFeature.exportFiles)) return; + final l10n = context.l10n; try { await StrategyImportExportService(ref).exportLibrary(); } on LibraryExportIncomplete catch (error, stackTrace) { @@ -139,7 +142,7 @@ class _FolderNavigatorState extends ConsumerState { ); } catch (error, stackTrace) { AppErrorReporter.reportError( - 'Failed to export library.', + l10n.libraryExportFailed, error: error, stackTrace: stackTrace, source: 'FolderNavigator.handleExportLibrary', @@ -204,7 +207,10 @@ class _FolderNavigatorState extends ConsumerState { /// Making something in a cloud folder needs the cloud. bool refusedOffline() => ref.read(libraryWorkspaceProvider) == LibraryWorkspace.cloud && - refuseCloudChangeWhileOffline(ref.read(cloudLibraryShowsCopyProvider)); + refuseCloudChangeWhileOffline( + ref.read(l10nProvider), + ref.read(cloudLibraryShowsCopyProvider), + ); Future showCreateFolderDialog() async { if (refusedOffline()) return; @@ -237,8 +243,8 @@ class _FolderNavigatorState extends ConsumerState { } catch (_) { Settings.showToast( message: isCloud - ? "Couldn't create cloud strategy right now. Please try logging in again." - : "Couldn't create strategy right now.", + ? ref.read(l10nProvider).libraryCreateCloudStrategyFailed + : ref.read(l10nProvider).libraryCreateStrategyFailed, backgroundColor: Settings.tacticalVioletTheme.destructive, ); return; @@ -279,12 +285,12 @@ class _FolderNavigatorState extends ConsumerState { ShadContextMenuItem( leading: const Icon(LucideIcons.folderPlus), onPressed: showCreateFolderDialog, - child: const Text('Create folder'), + child: Text(context.l10n.libraryCreateFolderMenu), ), ShadContextMenuItem( leading: const Icon(LucideIcons.filePlus), onPressed: showCreateDialog, - child: const Text('Create strategy'), + child: Text(context.l10n.libraryCreateStrategyMenu), ), ], // Tabs are navigation, so the destination appears on the diff --git a/lib/widgets/global_shortcuts.dart b/lib/widgets/global_shortcuts.dart index 5eae9cd6b..b68a21367 100644 --- a/lib/widgets/global_shortcuts.dart +++ b/lib/widgets/global_shortcuts.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/placed_classes.dart'; import 'package:icarus/const/settings.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/const/form_factor.dart'; import 'package:icarus/const/shortcut_info.dart'; import 'package:icarus/providers/action_provider.dart'; @@ -164,6 +165,7 @@ class _GlobalShortcutsState extends ConsumerState if (!capabilities.canAddPage) return null; _dismissDeleteMenu(); showNewPageGaps( + ref.read(l10nProvider), await ref.read(strategyProvider.notifier).addPage(), ); return null; @@ -318,19 +320,20 @@ class _GlobalShortcutsState extends ConsumerState _dismissDeleteMenu(); final strategyId = ref.read(strategyProvider).strategyId; if (strategyId == null) return null; + final l10n = context.l10n; try { await ref.read(strategyProvider.notifier).forceSaveNow( strategyId, ); if (!mounted) return null; Settings.showToast( - message: 'File saved', + message: l10n.globalShortcutsFileSaved, backgroundColor: Colors.green, ); } catch (_) { if (!mounted) return null; Settings.showToast( - message: 'Save failed', + message: l10n.globalShortcutsSaveFailed, backgroundColor: Colors.red, ); } diff --git a/lib/widgets/hovered_map_item_name_card.dart b/lib/widgets/hovered_map_item_name_card.dart index 672f383b0..d6632bba6 100644 --- a/lib/widgets/hovered_map_item_name_card.dart +++ b/lib/widgets/hovered_map_item_name_card.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/const/agents.dart'; import 'package:icarus/const/line_provider.dart'; import 'package:icarus/const/settings.dart'; @@ -190,7 +191,7 @@ class _HoveredMapItemNameCardState boxShadow: const [Settings.cardForegroundBackdrop], ), child: Text( - visibleName, + context.l10n.gameName(visibleName), maxLines: 2, overflow: TextOverflow.ellipsis, textAlign: TextAlign.center, diff --git a/lib/widgets/ica_drop_target.dart b/lib/widgets/ica_drop_target.dart index ade4a87e0..35980aaa8 100644 --- a/lib/widgets/ica_drop_target.dart +++ b/lib/widgets/ica_drop_target.dart @@ -2,6 +2,7 @@ import 'package:desktop_drop/desktop_drop.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/settings.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/strategy/strategy_import_export.dart'; import 'package:icarus/services/app_error_reporter.dart'; import 'package:icarus/widgets/replay/open_replays.dart'; @@ -9,7 +10,10 @@ import 'package:shadcn_ui/shadcn_ui.dart'; /// Says what came in and what didn't. Updating is the one thing a user can do /// about files from a newer Icarus, so the summary always asks for it. -String buildImportSummaryMessage(ImportBatchResult result) { +String buildImportSummaryMessage( + AppLocalizations l10n, + ImportBatchResult result, +) { final newerCount = result.issues .where((issue) => issue.code == ImportIssueCode.newerVersion) .length; @@ -17,81 +21,77 @@ String buildImportSummaryMessage(ImportBatchResult result) { newerCount > 0 && newerCount == result.issues.length) { return newerCount == 1 - ? NewerVersionImportException.userMessage - : 'These files were created in a newer version of Icarus. ' - 'Please update the app and try again.'; + ? NewerVersionImportException.userMessage(l10n) + : l10n.icaDropAllNewerVersion; } - final summary = _countedImportSummary(result); + final summary = _countedImportSummary(l10n, result); if (newerCount == 0) return summary; - final wasLabel = newerCount == 1 ? 'was' : 'were'; - return '$summary $newerCount $wasLabel created in a newer version of Icarus. ' - 'Please update the app and try again.'; + return '$summary${l10n.commonSentenceSpace}' + '${l10n.icaDropSomeNewerVersion(newerCount)}'; } -String _countedImportSummary(ImportBatchResult result) { +String _countedImportSummary(AppLocalizations l10n, ImportBatchResult result) { final skippedCount = result.issues.length; - final skippedLabel = skippedCount == 1 ? 'file' : 'files'; + final space = l10n.commonSentenceSpace; final extraSegments = []; if (result.themeProfilesImported > 0) { - final profilesLabel = - result.themeProfilesImported == 1 ? 'theme profile' : 'theme profiles'; extraSegments.add( - 'Imported ${result.themeProfilesImported} $profilesLabel.', + l10n.icaDropImportedThemeProfiles(result.themeProfilesImported), ); } if (result.globalStateRestored) { - extraSegments.add('Restored library settings.'); + extraSegments.add(l10n.icaDropRestoredLibrarySettings); } if (!result.hasImports) { - const baseMessage = 'No compatible strategies or folders were imported.'; + final baseMessage = l10n.icaDropNothingImported; if (skippedCount == 0) { return baseMessage; } - return '$baseMessage Skipped $skippedCount $skippedLabel.'; + return '$baseMessage$space${l10n.icaDropSkippedFiles(skippedCount)}'; } String message; if (result.strategiesImported == 0 && result.foldersCreated == 0 && extraSegments.isNotEmpty) { - message = extraSegments.join(' '); + message = extraSegments.join(space); extraSegments.clear(); } else if (result.strategiesImported > 0 && result.foldersCreated > 0) { - final strategiesLabel = - result.strategiesImported == 1 ? 'strategy' : 'strategies'; - final foldersLabel = result.foldersCreated == 1 ? 'folder' : 'folders'; - message = 'Imported ${result.strategiesImported} $strategiesLabel into ' - '${result.foldersCreated} $foldersLabel.'; + message = l10n.icaDropImportedStrategiesIntoFolders( + result.strategiesImported, + result.foldersCreated, + ); } else if (result.strategiesImported > 0) { - final strategiesLabel = - result.strategiesImported == 1 ? 'strategy' : 'strategies'; - message = 'Imported ${result.strategiesImported} $strategiesLabel.'; + message = l10n.icaDropImportedStrategies(result.strategiesImported); } else { - final foldersLabel = result.foldersCreated == 1 ? 'folder' : 'folders'; - message = 'Imported ${result.foldersCreated} $foldersLabel.'; + message = l10n.icaDropImportedFolders(result.foldersCreated); } if (skippedCount == 0) { if (extraSegments.isEmpty) { return message; } - return '$message ${extraSegments.join(' ')}'; + return '$message$space${extraSegments.join(space)}'; } final extraSuffix = - extraSegments.isEmpty ? '' : ' ${extraSegments.join(' ')}'; - return '$message$extraSuffix Skipped $skippedCount $skippedLabel.'; + extraSegments.isEmpty ? '' : '$space${extraSegments.join(space)}'; + return '$message$extraSuffix$space${l10n.icaDropSkippedFiles(skippedCount)}'; } /// Tells the user how an import went: what came in, what was skipped, or that /// nothing did. -void showImportResult(ImportBatchResult result, {required String source}) { +void showImportResult( + AppLocalizations l10n, + ImportBatchResult result, { + required String source, +}) { if (!result.hasImports && result.issues.isEmpty) return; - final message = buildImportSummaryMessage(result); + final message = buildImportSummaryMessage(l10n, result); if (!result.hasImports) { AppErrorReporter.reportError(message, source: source); return; @@ -147,14 +147,15 @@ class _CustomDropTargetState extends ConsumerState { // A replay in the same drop switches to the Replays tab, which // disposes this widget mid-import; the app's container outlives it. final container = ProviderScope.containerOf(context, listen: false); + final l10n = container.read(l10nProvider); try { final result = await StrategyImportExportService(container) .loadFromFileDrop(files); - showImportResult(result, source: 'IcaDropTarget.onDragDone'); + showImportResult(l10n, result, source: 'IcaDropTarget.onDragDone'); } catch (error, stackTrace) { AppErrorReporter.reportError( - 'Failed to import dropped items.', + l10n.icaDropImportFailed, error: error, stackTrace: stackTrace, source: 'IcaDropTarget.onDragDone', @@ -171,19 +172,19 @@ class _CustomDropTargetState extends ConsumerState { ), ), if (isDragging) - const Positioned.fill( + Positioned.fill( child: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(LucideIcons.download, size: 60), - SizedBox( + const Icon(LucideIcons.download, size: 60), + const SizedBox( height: 10, ), Text( - "Import strategies, folders, backups, or replays", - style: - TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + context.l10n.icaDropOverlayLabel, + style: const TextStyle( + fontSize: 20, fontWeight: FontWeight.bold), ) ], ), diff --git a/lib/widgets/image_drop_target.dart b/lib/widgets/image_drop_target.dart index 7e98dca39..272681326 100644 --- a/lib/widgets/image_drop_target.dart +++ b/lib/widgets/image_drop_target.dart @@ -1,6 +1,7 @@ import 'package:desktop_drop/desktop_drop.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/collab/strategy_capabilities_provider.dart'; import 'package:icarus/providers/image_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; @@ -79,19 +80,19 @@ class _ImageDropTargetState extends ConsumerState { ), ), if (isDragging) - const Positioned.fill( + Positioned.fill( child: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(LucideIcons.download, size: 60), - SizedBox( + const Icon(LucideIcons.download, size: 60), + const SizedBox( height: 10, ), Text( - "Import image file (.png, .jpg, .webp, .gif, .bmp)", - style: - TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + context.l10n.imageDropOverlayLabel, + style: const TextStyle( + fontSize: 20, fontWeight: FontWeight.bold), ) ], ), diff --git a/lib/widgets/library_breadcrumb.dart b/lib/widgets/library_breadcrumb.dart index 1e7c406ba..9df376d12 100644 --- a/lib/widgets/library_breadcrumb.dart +++ b/lib/widgets/library_breadcrumb.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/form_factor.dart'; import 'package:icarus/const/settings.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/collab/remote_library_provider.dart'; import 'package:icarus/providers/folder_provider.dart'; import 'package:icarus/providers/library_navigation_provider.dart'; @@ -70,8 +71,9 @@ class LibraryBreadcrumb extends ConsumerWidget { children: [ FolderTab( folder: null, - label: - tab == LibraryTab.shared ? 'Shared with Me' : 'My Library', + label: tab == LibraryTab.shared + ? context.l10n.libraryBreadcrumbSharedWithMe + : context.l10n.libraryTabMyLibrary, store: store, onOpen: goToRoot, ), @@ -176,7 +178,9 @@ class FolderTab extends ConsumerWidget { builder: (context, candidateData, rejectedData) { return Container( padding: const EdgeInsets.symmetric(vertical: 4), - child: Text(label ?? folder?.name ?? 'My Library'), + child: Text( + label ?? folder?.name ?? context.l10n.libraryTabMyLibrary, + ), ); }, ), diff --git a/lib/widgets/library_title_strip.dart b/lib/widgets/library_title_strip.dart index cefff22f5..4423c47a7 100644 --- a/lib/widgets/library_title_strip.dart +++ b/lib/widgets/library_title_strip.dart @@ -5,6 +5,7 @@ import 'package:icarus/widgets/asset_svg.dart'; import 'package:icarus/config/platform_policy.dart'; import 'package:icarus/const/form_factor.dart'; import 'package:icarus/const/settings.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/auth_provider.dart'; import 'package:icarus/providers/library_navigation_provider.dart'; import 'package:icarus/providers/library_workspace_provider.dart'; @@ -35,6 +36,21 @@ const double _tabRadius = 6; const double _sortMenuWidth = 168; const double _newMenuWidth = 140; const double _accountMenuWidth = 200; + +/// A menu at least [minWidth] wide, wider when its longest label needs it: +/// the same words run longer in some languages. +class _MenuBox extends StatelessWidget { + const _MenuBox({required this.minWidth, required this.child}); + + final double minWidth; + final Widget child; + + @override + Widget build(BuildContext context) => ConstrainedBox( + constraints: BoxConstraints(minWidth: minWidth, maxWidth: 360), + child: IntrinsicWidth(child: child), + ); +} const double _menuItemHorizontalPadding = 8; const double _menuIconWidth = 18; const double _menuItemGap = 8; @@ -99,6 +115,7 @@ class _LibraryTitleStripState extends ConsumerState { // so there is nothing to search, sort, or create. final signInRequired = ref.watch(librarySignInRequiredProvider); final navigation = ref.read(libraryNavigationProvider); + final l10n = context.l10n; return AppWindowStrip( child: Row( @@ -108,8 +125,8 @@ class _LibraryTitleStripState extends ConsumerState { _TabButton( key: const ValueKey('library-tab-library'), icon: LucideIcons.folder, - label: 'My Library', - semanticsLabel: 'My Library', + label: l10n.libraryTabMyLibrary, + semanticsLabel: l10n.libraryTabMyLibrary, selected: tab == LibraryTab.library, onTap: navigation.showLibrary, ), @@ -120,8 +137,8 @@ class _LibraryTitleStripState extends ConsumerState { _TabButton( key: const ValueKey('library-tab-replays'), icon: LucideIcons.film, - label: 'Replays', - semanticsLabel: 'Replays', + label: l10n.libraryTabReplays, + semanticsLabel: l10n.libraryTabReplays, selected: tab == LibraryTab.replays, onTap: navigation.showReplays, ), @@ -130,8 +147,8 @@ class _LibraryTitleStripState extends ConsumerState { _TabButton( key: const ValueKey('library-tab-shared'), icon: LucideIcons.users, - label: 'Shared', - semanticsLabel: 'Shared library', + label: l10n.libraryTabShared, + semanticsLabel: l10n.libraryTabSharedSemantics, selected: tab == LibraryTab.shared, dimmed: !cloudBrowsable, onTap: _showSharedTab, @@ -140,8 +157,8 @@ class _LibraryTitleStripState extends ConsumerState { _TabButton( key: const ValueKey('library-tab-community'), icon: LucideIcons.globe, - label: 'Community', - semanticsLabel: 'Community library', + label: l10n.libraryTabCommunity, + semanticsLabel: l10n.libraryTabCommunitySemantics, selected: tab == LibraryTab.community, onTap: navigation.showCommunity, ), @@ -168,7 +185,6 @@ class _LibraryTitleStripState extends ConsumerState { collapsedWidth: _controlHeight, expandedWidth: 220, compact: true, - hintText: 'Search', ), ), const SizedBox(width: 4), @@ -183,6 +199,7 @@ class _LibraryTitleStripState extends ConsumerState { padding: const EdgeInsets.symmetric(horizontal: 10), onPressed: () { if (refuseCloudChangeWhileOffline( + ref.read(l10nProvider), ref.read(cloudLibraryShowsCopyProvider), )) { return; @@ -190,7 +207,7 @@ class _LibraryTitleStripState extends ConsumerState { showAddSharedItemDialog(context); }, leading: const Icon(LucideIcons.link, size: 14), - child: const Text('Add by Link or Code'), + child: Text(l10n.libraryAddByLinkOrCode), ) else _buildNewMenu(), @@ -229,14 +246,15 @@ class _LibraryTitleStripState extends ConsumerState { tab != LibraryTab.community && !signInRequired; final searching = _phoneSearchOpen && canSearch; + final l10n = context.l10n; final tabs = Row( children: [ Expanded( child: _TabButton( key: const ValueKey('library-tab-library'), - label: 'My Library', - semanticsLabel: 'My Library', + label: l10n.libraryTabMyLibrary, + semanticsLabel: l10n.libraryTabMyLibrary, selected: tab == LibraryTab.library, height: _phoneControlHeight, onTap: navigation.showLibrary, @@ -246,8 +264,8 @@ class _LibraryTitleStripState extends ConsumerState { Expanded( child: _TabButton( key: const ValueKey('library-tab-shared'), - label: 'Shared', - semanticsLabel: 'Shared library', + label: l10n.libraryTabShared, + semanticsLabel: l10n.libraryTabSharedSemantics, selected: tab == LibraryTab.shared, dimmed: !cloudBrowsable, height: _phoneControlHeight, @@ -258,8 +276,8 @@ class _LibraryTitleStripState extends ConsumerState { Expanded( child: _TabButton( key: const ValueKey('library-tab-community'), - label: 'Community', - semanticsLabel: 'Community library', + label: l10n.libraryTabCommunity, + semanticsLabel: l10n.libraryTabCommunitySemantics, selected: tab == LibraryTab.community, height: _phoneControlHeight, onTap: navigation.showCommunity, @@ -296,7 +314,6 @@ class _LibraryTitleStripState extends ConsumerState { expandedWidth: constraints.maxWidth, compact: true, autofocus: true, - hintText: 'Search', ), ), ), @@ -334,6 +351,7 @@ class _LibraryTitleStripState extends ConsumerState { padding: EdgeInsets.zero, onPressed: () { if (refuseCloudChangeWhileOffline( + ref.read(l10nProvider), ref.read(cloudLibraryShowsCopyProvider), )) { return; @@ -401,6 +419,7 @@ class _LibraryTitleStripState extends ConsumerState { /// New is one button on a phone: the strategy goes in the menu with the /// folder, since there's no room for its label. Widget _buildPhoneNewMenu() { + final l10n = context.l10n; return ShadPopover( controller: _newController, padding: const EdgeInsets.all(6), @@ -409,8 +428,8 @@ class _LibraryTitleStripState extends ConsumerState { childAlignment: Alignment.topRight, overlayAlignment: Alignment.bottomRight, ), - popover: (context) => SizedBox( - width: _newMenuWidth + 24, + popover: (context) => _MenuBox( + minWidth: _newMenuWidth + 24, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, @@ -419,21 +438,21 @@ class _LibraryTitleStripState extends ConsumerState { menu: _newController, key: const ValueKey('library-new-strategy'), icon: LucideIcons.filePlus, - label: 'New Strategy', + label: l10n.libraryNewStrategy, onPressed: widget.onCreateStrategy, ), _MenuItem( menu: _newController, key: const ValueKey('library-new-folder'), icon: LucideIcons.folderPlus, - label: 'New Folder', + label: l10n.libraryNewFolder, onPressed: widget.onCreateFolder, ), ], ), ), child: Semantics( - label: 'New', + label: l10n.libraryNewMenuSemantics, button: true, child: ShadIconButton( key: const ValueKey('library-new-menu'), @@ -467,13 +486,14 @@ class _LibraryTitleStripState extends ConsumerState { if (file.path != null) file.path!, ]); }, - child: const Text('Add Replay'), + child: Text(context.l10n.libraryAddReplay), ); } Widget _buildSortMenu({double size = _controlHeight}) { final filter = ref.watch(strategyFilterProvider); final isAscending = filter.sortOrder == SortOrder.ascending; + final l10n = context.l10n; return ShadPopover( controller: _sortController, padding: const EdgeInsets.all(6), @@ -482,18 +502,18 @@ class _LibraryTitleStripState extends ConsumerState { childAlignment: Alignment.topRight, overlayAlignment: Alignment.bottomRight, ), - popover: (context) => SizedBox( - width: _sortMenuWidth, + popover: (context) => _MenuBox( + minWidth: _sortMenuWidth, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - const _MenuLabel('Sort by'), + _MenuLabel(l10n.librarySortBy), for (final value in SortBy.values) _MenuItem( menu: _sortController, icon: value == filter.sortBy ? LucideIcons.check : null, - label: StrategyFilterProvider.sortByLabels[value]!, + label: StrategyFilterProvider.sortByLabel(value, l10n), onPressed: () { ref.read(strategyFilterProvider.notifier).setSortBy(value); }, @@ -504,7 +524,10 @@ class _LibraryTitleStripState extends ConsumerState { icon: isAscending ? LucideIcons.arrowUpNarrowWide : LucideIcons.arrowDownWideNarrow, - label: StrategyFilterProvider.sortOrderLabels[filter.sortOrder]!, + label: StrategyFilterProvider.sortOrderLabel( + filter.sortOrder, + l10n, + ), onPressed: () { ref.read(strategyFilterProvider.notifier).setSortOrder( isAscending ? SortOrder.descending : SortOrder.ascending, @@ -515,7 +538,7 @@ class _LibraryTitleStripState extends ConsumerState { ), ), child: Tooltip( - message: 'Sort', + message: l10n.librarySortTooltip, child: ShadIconButton.ghost( key: const ValueKey('library-sort-menu'), width: size, @@ -549,6 +572,7 @@ class _LibraryTitleStripState extends ConsumerState { final policy = ref.watch(platformPolicyProvider); final canImport = policy.supports(PlatformFeature.importFiles); final canExport = policy.supports(PlatformFeature.exportFiles); + final l10n = context.l10n; return ShadPopover( controller: _newController, padding: const EdgeInsets.all(6), @@ -557,8 +581,8 @@ class _LibraryTitleStripState extends ConsumerState { childAlignment: Alignment.topRight, overlayAlignment: Alignment.bottomRight, ), - popover: (context) => SizedBox( - width: _newMenuWidth, + popover: (context) => _MenuBox( + minWidth: _newMenuWidth, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, @@ -567,7 +591,7 @@ class _LibraryTitleStripState extends ConsumerState { menu: _newController, key: const ValueKey('library-new-folder'), icon: LucideIcons.folderPlus, - label: 'New Folder', + label: l10n.libraryNewFolder, onPressed: widget.onCreateFolder, ), if (canImport || canExport) const _MenuDivider(), @@ -575,13 +599,13 @@ class _LibraryTitleStripState extends ConsumerState { _MenuItem( menu: _newController, icon: LucideIcons.fileDown, - label: 'Import .ica', + label: l10n.libraryImportIca, onPressed: widget.onImportIca, ), _MenuItem( menu: _newController, icon: LucideIcons.archiveRestore, - label: 'Import Backup', + label: l10n.libraryImportBackup, onPressed: widget.onImportBackup, ), ], @@ -589,7 +613,7 @@ class _LibraryTitleStripState extends ConsumerState { _MenuItem( menu: _newController, icon: LucideIcons.archive, - label: 'Export Library', + label: l10n.libraryExportLibrary, onPressed: widget.onExportLibrary, ), ], @@ -609,7 +633,7 @@ class _LibraryTitleStripState extends ConsumerState { ), onPressed: widget.onCreateStrategy, leading: const Icon(LucideIcons.plus, size: 16), - child: const Text('New Strategy'), + child: Text(l10n.libraryNewStrategy), ), // A rounded border must be one colour, so the seam is its own strip. const SizedBox( @@ -635,6 +659,7 @@ class _LibraryTitleStripState extends ConsumerState { Widget _buildAccount({double size = _controlHeight}) { final auth = ref.watch(authProvider); + final l10n = context.l10n; if (auth.isLoading) { return SizedBox( key: const ValueKey('library-account-action'), @@ -651,7 +676,7 @@ class _LibraryTitleStripState extends ConsumerState { } if (!auth.isAuthenticated) { return Semantics( - label: 'Log in to Icarus', + label: l10n.libraryLogInSemantics, button: true, onTap: _showAuthDialog, child: ShadButton.secondary( @@ -659,7 +684,7 @@ class _LibraryTitleStripState extends ConsumerState { height: size, padding: const EdgeInsets.symmetric(horizontal: 10), onPressed: _showAuthDialog, - child: const Text('Log In'), + child: Text(l10n.commonLogIn), ), ); } @@ -671,8 +696,8 @@ class _LibraryTitleStripState extends ConsumerState { childAlignment: Alignment.topRight, overlayAlignment: Alignment.bottomRight, ), - popover: (context) => SizedBox( - width: _accountMenuWidth, + popover: (context) => _MenuBox( + minWidth: _accountMenuWidth, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, @@ -683,7 +708,7 @@ class _LibraryTitleStripState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - auth.displayName, + auth.displayName(l10n), style: const TextStyle(fontWeight: FontWeight.w600), overflow: TextOverflow.ellipsis, ), @@ -703,14 +728,14 @@ class _LibraryTitleStripState extends ConsumerState { _MenuItem( menu: _accountController, icon: LucideIcons.logOut, - label: 'Sign Out', + label: l10n.commonSignOut, onPressed: _confirmSignOut, ), ], ), ), child: Semantics( - label: 'Account for ${auth.displayName}', + label: l10n.libraryAccountSemantics(auth.displayName(l10n)), button: true, onTap: _accountController.toggle, child: ShadButton.ghost( diff --git a/lib/widgets/line_up_media_carousel.dart b/lib/widgets/line_up_media_carousel.dart index 8fabedae0..b22ea67f8 100644 --- a/lib/widgets/line_up_media_carousel.dart +++ b/lib/widgets/line_up_media_carousel.dart @@ -6,6 +6,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter/services.dart'; import 'package:icarus/const/line_provider.dart'; import 'package:icarus/const/settings.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/collab/strategy_capabilities_provider.dart'; import 'package:icarus/providers/strategy_image_source.dart'; import 'package:icarus/widgets/dialogs/create_lineup_dialog.dart'; @@ -83,7 +84,7 @@ class LineUpMediaCarousel extends ConsumerWidget { ), ShadButton( leading: const Icon(LucideIcons.pencil), - child: const Text("Edit"), + child: Text(context.l10n.commonEdit), onPressed: () { Navigator.of(context).pop(); showDialog( @@ -170,10 +171,10 @@ class _LineUpMediaPagesState extends ConsumerState Widget content; if (widget.images.isEmpty && widget.youtubeLink.isEmpty) { - content = const Center( + content = Center( child: Text( - 'No images available', - style: TextStyle(color: Colors.white), + context.l10n.lineupMediaNoImages, + style: const TextStyle(color: Colors.white), ), ); } else { diff --git a/lib/widgets/line_up_placement_editor.dart b/lib/widgets/line_up_placement_editor.dart index eed3180db..e8d56d5e3 100644 --- a/lib/widgets/line_up_placement_editor.dart +++ b/lib/widgets/line_up_placement_editor.dart @@ -6,6 +6,7 @@ import 'package:icarus/const/line_provider.dart'; import 'package:icarus/const/maps.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/const/transition_data.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/canvas_selection_provider.dart'; import 'package:icarus/providers/interaction_state_provider.dart'; import 'package:icarus/providers/map_provider.dart'; @@ -18,18 +19,26 @@ import 'package:icarus/widgets/editor_operation_scope.dart'; /// The one thing to do next while editing placement. Empty once something /// has moved: the pill would cover what is being dragged, and Save is lit. -String lineUpEditStatus(LineUpState state) { +String lineUpEditStatus(AppLocalizations l10n, LineUpState state) { final edit = state.edit; if (edit == null || state.editMovesAnything) return ''; final agentType = edit.originIds .map((id) => state.originById(id)?.agent.type) .nonNulls .firstOrNull; - final agentName = AgentData.agents[agentType]?.name ?? 'the agent'; + final agentName = switch (AgentData.agents[agentType]?.name) { + final name? => l10n.gameName(name), + null => null, + }; final count = edit.linkIds.length; - if (count == 1) return 'Drag $agentName or the ability to its new spot'; - return 'Drag $agentName or an ability. These $count lineups share a spot, ' - 'so they move together'; + if (count == 1) { + return agentName == null + ? l10n.lineupEditStatusOneUnknownAgent + : l10n.lineupEditStatusOne(agentName); + } + return agentName == null + ? l10n.lineupEditStatusSharedSpotUnknownAgent(count) + : l10n.lineupEditStatusSharedSpot(agentName, count); } /// Placement editing: the lineups being moved, drawn over the dimmed page at @@ -262,7 +271,8 @@ class _LineUpPlacementEditorState extends ConsumerState { ), ), ), - if (lineUpEditStatus(state) case final status when status.isNotEmpty) + if (lineUpEditStatus(context.l10n, state) case final status + when status.isNotEmpty) LineUpStatusPill( key: const ValueKey('lineup-edit-status'), text: status, diff --git a/lib/widgets/line_up_placer.dart b/lib/widgets/line_up_placer.dart index a1fb5d3ee..9122202bf 100644 --- a/lib/widgets/line_up_placer.dart +++ b/lib/widgets/line_up_placer.dart @@ -7,6 +7,7 @@ import 'package:icarus/const/maps.dart'; import 'package:icarus/const/placed_classes.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/const/transition_data.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/ability_bar_provider.dart'; import 'package:icarus/providers/interaction_state_provider.dart'; import 'package:icarus/providers/canvas_selection_provider.dart'; @@ -23,19 +24,23 @@ import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:uuid/uuid.dart'; /// The one thing the user has to do next. Empty once both ends are down. -String lineUpPlacementStatus(LineUpPlacement placement) { +String lineUpPlacementStatus( + AppLocalizations l10n, + LineUpPlacement placement, +) { if (placement.isComplete) return ''; switch (placement.mode) { case LineUpPlacementMode.fresh: return placement.hasOrigin - ? 'Drag the ability to where it lands' - : 'Drag an agent to where you throw from'; + ? l10n.lineupPlacementDragAbility + : l10n.lineupPlacementDragAnAgent; case LineUpPlacementMode.fromPinnedOrigin: - return 'Drag the ability to where it lands'; + return l10n.lineupPlacementDragAbility; case LineUpPlacementMode.toPinnedLanding: - final agentName = - AgentData.agents[placement.pinnedAgentType]?.name ?? 'the agent'; - return 'Drag $agentName to where you throw from'; + final agentName = AgentData.agents[placement.pinnedAgentType]?.name; + return agentName == null + ? l10n.lineupPlacementDragUnknownAgent + : l10n.lineupPlacementDragAgent(l10n.gameName(agentName)); } } @@ -172,7 +177,7 @@ class _LineupPositionWidgetState extends ConsumerState { if (placement != null && !placement.isComplete) LineUpStatusPill( key: const ValueKey('lineup-placement-status'), - text: lineUpPlacementStatus(placement), + text: lineUpPlacementStatus(context.l10n, placement), ), if (draftAbility != null) EditorEntityLayer( diff --git a/lib/widgets/lineup_control_buttons.dart b/lib/widgets/lineup_control_buttons.dart index 1e90c803d..460387165 100644 --- a/lib/widgets/lineup_control_buttons.dart +++ b/lib/widgets/lineup_control_buttons.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/line_provider.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/interaction_state_provider.dart'; import 'package:icarus/widgets/dialogs/create_lineup_dialog.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; @@ -29,13 +30,14 @@ class _PlacingButtons extends ConsumerWidget { final hasOrigin = placement.hasOrigin; final hasLanding = placement.hasLanding; + final l10n = context.l10n; final tooltipMessage = !hasOrigin && !hasLanding - ? "Place an agent and ability to continue" + ? l10n.lineupControlPlaceBoth : !hasOrigin - ? "Place an agent to continue" + ? l10n.lineupControlPlaceAgent : !hasLanding - ? "Place an ability to continue" - : "Finalize lineup details"; + ? l10n.lineupControlPlaceAbility + : l10n.lineupControlFinalize; return _ButtonRow( primary: ShadTooltip( @@ -52,7 +54,7 @@ class _PlacingButtons extends ConsumerWidget { }, ); }, - child: const Text("Next"), + child: Text(l10n.lineupControlNext), ), ), ), @@ -76,7 +78,7 @@ class _EditingButtons extends ConsumerWidget { .read(interactionStateProvider.notifier) .update(InteractionState.navigation); }, - child: const Text("Save"), + child: Text(context.l10n.commonSave), ), ); } @@ -96,7 +98,7 @@ class _ButtonRow extends ConsumerWidget { mainAxisSize: MainAxisSize.min, children: [ ShadTooltip( - builder: (_) => const Text("Cancel"), + builder: (_) => Text(context.l10n.commonCancel), child: ShadIconButton.secondary( width: 40, height: 40, diff --git a/lib/widgets/lineup_editors_notice.dart b/lib/widgets/lineup_editors_notice.dart index ca10e98a3..4b0837593 100644 --- a/lib/widgets/lineup_editors_notice.dart +++ b/lib/widgets/lineup_editors_notice.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/collab/presence/presence_models.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/collab/editing_presence_provider.dart'; import 'package:icarus/widgets/strategy_presence.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; @@ -43,7 +44,7 @@ class LineUpEditorsNotice extends ConsumerWidget { const SizedBox(width: 8), Flexible( child: Text( - lineUpEditorsText(editors, lineups: lineups), + lineUpEditorsText(context.l10n, editors, lineups: lineups), // The label role: 12px/600. style: theme.textTheme.small.copyWith( color: theme.colorScheme.mutedForeground, @@ -59,13 +60,20 @@ class LineUpEditorsNotice extends ConsumerWidget { } /// "Sam is editing this lineup right now." and its plural forms. -String lineUpEditorsText(List editors, {bool lineups = false}) { - final what = lineups ? 'these lineups' : 'this lineup'; - final who = switch (editors) { - [final one] => '${one.name} is', - [final a, final b] => '${a.name} and ${b.name} are', - [final a, ...final rest] => '${a.name} and ${rest.length} others are', - [] => '', - }; - return '$who editing $what right now.'; -} +String lineUpEditorsText( + AppLocalizations l10n, + List editors, { + bool lineups = false, +}) => + switch (editors) { + [final one] => lineups + ? l10n.lineupEditorsOneOnLineups(one.name) + : l10n.lineupEditorsOneOnLineup(one.name), + [final a, final b] => lineups + ? l10n.lineupEditorsTwoOnLineups(a.name, b.name) + : l10n.lineupEditorsTwoOnLineup(a.name, b.name), + [final a, ...final rest] => lineups + ? l10n.lineupEditorsManyOnLineups(a.name, rest.length) + : l10n.lineupEditorsManyOnLineup(a.name, rest.length), + [] => '', + }; diff --git a/lib/widgets/map_selector.dart b/lib/widgets/map_selector.dart index 631e50f88..dd637628c 100644 --- a/lib/widgets/map_selector.dart +++ b/lib/widgets/map_selector.dart @@ -6,6 +6,7 @@ import 'package:icarus/const/custom_icons.dart'; import 'package:icarus/const/hive_boxes.dart'; import 'package:icarus/const/maps.dart'; import 'package:icarus/const/settings.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/map_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/widgets/map_tile.dart'; @@ -191,14 +192,16 @@ class _MapSelectorState extends ConsumerState } if (index == availableMaps.length) { - return const Padding( - padding: EdgeInsets.symmetric( + return Padding( + padding: + const EdgeInsets.symmetric( horizontal: 8, vertical: 5, ), child: Text( - "Out of rotation", - style: TextStyle( + context.l10n + .mapSelectorOutOfRotation, + style: const TextStyle( fontWeight: FontWeight.w500, color: Color.fromARGB( 255, @@ -303,12 +306,12 @@ class _SideToggle extends ConsumerWidget { (strategyId == null ? null : b.get(strategyId))?.pages ?? const []; final mixed = pages.any((page) => page.isAttack != pages.first.isAttack); - final nextSide = isAttack ? 'Defense' : 'Attack'; - final tooltip = mixed - ? 'Pages are on mixed sides\n' - 'Click: all pages to $nextSide\n' - 'Shift+click: this page only' - : 'Switch side on all pages\nShift+click: this page only'; + final l10n = context.l10n; + final tooltip = !mixed + ? l10n.mapSelectorSideTooltip + : isAttack + ? l10n.mapSelectorSideTooltipMixedToDefense + : l10n.mapSelectorSideTooltipMixedToAttack; // A Shad button, not an InkWell: ShadTooltip only follows hover // through Shad's own buttons. @@ -346,7 +349,9 @@ class _SideToggle extends ConsumerWidget { color: isAttack ? Colors.redAccent : Colors.blueAccent, ), Text( - isAttack ? "Attack" : "Defense", + isAttack + ? l10n.mapSelectorAttack + : l10n.mapSelectorDefense, style: const TextStyle( fontSize: 12, fontWeight: FontWeight.w500, diff --git a/lib/widgets/map_theme_settings_section.dart b/lib/widgets/map_theme_settings_section.dart index c7159a284..2e4b3945a 100644 --- a/lib/widgets/map_theme_settings_section.dart +++ b/lib/widgets/map_theme_settings_section.dart @@ -5,6 +5,7 @@ import 'package:icarus/widgets/asset_svg.dart'; import 'package:icarus/const/app_navigator.dart'; import 'package:icarus/const/maps.dart'; import 'package:icarus/const/settings.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/map_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/providers/user_preferences_provider.dart'; @@ -34,9 +35,12 @@ class MapThemeSettingsSection extends ConsumerWidget { .length; return SettingsScopeCard( - title: "Map theme", + title: context.l10n.mapThemeTitle, trailing: Text( - "$customCount/${MapThemeProfilesProvider.customProfilesSoftCap} custom", + context.l10n.mapThemeCustomCount( + customCount, + MapThemeProfilesProvider.customProfilesSoftCap, + ), style: ShadTheme.of(context).textTheme.small.copyWith( color: Settings.tacticalVioletTheme.mutedForeground, ), @@ -65,6 +69,7 @@ class _ThemeProfilesList extends ConsumerWidget { profilesState.profiles.where((p) => !p.isBuiltIn).length; final canCreate = customCount < MapThemeProfilesProvider.customProfilesSoftCap; + final l10n = context.l10n; return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -72,8 +77,8 @@ class _ThemeProfilesList extends ConsumerWidget { const SizedBox(height: 8), if (overridePalette != null) ...[ _ProfileListRow( - title: "Custom", - tags: const ["This strategy only"], + title: l10n.mapThemeCustomRowTitle, + tags: [l10n.mapThemeThisStrategyOnlyTag], palette: overridePalette, isSelected: true, onTap: null, @@ -90,7 +95,7 @@ class _ThemeProfilesList extends ConsumerWidget { initialPalette: overridePalette, ) : null, - child: const Text("Save as profile"), + child: Text(l10n.mapThemeSaveAsProfile), ), const SizedBox(width: 4), ShadButton.ghost( @@ -100,7 +105,7 @@ class _ThemeProfilesList extends ConsumerWidget { mode: MapThemeEditorMode.customizeStrategy, initialPalette: overridePalette, ), - child: const Text("Edit"), + child: Text(l10n.commonEdit), ), ], ), @@ -112,8 +117,8 @@ class _ThemeProfilesList extends ConsumerWidget { title: profile.name, tags: [ if (profile.id == profilesState.defaultProfileIdForNewStrategies) - "Default", - if (profile.isBuiltIn) "Built-in", + l10n.mapThemeDefaultTag, + if (profile.isBuiltIn) l10n.mapThemeBuiltInTag, ], palette: profile.palette, isSelected: activeProfileId == profile.id, @@ -138,7 +143,7 @@ class _ThemeProfilesList extends ConsumerWidget { ], _AddProfileRow( icon: LucideIcons.plus, - label: "New profile", + label: l10n.mapThemeNewProfile, enabled: canCreate, onTap: () => showMapThemeEditorDialog( context, @@ -148,7 +153,7 @@ class _ThemeProfilesList extends ConsumerWidget { ), _AddProfileRow( icon: LucideIcons.clipboardPaste, - label: "Import profile code", + label: l10n.mapThemeImportProfileCodeAction, enabled: canCreate, onTap: () => _importProfileCode(context, ref), ), @@ -165,9 +170,9 @@ class _ThemeProfilesList extends ConsumerWidget { // changes, so its action reads the app's container, not this ref. final container = ProviderScope.containerOf(context, listen: false); Settings.showToast( - message: "${added.name} added", + message: context.l10n.mapThemeProfileAdded(added.name), backgroundColor: Settings.tacticalVioletTheme.primary, - actionLabel: hasActiveStrategy ? "Use it" : null, + actionLabel: hasActiveStrategy ? context.l10n.mapThemeUseIt : null, onActionPressed: hasActiveStrategy ? () => _useImportedProfile(container, added) : null, @@ -205,7 +210,7 @@ class _ThemeProfilesList extends ConsumerWidget { mode: MapThemeEditorMode.customizeStrategy, initialPalette: ref.read(effectiveMapThemePaletteProvider), ), - child: const Text("Customize"), + child: Text(context.l10n.mapThemeCustomize), ), if (!(profile.isBuiltIn && isDefault)) _ProfileContextMenuButton(profile: profile, isDefault: isDefault), @@ -324,30 +329,31 @@ class _ProfileContextMenuButtonState } List _buildMenuItems() { + final l10n = context.l10n; return [ if (!widget.isDefault) ShadContextMenuItem( leading: const Icon(LucideIcons.star, size: 16), onPressed: _setAsDefault, - child: const Text("Set as default"), + child: Text(l10n.mapThemeSetAsDefault), ), if (!widget.profile.isBuiltIn) ShadContextMenuItem( leading: const Icon(LucideIcons.pencil, size: 16), onPressed: _renameProfile, - child: const Text("Rename"), + child: Text(l10n.commonRename), ), if (!widget.profile.isBuiltIn) ShadContextMenuItem( leading: const Icon(LucideIcons.palette, size: 16), onPressed: _editProfilePalette, - child: const Text("Edit colors"), + child: Text(l10n.mapThemeEditColors), ), if (!widget.profile.isBuiltIn) ShadContextMenuItem( leading: const Icon(LucideIcons.copy, size: 16), onPressed: _copyProfileCode, - child: const Text("Copy profile code"), + child: Text(l10n.mapThemeCopyProfileCode), ), if (!widget.profile.isBuiltIn) ShadContextMenuItem( @@ -358,7 +364,7 @@ class _ProfileContextMenuButtonState ), onPressed: _deleteProfile, child: Text( - "Delete", + l10n.commonDelete, style: TextStyle(color: Settings.tacticalVioletTheme.destructive), ), ), @@ -366,6 +372,7 @@ class _ProfileContextMenuButtonState } Future _renameProfile() async { + final l10n = context.l10n; final newName = await _showRenameDialog( context: context, currentName: widget.profile.name, @@ -377,13 +384,14 @@ class _ProfileContextMenuButtonState .renameProfile(profileId: widget.profile.id, newName: newName); if (!renamed) { Settings.showToast( - message: "Couldn't rename this profile.", + message: l10n.mapThemeRenameFailed, backgroundColor: Settings.tacticalVioletTheme.destructive, ); } } Future _copyProfileCode() async { + final l10n = context.l10n; try { await Clipboard.setData( ClipboardData( @@ -395,7 +403,7 @@ class _ProfileContextMenuButtonState ); } catch (_) { Settings.showToast( - message: "Couldn't copy the profile code.", + message: l10n.mapThemeCopyFailed, backgroundColor: Settings.tacticalVioletTheme.destructive, ); return; @@ -403,7 +411,7 @@ class _ProfileContextMenuButtonState if (!mounted) return; Settings.showToast( - message: "Profile code copied", + message: l10n.mapThemeCodeCopied, backgroundColor: Settings.tacticalVioletTheme.primary, ); } @@ -424,7 +432,7 @@ class _ProfileContextMenuButtonState if (!mounted) return; Settings.showToast( - message: "Default profile updated.", + message: context.l10n.mapThemeDefaultUpdated, backgroundColor: Settings.tacticalVioletTheme.primary, ); } @@ -436,7 +444,7 @@ class _ProfileContextMenuButtonState if (!mounted) return; Settings.showToast( - message: "Profile deleted.", + message: context.l10n.mapThemeProfileDeleted, backgroundColor: Settings.tacticalVioletTheme.primary, ); } @@ -585,18 +593,18 @@ Future _showRenameDialog({ context: context, builder: (dialogContext) { return ShadDialog( - title: const Text("Rename Profile"), + title: Text(dialogContext.l10n.mapThemeRenameDialogTitle), actions: [ ShadButton.secondary( onPressed: () => Navigator.of(dialogContext).pop(), - child: const Text("Cancel"), + child: Text(dialogContext.l10n.commonCancel), ), ShadButton( onPressed: () { final trimmed = controller.text.trim(); Navigator.of(dialogContext).pop(trimmed.isEmpty ? null : trimmed); }, - child: const Text("Rename"), + child: Text(dialogContext.l10n.commonRename), ), ], child: Material( @@ -607,7 +615,7 @@ Future _showRenameDialog({ builder: (context, ref, _) { return CustomTextField( controller: controller, - hintText: "Profile name", + hintText: context.l10n.mapThemeProfileNameHint, ); }, ), @@ -680,27 +688,28 @@ class _ImportProfileCodeDialogState .where((profile) => profile.palette == code.toPalette()) .firstOrNull; + final l10n = context.l10n; final (String? message, bool isError) = switch (result) { MapThemeProfileCodeEmpty() => (null, false), MapThemeProfileCodeInvalid() => ( - "That isn't an Icarus profile code.", + l10n.mapThemeCodeInvalid, true, ), MapThemeProfileCodeIncomplete() => ( - "This code is incomplete. Copy the whole code and paste it again.", + l10n.mapThemeCodeIncomplete, true, ), MapThemeProfileCodeNewerVersion() => ( - "This code is from a newer version of Icarus. Update Icarus to import it.", + l10n.mapThemeCodeNewerVersion, true, ), MapThemeProfileCodeValid() when duplicate != null => ( - "You already have these colors as “${duplicate.name}”.", + l10n.mapThemeCodeDuplicate(duplicate.name), false, ), MapThemeProfileCodeValid() => ( _controller.text == widget.clipboardCode - ? "Pasted from your clipboard." + ? l10n.mapThemeCodePastedFromClipboard : null, false, ), @@ -708,19 +717,17 @@ class _ImportProfileCodeDialogState final canAdd = code != null && duplicate == null && !_adding; return ShadDialog( - title: const Text("Import profile code"), - description: const Text( - "Paste a code someone shared to add their map colors to your profiles.", - ), + title: Text(l10n.mapThemeImportDialogTitle), + description: Text(l10n.mapThemeImportDialogDescription), actions: [ ShadButton.secondary( onPressed: () => Navigator.of(context).pop(), - child: const Text("Cancel"), + child: Text(l10n.commonCancel), ), ShadButton( enabled: canAdd, onPressed: canAdd ? () => _add(code) : null, - child: const Text("Add profile"), + child: Text(l10n.mapThemeAddProfile), ), ], child: Material( @@ -734,7 +741,7 @@ class _ImportProfileCodeDialogState const SizedBox(height: 8), CustomTextField( controller: _controller, - hintText: "Paste a profile code", + hintText: l10n.mapThemeCodeHint, hasError: isError, autofocus: widget.clipboardCode == null, onSubmitted: (_) { @@ -780,7 +787,7 @@ class _ImportProfileCodeDialogState if (created == null) { setState(() => _adding = false); Settings.showToast( - message: "Couldn't add this profile.", + message: context.l10n.mapThemeAddFailed, backgroundColor: Settings.tacticalVioletTheme.destructive, ); return; @@ -829,7 +836,7 @@ class _ProfileCodePreview extends ConsumerWidget { if (code == null) Center( child: Text( - "The colors show here once you paste a code.", + context.l10n.mapThemePreviewEmpty, style: ShadTheme.of(context).textTheme.small.copyWith( color: theme.mutedForeground, ), @@ -847,7 +854,7 @@ class _ProfileCodePreview extends ConsumerWidget { colorMapper: MapSvgColorMapper.forPalette(code.toPalette()), fit: BoxFit.contain, - semanticsLabel: 'Profile code preview', + semanticsLabel: context.l10n.mapThemePreviewSemantics, ), ), ), @@ -884,10 +891,9 @@ Future _confirmDiscardCustomColors( ) { return ConfirmAlertDialog.show( context: context, - title: "Discard custom colors?", - content: - "This strategy's custom colors will be replaced with \"${profile.name}\" and can't be brought back.", - confirmText: "Discard", + title: context.l10n.mapThemeDiscardCustomTitle, + content: context.l10n.mapThemeDiscardCustomBody(profile.name), + confirmText: context.l10n.commonDiscard, isDestructive: true, ); } @@ -913,7 +919,9 @@ Future _useImportedProfile( .any((existing) => existing.id == profile.id); if (!stillThere) { Settings.showToast( - message: "${profile.name} was deleted.", + message: container.read(l10nProvider).mapThemeProfileWasDeleted( + profile.name, + ), backgroundColor: Settings.tacticalVioletTheme.destructive, ); return; diff --git a/lib/widgets/map_tile.dart b/lib/widgets/map_tile.dart index 718680b13..1b85e1af3 100644 --- a/lib/widgets/map_tile.dart +++ b/lib/widgets/map_tile.dart @@ -6,6 +6,7 @@ import 'package:flutter/foundation.dart' show SynchronousFuture; import 'package:flutter/material.dart'; import 'package:flutter/services.dart' show rootBundle; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/const/settings.dart'; class MapTile extends ConsumerStatefulWidget { @@ -73,7 +74,7 @@ class _MapTileState extends ConsumerState { Align( alignment: Alignment.center, child: Text( - widget.name.toUpperCase(), + context.l10n.mapNameOf(widget.name).toUpperCase(), style: TextStyle( fontWeight: FontWeight.bold, fontSize: 16, diff --git a/lib/widgets/mouse_watch.dart b/lib/widgets/mouse_watch.dart index 665f332d6..0d25d011f 100644 --- a/lib/widgets/mouse_watch.dart +++ b/lib/widgets/mouse_watch.dart @@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/coordinate_system.dart'; import 'package:icarus/const/line_provider.dart'; import 'package:icarus/const/settings.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/collab/strategy_capabilities_provider.dart'; import 'package:icarus/providers/hovered_delete_target_provider.dart'; import 'package:icarus/providers/screenshot_provider.dart'; @@ -316,7 +317,7 @@ class _MouseWatchState extends ConsumerState { Icons.delete, color: Settings.tacticalVioletTheme.destructive, ), - child: const Text('Delete origin'), + child: Text(context.l10n.mouseWatchDeleteOrigin), onPressed: () { ref .read(lineUpProvider.notifier) diff --git a/lib/widgets/new_page_copy_toast.dart b/lib/widgets/new_page_copy_toast.dart index 2c8303717..933cd3b8c 100644 --- a/lib/widgets/new_page_copy_toast.dart +++ b/lib/widgets/new_page_copy_toast.dart @@ -1,27 +1,25 @@ import 'package:icarus/const/settings.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/strategy_provider.dart'; /// Says what a new page's copy lacks, and when the page hasn't reached /// the cloud yet, if either (see StrategyProvider.addPage). -void showNewPageGaps(NewPageGaps gaps) { +void showNewPageGaps(AppLocalizations l10n, NewPageGaps gaps) { if (gaps.notSaved) { Settings.showToast( - message: "The new page couldn't be saved on this computer, so it " - "wasn't added.", + message: l10n.newPageCopyNotSaved, backgroundColor: Settings.tacticalVioletTheme.destructive, ); } if (gaps.waitingForCloud) { Settings.showToast( - message: "The new page hasn't reached the cloud yet. It will appear " - 'once it does.', + message: l10n.newPageCopyWaitingForCloud, backgroundColor: Settings.tacticalVioletTheme.primary, ); } if (gaps.unsavedEdits) { Settings.showToast( - message: "Changes to the page you copied that didn't save won't be in " - 'the copy.', + message: l10n.newPageCopyUnsavedEdits, backgroundColor: Settings.tacticalVioletTheme.destructive, ); } diff --git a/lib/widgets/pages_bar.dart b/lib/widgets/pages_bar.dart index 0c6183e61..b8096f2bb 100644 --- a/lib/widgets/pages_bar.dart +++ b/lib/widgets/pages_bar.dart @@ -5,6 +5,8 @@ import 'package:icarus/const/hive_boxes.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/const/sort_index_order.dart'; import 'package:icarus/const/transition_data.dart'; +import 'package:icarus/l10n/l10n.dart'; +import 'package:icarus/l10n/page_names.dart'; import 'package:icarus/providers/collab/drawn_cloud_pages_provider.dart'; import 'package:icarus/providers/collab/remote_strategy_snapshot_provider.dart'; import 'package:icarus/providers/collab/strategy_capabilities_provider.dart'; @@ -42,6 +44,10 @@ class PageListItemViewModel { final String id; final String name; final bool isAutoNamed; + + /// The name as drawn (see [shownPageName]). + String shownName(AppLocalizations l10n) => + shownPageName(l10n, name: name, isAutoNamed: isAutoNamed); } class PagesBar extends ConsumerStatefulWidget { @@ -221,6 +227,7 @@ class _PagesBarState extends ConsumerState { final caps = ref.read(currentStrategyCapabilitiesProvider); if (!caps.canAddPage) return; showNewPageGaps( + ref.read(l10nProvider), await ref.read(strategyProvider.notifier).addPage(), ); } @@ -233,22 +240,23 @@ class _PagesBarState extends ConsumerState { Future _renamePage(PageListItemViewModel page) async { final caps = ref.read(currentStrategyCapabilitiesProvider); if (!caps.canRenamePage) return; - final controller = TextEditingController(text: page.name); + final shownName = page.shownName(context.l10n); + final controller = TextEditingController(text: shownName); final newName = await showShadDialog( context: context, builder: (ctx) => ShadDialog( - title: const Text("Rename page"), - description: const Text("Enter a new name for the page:"), + title: Text(ctx.l10n.pagesBarRenameTitle), + description: Text(ctx.l10n.pagesBarRenameDescription), actions: [ ShadButton.secondary( onPressed: () => Navigator.of(ctx).pop(), backgroundColor: Settings.tacticalVioletTheme.border, - child: const Text("Cancel"), + child: Text(ctx.l10n.commonCancel), ), ShadButton( onPressed: () => Navigator.of(ctx).pop(controller.text.trim()), leading: const Icon(LucideIcons.type, size: 16), - child: const Text("Rename"), + child: Text(ctx.l10n.commonRename), ), ], child: CustomTextField( @@ -261,9 +269,12 @@ class _PagesBarState extends ConsumerState { controller.dispose(); if (newName == null || newName.isEmpty || - (newName == page.name && !page.isAutoNamed)) { + (newName == shownName && !page.isAutoNamed)) { return; } + // An unnamed page confirmed under its translated name stays unnamed: + // saving the translation would show it to teammates in this language. + if (newName == shownName && shownName != page.name) return; await ref.read(strategyProvider.notifier).renamePage(page.id, newName); } @@ -277,7 +288,7 @@ class _PagesBarState extends ConsumerState { context, ref, pageId: page.id, - pageName: page.name, + pageName: page.shownName(context.l10n), ); if (confirm != true || !_showing(strategyId)) return; @@ -290,12 +301,13 @@ class _PagesBarState extends ConsumerState { // from the strategy it was deleted in. if (!deleted || !isCloud || !_showing(strategyId)) return; _dismissUndo(); + final l10n = ref.read(l10nProvider); late final ToastificationItem toast; toast = Settings.showToast( - message: "Deleted '${page.name}'", + message: l10n.pagesBarPageDeleted(page.shownName(l10n)), backgroundColor: Settings.tacticalVioletTheme.secondary, autoCloseDuration: const Duration(seconds: 6), - actionLabel: 'Undo', + actionLabel: l10n.pagesBarUndo, onActionPressed: () { // Taken once: a second tap finds nothing to undo. if (!identical(_undoToast, toast)) return; @@ -343,6 +355,7 @@ class _PagesBarState extends ConsumerState { }) async { bool stillHere() => _showing(strategyId); if (!stillHere()) return; + final l10n = context.l10n; final session = ref.read(strategyPageSessionProvider.notifier); final strategyName = ref.read(strategyProvider).strategyName; final outcome = await session.restorePageFromTrash(page.id); @@ -350,7 +363,8 @@ class _PagesBarState extends ConsumerState { // Said even after leaving, so no one thinks Undo worked. Settings.showToast( message: undoFailedMessage( - page.name, + l10n, + page.shownName(l10n), outcome, strategyName: stillHere() ? null : (strategyName ?? ''), ), @@ -365,7 +379,7 @@ class _PagesBarState extends ConsumerState { if (!_lists(page.id)) { // The server has it back; the list shows it when it next loads. Settings.showToast( - message: "Restored '${page.name}'.", + message: l10n.pagesBarRestored(page.shownName(l10n)), backgroundColor: Settings.tacticalVioletTheme.secondary, ); return; @@ -379,9 +393,8 @@ class _PagesBarState extends ConsumerState { if (!stillHere()) return; Settings.showToast( message: _lists(page.id) - ? "Restored '${page.name}', but could not open it. It is in the " - 'pages list.' - : "Restored '${page.name}', but could not open it.", + ? l10n.pagesBarRestoredNotOpenedListed(page.shownName(l10n)) + : l10n.pagesBarRestoredNotOpened(page.shownName(l10n)), backgroundColor: Settings.tacticalVioletTheme.destructive, ); } @@ -593,7 +606,7 @@ class _PagesBarState extends ConsumerState { (page) => page.id == resolvedActiveId, orElse: () => pages.first, ) - .name; + .shownName(context.l10n); return _PageBarData( pages: pages, activePageId: resolvedActiveId, @@ -637,7 +650,7 @@ class _CollapsedPill extends StatelessWidget { _SquareIconButton( icon: LucideIcons.plus, onTap: onAdd, - tooltip: "Add page", + tooltip: context.l10n.pagesBarAddPageTooltip, color: Settings.tacticalVioletTheme.primary, shortcutLabel: 'C', ), @@ -861,14 +874,15 @@ class _ExpandedPanel extends ConsumerWidget { _SquareIconButton( icon: LucideIcons.plus, onTap: canAddPage ? onAdd : null, - tooltip: "Add page", + tooltip: context.l10n.pagesBarAddPageTooltip, color: Settings.tacticalVioletTheme.primary, shortcutLabel: 'C', ), const Spacer(), if (onOpenRecentlyDeleted != null) ShadTooltip( - builder: (context) => const Text('Recently deleted'), + builder: (context) => + Text(context.l10n.pagesBarRecentlyDeletedTooltip), child: ShadIconButton.ghost( width: _pagesBarControlSize, height: _pagesBarControlSize, @@ -1142,7 +1156,7 @@ class _PageRowState extends State<_PageRow> { children: [ Expanded( child: Text( - widget.page.name, + widget.page.shownName(context.l10n), overflow: TextOverflow.ellipsis, style: theme.textTheme.titleMedium?.copyWith( color: Colors.white, @@ -1167,7 +1181,8 @@ class _PageRowState extends State<_PageRow> { // resting a step under white, white on hover. if (showActions) ...[ ShadTooltip( - builder: (context) => const Text("Rename"), + builder: (context) => + Text(context.l10n.commonRename), child: ShadIconButton.ghost( width: 24, enabled: widget.canRename, @@ -1182,7 +1197,8 @@ class _PageRowState extends State<_PageRow> { ), const SizedBox(width: 2), ShadTooltip( - builder: (context) => const Text("Delete"), + builder: (context) => + Text(context.l10n.commonDelete), // Red is barely there on violet (1.5:1), so on // a violet row the trash lights up as a red // button instead. @@ -1225,7 +1241,7 @@ class _PageRowState extends State<_PageRow> { ShadContextMenuItem( leading: const Icon(LucideIcons.pencil, size: 16), onPressed: _rename, - child: const Text('Rename'), + child: Text(context.l10n.commonRename), ), if (widget.onDelete != null) ShadContextMenuItem( @@ -1236,7 +1252,7 @@ class _PageRowState extends State<_PageRow> { ), enabled: !widget.disableDelete, onPressed: widget.disableDelete ? null : _delete, - child: const Text('Delete'), + child: Text(context.l10n.commonDelete), ), ]; if (menuItems.isEmpty) return row; @@ -1318,18 +1334,17 @@ class _SquareIconButton extends StatelessWidget { /// may be a reply that never arrived, so it says what it does not know. @visibleForTesting String undoFailedMessage( + AppLocalizations l10n, String pageName, DeletedPageRestore outcome, { required String? strategyName, }) { if (outcome == DeletedPageRestore.gone) { - return "'$pageName' can no longer be restored."; + return l10n.pagesBarRestoreGone(pageName); } - final (where, trash) = switch (strategyName) { - null => ('', 'Recently deleted'), - '' => ('', "its strategy's Recently deleted"), - final name => (' in $name', "that strategy's Recently deleted"), + return switch (strategyName) { + null => l10n.pagesBarRestoreUnconfirmed(pageName), + '' => l10n.pagesBarRestoreUnconfirmedLeft(pageName), + final name => l10n.pagesBarRestoreUnconfirmedIn(pageName, name), }; - return "Could not confirm that '$pageName' was restored$where. Once " - "you're connected, check the pages list or $trash."; } diff --git a/lib/widgets/platform_feature_toast.dart b/lib/widgets/platform_feature_toast.dart index d61946818..53388102b 100644 --- a/lib/widgets/platform_feature_toast.dart +++ b/lib/widgets/platform_feature_toast.dart @@ -1,13 +1,15 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/config/platform_policy.dart'; import 'package:icarus/const/settings.dart'; +import 'package:icarus/l10n/l10n.dart'; /// Returns true when [feature] runs here. Otherwise tells the user, in one /// short toast, that it is desktop-only or coming to the web beta, and /// returns false so the caller can stop. bool ensureFeatureAvailable(WidgetRef ref, PlatformFeature feature) { - final message = - ref.read(platformPolicyProvider).unavailableMessage(feature); + final message = ref + .read(platformPolicyProvider) + .unavailableMessage(ref.read(l10nProvider), feature); if (message == null) return true; // Not an error: the neutral surface, not the destructive one. Settings.showToast( diff --git a/lib/widgets/replay/open_replays.dart b/lib/widgets/replay/open_replays.dart index 45a9ae4d3..31021a2a0 100644 --- a/lib/widgets/replay/open_replays.dart +++ b/lib/widgets/replay/open_replays.dart @@ -6,6 +6,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/config/platform_policy.dart'; import 'package:icarus/const/app_navigator.dart'; import 'package:icarus/const/routes.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/agent_filter_provider.dart'; import 'package:icarus/providers/delete_menu_provider.dart'; import 'package:icarus/providers/interaction_state_provider.dart'; @@ -25,7 +26,8 @@ bool isReplayPath(String path) => /// Opens waiting their turn. Each runs after the one before it finishes, /// so two arriving together (a drop and an Explorer open, or two Explorer /// opens) never interleave their navigation or stack two guard dialogs. -final _waiting = <({WidgetRef ref, List paths})>[]; +final _waiting = + <({WidgetRef ref, List paths, AppLocalizations l10n})>[]; var _opening = false; /// Brings replays opened from outside the Replays tab (dropped anywhere on @@ -35,7 +37,7 @@ var _opening = false; /// toast. Leaving the editor goes through the unsaved-work guard; staying /// keeps the editor, with the replays already added. Future openReplays(WidgetRef ref, List paths) async { - _waiting.add((ref: ref, paths: paths)); + _waiting.add((ref: ref, paths: paths, l10n: ref.read(l10nProvider))); if (_opening) return; _opening = true; try { @@ -45,7 +47,7 @@ Future openReplays(WidgetRef ref, List paths) async { await _openReplays(next.ref, next.paths); } catch (error, stackTrace) { AppErrorReporter.reportError( - "Couldn't open the replay.", + next.l10n.replayOpenFailed, error: error, stackTrace: stackTrace, source: 'openReplays', diff --git a/lib/widgets/replay/replay_canvas.dart b/lib/widgets/replay/replay_canvas.dart index 8ed3ebd32..ab073301b 100644 --- a/lib/widgets/replay/replay_canvas.dart +++ b/lib/widgets/replay/replay_canvas.dart @@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/widgets/asset_svg.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/const/coordinate_system.dart'; import 'package:icarus/const/maps.dart'; import 'package:icarus/const/placed_classes.dart'; @@ -585,7 +586,8 @@ class _ReplayCanvasState extends ConsumerState flipY: !isAttack, child: AssetSvg( 'assets/maps/${mapName}_spawn_walls.svg', - semanticsLabel: 'Barrier', + semanticsLabel: context + .l10n.strategyViewBarrierSemanticLabel, fit: BoxFit.contain, ), ), diff --git a/lib/widgets/replay/replay_dock.dart b/lib/widgets/replay/replay_dock.dart index a87144ae3..c9be156dd 100644 --- a/lib/widgets/replay/replay_dock.dart +++ b/lib/widgets/replay/replay_dock.dart @@ -6,6 +6,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/widgets/asset_svg.dart'; import 'package:icarus/const/settings.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/replay/replay_document.dart'; import 'package:icarus/replay/replay_drawing.dart'; import 'package:icarus/replay/replay_playback.dart'; @@ -355,7 +356,9 @@ class _PlaybackRow extends StatelessWidget { level(_ViewToggle( icon: LucideIcons.pin, checked: pinned, - tooltip: pinned ? 'Unpin round story' : 'Pin round story', + tooltip: pinned + ? context.l10n.replayDockUnpinStoryTooltip + : context.l10n.replayDockPinStoryTooltip, onPressed: onPin, )), ], @@ -548,7 +551,7 @@ class _RoundStripState extends State<_RoundStrip> { height: _RoundStrip._slot, child: Center( child: Text( - 'OT', + context.l10n.replayDockOvertimeMark, style: TextStyle( fontSize: 9, fontWeight: FontWeight.w600, @@ -659,7 +662,8 @@ class _RoundSlot extends StatelessWidget { return ShadTooltip( // Its colour says who won and its glyph how; the number is all the // slot itself doesn't show. - builder: (context) => Text('Round ${round.index + 1}'), + builder: (context) => + Text(context.l10n.replayRoundNumber(round.index + 1)), // A Shad gesture detector, so the tooltip hears the hover. child: ShadGestureDetector( cursor: SystemMouseCursors.click, @@ -1647,7 +1651,7 @@ class _SpeedButton extends StatelessWidget { ? '${playback.speed.round()}×' : '${playback.speed}×'; return ShadTooltip( - builder: (context) => const Text('Playback speed'), + builder: (context) => Text(context.l10n.replayDockPlaybackSpeedTooltip), child: ShadButton.outline( height: 24, width: 40, diff --git a/lib/widgets/replay/replay_events.dart b/lib/widgets/replay/replay_events.dart index b26a88823..a33cf8606 100644 --- a/lib/widgets/replay/replay_events.dart +++ b/lib/widgets/replay/replay_events.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/widgets/asset_svg.dart'; import 'package:icarus/const/agents.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/const/weapons.dart'; import 'package:icarus/replay/replay_ability_catalog.dart'; @@ -31,13 +32,15 @@ enum ReplayEventKind { /// The choices in the killfeed's filter, each showing some kinds of event. enum ReplayEventFilter { - kills('Kills'), - spike('Spike'), - abilities('Abilities'); - - const ReplayEventFilter(this.label); - - final String label; + kills, + spike, + abilities; + + String label(AppLocalizations l10n) => switch (this) { + kills => l10n.replayRosterFilterKills, + spike => l10n.gameSpike, + abilities => l10n.replayRosterFilterAbilities, + }; } /// Which events the killfeed lists and the scrubber marks, kept while the diff --git a/lib/widgets/replay/replay_failure_text.dart b/lib/widgets/replay/replay_failure_text.dart new file mode 100644 index 000000000..d15214d47 --- /dev/null +++ b/lib/widgets/replay/replay_failure_text.dart @@ -0,0 +1,36 @@ +import 'package:icarus/l10n/l10n.dart'; +import 'package:icarus/replay/replay_decoder.dart'; +import 'package:icarus/widgets/replay/replay_library_content.dart' + show replayPatchLabel; + +/// What the user reads about why a replay couldn't be read. The decoder's +/// messages are English written for us, so each known [code] gets its own +/// words; a message this doesn't know is shown as it came. +String replayFailureText(AppLocalizations l10n, ReplayDecodeException error) { + final message = error.message; + return switch (error.code) { + 'notAReplay' => l10n.replayLibraryNotAReplay, + 'unsupportedBuild' => switch (error.build) { + final build? => + l10n.replayLibraryPatchUnsupported(replayPatchLabel(build)), + null => l10n.replayFailureUnsupportedBuild, + }, + 'unsupportedCompression' => l10n.replayFailureUnsupportedCompression, + 'transformCheckFailed' => l10n.replayFailureTransformCheckFailed, + 'corrupt' => l10n.replayFailureUnreadable, + 'unsupportedMap' => l10n.replayMapNotSupported, + // The rest of `io` is the system's own words about the file. + 'io' when message == replayDesktopOnlyMessage => + l10n.replayFailureDesktopOnly, + 'io' + when message.startsWith(replayDecoderMissingPrefix) && + message.endsWith(').') => + l10n.replayFailureDecoderMissing( + message.substring( + replayDecoderMissingPrefix.length, + message.length - 2, + ), + ), + _ => message, + }; +} diff --git a/lib/widgets/replay/replay_library_content.dart b/lib/widgets/replay/replay_library_content.dart index 5b6118cfc..293790bf0 100644 --- a/lib/widgets/replay/replay_library_content.dart +++ b/lib/widgets/replay/replay_library_content.dart @@ -8,6 +8,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/agents.dart'; import 'package:icarus/const/maps.dart'; import 'package:icarus/const/settings.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/replay_library_provider.dart'; import 'package:icarus/replay/recycle_bin.dart'; import 'package:icarus/replay/replay_agents.dart'; @@ -18,6 +19,7 @@ import 'package:icarus/services/app_error_reporter.dart'; import 'package:icarus/widgets/context_menu_region.dart'; import 'package:icarus/widgets/dialogs/confirm_alert_dialog.dart'; import 'package:icarus/widgets/hover_dot_grid.dart'; +import 'package:icarus/widgets/replay/replay_failure_text.dart'; import 'package:icarus/widgets/strategy_tile/strategy_tile.dart'; import 'package:icarus/widgets/strategy_tile/strategy_tile_sections.dart'; import 'package:path/path.dart' as p; @@ -44,14 +46,13 @@ class ReplayLibraryContent extends ConsumerWidget { child: listings.when( loading: () => const SizedBox.shrink(), error: (error, _) => _Message( - title: "Couldn't list replays", + title: context.l10n.replayLibraryListFailed, hint: '$error', ), data: (listings) => listings.isEmpty - ? const _Message( - title: 'No replays yet', - hint: 'Download a match from your career page in ' - 'Valorant, or drop a .vrf file here', + ? _Message( + title: context.l10n.replayLibraryEmptyTitle, + hint: context.l10n.replayLibraryEmptyHint, ) : _ReplayGrid(listings: listings), ), @@ -200,17 +201,18 @@ class _ReplayTileState extends ConsumerState { }); Future _keep() async { + final l10n = context.l10n; try { await ref.read(replayLibraryProvider.notifier).import( widget.listing.file.path, ); Settings.showToast( - message: 'Kept in Icarus. Valorant can no longer remove it.', + message: l10n.replayLibraryKept, backgroundColor: Settings.tacticalVioletTheme.primary, ); } catch (error, stackTrace) { AppErrorReporter.reportError( - "Couldn't keep this replay.", + l10n.replayLibraryKeepFailed, error: error, stackTrace: stackTrace, source: 'ReplayTile.keep', @@ -226,16 +228,14 @@ class _ReplayTileState extends ConsumerState { var file = widget.listing.file; while (true) { if (!mounted) return; + final l10n = context.l10n; final confirmed = await ConfirmAlertDialog.show( context: context, - title: 'Remove this replay?', + title: l10n.replayLibraryRemoveTitle, content: file.valorantHasCopy - ? "This moves Icarus's copy to the Recycle Bin. Valorant still " - 'has its own, so the replay stays in the list until Valorant ' - 'deletes it.' - : 'This moves the replay file to the Recycle Bin. Pages you ' - 'captured from it stay in your strategies.', - confirmText: 'Remove', + ? l10n.replayLibraryRemoveValorantCopyBody + : l10n.replayLibraryRemoveBody, + confirmText: l10n.replayLibraryRemoveConfirm, isDestructive: true, ); if (!confirmed) return; @@ -245,10 +245,8 @@ class _ReplayTileState extends ConsumerState { } on RecycleBinUnavailable catch (error) { Settings.showToast( message: error.deleted - ? 'Windows deleted this replay instead of moving it to the ' - 'Recycle Bin.' - : "Windows couldn't move this replay to the Recycle Bin, so " - 'Icarus left it where it is.', + ? l10n.replayLibraryRecycleBinDeleted + : l10n.replayLibraryRecycleBinFailed, backgroundColor: Settings.tacticalVioletTheme.destructive, ); return; @@ -260,16 +258,14 @@ class _ReplayTileState extends ConsumerState { } Settings.showToast( message: refused.reason == ReplayRemoveRefusal.fileChanged - ? 'This replay changed after you chose Remove. Nothing was ' - 'deleted.' - : "Icarus only removes replays from its own folder. Nothing " - 'was deleted.', + ? l10n.replayLibraryRemoveFileChanged + : l10n.replayLibraryRemoveNotOwned, backgroundColor: Settings.tacticalVioletTheme.destructive, ); return; } catch (error, stackTrace) { AppErrorReporter.reportError( - "Couldn't remove this replay.", + l10n.replayLibraryRemoveFailed, error: error, stackTrace: stackTrace, source: 'ReplayTile.remove', @@ -301,7 +297,7 @@ class _ReplayTileState extends ConsumerState { ShadContextMenuItem( leading: const Icon(LucideIcons.archive), onPressed: _keep, - child: const Text('Keep in Icarus'), + child: Text(context.l10n.replayLibraryKeepInIcarus), ) // Removing sends the file to the Recycle Bin, which only // Windows has. @@ -310,7 +306,7 @@ class _ReplayTileState extends ConsumerState { leading: Icon(LucideIcons.trash2, color: theme.destructive), onPressed: _remove, child: Text( - 'Remove from Icarus', + context.l10n.replayLibraryRemoveFromIcarus, style: TextStyle(color: theme.destructive), ), ), @@ -384,7 +380,7 @@ void openReplayListing(BuildContext context, ReplayListing listing) { final probe = listing.probe; if (probe == null || !probe.supported) { Settings.showToast( - message: _unavailableReason(listing), + message: _unavailableReason(context.l10n, listing), backgroundColor: Settings.tacticalVioletTheme.destructive, ); return; @@ -392,16 +388,18 @@ void openReplayListing(BuildContext context, ReplayListing listing) { Navigator.push(context, ReplayView.route(file: listing.file, probe: probe)); } -String _unavailableReason(ReplayListing listing) { +String _unavailableReason(AppLocalizations l10n, ReplayListing listing) { final probe = listing.probe; if (probe == null) { - return listing.error?.message ?? "This file isn't a Valorant replay."; + final error = listing.error; + return error == null + ? l10n.replayLibraryNotAReplay + : replayFailureText(l10n, error); } if (!probe.supported) { - return 'Replays from patch ${replayPatchLabel(probe.build)} ' - "can't be read yet. An Icarus update will add them."; + return l10n.replayLibraryPatchUnsupported(replayPatchLabel(probe.build)); } - return "This replay's map isn't in Icarus yet."; + return l10n.replayMapNotSupported; } /// `++Ares-Core+release-13.06` reads as `13.06`. @@ -434,7 +432,9 @@ class _ReplayDetails extends StatelessWidget { final probe = listing.probe; final theme = ShadTheme.of(context); final mapName = map == null - ? (probe == null ? 'Unreadable file' : 'Unknown map') + ? (probe == null + ? context.l10n.replayLibraryUnreadableFile + : context.l10n.replayLibraryUnknownMap) : Maps.mapNames[map]!.replaceRange( 0, 1, @@ -443,8 +443,9 @@ class _ReplayDetails extends StatelessWidget { final date = listing.date; final status = probe == null || !listing.canOpen ? (probe == null - ? 'Not a replay' - : 'Patch ${replayPatchLabel(probe.build)}') + ? context.l10n.replayLibraryNotAReplayStatus + : context.l10n.replayLibraryPatchStatus( + replayPatchLabel(probe.build))) : replayDurationLabel(probe.durationMs); return Container( @@ -502,10 +503,7 @@ class _KeepButton extends StatelessWidget { Widget build(BuildContext context) { final theme = ShadTheme.of(context); return ShadTooltip( - builder: (context) => const Text( - "Only in Valorant's replay folder, which Valorant may clear.\n" - 'Keep a copy in Icarus.', - ), + builder: (context) => Text(context.l10n.replayLibraryKeepTooltip), child: ShadButton.ghost( height: 22, padding: const EdgeInsets.symmetric(horizontal: 6), @@ -513,9 +511,9 @@ class _KeepButton extends StatelessWidget { hoverForegroundColor: theme.colorScheme.foreground, leading: const Icon(LucideIcons.hourglass, size: 14), onPressed: onPressed, - child: const Text( - 'Keep', - style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600), + child: Text( + context.l10n.replayLibraryKeepButton, + style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600), ), ), ); @@ -606,12 +604,13 @@ Future addReplays(WidgetRef ref, List paths) async { // Read before the add: the widget that asked may be gone when it finishes. final library = ref.read(replayLibraryProvider.notifier); final reveal = ref.read(replayRevealProvider.notifier); + final l10n = ref.read(l10nProvider); final ReplayAddResult result; try { result = await library.add(paths); } catch (error, stackTrace) { AppErrorReporter.reportError( - "Couldn't add replays.", + l10n.replayLibraryAddFailed, error: error, stackTrace: stackTrace, source: 'addReplays', @@ -620,7 +619,7 @@ Future addReplays(WidgetRef ref, List paths) async { } final shown = [...result.added, ...result.alreadyListed]; Settings.showToast( - message: replayAddMessage(result), + message: replayAddMessage(l10n, result), backgroundColor: shown.isEmpty ? Settings.tacticalVioletTheme.destructive : Settings.tacticalVioletTheme.primary, @@ -633,28 +632,23 @@ Future addReplays(WidgetRef ref, List paths) async { } /// One toast's worth of what an add did, the way strategy imports report. -String replayAddMessage(ReplayAddResult result) { +String replayAddMessage(AppLocalizations l10n, ReplayAddResult result) { final added = result.added.length; final already = result.alreadyListed.length; final unreadable = result.unreadable.length; final failed = result.failed.length; return [ - if (added > 0) 'Added ${added == 1 ? '1 replay' : '$added replays'}.', - if (already > 0 && added == 0) - already == 1 - ? 'That match is already in Replays.' - : 'Those $already matches are already in Replays.', + if (added > 0) l10n.replayLibraryAdded(added), + if (already > 0 && added == 0) l10n.replayLibraryAlreadyListed(already), if (already > 0 && added > 0) - already == 1 - ? '1 was already in Replays.' - : '$already were already in Replays.', + l10n.replayLibrarySomeAlreadyListed(already), if (unreadable == 1) - "${p.basename(result.unreadable.single)} isn't a replay Icarus can read.", - if (unreadable > 1) - "Skipped $unreadable files that aren't replays Icarus can read.", - if (failed == 1) "Couldn't copy ${p.basename(result.failed.single)}.", - if (failed > 1) "Couldn't copy $failed replays.", - ].join(' '); + l10n.replayLibraryUnreadableOne(p.basename(result.unreadable.single)), + if (unreadable > 1) l10n.replayLibraryUnreadableMany(unreadable), + if (failed == 1) + l10n.replayLibraryCopyFailedOne(p.basename(result.failed.single)), + if (failed > 1) l10n.replayLibraryCopyFailedMany(failed), + ].join(l10n.commonSentenceSpace); } /// Says what a drop on the Replays tab will do. The replays in it are taken @@ -693,17 +687,17 @@ class _ReplayDropTargetState extends ConsumerState { const Positioned.fill( child: ColoredBox(color: Color.fromARGB(118, 2, 2, 2)), ), - const Positioned.fill( + Positioned.fill( child: Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ - Icon(LucideIcons.download, size: 60), - SizedBox(height: 10), + const Icon(LucideIcons.download, size: 60), + const SizedBox(height: 10), Text( - 'Add replays', - style: - TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + context.l10n.replayLibraryDropToAdd, + style: const TextStyle( + fontSize: 20, fontWeight: FontWeight.bold), ), ], ), diff --git a/lib/widgets/replay/replay_match_card.dart b/lib/widgets/replay/replay_match_card.dart index 65069fdd4..687b5f647 100644 --- a/lib/widgets/replay/replay_match_card.dart +++ b/lib/widgets/replay/replay_match_card.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/maps.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/collab/replay_capture_record.dart'; import 'package:icarus/const/custom_icons.dart'; import 'package:icarus/const/settings.dart'; @@ -65,6 +66,7 @@ class _ReplayMatchCardState extends ConsumerState { MaterialLocalizations.of(context).formatShortDate(date ?? DateTime.now()); Future _captureMoment() async { + final l10n = context.l10n; final playback = widget.playback; playback.pause(); try { @@ -79,7 +81,7 @@ class _ReplayMatchCardState extends ConsumerState { _strategyIds.add(strategy.id); } catch (error, stackTrace) { AppErrorReporter.reportError( - "Couldn't capture this moment.", + l10n.replayMatchCaptureFailed, error: error, stackTrace: stackTrace, source: 'ReplayMatchCard.capture', @@ -127,15 +129,12 @@ class _ReplayMatchCardState extends ConsumerState { List waiting, ) { final failure = waiting.firstWhere(queue.needsUser).failure; + final l10n = context.l10n; Settings.showToast( message: switch (failure) { - CaptureFailureKind.deleted => - "This strategy was deleted, so it can't be opened.", - CaptureFailureKind.mapChanged => - "This strategy is on another map now, so the replay's captures " - "can't go there.", - _ => "A capture couldn't be saved, so the strategy can't open on " - 'it yet. Retry or discard it first.', + CaptureFailureKind.deleted => l10n.replayMatchStrategyDeleted, + CaptureFailureKind.mapChanged => l10n.replayMatchStrategyMapChanged, + _ => l10n.replayMatchCaptureUnsaved, }, backgroundColor: Settings.tacticalVioletTheme.destructive, ); @@ -169,7 +168,7 @@ class _ReplayMatchCardState extends ConsumerState { initialMapValue: strategy.map, initialIsAttack: strategy.lastPageIsAttack, initialPageId: pageId, - backTooltip: 'Back to replay', + backTooltip: context.l10n.replayMatchBackToReplay, ), ); if (!mounted) return; @@ -190,13 +189,14 @@ class _ReplayMatchCardState extends ConsumerState { } Future _retry(List failed) async { + final l10n = context.l10n; try { await ref .read(replayCaptureQueueProvider.notifier) .retry([for (final capture in failed) capture.id]); } catch (error, stackTrace) { AppErrorReporter.reportError( - "Couldn't retry the capture.", + l10n.replayMatchRetryFailed, error: error, stackTrace: stackTrace, source: 'ReplayMatchCard.retry', @@ -205,6 +205,7 @@ class _ReplayMatchCardState extends ConsumerState { } Future _discard(List failed) async { + final l10n = context.l10n; final bool mayHaveLanded; try { mayHaveLanded = await ref @@ -212,7 +213,7 @@ class _ReplayMatchCardState extends ConsumerState { .discard([for (final capture in failed) capture.id]); } catch (error, stackTrace) { AppErrorReporter.reportError( - "Couldn't discard the capture.", + l10n.replayMatchDiscardFailed, error: error, stackTrace: stackTrace, source: 'ReplayMatchCard.discard', @@ -221,7 +222,7 @@ class _ReplayMatchCardState extends ConsumerState { } if (mayHaveLanded) { Settings.showToast( - message: 'Discarded. It may still appear in your library.', + message: l10n.replayMatchDiscarded, backgroundColor: Settings.tacticalVioletTheme.primary, ); } @@ -231,6 +232,7 @@ class _ReplayMatchCardState extends ConsumerState { @override Widget build(BuildContext context) { final theme = ShadTheme.of(context); + final l10n = context.l10n; ref.listen(replayCaptureQueueProvider, (_, next) => _queueChanged(next)); final strategy = _capture.strategy; final inCloud = strategy?.source == StrategySource.cloud; @@ -284,7 +286,9 @@ class _ReplayMatchCardState extends ConsumerState { textBaseline: TextBaseline.alphabetic, children: [ Text( - round == null ? 'Warmup' : 'Round ${round.index + 1}', + round == null + ? l10n.replayMatchWarmup + : l10n.replayRoundNumber(round.index + 1), style: const TextStyle( fontSize: 16, fontWeight: FontWeight.w600, @@ -292,7 +296,7 @@ class _ReplayMatchCardState extends ConsumerState { ), const SizedBox(width: 8), Text( - isAttack ? 'Attack' : 'Defense', + isAttack ? l10n.replayMatchAttack : l10n.replayMatchDefense, style: theme.textTheme.muted, ), const Spacer(), @@ -307,7 +311,7 @@ class _ReplayMatchCardState extends ConsumerState { height: 32, onPressed: _captureMoment, leading: const Icon(LucideIcons.camera, size: 16), - child: const Text('Capture'), + child: Text(l10n.replayMatchCapture), ), ), const SizedBox(width: 8), @@ -325,16 +329,16 @@ class _ReplayMatchCardState extends ConsumerState { _ToolButton( icon: LucideIcons.pencil, tooltip: drawing - ? 'Stop drawing ($drawKey)' - : 'Draw ($drawKey)', + ? l10n.replayMatchStopDrawingTooltip(drawKey) + : l10n.replayMatchDrawTooltip(drawKey), selected: drawing, onPressed: widget.drawing.toggle, ), _ToolButton( icon: CustomIcons.eraser, tooltip: erasing - ? 'Stop erasing ($eraseKey)' - : 'Eraser ($eraseKey)', + ? l10n.replayMatchStopErasingTooltip(eraseKey) + : l10n.replayMatchEraserTooltip(eraseKey), selected: erasing, onPressed: widget.drawing.toggleEraser, ), @@ -345,7 +349,7 @@ class _ReplayMatchCardState extends ConsumerState { const SizedBox(width: 8), ShadTooltip( builder: (context) => - const Text("See the other team's side"), + Text(l10n.replayMatchSwitchSideTooltip), child: ShadIconButton.outline( width: 32, height: 32, @@ -363,9 +367,7 @@ class _ReplayMatchCardState extends ConsumerState { children: [ Expanded( child: Text( - captured == 1 - ? '1 page captured' - : '$captured pages captured', + l10n.replayMatchPagesCaptured(captured), style: theme.textTheme.muted, ), ), @@ -374,7 +376,9 @@ class _ReplayMatchCardState extends ConsumerState { padding: EdgeInsets.zero, onPressed: _openStrategy, child: Text( - _awaitingPageId == null ? 'Open' : 'Sending…', + _awaitingPageId == null + ? l10n.replayMatchOpenStrategy + : l10n.replayMatchSending, ), ), ], @@ -438,6 +442,7 @@ class _CloudStatus extends StatelessWidget { @override Widget build(BuildContext context) { final theme = ShadTheme.of(context); + final l10n = context.l10n; final failed = [ for (final capture in unsaved) if (queue.needsUser(capture)) capture, @@ -451,15 +456,14 @@ class _CloudStatus extends StatelessWidget { padding: const EdgeInsets.only(top: 4), child: Text( sending == 0 - ? 'Saved to your cloud library' + ? l10n.replayMatchCloudSaved : upgradeRequired - ? 'Update Icarus to finish saving $sending' + ? l10n.replayMatchCloudUpdateRequired(sending) : !online - ? '$sending waiting for the connection' + ? l10n.replayMatchCloudWaiting(sending) : unconfirmed - ? "$sending sending. Couldn't confirm the last " - 'send, trying again.' - : '$sending sending', + ? l10n.replayMatchCloudUnconfirmed(sending) + : l10n.replayMatchCloudSending(sending), style: theme.textTheme.muted.copyWith(fontSize: 12), ), ); @@ -476,16 +480,18 @@ class _CloudStatus extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( - failed.length == 1 - ? "1 capture couldn't be saved" - : "${failed.length} captures couldn't be saved", + l10n.replayMatchCapturesFailed(failed.length), style: TextStyle( fontSize: 12, color: theme.colorScheme.destructive, ), ), Text( - queue.stuck[failed.first.id] ?? _reason(failed.first), + switch (queue.stuck[failed.first.id]) { + ReplayCaptureStuckCause.storage => l10n.replayMatchStuckStorage, + ReplayCaptureStuckCause.device => l10n.replayMatchStuckDevice, + null => _reason(l10n, failed.first), + }, style: theme.textTheme.muted.copyWith(fontSize: 12), ), Row( @@ -495,7 +501,7 @@ class _CloudStatus extends StatelessWidget { height: 24, padding: EdgeInsets.zero, onPressed: () => onRetry(retryable), - child: const Text('Retry'), + child: Text(l10n.commonRetry), ), const SizedBox(width: 12), ], @@ -503,7 +509,7 @@ class _CloudStatus extends StatelessWidget { height: 24, padding: EdgeInsets.zero, onPressed: () => onDiscard(failed), - child: const Text('Discard'), + child: Text(l10n.commonDiscard), ), ], ), @@ -512,12 +518,15 @@ class _CloudStatus extends StatelessWidget { ); } - static String _reason(ReplayCaptureRecord capture) => + static String _reason( + AppLocalizations l10n, + ReplayCaptureRecord capture, + ) => switch (capture.failure) { - CaptureFailureKind.deleted => 'Its strategy was deleted.', - CaptureFailureKind.mapChanged => 'Its strategy is on another map now.', - CaptureFailureKind.pageDeleted => 'Its page was deleted.', - _ => 'The cloud refused it.', + CaptureFailureKind.deleted => l10n.replayMatchFailureDeleted, + CaptureFailureKind.mapChanged => l10n.replayMatchFailureMapChanged, + CaptureFailureKind.pageDeleted => l10n.replayMatchFailurePageDeleted, + _ => l10n.replayMatchFailureRefused, }; } @@ -551,7 +560,7 @@ class _DrawingTools extends StatelessWidget { height: 24, padding: EdgeInsets.zero, onPressed: drawing.clear, - child: const Text('Clear'), + child: Text(context.l10n.commonClear), ), ), ], diff --git a/lib/widgets/replay/replay_roster.dart b/lib/widgets/replay/replay_roster.dart index 0ac81fc2c..b19ecba28 100644 --- a/lib/widgets/replay/replay_roster.dart +++ b/lib/widgets/replay/replay_roster.dart @@ -4,6 +4,7 @@ import 'package:icarus/widgets/asset_svg.dart'; import 'package:icarus/const/agents.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/const/weapons.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/replay/replay_agents.dart'; import 'package:icarus/replay/replay_document.dart'; import 'package:icarus/replay/replay_frame.dart'; @@ -44,13 +45,13 @@ class ReplayRoster extends StatelessWidget { _TeamBlock( playback: playback, team: perspective, - label: 'Allies', + label: context.l10n.replayRosterAllies, ), Divider(height: 1, color: theme.colorScheme.border), _TeamBlock( playback: playback, team: perspective.other, - label: 'Enemies', + label: context.l10n.replayRosterEnemies, ), Divider(height: 1, color: theme.colorScheme.border), Expanded(child: _Killfeed(playback: playback)), @@ -126,7 +127,8 @@ class _TeamBlock extends StatelessWidget { ), ), const Spacer(), - Text('$alive alive', style: theme.textTheme.muted), + Text(context.l10n.replayRosterAlive(alive), + style: theme.textTheme.muted), ], ), ), @@ -183,13 +185,21 @@ class _PlayerRowState extends State<_PlayerRow> { /// What clicking the row (or pressing its key) does. String _tooltip(String name) { - final action = !widget.watched - ? 'Watch' - : widget.following - ? 'Stop watching' - : 'Follow'; + final l10n = context.l10n; final key = widget.watchKey; - return key == null ? '$action $name' : '$action $name ($key)'; + if (!widget.watched) { + return key == null + ? l10n.replayRosterWatchTooltip(name) + : l10n.replayRosterWatchWithKeyTooltip(name, key); + } + if (widget.following) { + return key == null + ? l10n.replayRosterStopWatchingTooltip(name) + : l10n.replayRosterStopWatchingWithKeyTooltip(name, key); + } + return key == null + ? l10n.replayRosterFollowTooltip(name) + : l10n.replayRosterFollowWithKeyTooltip(name, key); } @override @@ -200,7 +210,11 @@ class _PlayerRowState extends State<_PlayerRow> { final agent = replayAgentType(state.player.agentId); final agentData = agent == null ? null : AgentData.agents[agent]; final weapon = replayWeaponType(economy?.weapon); - final name = state.player.name ?? agentData?.name ?? 'Unknown agent'; + final name = state.player.name ?? + switch (agentData?.name) { + final agent? => context.l10n.gameName(agent), + null => context.l10n.replayRosterUnknownAgent, + }; final credits = economy?.credits; const radius = 8.0; return ShadTooltip( @@ -415,7 +429,7 @@ class _Killfeed extends ConsumerWidget { child: events.isEmpty ? Center( child: Text( - _emptyText(filters), + _emptyText(context.l10n, filters), style: theme.textTheme.muted, ), ) @@ -443,16 +457,19 @@ class _Killfeed extends ConsumerWidget { } /// What an empty list means: with kills shown, that no one has died. - static String _emptyText(Set filters) { + static String _emptyText( + AppLocalizations l10n, + Set filters, + ) { final spike = filters.contains(ReplayEventFilter.spike); final abilities = filters.contains(ReplayEventFilter.abilities); if (filters.contains(ReplayEventFilter.kills)) { - return 'No kills yet this round'; + return l10n.replayRosterNoKills; } - if (spike && abilities) return 'No plant or abilities yet this round'; - if (spike) return 'No plant yet this round'; - if (abilities) return 'No abilities used yet this round'; - return 'Choose what to show'; + if (spike && abilities) return l10n.replayRosterNoPlantOrAbilities; + if (spike) return l10n.replayRosterNoPlant; + if (abilities) return l10n.replayRosterNoAbilities; + return l10n.replayRosterNothingChosen; } } @@ -505,7 +522,7 @@ class _FilterChip extends StatelessWidget { const Icon(LucideIcons.sparkles, size: 14), }, child: Text( - filter.label, + filter.label(context.l10n), style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500), ), ), diff --git a/lib/widgets/role_badge.dart b/lib/widgets/role_badge.dart index 40983778b..ae397bd93 100644 --- a/lib/widgets/role_badge.dart +++ b/lib/widgets/role_badge.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:icarus/const/settings.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:icarus/widgets/loading_spinner.dart'; @@ -87,10 +88,10 @@ class DeviceOnlyBadge extends StatelessWidget { @override Widget build(BuildContext context) { - return const StrategyBadge( + return StrategyBadge( icon: LucideIcons.monitor, - label: 'On this device', - tooltip: 'Saved only on this computer', + label: context.l10n.strategyBadgeDeviceOnly, + tooltip: context.l10n.strategyBadgeDeviceOnlyTooltip, ); } } @@ -102,10 +103,10 @@ class NeedsConnectionBadge extends StatelessWidget { @override Widget build(BuildContext context) { - return const StrategyBadge( + return StrategyBadge( icon: LucideIcons.cloudOff, - label: 'Needs a connection', - tooltip: 'Needs a connection the first time you open it', + label: context.l10n.strategyBadgeNeedsConnection, + tooltip: context.l10n.strategyBadgeNeedsConnectionTooltip, ); } } @@ -128,8 +129,8 @@ class MovingToCloudBadge extends StatelessWidget { color: Settings.tacticalVioletTheme.foreground, ), ), - label: 'Moving to cloud', - tooltip: 'Stays on this computer until all of it is in the cloud', + label: context.l10n.strategyBadgeMovingToCloud, + tooltip: context.l10n.strategyBadgeMovingToCloudTooltip, ); } } @@ -146,16 +147,16 @@ class CloudRoleBadge extends StatelessWidget { case CloudBadgeKind.owned: return const SizedBox.shrink(); case CloudBadgeKind.editor: - return const StrategyBadge( + return StrategyBadge( icon: LucideIcons.users, - label: 'Shared · Can edit', - tooltip: 'Shared with you. You can edit it.', + label: context.l10n.strategyBadgeSharedCanEdit, + tooltip: context.l10n.strategyBadgeSharedCanEditTooltip, ); case CloudBadgeKind.viewer: - return const StrategyBadge( + return StrategyBadge( icon: LucideIcons.users, - label: 'Shared · View only', - tooltip: 'Shared with you. Ask the owner for edit access.', + label: context.l10n.strategyBadgeSharedViewOnly, + tooltip: context.l10n.strategyBadgeSharedViewOnlyTooltip, ); } } diff --git a/lib/widgets/settings_tab.dart b/lib/widgets/settings_tab.dart index 37b0dccc0..3482b8c96 100644 --- a/lib/widgets/settings_tab.dart +++ b/lib/widgets/settings_tab.dart @@ -8,6 +8,7 @@ import 'package:icarus/widgets/client_upgrade_button.dart'; import 'package:icarus/const/hive_boxes.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/const/shortcut_info.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/auth_provider.dart'; import 'package:icarus/providers/action_provider.dart'; import 'package:icarus/providers/map_provider.dart'; @@ -37,6 +38,7 @@ enum _SettingsSection { strategyObjects, strategyMapTheme, globalAccount, + globalLanguage, globalDefaults, globalSaving, globalMapVisibility, @@ -176,6 +178,7 @@ class _SettingsTabState extends ConsumerState { case _SettingsSection.strategyMapTheme: return _SettingsMode.strategy; case _SettingsSection.globalAccount: + case _SettingsSection.globalLanguage: case _SettingsSection.globalDefaults: case _SettingsSection.globalSaving: case _SettingsSection.globalMapVisibility: @@ -199,20 +202,20 @@ class _StrategySettingsSections extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final l10n = context.l10n; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ SettingsScopeCard( key: sectionKeys[_SettingsSection.strategyObjects], - title: "Object styling", + title: l10n.settingsObjectStyling, child: Column( children: [ _SettingsSliderTile( icon: LucideIcons.personStanding, iconColor: Settings.settingsAgentAccent, - title: "Agent markers", - description: - "Resize placed agents and view tools for this strategy.", + title: l10n.settingsAgentMarkersTitle, + description: l10n.settingsAgentMarkersDescription, value: strategySettings.agentSize, min: Settings.agentSizeMin, max: Settings.agentSizeMax, @@ -238,9 +241,8 @@ class _StrategySettingsSections extends ConsumerWidget { _SettingsSliderTile( icon: LucideIcons.sparkles, iconColor: Settings.settingsAbilityAccent, - title: "Ability markers", - description: - "Resize utility icons and placement helpers for this strategy.", + title: l10n.settingsAbilityMarkersTitle, + description: l10n.settingsAbilityMarkersDescription, value: strategySettings.abilitySize, min: Settings.abilitySizeMin, max: Settings.abilitySizeMax, @@ -266,9 +268,8 @@ class _StrategySettingsSections extends ConsumerWidget { _SettingsToggleTile( icon: LucideIcons.contrast, iconColor: Settings.settingsNeutralAccent, - title: "Neutral team marker colors", - description: - "Render ally and enemy marker accents as matching-brightness greys.", + title: l10n.settingsNeutralTeamColorsTitle, + description: l10n.settingsNeutralTeamColorsDescription, value: strategySettings.useNeutralTeamColors, onChanged: (value) { WidgetsBinding.instance.addPostFrameCallback((_) { @@ -315,6 +316,7 @@ class _GlobalSettingsSections extends ConsumerWidget { ref.watch(platformPolicyProvider).allowsLocalLibrary; final discordPresenceSupported = ref.watch(discordPresenceSupportedProvider); + final l10n = context.l10n; return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -325,17 +327,22 @@ class _GlobalSettingsSections extends ConsumerWidget { const SizedBox(height: 20), const _SettingsItemDivider(), const SizedBox(height: 20), + SettingsScopeCard( + key: sectionKeys[_SettingsSection.globalLanguage], + title: l10n.settingsLanguage, + child: _LanguageTile(languageCode: appPreferences.languageCode), + ), + const SizedBox(height: 28), SettingsScopeCard( key: sectionKeys[_SettingsSection.globalDefaults], - title: "New strategy defaults", + title: l10n.settingsNewStrategyDefaultsTitle, child: Column( children: [ _SettingsSliderTile( icon: LucideIcons.personStanding, iconColor: Settings.settingsAgentAccent, - title: "Default agent markers", - description: - "Default size for agent markers in new strategies.", + title: l10n.settingsDefaultAgentMarkersTitle, + description: l10n.settingsDefaultAgentMarkersDescription, value: appPreferences.defaultAgentSizeForNewStrategies, min: Settings.agentSizeMin, max: Settings.agentSizeMax, @@ -350,9 +357,8 @@ class _GlobalSettingsSections extends ConsumerWidget { _SettingsSliderTile( icon: LucideIcons.sparkles, iconColor: Settings.settingsAbilityAccent, - title: "Default ability markers", - description: - "Default size for ability markers in new strategies.", + title: l10n.settingsDefaultAbilityMarkersTitle, + description: l10n.settingsDefaultAbilityMarkersDescription, value: appPreferences.defaultAbilitySizeForNewStrategies, min: Settings.abilitySizeMin, max: Settings.abilitySizeMax, @@ -367,9 +373,8 @@ class _GlobalSettingsSections extends ConsumerWidget { _SettingsToggleTile( icon: LucideIcons.contrast, iconColor: Settings.settingsNeutralAccent, - title: "Neutral marker colors by default", - description: - "Use grey ally and enemy accents for new strategies.", + title: l10n.settingsDefaultNeutralColorsTitle, + description: l10n.settingsDefaultNeutralColorsDescription, value: appPreferences.defaultNeutralTeamColorsForNewStrategies, onChanged: (value) { WidgetsBinding.instance.addPostFrameCallback((_) { @@ -387,7 +392,7 @@ class _GlobalSettingsSections extends ConsumerWidget { const SizedBox(height: 28), SettingsScopeCard( key: sectionKeys[_SettingsSection.globalSaving], - title: "Workspace behavior", + title: l10n.settingsWorkspaceBehaviorTitle, child: Column( children: [ // Autosave only covers strategies on this device. @@ -395,9 +400,8 @@ class _GlobalSettingsSections extends ConsumerWidget { _SettingsToggleTile( icon: LucideIcons.save, iconColor: Settings.settingsPersistenceAccent, - title: "Autosave", - description: - "Save strategies on this device after 15 seconds idle. When off, Icarus asks before you leave unsaved work. Cloud strategies sync either way.", + title: l10n.settingsAutosaveTitle, + description: l10n.settingsAutosaveDescription, value: appPreferences.autosaveEnabled, onChanged: (value) async { await ref @@ -412,9 +416,8 @@ class _GlobalSettingsSections extends ConsumerWidget { _SettingsToggleTile( icon: LucideIcons.gamepad2, iconColor: Settings.settingsDiscordAccent, - title: "Discord Rich Presence", - description: - "Show the current map and attack or defense side on your Discord profile. Strategy names are never shared.", + title: l10n.settingsDiscordPresenceTitle, + description: l10n.settingsDiscordPresenceDescription, value: appPreferences.discordPresenceEnabled, onChanged: (value) { ref @@ -429,15 +432,14 @@ class _GlobalSettingsSections extends ConsumerWidget { const SizedBox(height: 28), SettingsScopeCard( key: sectionKeys[_SettingsSection.globalMapVisibility], - title: "Workspace map visibility", + title: l10n.settingsMapVisibilityTitle, child: Column( children: [ _SettingsToggleTile( icon: LucideIcons.grid3x3, iconColor: Settings.settingsMapAccent, - title: "Spawn barriers", - description: - "Keep round-start barrier guides visible on the map.", + title: l10n.settingsSpawnBarriersTitle, + description: l10n.settingsSpawnBarriersDescription, value: mapState.showSpawnBarrier, onChanged: (value) { ref.read(mapProvider.notifier).updateSpawnBarrier(value); @@ -446,8 +448,8 @@ class _GlobalSettingsSections extends ConsumerWidget { _SettingsToggleTile( icon: LucideIcons.mapPin, iconColor: Settings.settingsMapAccent, - title: "Region names", - description: "Show map callout names directly on the canvas.", + title: l10n.settingsRegionNamesTitle, + description: l10n.settingsRegionNamesDescription, value: mapState.showRegionNames, onChanged: (value) { ref.read(mapProvider.notifier).updateRegionNames(value); @@ -456,8 +458,8 @@ class _GlobalSettingsSections extends ConsumerWidget { _SettingsToggleTile( icon: LucideIcons.circleDot, iconColor: Settings.settingsMapAccent, - title: "Ultimate orbs", - description: "Display orb pickup markers on supported maps.", + title: l10n.settingsUltOrbsTitle, + description: l10n.settingsUltOrbsDescription, value: mapState.showUltOrbs, onChanged: (value) { ref.read(mapProvider.notifier).updateUltOrbs(value); @@ -466,9 +468,8 @@ class _GlobalSettingsSections extends ConsumerWidget { _SettingsToggleTile( icon: LucideIcons.messageSquareText, iconColor: Settings.settingsMapAccent, - title: "Captions", - description: - "Show the notes kept on placed items. This device only.", + title: l10n.settingsCaptionsTitle, + description: l10n.settingsCaptionsDescription, value: appPreferences.showCaptions, onChanged: (value) { ref @@ -479,15 +480,15 @@ class _GlobalSettingsSections extends ConsumerWidget { _SettingsSliderTile( icon: LucideIcons.grip, iconColor: Settings.settingsMapAccent, - title: "Background dots", - description: - "How strongly the dot grid shows behind the map and your library. 0% hides it.", + title: l10n.settingsBackgroundDotsTitle, + description: l10n.settingsBackgroundDotsDescription, value: appPreferences.backgroundDotOpacity, min: 0, max: 1, divisions: 20, accentColor: Settings.accentInk, - valueLabel: (value) => "${(value * 100).round()}%", + valueLabel: (value) => + l10n.settingsPercentValue((value * 100).round()), onChanged: (value) { ref .read(appPreferencesProvider.notifier) @@ -497,15 +498,15 @@ class _GlobalSettingsSections extends ConsumerWidget { _SettingsSliderTile( icon: LucideIcons.eye, iconColor: Settings.settingsMapAccent, - title: "View cones", - description: - "How strongly view cones show on the map, here and in screenshots and videos.", + title: l10n.settingsViewConesTitle, + description: l10n.settingsViewConesDescription, value: appPreferences.viewConeOpacity, min: AppPreferences.minViewConeOpacity, max: 1, divisions: 18, accentColor: Settings.accentInk, - valueLabel: (value) => "${(value * 100).round()}%", + valueLabel: (value) => + l10n.settingsPercentValue((value * 100).round()), onChanged: (value) { ref .read(appPreferencesProvider.notifier) @@ -518,13 +519,12 @@ class _GlobalSettingsSections extends ConsumerWidget { const SizedBox(height: 28), SettingsScopeCard( key: sectionKeys[_SettingsSection.globalPrivacy], - title: "Privacy", + title: l10n.settingsPrivacy, child: _SettingsToggleTile( icon: LucideIcons.chartColumn, iconColor: Settings.settingsPersistenceAccent, - title: "Anonymous analytics", - description: - "Share app opens and a few feature-use events — never strategy content or personal details.", + title: l10n.settingsAnalyticsTitle, + description: l10n.settingsAnalyticsDescription, value: ref.watch(analyticsEnabledProvider), onChanged: (value) async { await ref @@ -562,6 +562,7 @@ class _ShortcutSettingsSectionState @override Widget build(BuildContext context) { + final l10n = context.l10n; final customBindings = ref.watch( appPreferencesProvider.select((prefs) => prefs.customShortcutBindings), ); @@ -571,12 +572,14 @@ class _ShortcutSettingsSectionState definition, _query, customBindings, + l10n, ), ) .toList(growable: false); + final snapToMapMatches = _snapToMapMatches(l10n); return SettingsScopeCard( - title: "Keybinds", + title: l10n.settingsKeybinds, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -604,7 +607,7 @@ class _ShortcutSettingsSectionState .read(appPreferencesProvider.notifier) .resetAllCustomShortcutBindings(); }, - child: const Text("Restore Defaults"), + child: Text(l10n.settingsKeybindsRestoreDefaults), ), ], ), @@ -612,7 +615,7 @@ class _ShortcutSettingsSectionState const _ShortcutTableHeader(), const SizedBox(height: 4), // The Alt row isn't a definition, but it can be what matched. - if (visibleDefinitions.isEmpty && !_snapToMapMatches) + if (visibleDefinitions.isEmpty && !snapToMapMatches) const _ShortcutEmptySearch() else for (final definition in visibleDefinitions) ...[ @@ -658,8 +661,10 @@ class _ShortcutSettingsSectionState if (duplicate != null) { triggerShake(); setState(() { - _duplicateMessage = - "${binding.displayLabel()} is already bound to ${duplicate.title}."; + _duplicateMessage = l10n.settingsKeybindsAlreadyBound( + binding.displayLabel(), + duplicate.title(l10n), + ); }); return; } @@ -677,16 +682,16 @@ class _ShortcutSettingsSectionState ), const _SettingsItemDivider(), ], - if (_snapToMapMatches) const _SnapToMapRow(), + if (snapToMapMatches) const _SnapToMapRow(), ], ), ); } - bool get _snapToMapMatches { + bool _snapToMapMatches(AppLocalizations l10n) { final query = ShortcutInfo.normalizeSearch(_query); return ShortcutInfo.normalizeSearch( - '${_SnapToMapRow.title} ${_SnapToMapRow.keys}', + '${l10n.settingsKeybindsSnapToMap} ${l10n.settingsKeybindsHoldAlt}', ).contains(query); } } @@ -697,18 +702,16 @@ class _ShortcutSettingsSectionState class _SnapToMapRow extends StatelessWidget { const _SnapToMapRow(); - static const title = 'Snap to map lines while dragging'; - static const keys = 'Hold Alt'; - @override Widget build(BuildContext context) { + final l10n = context.l10n; return Padding( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), child: Row( children: [ Expanded( child: Text( - title, + l10n.settingsKeybindsSnapToMap, style: ShadTheme.of(context).textTheme.p.copyWith( color: Settings.tacticalVioletTheme.foreground, fontWeight: FontWeight.w400, @@ -716,11 +719,14 @@ class _SnapToMapRow extends StatelessWidget { ), ), const SizedBox(width: 40), - const SizedBox( + SizedBox( width: 132, child: Align( alignment: Alignment.centerRight, - child: _ShortcutBindingPill(label: keys, isEditing: false), + child: _ShortcutBindingPill( + label: l10n.settingsKeybindsHoldAlt, + isEditing: false, + ), ), ), ], @@ -753,7 +759,7 @@ class _ShortcutSearchField extends StatelessWidget { cursorColor: Settings.accentInk, decoration: InputDecoration( isDense: true, - hintText: "Search actions or keys...", + hintText: context.l10n.settingsKeybindsSearchHint, hintStyle: TextStyle( color: Settings.tacticalVioletTheme.mutedForeground, fontSize: 13, @@ -793,13 +799,14 @@ class _ShortcutTableHeader extends StatelessWidget { @override Widget build(BuildContext context) { final theme = ShadTheme.of(context); + final l10n = context.l10n; return Padding( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), child: Row( children: [ Expanded( child: Text( - "Action", + l10n.settingsKeybindsActionColumn, style: theme.textTheme.small.copyWith( color: Settings.tacticalVioletTheme.mutedForeground, fontSize: 12, @@ -811,7 +818,7 @@ class _ShortcutTableHeader extends StatelessWidget { child: Align( alignment: Alignment.centerRight, child: Text( - "Binding", + l10n.settingsKeybindsBindingColumn, style: theme.textTheme.small.copyWith( color: Settings.tacticalVioletTheme.mutedForeground, fontSize: 12, @@ -868,7 +875,7 @@ class _ShortcutBindingRow extends StatelessWidget { children: [ Expanded( child: Text( - definition.title, + definition.title(context.l10n), style: theme.textTheme.p.copyWith( color: Settings.tacticalVioletTheme.foreground, fontWeight: FontWeight.w400, @@ -877,7 +884,7 @@ class _ShortcutBindingRow extends StatelessWidget { ), if (onReset != null) ShadTooltip( - builder: (_) => const Text("Reset to default"), + builder: (_) => Text(context.l10n.settingsKeybindsResetTooltip), child: ShadIconButton.ghost( icon: const Icon(LucideIcons.undo2, size: 15), onPressed: onReset, @@ -1078,7 +1085,7 @@ class _ShortcutCaptureFieldState extends State<_ShortcutCaptureField> const SizedBox(width: 9), Expanded( child: Text( - "Press new shortcut...", + context.l10n.settingsKeybindsCapturePrompt, style: ShadTheme.of(context).textTheme.small.copyWith( color: Settings.tacticalVioletTheme.foreground, ), @@ -1087,7 +1094,7 @@ class _ShortcutCaptureFieldState extends State<_ShortcutCaptureField> ShadButton.ghost( size: ShadButtonSize.sm, onPressed: widget.onCancel, - child: const Text("Cancel"), + child: Text(context.l10n.commonCancel), ), ], ), @@ -1111,7 +1118,7 @@ class _ShortcutCaptureFieldState extends State<_ShortcutCaptureField> : Padding( padding: const EdgeInsets.only(top: 7), child: Text( - "Escape cancels. Ctrl and Cmd are saved as one platform-aware Primary modifier.", + context.l10n.settingsKeybindsCaptureHelp, style: ShadTheme.of(context).textTheme.small.copyWith( color: Settings.tacticalVioletTheme.mutedForeground, ), @@ -1133,7 +1140,7 @@ class _ShortcutEmptySearch extends StatelessWidget { padding: const EdgeInsets.symmetric(vertical: 28), child: Center( child: Text( - "No shortcuts match that search.", + context.l10n.settingsKeybindsNoMatches, style: ShadTheme.of(context).textTheme.small.copyWith( color: Settings.tacticalVioletTheme.mutedForeground, ), @@ -1149,12 +1156,13 @@ class AccountSettingsSection extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final authState = ref.watch(authProvider); + final l10n = context.l10n; return SettingsScopeCard( - title: 'Account', + title: l10n.settingsAccount, description: authState.isAuthenticated - ? 'Your cloud identity and sync connection.' - : 'Sign in to sync strategies to the cloud and share them.', + ? l10n.settingsAccountSignedInDescription + : l10n.settingsAccountSignedOutDescription, child: AnimatedSwitcher( duration: const Duration(milliseconds: 180), switchInCurve: Settings.easeOut, @@ -1177,7 +1185,7 @@ class AccountSettingsSection extends ConsumerWidget { const SizedBox(width: 10), Expanded( child: Text( - 'Not signed in', + l10n.settingsAccountNotSignedIn, style: TextStyle( color: Settings.tacticalVioletTheme.mutedForeground, ), @@ -1193,7 +1201,7 @@ class AccountSettingsSection extends ConsumerWidget { builder: (_) => const AuthDialog(), ); }, - child: const Text('Log In'), + child: Text(l10n.commonLogIn), ), ], ), @@ -1216,37 +1224,38 @@ class _SignedInAccountRow extends ConsumerWidget { // The server refuses this build. Reconnecting can't help; reloading or // updating can, so this row says what the sync button says. final needsNewerIcarus = ref.watch(clientUpgradeRequiredProvider); + final l10n = context.l10n; final (String statusLabel, IconData statusIcon, Color statusColor) = needsNewerIcarus ? ( - 'Cloud sync needs a newer Icarus', + l10n.settingsSyncNeedsNewerIcarus, LucideIcons.circleAlert, theme.destructive, ) : switch (authState.convexAuthStatus) { ConvexAuthStatus.ready => ( - 'Cloud sync active', + l10n.settingsSyncActive, LucideIcons.cloudCheck, theme.mutedForeground, ), ConvexAuthStatus.configuring => ( - 'Connecting to cloud…', + l10n.settingsSyncConnecting, LucideIcons.refreshCw, theme.mutedForeground, ), ConvexAuthStatus.offline => ( - "Offline. Cloud sync resumes when you're back online", + l10n.settingsSyncOffline, LucideIcons.cloudOff, theme.mutedForeground, ), ConvexAuthStatus.incident => ( - 'Cloud connection needs attention', + l10n.settingsSyncNeedsAttention, LucideIcons.circleAlert, theme.destructive, ), ConvexAuthStatus.signedOut => ( - 'Cloud sync inactive', + l10n.settingsSyncInactive, LucideIcons.cloudOff, theme.mutedForeground, ), @@ -1261,7 +1270,7 @@ class _SignedInAccountRow extends ConsumerWidget { backgroundColor: theme.muted, avatarUrl: avatarUrl, fallback: Text( - authState.displayName.characters.first.toUpperCase(), + authState.displayName(l10n).characters.first.toUpperCase(), style: TextStyle( fontSize: 13, fontWeight: FontWeight.w600, @@ -1275,11 +1284,11 @@ class _SignedInAccountRow extends ConsumerWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - authState.displayName, + authState.displayName(l10n), overflow: TextOverflow.ellipsis, style: const TextStyle(fontWeight: FontWeight.w600), ), - if (email != null && email != authState.displayName) + if (email != null && email != authState.displayName(l10n)) Text( email, overflow: TextOverflow.ellipsis, @@ -1325,7 +1334,7 @@ class _SignedInAccountRow extends ConsumerWidget { .read(authProvider.notifier) .reinitializeConvexAuth(source: 'settings_account'); }, - child: const Text('Reconnect'), + child: Text(l10n.commonReconnect), ), const SizedBox(width: 8), ], @@ -1337,7 +1346,7 @@ class _SignedInAccountRow extends ConsumerWidget { : () async { await ref.read(guardedSignOutRequestProvider)(context); }, - child: const Text('Sign Out'), + child: Text(l10n.commonSignOut), ), ], ), @@ -1358,6 +1367,7 @@ class _SettingsNavigationRail extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final allowsLocalLibrary = ref.watch(platformPolicyProvider).allowsLocalLibrary; + final l10n = context.l10n; return Container( width: SettingsTab._navigationWidth, @@ -1367,65 +1377,71 @@ class _SettingsNavigationRail extends ConsumerWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - "Settings", + l10n.settingsTitle, style: ShadTheme.of(context).textTheme.h4.copyWith( color: Settings.tacticalVioletTheme.foreground, fontWeight: FontWeight.w500, ), ), const SizedBox(height: 20), - const _SettingsNavHeader(label: "Current strategy"), + _SettingsNavHeader(label: l10n.settingsNavCurrentStrategy), const SizedBox(height: 4), _SettingsNavItem( icon: LucideIcons.slidersHorizontal, - label: "Object styling", + label: l10n.settingsObjectStyling, isSelected: selectedSection == _SettingsSection.strategyObjects, onTap: () => onSectionSelected(_SettingsSection.strategyObjects), ), _SettingsNavItem( icon: LucideIcons.palette, - label: "Map theme", + label: l10n.mapThemeTitle, isSelected: selectedSection == _SettingsSection.strategyMapTheme, onTap: () => onSectionSelected(_SettingsSection.strategyMapTheme), ), const SizedBox(height: 20), - const _SettingsNavHeader(label: "App-wide"), + _SettingsNavHeader(label: l10n.settingsNavAppWide), const SizedBox(height: 4), _SettingsNavItem( icon: LucideIcons.user, - label: "Account", + label: l10n.settingsAccount, isSelected: selectedSection == _SettingsSection.globalAccount, onTap: () => onSectionSelected(_SettingsSection.globalAccount), ), + _SettingsNavItem( + icon: LucideIcons.languages, + label: l10n.settingsLanguage, + isSelected: selectedSection == _SettingsSection.globalLanguage, + onTap: () => onSectionSelected(_SettingsSection.globalLanguage), + ), _SettingsNavItem( icon: LucideIcons.wandSparkles, - label: "Defaults", + label: l10n.settingsNavDefaults, isSelected: selectedSection == _SettingsSection.globalDefaults, onTap: () => onSectionSelected(_SettingsSection.globalDefaults), ), if (allowsLocalLibrary) _SettingsNavItem( icon: LucideIcons.save, - label: "Autosave", + label: l10n.settingsNavAutosave, isSelected: selectedSection == _SettingsSection.globalSaving, onTap: () => onSectionSelected(_SettingsSection.globalSaving), ), _SettingsNavItem( icon: LucideIcons.map, - label: "Map layers", + label: l10n.settingsNavMapLayers, isSelected: selectedSection == _SettingsSection.globalMapVisibility, onTap: () => onSectionSelected(_SettingsSection.globalMapVisibility), ), _SettingsNavItem( icon: LucideIcons.shield, - label: "Privacy", + label: l10n.settingsPrivacy, isSelected: selectedSection == _SettingsSection.globalPrivacy, onTap: () => onSectionSelected(_SettingsSection.globalPrivacy), ), _SettingsNavItem( icon: LucideIcons.keyboard, - label: "Keybinds", + label: l10n.settingsKeybinds, isSelected: selectedSection == _SettingsSection.shortcuts, onTap: () => onSectionSelected(_SettingsSection.shortcuts), ), @@ -1449,7 +1465,7 @@ class _VersionFooter extends StatelessWidget { Widget build(BuildContext context) { const theme = Settings.tacticalVioletTheme; return ShadTooltip( - builder: (context) => const Text('Copy version'), + builder: (context) => Text(context.l10n.settingsCopyVersionTooltip), child: ShadButton.ghost( key: const ValueKey('settings-version'), height: 24, @@ -1459,7 +1475,7 @@ class _VersionFooter extends StatelessWidget { onPressed: () { Clipboard.setData(const ClipboardData(text: _label)); Settings.showToast( - message: 'Version copied', + message: context.l10n.settingsVersionCopied, backgroundColor: theme.primary, ); }, @@ -1741,6 +1757,77 @@ class _SettingsToggleTile extends StatelessWidget { } } +/// Picks the language Icarus shows. Each language is listed under its own +/// name, so it can be found by someone who can't read the current one. +class _LanguageTile extends ConsumerWidget { + const _LanguageTile({required this.languageCode}); + + /// The language picked, or null while Icarus follows the system. + final String? languageCode; + + static const _system = ''; + static const _names = {'en': 'English', 'ja': '日本語', 'zh': '简体中文'}; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = context.l10n; + String label(String code) => + code == _system ? l10n.settingsLanguageSystem : _names[code]!; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 10), + child: Row( + children: [ + const SizedBox( + width: 24, + height: 24, + child: Icon( + LucideIcons.languages, + size: 20, + color: Settings.settingsPersistenceAccent, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(l10n.settingsLanguageTitle), + const SizedBox(height: 2), + Text( + l10n.settingsLanguageDescription, + style: ShadTheme.of(context).textTheme.small.copyWith( + color: Settings.tacticalVioletTheme.mutedForeground, + fontSize: 12, + height: 1.35, + ), + ), + ], + ), + ), + const SizedBox(width: 12), + ShadSelect( + // Keyed by the language shown, so the list's own words follow it. + key: ValueKey('language-${l10n.localeName}-$languageCode'), + initialValue: languageCode ?? _system, + minWidth: 168, + selectedOptionBuilder: (context, value) => Text(label(value)), + options: [ + for (final code in [_system, ..._names.keys]) + ShadOption(value: code, child: Text(label(code))), + ], + onChanged: (value) { + if (value == null) return; + ref + .read(appPreferencesProvider.notifier) + .setLanguageCode(value == _system ? null : value); + }, + ), + ], + ), + ); + } +} + class _SettingsItemDivider extends StatelessWidget { const _SettingsItemDivider(); @@ -1798,7 +1885,7 @@ class _PageMarkerSizesSyncBannerState crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( - 'Marker sizes differ across pages. Apply these sizes everywhere?', + context.l10n.settingsMarkerSizesDiffer, style: ShadTheme.of(context).textTheme.small.copyWith( color: Settings.tacticalVioletTheme.mutedForeground, height: 1.35, @@ -1819,7 +1906,11 @@ class _PageMarkerSizesSyncBannerState if (mounted) setState(() => _busy = false); } }, - child: Text(_busy ? 'Applying…' : 'Apply to all pages'), + child: Text( + _busy + ? context.l10n.settingsApplyingMarkerSizes + : context.l10n.settingsApplyMarkerSizesToAllPages, + ), ), ], ), diff --git a/lib/widgets/sidebar_widgets/agent_filter.dart b/lib/widgets/sidebar_widgets/agent_filter.dart index 74a830809..7e7ec233d 100644 --- a/lib/widgets/sidebar_widgets/agent_filter.dart +++ b/lib/widgets/sidebar_widgets/agent_filter.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/agent_filter_provider.dart'; import 'package:icarus/widgets/custom_segmented_tabs.dart'; @@ -13,18 +14,18 @@ class AgentFilter extends ConsumerWidget { return CustomSegmentedTabs( compactness: compactness, value: ref.watch(agentFilterProvider).currentFilter, - items: const [ + items: [ SegmentedTabItem( value: FilterState.all, - child: Text("All"), + child: Text(context.l10n.sidebarFilterAll), ), SegmentedTabItem( value: FilterState.onMap, - child: Text("On Map"), + child: Text(context.l10n.sidebarFilterOnMap), ), SegmentedTabItem( value: FilterState.role, - child: Text("Role"), + child: Text(context.l10n.sidebarFilterRole), ), ], onChanged: (value) { diff --git a/lib/widgets/sidebar_widgets/agent_role_icon_tools.dart b/lib/widgets/sidebar_widgets/agent_role_icon_tools.dart index 0ace1d118..bb6b7be63 100644 --- a/lib/widgets/sidebar_widgets/agent_role_icon_tools.dart +++ b/lib/widgets/sidebar_widgets/agent_role_icon_tools.dart @@ -6,6 +6,7 @@ import 'package:icarus/const/placed_classes.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/const/transition_data.dart'; import 'package:icarus/const/utilities.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/interaction_state_provider.dart'; import 'package:icarus/providers/placement_center_provider.dart'; import 'package:icarus/providers/screen_zoom_provider.dart'; @@ -26,7 +27,7 @@ class AgentRoleIconTools extends ConsumerWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text("Agent Role Icons"), + Text(context.l10n.sidebarAgentRoleIconsTitle), const SizedBox(height: 8), Container( decoration: BoxDecoration( @@ -69,7 +70,7 @@ class AgentRoleIconTools extends ConsumerWidget { Padding( padding: const EdgeInsets.symmetric(horizontal: 6), child: Text( - "Drag or click to place", + context.l10n.sidebarDragOrClickToPlace, style: TextStyle( fontSize: 12, fontStyle: FontStyle.italic, @@ -132,7 +133,7 @@ class _RoleIconTile extends ConsumerWidget { ), ), child: ShadTooltip( - builder: (context) => Text(label), + builder: (context) => Text(context.l10n.gameName(label)), child: GestureDetector( onTap: () => _placeAtCenter(ref), child: utility.createWidget( diff --git a/lib/widgets/sidebar_widgets/color_library.dart b/lib/widgets/sidebar_widgets/color_library.dart index 99734939c..5412e5a66 100644 --- a/lib/widgets/sidebar_widgets/color_library.dart +++ b/lib/widgets/sidebar_widgets/color_library.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/settings.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/color_library_provider.dart'; import 'package:icarus/widgets/better_color_picker.dart'; import 'package:icarus/widgets/context_menu_region.dart'; @@ -50,7 +51,7 @@ class ColorLibrary extends ConsumerWidget { items: [ ShadContextMenuItem( leading: const Icon(LucideIcons.pencil, size: 14), - child: const Text('Edit'), + child: Text(context.l10n.commonEdit), onPressed: () => _editColor(context, ref, entry), ), ShadContextMenuItem( @@ -60,7 +61,7 @@ class ColorLibrary extends ConsumerWidget { color: Settings.tacticalVioletTheme.destructive, ), child: Text( - 'Delete', + context.l10n.commonDelete, style: TextStyle( color: Settings.tacticalVioletTheme.destructive, ), @@ -100,7 +101,7 @@ class ColorLibrary extends ConsumerWidget { final picked = await showIcarusColorPickerDialog( context: context, initialColor: Colors.white, - title: 'Add color', + title: context.l10n.sidebarAddColorTitle, ); if (picked == null) return; await ref.read(colorLibraryControllerProvider.notifier).addColor(picked); @@ -115,7 +116,7 @@ class ColorLibrary extends ConsumerWidget { final picked = await showIcarusColorPickerDialog( context: context, initialColor: entry.color, - title: 'Edit color', + title: context.l10n.sidebarEditColorTitle, ); if (picked == null) return; await ref @@ -141,11 +142,11 @@ Future showIcarusColorPickerDialog({ actions: [ ShadButton.secondary( onPressed: () => Navigator.of(context).pop(), - child: const Text('Cancel'), + child: Text(context.l10n.commonCancel), ), ShadButton( onPressed: () => Navigator.of(context).pop(workingColor), - child: const Text('Apply'), + child: Text(context.l10n.commonApply), ), ], child: Material( diff --git a/lib/widgets/sidebar_widgets/custom_shape_tools.dart b/lib/widgets/sidebar_widgets/custom_shape_tools.dart index 693c4b96d..61ceb948b 100644 --- a/lib/widgets/sidebar_widgets/custom_shape_tools.dart +++ b/lib/widgets/sidebar_widgets/custom_shape_tools.dart @@ -6,6 +6,7 @@ import 'package:icarus/const/maps.dart'; import 'package:icarus/const/placed_classes.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/const/utilities.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/interaction_state_provider.dart'; import 'package:icarus/providers/map_provider.dart'; import 'package:icarus/providers/placement_center_provider.dart'; @@ -55,7 +56,7 @@ class _CustomShapeToolsState extends ConsumerState { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text("Shapes"), + Text(context.l10n.shapeToolsTitle), const SizedBox(height: 4), _buildShapeSelector(), const SizedBox(height: 4), @@ -66,8 +67,8 @@ class _CustomShapeToolsState extends ConsumerState { // A zone is the rectangle: it colours the map itself, where a // circle is drawn over everything. _shape == _CustomShapeKind.rectangle - ? "Drag or click to place. Colours the map floor inside it." - : "Drag or click to place", + ? context.l10n.shapeToolsZoneHint + : context.l10n.sidebarDragOrClickToPlace, style: TextStyle( fontSize: 11, fontStyle: FontStyle.italic, @@ -97,11 +98,15 @@ class _CustomShapeToolsState extends ConsumerState { mainAxisSize: MainAxisSize.min, spacing: 4, children: [ - _kindButton(_CustomShapeKind.circle, LucideIcons.circle, "Circle"), + _kindButton( + _CustomShapeKind.circle, + LucideIcons.circle, + context.l10n.shapeToolsCircle, + ), _kindButton( _CustomShapeKind.rectangle, LucideIcons.squareDashed, - "Zone", + context.l10n.shapeToolsZone, ), ], ), @@ -137,11 +142,12 @@ class _CustomShapeToolsState extends ConsumerState { } Widget _buildPanel(CustomShapeToolData draggableData, double mapScale) { + final l10n = context.l10n; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (_shape == _CustomShapeKind.circle) ...[ - const Text("Diameter"), + Text(l10n.shapeToolsDiameterLabel), const SizedBox(height: 4), SizedBox( height: 40, @@ -151,14 +157,14 @@ class _CustomShapeToolsState extends ConsumerState { onChanged: (v) => setState(() => _diameterMeters = v), min: 1.0, max: CustomCircleUtility.maxDiameterMeters, - label: 'D', - hintText: 'Enter Diameter', + label: l10n.shapeToolsDiameterShortLabel, + hintText: l10n.shapeToolsDiameterHint, suffix: 'm', ), ), const SizedBox(height: 4), ] else ...[ - const Text("Dimensions"), + Text(l10n.shapeToolsDimensionsLabel), const SizedBox(height: 4), Row(spacing: 4, children: [ Flexible( @@ -170,8 +176,8 @@ class _CustomShapeToolsState extends ConsumerState { onChanged: (v) => setState(() => _rectLengthMeters = v), min: 1, max: CustomRectangleUtility.maxSideMeters, - label: 'L', - hintText: 'Enter Length', + label: l10n.shapeToolsLengthShortLabel, + hintText: l10n.shapeToolsLengthHint, suffix: 'm', ), ), @@ -185,8 +191,8 @@ class _CustomShapeToolsState extends ConsumerState { onChanged: (v) => setState(() => _rectWidthMeters = v), min: 0.5, max: CustomRectangleUtility.maxSideMeters, - label: 'W', - hintText: 'Enter Width', + label: l10n.shapeToolsWidthShortLabel, + hintText: l10n.shapeToolsWidthHint, suffix: 'm', ), ), @@ -194,7 +200,7 @@ class _CustomShapeToolsState extends ConsumerState { ]), const SizedBox(height: 4), ], - const Text("Opacity"), + Text(l10n.shapeToolsOpacityLabel), const SizedBox(height: 4), SizedBox( height: 40, @@ -207,12 +213,12 @@ class _CustomShapeToolsState extends ConsumerState { leading: Icon(LucideIcons.droplet, size: 16, color: Settings.tacticalVioletTheme.mutedForeground), // label: 'O', - hintText: 'Enter Opacity', + hintText: l10n.shapeToolsOpacityHint, suffix: '%', ), ), const SizedBox(height: 4), - const Text("Color"), + Text(l10n.shapeToolsColorLabel), const SizedBox(height: 4), _buildColorPicker(), const SizedBox(height: 4), @@ -245,8 +251,8 @@ class _CustomShapeToolsState extends ConsumerState { onPressed: () => _placeAtCenter(mapScale), child: Text( _shape == _CustomShapeKind.rectangle - ? "+ Place zone" - : "+ Place circle", + ? l10n.shapeToolsPlaceZoneButton + : l10n.shapeToolsPlaceCircleButton, ), ), ), diff --git a/lib/widgets/sidebar_widgets/delete_options.dart b/lib/widgets/sidebar_widgets/delete_options.dart index 4e4f9e16b..06c04b91b 100644 --- a/lib/widgets/sidebar_widgets/delete_options.dart +++ b/lib/widgets/sidebar_widgets/delete_options.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/settings.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/action_provider.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; @@ -22,41 +23,42 @@ class DeleteOptions extends ConsumerWidget { static const double _panelPadding = 6; static const double _controlRadius = _panelRadius - _panelPadding; - static const List<_DeleteOptionData> _options = [ + static List<_DeleteOptionData> _options(AppLocalizations l10n) => [ _DeleteOptionData( group: ActionGroup.agent, icon: LucideIcons.user, - label: 'Agents', + clearTooltip: l10n.sidebarClearAgentsTooltip, ), _DeleteOptionData( group: ActionGroup.ability, icon: LucideIcons.zap, - label: 'Abilities', + clearTooltip: l10n.sidebarClearAbilitiesTooltip, ), _DeleteOptionData( group: ActionGroup.drawing, icon: LucideIcons.pencil, - label: 'Drawings', + clearTooltip: l10n.sidebarClearDrawingsTooltip, ), _DeleteOptionData( group: ActionGroup.text, icon: LucideIcons.type, - label: 'Text', + clearTooltip: l10n.sidebarClearTextTooltip, ), _DeleteOptionData( group: ActionGroup.image, icon: LucideIcons.image, - label: 'Images', + clearTooltip: l10n.sidebarClearImagesTooltip, ), _DeleteOptionData( group: ActionGroup.utility, icon: LucideIcons.square, - label: 'Utilities', + clearTooltip: l10n.sidebarClearUtilitiesTooltip, ), ]; @override Widget build(BuildContext context, WidgetRef ref) { + final options = _options(context.l10n); final panel = Container( width: 146, height: 98, @@ -86,7 +88,7 @@ class DeleteOptions extends ConsumerWidget { child: SizedBox( height: 24, child: ShadTooltip( - builder: (_) => const Text("Delete all"), + builder: (_) => Text(context.l10n.sidebarDeleteAllTooltip), child: ShadButton.destructive( onPressed: () async { ref.read(actionProvider.notifier).clearAllAsAction(); @@ -99,7 +101,7 @@ class DeleteOptions extends ConsumerWidget { ), ), child: Text( - "Delete all", + context.l10n.sidebarDeleteAllButton, style: ShadTheme.of(context).textTheme.small.copyWith( fontSize: 10, fontWeight: FontWeight.w600, @@ -111,7 +113,7 @@ class DeleteOptions extends ConsumerWidget { ), const SizedBox(height: 6), ...List.generate(2, (rowIndex) { - final rowOptions = _options.skip(rowIndex * 3).take(3).toList(); + final rowOptions = options.skip(rowIndex * 3).take(3).toList(); return Padding( padding: EdgeInsets.only(bottom: rowIndex == 0 ? 6 : 0), child: Row( @@ -128,7 +130,7 @@ class DeleteOptions extends ConsumerWidget { .clamp(0.0, 1.0), child: ShadTooltip( builder: (_) => - Text("Clear ${rowOptions[columnIndex].label}"), + Text(rowOptions[columnIndex].clearTooltip), child: ShadIconButton.secondary( cursor: SystemMouseCursors.click, width: double.infinity, @@ -254,10 +256,10 @@ class _DeleteOptionData { const _DeleteOptionData({ required this.group, required this.icon, - required this.label, + required this.clearTooltip, }); final ActionGroup group; final IconData icon; - final String label; + final String clearTooltip; } diff --git a/lib/widgets/sidebar_widgets/drawing_tools.dart b/lib/widgets/sidebar_widgets/drawing_tools.dart index d2ad600cf..ae71fe9b6 100644 --- a/lib/widgets/sidebar_widgets/drawing_tools.dart +++ b/lib/widgets/sidebar_widgets/drawing_tools.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/custom_icons.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/const/traversal_speed.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/pen_provider.dart'; import 'package:icarus/widgets/custom_segmented_tabs.dart'; import 'package:icarus/widgets/selectable_icon_button.dart'; @@ -28,13 +29,14 @@ class DrawingTools extends ConsumerWidget { final activeTraversalSpeedProfile = ref.watch( penProvider.select((state) => state.activeTraversalSpeedProfile), ); + final l10n = context.l10n; return Padding( padding: padding, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text("Color"), + Text(l10n.drawingToolsColorLabel), // const SizedBox(height: 10), ColorLibrary( selectedColorValue: ref.watch( @@ -54,7 +56,7 @@ class DrawingTools extends ConsumerWidget { Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text("Shape"), + Text(l10n.drawingToolsShapeLabel), const SizedBox(height: 4), Row( children: [ @@ -87,7 +89,7 @@ class DrawingTools extends ConsumerWidget { .read(penProvider.notifier) .updateValue(penMode: PenMode.freeDraw); }, - tooltip: "Free draw", + tooltip: l10n.drawingToolsFreeDrawTooltip, ), SelectableIconButton( icon: const Icon( @@ -100,7 +102,7 @@ class DrawingTools extends ConsumerWidget { .read(penProvider.notifier) .updateValue(penMode: PenMode.line); }, - tooltip: "Straight line", + tooltip: l10n.drawingToolsStraightLineTooltip, ), SelectableIconButton( icon: const Icon( @@ -113,7 +115,7 @@ class DrawingTools extends ConsumerWidget { .read(penProvider.notifier) .updateValue(penMode: PenMode.square); }, - tooltip: "Rectangle", + tooltip: l10n.drawingToolsRectangleTooltip, ), SelectableIconButton( icon: const Icon( @@ -126,7 +128,7 @@ class DrawingTools extends ConsumerWidget { .read(penProvider.notifier) .updateValue(penMode: PenMode.ellipse); }, - tooltip: "Ellipse", + tooltip: l10n.drawingToolsEllipseTooltip, ), ], ), @@ -139,7 +141,7 @@ class DrawingTools extends ConsumerWidget { Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text("Stroke"), + Text(l10n.drawingToolsStrokeLabel), const SizedBox(height: 4), Row( children: [ @@ -154,7 +156,7 @@ class DrawingTools extends ConsumerWidget { .read(penProvider.notifier) .updateValue(isDotted: !isDotted); }, - tooltip: "Dotted line", + tooltip: l10n.drawingToolsDottedLineTooltip, ), const SizedBox(width: 5), SelectableIconButton( @@ -166,7 +168,7 @@ class DrawingTools extends ConsumerWidget { onPressed: () { ref.read(penProvider.notifier).toggleArrow(); }, - tooltip: "Arrow", + tooltip: l10n.drawingToolsArrowTooltip, ), ], ), @@ -175,7 +177,7 @@ class DrawingTools extends ConsumerWidget { ], ), const SizedBox(height: 4), - const Text("Thickness"), + Text(l10n.drawingToolsThicknessLabel), const SizedBox(height: 4), CustomSegmentedTabs( compactness: 0.55, @@ -193,7 +195,7 @@ class DrawingTools extends ConsumerWidget { ), const SizedBox(height: 4), - const Text("Traversal Time"), + Text(l10n.drawingToolsTraversalTimeLabel), const SizedBox(height: 4), Row( spacing: 4, @@ -211,7 +213,7 @@ class DrawingTools extends ConsumerWidget { .read(penProvider.notifier) .setTraversalMode(TraversalSpeedProfile.running); }, - tooltip: "Running w/ knife out", + tooltip: l10n.drawingToolsRunningTooltip, ), SelectableIconButton( icon: const Icon( @@ -226,7 +228,7 @@ class DrawingTools extends ConsumerWidget { .read(penProvider.notifier) .setTraversalMode(TraversalSpeedProfile.walking); }, - tooltip: "Walking w/ knife out", + tooltip: l10n.drawingToolsWalkingTooltip, ), SelectableIconButton( icon: Image.asset( @@ -242,7 +244,8 @@ class DrawingTools extends ConsumerWidget { .read(penProvider.notifier) .setTraversalMode(TraversalSpeedProfile.brimStim); }, - tooltip: "Brimstone Stim w/ knife out", + tooltip: + l10n.drawingToolsAbilityWithKnifeTooltip('Brimstone Stim'), ), SelectableIconButton( icon: Image.asset( @@ -258,7 +261,7 @@ class DrawingTools extends ConsumerWidget { .read(penProvider.notifier) .setTraversalMode(TraversalSpeedProfile.neonRun); }, - tooltip: "neon run", + tooltip: l10n.drawingToolsAgentRunTooltip('neon'), ), ], ), diff --git a/lib/widgets/sidebar_widgets/team_picker.dart b/lib/widgets/sidebar_widgets/team_picker.dart index 333b94228..1e0d83227 100644 --- a/lib/widgets/sidebar_widgets/team_picker.dart +++ b/lib/widgets/sidebar_widgets/team_picker.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/settings.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/widgets/press_scale.dart'; import 'package:icarus/providers/team_provider.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; @@ -20,14 +21,14 @@ class TeamPicker extends ConsumerWidget { crossAxisAlignment: CrossAxisAlignment.end, children: [ _TeamTextButton( - label: 'Ally', + label: context.l10n.sidebarTeamAlly, isSelected: isAlly, selectedColor: Settings.allyOutlineColor.withValues(alpha: 1), onTap: () => notifier.isAlly(true), ), const SizedBox(height: 4), _TeamTextButton( - label: 'Enemy', + label: context.l10n.sidebarTeamEnemy, isSelected: !isAlly, selectedColor: Settings.enemyOutlineColor.withValues(alpha: 1), onTap: () => notifier.isAlly(false), diff --git a/lib/widgets/sidebar_widgets/text_tools.dart b/lib/widgets/sidebar_widgets/text_tools.dart index 3aa2efdeb..a8db68450 100644 --- a/lib/widgets/sidebar_widgets/text_tools.dart +++ b/lib/widgets/sidebar_widgets/text_tools.dart @@ -4,6 +4,7 @@ import 'package:icarus/const/coordinate_system.dart'; import 'package:icarus/const/placed_classes.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/const/utilities.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/interaction_state_provider.dart'; import 'package:icarus/providers/placement_center_provider.dart'; import 'package:icarus/providers/screen_zoom_provider.dart'; @@ -35,7 +36,7 @@ class _TextToolsState extends ConsumerState { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text("Tag Color"), + Text(context.l10n.sidebarTagColorLabel), const SizedBox(height: 4), _buildColorPicker(), const SizedBox(height: 4), @@ -62,7 +63,7 @@ class _TextToolsState extends ConsumerState { child: ZoomTransform( child: TextWidget( id: "text-tool-preview", - text: "Write here...", + text: context.l10n.canvasItemTextPlaceholder, size: draggableData.width, fontSize: 16, tagColorValue: draggableData.tagColorValue, @@ -75,14 +76,14 @@ class _TextToolsState extends ConsumerState { child: ShadButton( backgroundColor: Settings.tacticalVioletTheme.primary, onPressed: _placeAtCenter, - child: const Text("+ Place Text"), + child: Text(context.l10n.sidebarPlaceTextButton), ), ), ), Padding( padding: const EdgeInsets.symmetric(vertical: 2), child: Text( - "Drag or click to place", + context.l10n.sidebarDragOrClickToPlace, style: TextStyle( fontSize: 11, fontStyle: FontStyle.italic, diff --git a/lib/widgets/sidebar_widgets/tool_grid.dart b/lib/widgets/sidebar_widgets/tool_grid.dart index 8633b14ec..d04d27642 100644 --- a/lib/widgets/sidebar_widgets/tool_grid.dart +++ b/lib/widgets/sidebar_widgets/tool_grid.dart @@ -8,6 +8,7 @@ import 'package:icarus/const/placed_classes.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/const/shortcut_info.dart'; import 'package:icarus/const/utilities.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/image_provider.dart'; import 'package:icarus/providers/interaction_state_provider.dart'; import 'package:icarus/providers/user_preferences_provider.dart'; @@ -96,6 +97,7 @@ class ToolGrid extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final l10n = context.l10n; final currentInteractionState = ref.watch(interactionStateProvider); final shortcutBindings = ref.watch(appPreferencesProvider).customShortcutBindings; @@ -115,11 +117,11 @@ class ToolGrid extends ConsumerWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Padding( - padding: EdgeInsets.fromLTRB(16, 16, 16, 8), + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), child: Text( - "Tools", - style: TextStyle(fontSize: 20), + l10n.sidebarToolsTitle, + style: const TextStyle(fontSize: 20), ), ), Padding( @@ -132,7 +134,7 @@ class ToolGrid extends ConsumerWidget { children: [ SelectableIconButton( icon: const Icon(LucideIcons.pencil, size: 22), - tooltip: "Draw", + tooltip: l10n.sidebarDrawTooltip, shortcutLabel: shortcutLabel(IcarusShortcutAction.draw), onPressed: () { switch (currentInteractionState) { @@ -149,7 +151,7 @@ class ToolGrid extends ConsumerWidget { isSelected: currentInteractionState == InteractionState.drawing, ), SelectableIconButton( - tooltip: "Eraser", + tooltip: l10n.sidebarEraserTooltip, shortcutLabel: shortcutLabel(IcarusShortcutAction.erase), onPressed: () async { await ref.read(penProvider.notifier).buildCursors(); @@ -171,7 +173,7 @@ class ToolGrid extends ConsumerWidget { isSelected: currentInteractionState == InteractionState.erasing, ), SelectableIconButton( - tooltip: "Add Text", + tooltip: l10n.sidebarAddTextTooltip, shortcutLabel: shortcutLabel(IcarusShortcutAction.addText), onPressed: () { switch (currentInteractionState) { @@ -190,7 +192,7 @@ class ToolGrid extends ConsumerWidget { currentInteractionState == InteractionState.textTools, ), ShadTooltip( - builder: (context) => const Text("Add Image"), + builder: (context) => Text(l10n.sidebarAddImageTooltip), child: ShadIconButton.secondary( onPressed: () async { if (!ensureFeatureAvailable( @@ -216,7 +218,7 @@ class ToolGrid extends ConsumerWidget { ); if (!added) { Settings.showToast( - message: 'Upload failed', + message: l10n.sidebarImageUploadFailed, backgroundColor: Settings.tacticalVioletTheme.destructive, ); @@ -226,7 +228,7 @@ class ToolGrid extends ConsumerWidget { ), ), SelectableIconButton( - tooltip: "Add Lineup", + tooltip: l10n.sidebarAddLineupTooltip, shortcutLabel: shortcutLabel(IcarusShortcutAction.addLineup), onPressed: () async { if (!ensureFeatureAvailable( @@ -252,7 +254,7 @@ class ToolGrid extends ConsumerWidget { InteractionState.lineUpPlacing, ), SelectableIconButton( - tooltip: "Vision Cone Tools", + tooltip: l10n.sidebarVisionConeToolsTooltip, onPressed: () { switch (currentInteractionState) { case InteractionState.visionCone: @@ -270,7 +272,7 @@ class ToolGrid extends ConsumerWidget { currentInteractionState == InteractionState.visionCone, ), SelectableIconButton( - tooltip: "Shapes", + tooltip: l10n.sidebarCustomShapesTooltip, onPressed: () { switch (currentInteractionState) { case InteractionState.customShapes: @@ -288,7 +290,7 @@ class ToolGrid extends ConsumerWidget { currentInteractionState == InteractionState.customShapes, ), SelectableIconButton( - tooltip: "Agent Roles", + tooltip: l10n.sidebarAgentRolesTooltip, onPressed: () { switch (currentInteractionState) { case InteractionState.roleIcons: @@ -307,7 +309,7 @@ class ToolGrid extends ConsumerWidget { currentInteractionState == InteractionState.roleIcons, ), ShadTooltip( - builder: (context) => const Text("Spike"), + builder: (context) => Text(context.l10n.gameSpike), child: Draggable( data: SpikeToolData.fromUtility( UtilityData.utilityWidgets[UtilityType.spike]! diff --git a/lib/widgets/sidebar_widgets/vision_cone_tools.dart b/lib/widgets/sidebar_widgets/vision_cone_tools.dart index 2f80a89f0..ed45378f8 100644 --- a/lib/widgets/sidebar_widgets/vision_cone_tools.dart +++ b/lib/widgets/sidebar_widgets/vision_cone_tools.dart @@ -5,6 +5,7 @@ import 'package:icarus/const/default_placement.dart'; import 'package:icarus/const/placed_classes.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/const/utilities.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/interaction_state_provider.dart'; import 'package:icarus/providers/placement_center_provider.dart'; import 'package:icarus/providers/screen_zoom_provider.dart'; @@ -23,7 +24,7 @@ class VisionConeTools extends ConsumerWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text("Vision Cones"), + Text(context.l10n.sidebarVisionConesTitle), const SizedBox(height: 8), Container( decoration: BoxDecoration( @@ -74,7 +75,7 @@ class VisionConeTools extends ConsumerWidget { Padding( padding: const EdgeInsets.symmetric(horizontal: 6), child: Text( - "Drag to place", + context.l10n.sidebarDragToPlace, style: TextStyle( fontSize: 12, fontStyle: FontStyle.italic, @@ -143,7 +144,8 @@ class _VisionConePresetTile extends ConsumerWidget { ), ), child: ShadTooltip( - builder: (context) => Text("View Cone $label°"), + builder: (context) => + Text(context.l10n.sidebarViewConePresetTooltip(label)), child: ShadIconButton.secondary( width: 46, height: 46, diff --git a/lib/widgets/strategy_access_watcher.dart b/lib/widgets/strategy_access_watcher.dart index 8e76211af..26af0c36f 100644 --- a/lib/widgets/strategy_access_watcher.dart +++ b/lib/widgets/strategy_access_watcher.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/collab/convex_strategy_repository.dart'; import 'package:icarus/const/settings.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/collab/cloud_media_upload_queue_provider.dart'; import 'package:icarus/providers/collab/remote_strategy_snapshot_provider.dart'; import 'package:icarus/providers/collab/strategy_capabilities_provider.dart'; @@ -47,7 +48,7 @@ class _StrategyAccessWatcherState extends ConsumerState { return; } Settings.showToast( - message: 'Your access to this strategy changed to view only.', + message: context.l10n.strategyAccessChangedToViewOnly, backgroundColor: Settings.tacticalVioletTheme.primary, ); } @@ -89,16 +90,17 @@ class _StrategyAccessWatcherState extends ConsumerState { /// Discards [accountId]'s unsent work for [strategyId] once the server /// has refused it again, and says what happened otherwise. Future _discard(String strategyId, String accountId) async { + final l10n = context.l10n; void tell(String message) => Settings.showToast( message: message, backgroundColor: Settings.tacticalVioletTheme.destructive, ); switch (await _recheck(strategyId, accountId)) { case _Recheck.openAgain: - tell('You can open this strategy again, so nothing was discarded.'); + tell(l10n.strategyAccessCanOpenAgain); return; case _Recheck.unsure: - tell("Couldn't confirm you lost access, so nothing was discarded."); + tell(l10n.strategyAccessCouldNotConfirm); return; case _Recheck.refused: break; @@ -113,18 +115,10 @@ class _StrategyAccessWatcherState extends ConsumerState { .read(cloudMediaUploadQueueProvider.notifier) .discardUnreferencedJobs(strategyId, accountId: accountId); if (!uploadsGone) { - tell("Couldn't discard everything. Try again from the library."); + tell(l10n.strategyAccessDiscardFailed); } } - static String _unsentNote(int unsent) => switch (unsent) { - 0 => '', - 1 => " 1 change you made here hasn't reached the cloud and can't be " - 'saved.', - _ => " $unsent changes you made here haven't reached the cloud and " - "can't be saved.", - }; - Future _showAccessLost() async { if (_showingAccessLost) return; _showingAccessLost = true; @@ -135,30 +129,31 @@ class _StrategyAccessWatcherState extends ConsumerState { lost is StrategyAccessLost ? lost.strategyPublicId : open.strategyId; final name = open.strategyId == strategyId ? open.strategyName?.trim() : null; - final strategy = name == null || name.isEmpty ? 'this strategy' : '"$name"'; final accountId = ref.read(strategyOpQueueProvider).accountId; final unsent = _unsentChanges(strategyId); + final l10n = context.l10n; bool? discard; try { discard = await showShadDialog( context: context, barrierDismissible: false, builder: (context) => ShadDialog.alert( - title: const Text('You no longer have access'), + title: Text(l10n.strategyAccessLostTitle), description: Text( - 'The owner removed you from $strategy, or stopped sharing it.' - '${_unsentNote(unsent)}', + name == null || name.isEmpty + ? l10n.strategyAccessLostBodyUnnamed(unsent) + : l10n.strategyAccessLostBodyNamed(name, unsent), ), actions: [ if (unsent > 0) ShadButton.destructive( key: const ValueKey('access-lost-discard'), onPressed: () => Navigator.of(context).pop(true), - child: const Text('Discard'), + child: Text(l10n.commonDiscard), ), ShadButton( onPressed: () => Navigator.of(context).pop(false), - child: const Text('Back to library'), + child: Text(l10n.strategyAccessBackToLibrary), ), ], ), @@ -174,7 +169,7 @@ class _StrategyAccessWatcherState extends ConsumerState { await _discard(strategyId, accountId); } catch (_) { Settings.showToast( - message: "Couldn't discard everything. Try again from the library.", + message: l10n.strategyAccessDiscardFailed, backgroundColor: Settings.tacticalVioletTheme.destructive, ); } diff --git a/lib/widgets/strategy_presence.dart b/lib/widgets/strategy_presence.dart index 11f357f4e..5577d92ff 100644 --- a/lib/widgets/strategy_presence.dart +++ b/lib/widgets/strategy_presence.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/coordinate_system.dart'; import 'package:icarus/const/settings.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/collab/strategy_presence_provider.dart'; import 'package:icarus/providers/screen_zoom_provider.dart'; import 'package:icarus/providers/strategy_page_session_provider.dart'; @@ -76,8 +77,8 @@ const _belowStrip = ShadAnchor( overlayAlignment: Alignment.bottomCenter, ); -String _describe(PresencePeer peer) => - peer.role == 'viewer' ? '${peer.name} (viewing)' : peer.name; +String _describe(AppLocalizations l10n, PresencePeer peer) => + peer.role == 'viewer' ? l10n.strategyPresenceViewing(peer.name) : peer.name; class _PresenceAvatar extends StatelessWidget { const _PresenceAvatar({required this.peer, required this.size}); @@ -90,7 +91,7 @@ class _PresenceAvatar extends StatelessWidget { const theme = Settings.tacticalVioletTheme; final initial = peer.name.isEmpty ? '?' : peer.name.characters.first; return ShadTooltip( - builder: (context) => Text(_describe(peer)), + builder: (context) => Text(_describe(context.l10n, peer)), // The strip is the top of the window; there is no room above it. anchor: _belowStrip, child: _Ring( @@ -124,7 +125,9 @@ class _OverflowAvatar extends StatelessWidget { Widget build(BuildContext context) { const theme = Settings.tacticalVioletTheme; return ShadTooltip( - builder: (context) => Text(people.map(_describe).join('\n')), + builder: (context) => Text( + people.map((peer) => _describe(context.l10n, peer)).join('\n'), + ), anchor: _belowStrip, child: _Ring( color: theme.border, diff --git a/lib/widgets/strategy_quick_switcher.dart b/lib/widgets/strategy_quick_switcher.dart index 81a5500a4..f4719f207 100644 --- a/lib/widgets/strategy_quick_switcher.dart +++ b/lib/widgets/strategy_quick_switcher.dart @@ -12,6 +12,8 @@ import 'package:icarus/const/hive_boxes.dart'; import 'package:icarus/const/maps.dart'; import 'package:icarus/const/shortcut_info.dart'; import 'package:icarus/const/settings.dart'; +import 'package:icarus/l10n/cloud_sync_error_text.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/agent_filter_provider.dart'; import 'package:icarus/providers/collab/strategy_capabilities_provider.dart'; import 'package:icarus/providers/interaction_state_provider.dart'; @@ -170,7 +172,7 @@ class _StrategyQuickSwitcherState extends ConsumerState { if (nextName.isEmpty) { Settings.showToast( - message: 'Strategy name cannot be empty.', + message: context.l10n.quickSwitcherNameEmpty, backgroundColor: Settings.tacticalVioletTheme.destructive, ); WidgetsBinding.instance.addPostFrameCallback((_) { @@ -209,8 +211,8 @@ class _StrategyQuickSwitcherState extends ConsumerState { _cancelEditingName(); Settings.showToast( message: isTypedConvexForbiddenError(error) - ? "You can't rename this strategy." - : friendlyCloudSyncError('$error'), + ? context.l10n.quickSwitcherCannotRename + : friendlyCloudSyncError(context.l10n, '$error'), backgroundColor: Settings.tacticalVioletTheme.destructive, ); } @@ -244,6 +246,14 @@ class _StrategyQuickSwitcherState extends ConsumerState { return first ? 'Attack' : 'Defend'; } + String _attackLabelText(AppLocalizations l10n, String attackLabel) => + switch (attackLabel) { + 'Attack' => l10n.strategyTileSideAttack, + 'Defend' => l10n.strategyTileSideDefend, + 'Mixed' => l10n.strategyTileSideMixed, + _ => l10n.strategyTileSideUnknown, + }; + Color _attackColor(String attackLabel) { switch (attackLabel) { case 'Attack': @@ -255,27 +265,20 @@ class _StrategyQuickSwitcherState extends ConsumerState { } } - String _timeAgo(DateTime date) { + String _timeAgo(AppLocalizations l10n, DateTime date) { final difference = DateTime.now().difference(date); - if (difference.inMinutes < 1) return 'Just now'; + if (difference.inMinutes < 1) return l10n.strategyTileEditedJustNow; if (difference.inMinutes < 60) { - final minutes = difference.inMinutes; - final plural = minutes == 1 ? '' : 's'; - return '$minutes min$plural ago'; + return l10n.strategyTileEditedMinutesAgo(difference.inMinutes); } if (difference.inHours < 24) { - final hours = difference.inHours; - final plural = hours == 1 ? '' : 's'; - return '$hours hour$plural ago'; + return l10n.strategyTileEditedHoursAgo(difference.inHours); } if (difference.inDays < 30) { - final days = difference.inDays; - final plural = days == 1 ? '' : 's'; - return '$days day$plural ago'; + return l10n.strategyTileEditedDaysAgo(difference.inDays); } final months = (difference.inDays / 30).floor(); - final plural = months == 1 ? '' : 's'; - return '$months month$plural ago'; + return l10n.strategyTileEditedMonthsAgo(months); } @override @@ -285,7 +288,9 @@ class _StrategyQuickSwitcherState extends ConsumerState { if (currentStrategyId == null) { return const SizedBox.shrink(); } - final strategyName = currentStrategy.strategyName ?? 'Untitled Strategy'; + final l10n = context.l10n; + final strategyName = + currentStrategy.strategyName ?? l10n.quickSwitcherUntitled; final canRename = currentStrategy.strategyName != null && ref.watch(currentStrategyCapabilitiesProvider).canRenameStrategy; final strategiesBox = Hive.box(HiveBoxNames.strategiesBox); @@ -359,7 +364,7 @@ class _StrategyQuickSwitcherState extends ConsumerState { _menuInset + 6, ), child: Text( - 'No recent strategies', + l10n.quickSwitcherNoRecent, style: TextStyle( fontSize: 12, color: Settings @@ -376,15 +381,18 @@ class _StrategyQuickSwitcherState extends ConsumerState { itemBuilder: (context, index) { final strategy = recents[index]; final attackLabel = _attackLabel(strategy); - final mapName = _mapName(strategy); + final mapName = + l10n.mapNameOf(_mapName(strategy)); final thumbnail = 'assets/maps/thumbnails/${Maps.mapNames[strategy.mapData]}_thumbnail.webp'; return _StrategyQuickSwitchItem( strategyName: strategy.name, mapName: mapName, - attackLabel: attackLabel, + attackLabel: + _attackLabelText(l10n, attackLabel), attackColor: _attackColor(attackLabel), - lastEdited: _timeAgo(strategy.lastEdited), + lastEdited: + _timeAgo(l10n, strategy.lastEdited), thumbnailPath: thumbnail, onTap: _isSwitching || _isEditingName ? null @@ -462,7 +470,7 @@ class _StrategyQuickSwitcherState extends ConsumerState { isDense: true, contentPadding: EdgeInsets.zero, border: InputBorder.none, - hintText: 'Untitled Strategy', + hintText: l10n.quickSwitcherUntitled, hintStyle: ShadTheme.of(context) .textTheme .small @@ -480,10 +488,10 @@ class _StrategyQuickSwitcherState extends ConsumerState { : ShadTooltip( builder: (context) => Text( currentStrategy.strategyName == null - ? 'Load a strategy to rename it' + ? l10n.quickSwitcherLoadToRename : canRename - ? 'Rename strategy' - : "You can view this strategy but not rename it", + ? l10n.quickSwitcherRenameTooltip + : l10n.quickSwitcherViewOnlyTooltip, ), child: Material( color: Colors.transparent, diff --git a/lib/widgets/strategy_save_icon_button.dart b/lib/widgets/strategy_save_icon_button.dart index 50e08ff42..4c6ce0398 100644 --- a/lib/widgets/strategy_save_icon_button.dart +++ b/lib/widgets/strategy_save_icon_button.dart @@ -2,8 +2,8 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:icarus/collab/cloud_sync_error_message.dart'; import 'package:icarus/const/settings.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/auto_save_notifier.dart'; import 'package:icarus/providers/collab/cloud_media_upload_queue_provider.dart'; import 'package:icarus/providers/collab/cloud_sync_status_provider.dart'; @@ -45,7 +45,7 @@ Future saveStrategyNow(BuildContext context, WidgetRef ref) async { if (!context.mounted) return; final (:message, :failed) = threw - ? (message: saveFailedMessage, failed: true) + ? (message: context.l10n.saveButtonFailed, failed: true) : ref.read(strategyProvider).source == StrategySource.cloud ? _cloudSaveOutcome(ref) : _localSaveOutcome(ref, pressedAt); @@ -92,9 +92,10 @@ Future saveStrategyNow(BuildContext context, WidgetRef ref) async { final lastPersistedAt = ref.read(strategySaveStateProvider).lastPersistedAt; final persisted = lastPersistedAt != null && !lastPersistedAt.isBefore(pressedAt); + final l10n = ref.read(l10nProvider); return persisted - ? (message: 'Saved', failed: false) - : (message: saveFailedMessage, failed: true); + ? (message: l10n.saveButtonSaved, failed: false) + : (message: l10n.saveButtonFailed, failed: true); } /// A cloud save is in the outbox unless a write to it failed; whether it has @@ -102,21 +103,25 @@ Future saveStrategyNow(BuildContext context, WidgetRef ref) async { ({String message, bool failed}) _cloudSaveOutcome(WidgetRef ref) { final opQueue = ref.read(strategyOpQueueProvider); final mediaQueue = ref.read(cloudMediaUploadQueueProvider); + final l10n = ref.read(l10nProvider); if (opQueue.hasDurabilityFailure || mediaQueue.durabilityError != null) { - return (message: unverifiedCloudWorkMessage, failed: true); + return (message: l10n.syncErrorUnverifiedCloudWork, failed: true); } final saveState = ref.read(strategySaveStateProvider); final message = switch (ref.read(cloudSyncStatusProvider)) { - CloudSyncStatus.synced => 'Saved', - CloudSyncStatus.editing => 'Edit not synced yet', + CloudSyncStatus.synced => l10n.saveButtonSaved, + CloudSyncStatus.editing => l10n.saveButtonEditNotSynced, CloudSyncStatus.syncing => saveState.hasPendingMediaSync - ? 'Saved on this device. Media still syncing.' - : 'Saved on this device. Syncing…', + ? l10n.saveButtonSyncingMedia + : l10n.saveButtonSyncing, CloudSyncStatus.offline => - offlineSyncLabel(ref.read(openStrategyUnsentChangesProvider)), + offlineSyncLabel( + ref.read(l10nProvider), + ref.read(openStrategyUnsentChangesProvider), + ), CloudSyncStatus.attention => saveState.mediaSyncErrorCount > 0 - ? 'Saved on this device. Media sync needs retry.' - : 'Saved on this device. Sync needs attention.', + ? l10n.saveButtonMediaNeedsRetry + : l10n.saveButtonNeedsAttention, }; return (message: message, failed: false); } @@ -217,7 +222,9 @@ class _AutoSaveButtonState extends ConsumerState { return EditorToolbarButton( key: const ValueKey('local-save-button'), style: widget.style, - tooltip: canEditPages ? 'Save' : 'View only', + tooltip: canEditPages + ? context.l10n.commonSave + : context.l10n.saveButtonViewOnlyTooltip, enabled: canEditPages, onPressed: () => saveStrategyNow(context, ref), icon: AnimatedSwitcher( diff --git a/lib/widgets/strategy_tile/strategy_tile.dart b/lib/widgets/strategy_tile/strategy_tile.dart index 359b17511..0ac81364c 100644 --- a/lib/widgets/strategy_tile/strategy_tile.dart +++ b/lib/widgets/strategy_tile/strategy_tile.dart @@ -8,7 +8,6 @@ import 'package:icarus/providers/auth_provider.dart'; import 'package:icarus/providers/collab/move_to_cloud_provider.dart'; import 'package:icarus/services/cloud_needs_connection.dart'; import 'package:icarus/providers/library_workspace_provider.dart'; -import 'package:icarus/providers/collab/remote_strategy_snapshot_provider.dart'; import 'package:icarus/providers/collab/strategy_copy_provider.dart'; import 'package:icarus/providers/pinned_items_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; @@ -26,6 +25,7 @@ import 'package:icarus/widgets/folder_navigator.dart'; import 'package:icarus/widgets/role_badge.dart'; import 'package:icarus/widgets/strategy_tile/strategy_tile_sections.dart'; import 'package:icarus/config/platform_policy.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/widgets/platform_feature_toast.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; @@ -87,7 +87,7 @@ class StrategyTileActionsButton extends StatelessWidget { @override Widget build(BuildContext context) { - final label = 'More actions for $strategyName'; + final label = context.l10n.strategyTileMoreActions(strategyName); return Semantics( label: label, button: true, @@ -161,8 +161,11 @@ class _StrategyTileState extends ConsumerState { } StrategyTileViewData get _viewData => widget.strategyData != null - ? StrategyTileViewData.fromStrategy(widget.strategyData!) - : StrategyTileViewData.fromCloudEntry(widget.cloudStrategy!); + ? StrategyTileViewData.fromStrategy(widget.strategyData!, context.l10n) + : StrategyTileViewData.fromCloudEntry( + widget.cloudStrategy!, + context.l10n, + ); @override void dispose() { @@ -432,16 +435,20 @@ class _StrategyTileState extends ConsumerState { /// at once, with the reason. Each of these needs the server. bool _refusedOffline() => _isCloud && - refuseCloudChangeWhileOffline(ref.read(cloudLibraryShowsCopyProvider)); + refuseCloudChangeWhileOffline( + ref.read(l10nProvider), + ref.read(cloudLibraryShowsCopyProvider), + ); List _buildMenuItems() { + final l10n = context.l10n; final pinned = ref.watch(pinnedItemsProvider); final id = _strategyId; final isPinned = pinned.containsKey(id); return [ ShadContextMenuItem( leading: Icon(isPinned ? LucideIcons.pinOff : LucideIcons.pin), - child: Text(isPinned ? 'Unpin' : 'Pin'), + child: Text(isPinned ? l10n.strategyTileUnpin : l10n.strategyTilePin), onPressed: () { _closeMenus(); ref.read(pinnedItemsProvider.notifier).togglePin(id); @@ -457,7 +464,7 @@ class _StrategyTileState extends ConsumerState { _showRenameDialog(); } : null, - child: const Text('Rename'), + child: Text(l10n.commonRename), ), ShadContextMenuItem( leading: const Icon(LucideIcons.copy), @@ -469,13 +476,13 @@ class _StrategyTileState extends ConsumerState { _duplicateStrategy(); } : null, - child: const Text('Duplicate'), + child: Text(l10n.strategyTileDuplicate), ), // Signed in, a strategy on this device can join the cloud library. if (widget.showDeviceBadge) ShadContextMenuItem( leading: const Icon(LucideIcons.cloudUpload), - child: const Text('Move to cloud'), + child: Text(l10n.strategyTileMoveToCloud), onPressed: () { _closeMenus(); ref.read(moveToCloudProvider.notifier).moveStrategy(_strategyId); @@ -483,7 +490,7 @@ class _StrategyTileState extends ConsumerState { ), ShadContextMenuItem( leading: const Icon(LucideIcons.upload), - child: const Text('Export'), + child: Text(l10n.commonExport), onPressed: () { _closeMenus(); if (_refusedOffline()) return; @@ -493,7 +500,7 @@ class _StrategyTileState extends ConsumerState { if (_canShare) ShadContextMenuItem( leading: const Icon(LucideIcons.link2), - child: const Text('Share'), + child: Text(l10n.commonShare), onPressed: () { _closeMenus(); if (_refusedOffline()) return; @@ -504,7 +511,7 @@ class _StrategyTileState extends ConsumerState { ShadContextMenuItem( key: const ValueKey('strategy-tile-leave'), leading: const Icon(LucideIcons.logOut), - child: const Text('Leave'), + child: Text(l10n.commonLeave), onPressed: () { _closeMenus(); if (_refusedOffline()) return; @@ -528,7 +535,7 @@ class _StrategyTileState extends ConsumerState { _showDeleteDialog(); } : null, - child: Text('Delete', + child: Text(l10n.commonDelete, style: TextStyle(color: Settings.tacticalVioletTheme.destructive)), ), ]; @@ -548,7 +555,7 @@ class _StrategyTileState extends ConsumerState { ref.read(cloudOfflineProvider) && !ref.read(strategyKeptOfflineProvider(_strategyId))) { Settings.showToast( - message: strategyNeedsConnectionMessage, + message: context.l10n.syncStatusStrategyNeedsConnection, // Not an error: the neutral surface, as for other offline refusals. backgroundColor: Settings.tacticalVioletTheme.secondary, ); diff --git a/lib/widgets/strategy_tile/strategy_tile_sections.dart b/lib/widgets/strategy_tile/strategy_tile_sections.dart index a1beed271..fc929f7c2 100644 --- a/lib/widgets/strategy_tile/strategy_tile_sections.dart +++ b/lib/widgets/strategy_tile/strategy_tile_sections.dart @@ -3,6 +3,7 @@ import 'package:icarus/collab/cloud_library_models.dart'; import 'package:icarus/const/agents.dart'; import 'package:icarus/const/maps.dart'; import 'package:icarus/const/settings.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/strategy_page.dart'; import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/strategy/strategy_models.dart'; @@ -31,30 +32,36 @@ class StrategyTileViewData { this.cloudBadge, }); - factory StrategyTileViewData.fromStrategy(StrategyData strategy) { + factory StrategyTileViewData.fromStrategy( + StrategyData strategy, + AppLocalizations l10n, + ) { return StrategyTileViewData( name: strategy.name, - mapName: _mapName(strategy.mapData), - attackLabel: _attackLabel(strategy.pages), + mapName: l10n.mapNameOf(_mapName(strategy.mapData)), + attackLabel: _attackLabelText(l10n, _attackLabel(strategy.pages)), attackColor: _attackColor(_attackLabel(strategy.pages)), thumbnailAsset: 'assets/maps/thumbnails/${Maps.mapNames[strategy.mapData]}_thumbnail.webp', - lastEditedLabel: _timeAgo(strategy.lastEdited), + lastEditedLabel: _timeAgo(l10n, strategy.lastEdited), agentTypes: _collectAgentTypes(strategy.pages), ); } - factory StrategyTileViewData.fromCloudEntry(CloudStrategyEntry entry) { + factory StrategyTileViewData.fromCloudEntry( + CloudStrategyEntry entry, + AppLocalizations l10n, + ) { final strategy = entry.strategy; final attackLabel = entry.attackLabel; return StrategyTileViewData( name: strategy.name, - mapName: _mapName(strategy.mapData), - attackLabel: attackLabel, + mapName: l10n.mapNameOf(_mapName(strategy.mapData)), + attackLabel: _attackLabelText(l10n, attackLabel), attackColor: _attackColor(attackLabel), thumbnailAsset: 'assets/maps/thumbnails/${Maps.mapNames[strategy.mapData]}_thumbnail.webp', - lastEditedLabel: _timeAgo(strategy.lastEdited), + lastEditedLabel: _timeAgo(l10n, strategy.lastEdited), agentTypes: _inRoleOrder(entry.agentTypes), cloudBadge: cloudBadgeKindForRole(entry.role), ); @@ -97,6 +104,17 @@ class StrategyTileViewData { return first ? 'Attack' : 'Defend'; } + /// The words for an attack label, which is 'Attack', 'Defend', 'Mixed' or + /// 'Unknown' (the server's names). Anything else is shown as it came. + static String _attackLabelText(AppLocalizations l10n, String label) => + switch (label) { + 'Attack' => l10n.strategyTileSideAttack, + 'Defend' => l10n.strategyTileSideDefend, + 'Mixed' => l10n.strategyTileSideMixed, + 'Unknown' => l10n.strategyTileSideUnknown, + _ => label, + }; + static Color _attackColor(String label) { switch (label) { case 'Attack': @@ -131,27 +149,20 @@ class StrategyTileViewData { ]; } - static String _timeAgo(DateTime date) { + static String _timeAgo(AppLocalizations l10n, DateTime date) { final difference = DateTime.now().difference(date); - if (difference.inMinutes < 1) return 'Just now'; + if (difference.inMinutes < 1) return l10n.strategyTileEditedJustNow; if (difference.inMinutes < 60) { - final minutes = difference.inMinutes; - final plural = minutes == 1 ? '' : 's'; - return '$minutes min$plural ago'; + return l10n.strategyTileEditedMinutesAgo(difference.inMinutes); } if (difference.inHours < 24) { - final hours = difference.inHours; - final plural = hours == 1 ? '' : 's'; - return '$hours hour$plural ago'; + return l10n.strategyTileEditedHoursAgo(difference.inHours); } if (difference.inDays < 30) { - final days = difference.inDays; - final plural = days == 1 ? '' : 's'; - return '$days day$plural ago'; + return l10n.strategyTileEditedDaysAgo(difference.inDays); } final months = (difference.inDays / 30).floor(); - final plural = months == 1 ? '' : 's'; - return '$months month$plural ago'; + return l10n.strategyTileEditedMonthsAgo(months); } } diff --git a/lib/widgets/strategy_view_skeleton.dart b/lib/widgets/strategy_view_skeleton.dart index 0a5fb2ea3..6e72afcfa 100644 --- a/lib/widgets/strategy_view_skeleton.dart +++ b/lib/widgets/strategy_view_skeleton.dart @@ -4,6 +4,7 @@ import 'package:icarus/const/coordinate_system.dart'; import 'package:icarus/const/custom_icons.dart'; import 'package:icarus/const/maps.dart'; import 'package:icarus/const/settings.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/widgets/window_chrome.dart'; import 'package:icarus/widgets/dot_painter.dart'; import 'package:icarus/widgets/canonical_map_artwork.dart'; @@ -353,7 +354,7 @@ class _MapCanvasSkeleton extends StatelessWidget { isAttack: isAttack, child: AssetSvg( assetName, - semanticsLabel: 'Map', + semanticsLabel: context.l10n.strategyViewSkeletonMapSemantics, fit: BoxFit.contain, ), ), diff --git a/lib/widgets/strip_status_icons.dart b/lib/widgets/strip_status_icons.dart index 68c65ca4f..07814ab1c 100644 --- a/lib/widgets/strip_status_icons.dart +++ b/lib/widgets/strip_status_icons.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/settings.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/const/update_checker.dart'; import 'package:icarus/providers/desktop_update_provider.dart'; import 'package:icarus/providers/update_status_provider.dart'; @@ -50,7 +51,7 @@ class WhatsNewIcon extends StatelessWidget { Widget build(BuildContext context) { return StripIcon( key: const ValueKey('strip-whats-new'), - tooltip: "What's new", + tooltip: context.l10n.stripWhatsNewTooltip, icon: LucideIcons.inbox, onPressed: () => ReleaseNotesDialog.show(context), ); @@ -68,16 +69,18 @@ class UpdateAvailableIcon extends StatelessWidget { Widget build(BuildContext context) { return WaitingUpdateBuilder( builder: (context, openUpdate, _) => - openUpdate == null ? const SizedBox.shrink() : _icon(openUpdate), + openUpdate == null + ? const SizedBox.shrink() + : _icon(context, openUpdate), ); } - Widget _icon(VoidCallback onPressed) { + Widget _icon(BuildContext context, VoidCallback onPressed) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 4), child: StripIcon( key: const ValueKey('strip-update-available'), - tooltip: 'Update available', + tooltip: context.l10n.stripUpdateAvailableTooltip, icon: LucideIcons.download, foregroundColor: Settings.tacticalVioletTheme.primary, onPressed: onPressed, diff --git a/lib/widgets/touch_strategy_viewer.dart b/lib/widgets/touch_strategy_viewer.dart index 2073423c3..3a12f36df 100644 --- a/lib/widgets/touch_strategy_viewer.dart +++ b/lib/widgets/touch_strategy_viewer.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/maps.dart'; import 'package:icarus/const/settings.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/interactive_map.dart'; import 'package:icarus/providers/auth_provider.dart'; import 'package:icarus/providers/collab/drawn_cloud_pages_provider.dart'; @@ -138,7 +139,7 @@ class _ViewerStrip extends ConsumerWidget { child: Row( children: [ Semantics( - label: backTooltip ?? 'Library', + label: backTooltip ?? context.l10n.touchViewerLibrary, button: true, child: ShadIconButton.ghost( key: const ValueKey('viewer-leave'), @@ -171,7 +172,7 @@ class _ViewerStrip extends ConsumerWidget { ), ), Text( - 'View only', + context.l10n.touchViewerViewOnly, style: TextStyle( fontSize: 12, color: theme.mutedForeground, @@ -217,7 +218,7 @@ class _ViewerAction extends ConsumerWidget { .read(shareLinkControllerProvider.notifier) .addViewedLinkToLibrary(), leading: const Icon(LucideIcons.bookmarkPlus, size: 16), - child: const Text('Add to library'), + child: Text(context.l10n.touchViewerAddToLibrary), ); } return ShadButton( @@ -228,7 +229,7 @@ class _ViewerAction extends ConsumerWidget { context: context, builder: (_) => const AuthDialog(), ), - child: const Text('Log In'), + child: Text(context.l10n.commonLogIn), ); } } diff --git a/lib/widgets/turn_phone_notice.dart b/lib/widgets/turn_phone_notice.dart index fcfbd69a2..32bd11de6 100644 --- a/lib/widgets/turn_phone_notice.dart +++ b/lib/widgets/turn_phone_notice.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:icarus/const/settings.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; /// Covers the editor on an upright phone, which is too narrow to place @@ -32,14 +33,17 @@ class TurnPhoneNotice extends StatelessWidget { color: theme.mutedForeground, ), const SizedBox(height: 16), - const Text( - 'Turn your phone sideways to edit', + Text( + context.l10n.turnPhoneTitle, textAlign: TextAlign.center, - style: TextStyle(fontSize: 20, fontWeight: FontWeight.w500), + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.w500, + ), ), const SizedBox(height: 8), Text( - 'If nothing happens, check that rotation lock is off.', + context.l10n.turnPhoneRotationLockHint, textAlign: TextAlign.center, style: TextStyle(color: theme.mutedForeground), ), @@ -47,7 +51,9 @@ class TurnPhoneNotice extends StatelessWidget { ShadButton.secondary( key: const ValueKey('turn-phone-leave'), onPressed: onLeave, - child: Text(backTooltip ?? 'Back to My Library'), + child: Text( + backTooltip ?? context.l10n.turnPhoneBackToLibrary, + ), ), ], ), diff --git a/lib/widgets/web_beta_tag.dart b/lib/widgets/web_beta_tag.dart index 0833c3c90..79a938fa3 100644 --- a/lib/widgets/web_beta_tag.dart +++ b/lib/widgets/web_beta_tag.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:icarus/const/settings.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/widgets/dialogs/web_beta_dialog.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; @@ -16,7 +17,7 @@ class WebBetaTag extends StatelessWidget { Widget build(BuildContext context) { const theme = Settings.tacticalVioletTheme; return Semantics( - label: 'About the Icarus web beta', + label: context.l10n.webBetaTagSemantics, button: true, excludeSemantics: true, child: ShadButton.outline( @@ -29,9 +30,9 @@ class WebBetaTag extends StatelessWidget { context: context, builder: (_) => const WebBetaDialog(), ), - child: const Text( - 'Beta', - style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600), + child: Text( + context.l10n.webBetaTag, + style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600), ), ), ); diff --git a/lib/widgets/youtube_view.dart b/lib/widgets/youtube_view.dart index e7f8ef2fc..4f39f25d9 100644 --- a/lib/widgets/youtube_view.dart +++ b/lib/widgets/youtube_view.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_inappwebview/flutter_inappwebview.dart'; import 'package:icarus/const/youtube_handler.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/main.dart'; import 'package:icarus/widgets/dialogs/web_view_dialog.dart'; @@ -86,7 +87,7 @@ class _YoutubeViewState extends State if (video == null) { return Center( child: Text( - "This link doesn't point at a YouTube video.", + context.l10n.youtubeNotAVideo, style: TextStyle( color: ShadTheme.of(context).colorScheme.mutedForeground, ), diff --git a/pubspec.lock b/pubspec.lock index a2e57964f..6ae0178b7 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -529,7 +529,7 @@ packages: source: hosted version: "3.0.2" flutter_localizations: - dependency: transitive + dependency: "direct main" description: flutter source: sdk version: "0.0.0" @@ -738,7 +738,7 @@ packages: source: sdk version: "0.0.0" intl: - dependency: transitive + dependency: "direct main" description: name: intl sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" diff --git a/pubspec.yaml b/pubspec.yaml index 428748bbc..036bfd48d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -11,6 +11,9 @@ environment: dependencies: flutter: sdk: flutter + flutter_localizations: + sdk: flutter + intl: any cupertino_icons: ^1.0.6 collection: ^1.19.1 @@ -103,6 +106,8 @@ msix_config: store: true flutter: + # Builds lib/l10n/generated from the .arb files on every pub get and build. + generate: true shaders: - shaders/dot_grid.frag - shaders/dot_lattice.frag diff --git a/test/auth_callback_uri_test.dart b/test/auth_callback_uri_test.dart index 9b1bdbb08..a6eeb7848 100644 --- a/test/auth_callback_uri_test.dart +++ b/test/auth_callback_uri_test.dart @@ -1,6 +1,9 @@ +import 'dart:ui'; + import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart'; import 'package:http/testing.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/services/auth_callback_uri.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; @@ -88,7 +91,10 @@ void main() { reason: link, ); } - expect(AuthLinkNotice.linkExpired.message, isNot(contains('confirmed.'))); + expect( + AuthLinkNotice.linkExpired + .message(lookupAppLocalizations(const Locale('en'))), + isNot(contains('confirmed.'))); }); test('anything else is a real failure', () { diff --git a/test/canvas_paste_test.dart b/test/canvas_paste_test.dart index 1e87e65d3..6960ccacb 100644 --- a/test/canvas_paste_test.dart +++ b/test/canvas_paste_test.dart @@ -7,6 +7,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:icarus/const/line_provider.dart'; import 'package:icarus/const/maps.dart'; import 'package:icarus/const/shortcut_info.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/canvas_clipboard_provider.dart'; import 'package:icarus/providers/image_provider.dart'; import 'package:icarus/providers/interaction_state_provider.dart'; @@ -186,7 +187,14 @@ void main() { await pressPaste(tester); await tester.pump(const Duration(seconds: 1)); - expect(find.text(ClipboardService.busyMessage), findsOneWidget); + expect( + find.text( + ClipboardService.busyMessage( + lookupAppLocalizations(const Locale('en')), + ), + ), + findsOneWidget, + ); expect(items.pastes, 0); await tester.pump(const Duration(seconds: 4)); }); diff --git a/test/capture_images_test.dart b/test/capture_images_test.dart index c5d6733ac..4cd74513e 100644 --- a/test/capture_images_test.dart +++ b/test/capture_images_test.dart @@ -4,6 +4,7 @@ import 'dart:typed_data'; import 'package:flutter/painting.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/collab/cloud_media_cache_provider.dart'; import 'package:icarus/providers/strategy_image_source.dart'; import 'package:icarus/screenshot/capture_images.dart'; @@ -271,7 +272,8 @@ void main() { isA() .having((error) => error.cause, 'cause', isNull) .having( - (error) => error.userMessage, + (error) => + error.userMessage(lookupAppLocalizations(const Locale('en'))), 'userMessage', contains('still loading'), ), @@ -290,7 +292,8 @@ void main() { isA() .having((error) => error.cause, 'cause', same(failure)) .having( - (error) => error.userMessage, + (error) => + error.userMessage(lookupAppLocalizations(const Locale('en'))), 'userMessage', contains('connection'), ), diff --git a/test/capture_waits_for_pictures_test.dart b/test/capture_waits_for_pictures_test.dart index 6c4da6b20..7ea9a7764 100644 --- a/test/capture_waits_for_pictures_test.dart +++ b/test/capture_waits_for_pictures_test.dart @@ -5,6 +5,7 @@ import 'dart:ui' as ui; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/screenshot/capture_images.dart'; import 'package:icarus/screenshot/persistent_offscreen_renderer.dart'; import 'package:icarus/services/video_export/video_export_errors.dart'; @@ -179,7 +180,8 @@ void main() { capture.captureRawRgba(image('stalled.png')), throwsA( isA().having( - (error) => error.userMessage, + (error) => + error.userMessage(lookupAppLocalizations(const Locale('en'))), 'message', contains('still loading'), ), @@ -226,7 +228,8 @@ void main() { rgba, throwsA( isA().having( - (error) => error.userMessage, + (error) => + error.userMessage(lookupAppLocalizations(const Locale('en'))), 'message', contains("Couldn't load"), ), diff --git a/test/collab/cloud_sync_error_message_test.dart b/test/collab/cloud_sync_error_message_test.dart index 7f199acf3..da9cd4db9 100644 --- a/test/collab/cloud_sync_error_message_test.dart +++ b/test/collab/cloud_sync_error_message_test.dart @@ -1,13 +1,20 @@ +import 'dart:ui' show Locale; + import 'package:flutter_test/flutter_test.dart'; import 'package:icarus/collab/cloud_sync_error_message.dart'; import 'package:icarus/collab/collab_models.dart'; import 'package:icarus/collab/generated/generated.dart'; import 'package:icarus/collab/src/convex_client_types.dart'; import 'package:icarus/collab/transport/convex_transport.dart'; +import 'package:icarus/l10n/cloud_sync_error_text.dart'; +import 'package:icarus/l10n/l10n.dart'; + +final _english = lookupAppLocalizations(const Locale('en')); void main() { test('turns a forbidden Convex failure into a permission explanation', () { final message = friendlyCloudSyncError( + _english, 'ConvexFunctionException(FORBIDDEN, Forbidden) ' '(retry paused after 8 attempts)', ); @@ -19,13 +26,14 @@ void main() { test('says plainly when edit access is gone, and that the work is kept', () { expect( - friendlyCloudSyncError(editAccessRemovedMessage), + friendlyCloudSyncError(_english, editAccessRemovedMessage), 'You no longer have edit access to this strategy. These changes stay ' 'on this device and were not saved to the cloud.', ); // With other saved work waiting beside it, the reason still leads. expect( friendlyCloudSyncError( + _english, '$editAccessRemovedMessage $otherWorkNeedsAttentionNote', ), editAccessRemovedMessage, @@ -35,6 +43,7 @@ void main() { test('explains a lineup that clashes with one on another page', () { final message = friendlyCloudSyncError( + _english, 'ConvexFunctionException(LINEUP_PAGE_MISMATCH, This lineup belongs to ' 'another page and cannot be moved)', ); @@ -47,7 +56,7 @@ void main() { }); test('explains a change to something a teammate deleted', () { - final message = friendlyCloudSyncError(teammateDeletedMessage); + final message = friendlyCloudSyncError(_english, teammateDeletedMessage); expect(message, contains('A teammate deleted what you changed')); expect(message, contains('Keep mine brings it back')); @@ -55,7 +64,10 @@ void main() { }); test('says when Keep mine cannot bring a deleted item back', () { - final message = friendlyCloudSyncError(teammateDeletedCannotRestoreMessage); + final message = friendlyCloudSyncError( + _english, + teammateDeletedCannotRestoreMessage, + ); expect(message, contains('cannot bring it back')); expect(message, contains('Use cloud')); @@ -64,7 +76,7 @@ void main() { }); test('explains a lineup change saved in the old cloud format', () { - final message = friendlyCloudSyncError(retiredLineupOpMessage); + final message = friendlyCloudSyncError(_english, retiredLineupOpMessage); expect(message, contains('older version of Icarus')); expect(message, contains('cannot be sent')); @@ -73,7 +85,7 @@ void main() { }); test('explains a lineup group refused for overlapping another', () { - final message = friendlyCloudSyncError(lineupOverlapMessage); + final message = friendlyCloudSyncError(_english, lineupOverlapMessage); expect(message, contains('shares a spot')); expect(message, contains('not saved to the cloud')); @@ -98,7 +110,7 @@ void main() { }); test('explains a page copy too large to make', () { - final message = friendlyCloudSyncError(pageTooLargeToCopyMessage); + final message = friendlyCloudSyncError(_english, pageTooLargeToCopyMessage); expect(message, contains('too large for the cloud to copy')); expect(message, contains('Keep mine')); @@ -126,13 +138,15 @@ void main() { }); test('does not expose unknown transport details', () { - final message = friendlyCloudSyncError('socket exploded at 10.0.0.4'); + final message = + friendlyCloudSyncError(_english, 'socket exploded at 10.0.0.4'); expect(message, "Some changes haven't reached the cloud yet."); }); test('does not promise local durability when the outbox is uncertain', () { final message = friendlyCloudSyncError( + _english, 'Cloud work could not be verified in the durable outbox. ' 'Nothing was sent.', ); @@ -144,6 +158,7 @@ void main() { test('a failed outbox write is not called merely unsent', () { final message = friendlyCloudSyncError( + _english, 'Cloud work could not be saved to the durable outbox: ' 'Bad state: disk write failed', ); @@ -154,18 +169,19 @@ void main() { group('a server that needs a newer Icarus', () { test('is told on the web to reload', () { expect( - clientUpgradeRequiredMessage(isWeb: true), + clientUpgradeRequiredMessage(_english, isWeb: true), 'Icarus was updated. Reload to keep syncing.', ); }); test('is told on desktop to install the update, once there is one', () { expect( - clientUpgradeRequiredMessage(isWeb: false), + clientUpgradeRequiredMessage(_english, isWeb: false), 'Icarus was updated. Install the update to keep syncing.', ); expect( clientUpgradeRequiredMessage( + _english, isWeb: false, update: ClientUpdateAvailability.unavailable, ), @@ -175,6 +191,7 @@ void main() { ); expect( clientUpgradeRequiredMessage( + _english, isWeb: false, update: ClientUpdateAvailability.checking, ), @@ -213,8 +230,8 @@ void main() { expect(isClientUpgradeRequiredReason(reason), isTrue, reason: reason); expect(isClientUpgradeRequiredError(reason), isTrue, reason: reason); expect( - friendlyCloudSyncError(reason), - clientUpgradeRequiredMessage(), + friendlyCloudSyncError(_english, reason), + clientUpgradeRequiredMessage(_english), reason: reason, ); } @@ -244,10 +261,11 @@ void main() { } expect( friendlyCloudSyncError( + _english, 'ConvexFunctionException(INVALID_PAYLOAD, name was ' 'CLIENT_UPGRADE_REQUIRED)', ), - isNot(clientUpgradeRequiredMessage()), + isNot(clientUpgradeRequiredMessage(_english)), ); }); }); diff --git a/test/desktop_image_size_test.dart b/test/desktop_image_size_test.dart index c1a753e9c..90b8534e9 100644 --- a/test/desktop_image_size_test.dart +++ b/test/desktop_image_size_test.dart @@ -1,8 +1,10 @@ import 'dart:io'; +import 'dart:ui' show Locale; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/collab/media_bytes_source.dart'; import 'package:icarus/providers/image_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; @@ -72,7 +74,8 @@ void main() { ), throwsA( isA().having( - (error) => error.userMessage, + (error) => + error.userMessage(lookupAppLocalizations(const Locale('en'))), 'userMessage', 'This image is over 15 MB. Pick a smaller one.', ), diff --git a/test/discord_presence_service_test.dart b/test/discord_presence_service_test.dart index 2df06797a..0ec0c0997 100644 --- a/test/discord_presence_service_test.dart +++ b/test/discord_presence_service_test.dart @@ -1,5 +1,8 @@ +import 'dart:ui'; + import 'package:flutter_test/flutter_test.dart'; import 'package:icarus/const/maps.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/map_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/services/discord_presence_service.dart'; @@ -8,6 +11,7 @@ void main() { group('DiscordPresenceData', () { test('uses a generic library presence when no strategy is open', () { final presence = DiscordPresenceData.fromAppState( + l10n: _english, strategy: const StrategyState( isSaved: true, stratName: null, @@ -24,6 +28,7 @@ void main() { test('shares map and side without sharing the strategy name', () { final presence = DiscordPresenceData.fromAppState( + l10n: _english, strategy: const StrategyState( isSaved: false, stratName: 'Secret tournament execute', @@ -46,6 +51,7 @@ void main() { test('summarizes what is on the board', () { DiscordPresenceData build({int agents = 0, int abilities = 0}) => DiscordPresenceData.fromAppState( + l10n: _english, strategy: const StrategyState( isSaved: false, stratName: 'A-site rush', @@ -69,3 +75,5 @@ void main() { }); }); } + +final _english = lookupAppLocalizations(const Locale('en')); diff --git a/test/export_save_path_test.dart b/test/export_save_path_test.dart index 9ef809bf9..d286389a5 100644 --- a/test/export_save_path_test.dart +++ b/test/export_save_path_test.dart @@ -1,8 +1,10 @@ import 'dart:io'; import 'dart:typed_data'; +import 'dart:ui' show Locale; import 'package:file_picker/file_picker.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/services/export_save_path.dart'; import 'package:path/path.dart' as p; @@ -46,25 +48,28 @@ void main() { test('gives a renamed file its extension back', () async { FilePicker.platform = _SaveDialog([inFolder('retakes')]); - expect(await chooseExportPath('Plan.ica'), inFolder('retakes.ica')); + expect(await chooseExportPath(_english, 'Plan.ica'), + inFolder('retakes.ica')); }); test('keeps a name that already ends right, however it is cased', () async { FilePicker.platform = _SaveDialog([inFolder('RETAKES.ICA')]); - expect(await chooseExportPath('Plan.ica'), inFolder('RETAKES.ICA')); + expect(await chooseExportPath(_english, 'Plan.ica'), + inFolder('RETAKES.ICA')); }); test('adds to a name with a dot that is not the extension', () async { FilePicker.platform = _SaveDialog([inFolder('ascent v1.2')]); - expect(await chooseExportPath('Plan.ica'), inFolder('ascent v1.2.ica')); + expect(await chooseExportPath(_english, 'Plan.ica'), + inFolder('ascent v1.2.ica')); }); test('a cancelled dialog chooses nothing', () async { FilePicker.platform = _SaveDialog([null]); - expect(await chooseExportPath('Plan.ica'), isNull); + expect(await chooseExportPath(_english, 'Plan.ica'), isNull); }); test( @@ -75,7 +80,7 @@ void main() { FilePicker.platform = dialog; // The second answer is the dialog's own: it has asked about replacing. - expect(await chooseExportPath('Plan.ica'), taken.path); + expect(await chooseExportPath(_english, 'Plan.ica'), taken.path); expect(dialog.offered, ['Plan.ica', 'retakes.ica']); expect(dialog.folders, [null, folder.path]); }, @@ -86,7 +91,7 @@ void main() { File(inFolder('retakes.png')).writeAsStringSync('mine'); FilePicker.platform = _SaveDialog([inFolder('retakes'), null]); - await saveExportBytes('Plan.png', Uint8List.fromList([1, 2, 3])); + await saveExportBytes(_english, 'Plan.png', Uint8List.fromList([1, 2, 3])); expect(File(inFolder('retakes.png')).readAsStringSync(), 'mine'); }); @@ -94,7 +99,7 @@ void main() { test('saveExportBytes writes under the corrected name alone', () async { FilePicker.platform = _SaveDialog([inFolder('retakes')]); - await saveExportBytes('Plan.png', Uint8List.fromList([1, 2, 3])); + await saveExportBytes(_english, 'Plan.png', Uint8List.fromList([1, 2, 3])); expect(folder.listSync().map((file) => p.basename(file.path)), [ 'retakes.png', @@ -102,3 +107,5 @@ void main() { expect(File(inFolder('retakes.png')).readAsBytesSync(), [1, 2, 3]); }); } + +final _english = lookupAppLocalizations(const Locale('en')); diff --git a/test/export_video_dialog_quality_copy_test.dart b/test/export_video_dialog_quality_copy_test.dart index ecb061aec..a7cda64a7 100644 --- a/test/export_video_dialog_quality_copy_test.dart +++ b/test/export_video_dialog_quality_copy_test.dart @@ -1,15 +1,20 @@ +import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/services/video_export/video_export_quality.dart'; import 'package:icarus/widgets/dialogs/export_video_dialog.dart'; void main() { group('videoExportQualityCaption', () { + final l10n = lookupAppLocalizations(const Locale('en')); test('states each option\'s distinguishing fact, no encoder vocabulary', () { - expect(videoExportQualityCaption(VideoExportQuality.potato), '~10 MB'); - expect(videoExportQualityCaption(VideoExportQuality.social), '~20 MB'); expect( - videoExportQualityCaption(VideoExportQuality.max), + videoExportQualityCaption(l10n, VideoExportQuality.potato), '~10 MB'); + expect( + videoExportQualityCaption(l10n, VideoExportQuality.social), '~20 MB'); + expect( + videoExportQualityCaption(l10n, VideoExportQuality.max), '60 fps, no size cap', ); }); diff --git a/test/ffmpeg_video_encoder_test.dart b/test/ffmpeg_video_encoder_test.dart index e10d70fe2..70b83abc2 100644 --- a/test/ffmpeg_video_encoder_test.dart +++ b/test/ffmpeg_video_encoder_test.dart @@ -1,6 +1,8 @@ import 'dart:io'; +import 'dart:ui' show Locale; import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/services/video_export/ffmpeg_video_encoder.dart'; import 'package:icarus/services/video_export/video_export_quality.dart'; @@ -109,6 +111,7 @@ void main() { final invocations = >[]; final encoder = FfmpegVideoEncoder( + l10n: _english, processStarter: ( executable, arguments, { @@ -176,3 +179,5 @@ class _SuccessfulProcess implements Process { @override bool kill([ProcessSignal signal = ProcessSignal.sigterm]) => true; } + +final _english = lookupAppLocalizations(const Locale('en')); diff --git a/test/ica_drop_target_test.dart b/test/ica_drop_target_test.dart index 69765dfa0..9911784f7 100644 --- a/test/ica_drop_target_test.dart +++ b/test/ica_drop_target_test.dart @@ -1,10 +1,15 @@ +import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/strategy/strategy_import_export.dart'; import 'package:icarus/widgets/ica_drop_target.dart'; +final _en = lookupAppLocalizations(const Locale('en')); + void main() { test('summary uses folder-only success phrasing', () { final summary = buildImportSummaryMessage( + _en, const ImportBatchResult( strategiesImported: 0, foldersCreated: 1, @@ -17,6 +22,7 @@ void main() { test('summary uses folder-only partial phrasing', () { final summary = buildImportSummaryMessage( + _en, const ImportBatchResult( strategiesImported: 0, foldersCreated: 1, @@ -34,6 +40,7 @@ void main() { test('summary uses no-import wording when everything is skipped', () { final summary = buildImportSummaryMessage( + _en, const ImportBatchResult( strategiesImported: 0, foldersCreated: 0, @@ -58,6 +65,7 @@ void main() { test('summary uses mixed success phrasing for clean imports', () { final summary = buildImportSummaryMessage( + _en, const ImportBatchResult( strategiesImported: 10, foldersCreated: 3, @@ -86,14 +94,14 @@ void main() { test('one file, and nothing else', () { expect( - buildImportSummaryMessage(resultOf(0, const [newer])), - NewerVersionImportException.userMessage, + buildImportSummaryMessage(_en, resultOf(0, const [newer])), + NewerVersionImportException.userMessage(_en), ); }); test('several files, and nothing else', () { expect( - buildImportSummaryMessage(resultOf(0, const [newer, newer])), + buildImportSummaryMessage(_en, resultOf(0, const [newer, newer])), 'These files were created in a newer version of Icarus. ' 'Please update the app and try again.', ); @@ -101,7 +109,7 @@ void main() { test('beside files that did import', () { expect( - buildImportSummaryMessage(resultOf(2, const [newer])), + buildImportSummaryMessage(_en, resultOf(2, const [newer])), 'Imported 2 strategies. Skipped 1 file. 1 was created in a newer ' 'version of Icarus. Please update the app and try again.', ); @@ -110,6 +118,7 @@ void main() { test('beside files that could never import', () { expect( buildImportSummaryMessage( + _en, resultOf(0, const [newer, newer, unsupported]), ), 'No compatible strategies or folders were imported. Skipped 3 files. ' @@ -121,6 +130,7 @@ void main() { test('summary mentions restored library globals', () { final summary = buildImportSummaryMessage( + _en, const ImportBatchResult( strategiesImported: 2, foldersCreated: 1, diff --git a/test/l10n_test.dart b/test/l10n_test.dart new file mode 100644 index 000000000..5bca35834 --- /dev/null +++ b/test/l10n_test.dart @@ -0,0 +1,192 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:ui' show Locale; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive_ce/hive.dart'; +import 'package:icarus/const/agents.dart'; +import 'package:icarus/const/hive_boxes.dart'; +import 'package:icarus/const/maps.dart'; +import 'package:icarus/const/weapons.dart'; +import 'package:icarus/hive/hive_adapters.dart'; +import 'package:icarus/hive/hive_registration.dart'; +import 'package:icarus/l10n/l10n.dart'; +import 'package:icarus/providers/user_preferences_provider.dart'; + +/// Writes AppPreferences the way builds before languages did, with no +/// language field on disk. +class _PreLanguageAdapter extends TypeAdapter { + @override + final typeId = 28; + + @override + AppPreferences read(BinaryReader reader) => throw UnimplementedError(); + + @override + void write(BinaryWriter writer, AppPreferences obj) { + writer + ..writeByte(2) + ..writeByte(0) + ..write(obj.defaultThemeProfileIdForNewStrategies) + ..writeByte(1) + ..write(obj.autosaveEnabled); + } +} + +Map _messages(String language) { + final arb = jsonDecode(File('lib/l10n/app_$language.arb').readAsStringSync()) + as Map; + return { + for (final entry in arb.entries) + if (!entry.key.startsWith('@')) entry.key: entry.value as String, + }; +} + +/// The names a message fills in: `{count}`, `{name}`. +Set _placeholders(String message) => { + for (final match + in RegExp(r'\{\s*([A-Za-z_]\w*)\s*[,}]').allMatches(message)) + match.group(1)!, + }..remove('other'); + +void main() { + final english = _messages('en'); + + for (final language in ['ja', 'zh']) { + test('$language says everything English says', () { + final translated = _messages(language); + // A missing key would quietly show English in the middle of the app. + expect( + english.keys.toSet().difference(translated.keys.toSet()), + isEmpty, + reason: 'keys missing from app_$language.arb', + ); + expect( + translated.keys.toSet().difference(english.keys.toSet()), + isEmpty, + reason: 'keys app_$language.arb has that English dropped', + ); + for (final key in english.keys) { + // A dropped placeholder loses the page name or count from a sentence. + expect( + _placeholders(translated[key]!), + _placeholders(english[key]!), + reason: 'placeholders of $key in app_$language.arb', + ); + } + }); + } + + group('the language shown', () { + Locale shown(String? picked, List system) => + resolveAppLocale(picked: picked, system: system); + + test('follows the system when nothing is picked', () { + expect(shown(null, const [Locale('ja', 'JP')]), const Locale('ja')); + expect(shown(null, const [Locale('en', 'GB')]), const Locale('en')); + }); + + test('is Simplified Chinese for every Chinese system', () { + expect( + shown(null, const [ + Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant'), + ]), + const Locale('zh'), + ); + }); + + test('is the first of the system languages Icarus has', () { + expect( + shown(null, const [Locale('fr'), Locale('ja'), Locale('en')]), + const Locale('ja'), + ); + }); + + test('is English when Icarus has none of them', () { + expect(shown(null, const [Locale('fr')]), const Locale('en')); + }); + + test('is the one picked in settings, over the system', () { + expect(shown('zh', const [Locale('ja')]), const Locale('zh')); + }); + }); + + test('every agent, weapon and map has the game\'s own name', () { + for (final language in ['ja', 'zh']) { + final l10n = lookupAppLocalizations(Locale(language)); + final agents = AgentData.agents.values.map((agent) => agent.name); + final weapons = WeaponType.values + .where((weapon) => weapon != WeaponType.none) + .map((weapon) => weapon.displayName); + for (final name in [...agents, ...weapons]) { + expect(l10n.gameName(name), isNot(name), reason: '$name in $language'); + } + for (final map in MapValue.values) { + expect( + l10n.mapName(map), + isNot(Maps.displayName(map)), + reason: '$map in $language', + ); + } + } + }); + + group('the picked language', () { + late Directory tempDir; + + setUpAll(() => registerIcarusAdapters(Hive)); + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('icarus-language-'); + Hive.init(tempDir.path); + }); + + tearDown(() async { + await Hive.close(); + Hive.registerAdapter(AppPreferencesAdapter(), override: true); + if (await tempDir.exists()) await tempDir.delete(recursive: true); + }); + + test('is the system\'s for preferences saved before languages', () async { + Hive.registerAdapter(_PreLanguageAdapter(), override: true); + var box = + await Hive.openBox(HiveBoxNames.appPreferencesBox); + await box.put( + MapThemeProfilesProvider.appPreferencesSingletonKey, + AppPreferences(defaultThemeProfileIdForNewStrategies: 'old-profile'), + ); + await box.close(); + Hive.registerAdapter(AppPreferencesAdapter(), override: true); + + box = await Hive.openBox(HiveBoxNames.appPreferencesBox); + final read = + box.get(MapThemeProfilesProvider.appPreferencesSingletonKey)!; + + expect(read.defaultThemeProfileIdForNewStrategies, 'old-profile'); + expect(read.languageCode, isNull); + }); + + test('survives a restart, and can go back to the system\'s', () async { + await Hive.openBox(HiveBoxNames.appPreferencesBox); + var container = ProviderContainer(); + await container + .read(appPreferencesProvider.notifier) + .setLanguageCode('ja'); + await container + .read(appPreferencesProvider.notifier) + .flushPendingWrites(); + container.dispose(); + + container = ProviderContainer(); + addTearDown(container.dispose); + expect(container.read(appLocaleProvider), const Locale('ja')); + expect(container.read(l10nProvider).commonCancel, 'キャンセル'); + + await container + .read(appPreferencesProvider.notifier) + .setLanguageCode(null); + expect(container.read(appPreferencesProvider).languageCode, isNull); + }); + }); +} diff --git a/test/lineup_group_changes_test.dart b/test/lineup_group_changes_test.dart index 3c9bbd24d..3b9a210d0 100644 --- a/test/lineup_group_changes_test.dart +++ b/test/lineup_group_changes_test.dart @@ -1,9 +1,14 @@ +import 'dart:ui' show Locale; + import 'package:flutter_test/flutter_test.dart'; import 'package:icarus/const/agents.dart'; import 'package:icarus/const/line_provider.dart'; import 'package:icarus/const/placed_classes.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/strategy/lineup_group_changes.dart'; +final _english = lookupAppLocalizations(const Locale('en')); + LineUpOrigin _origin(String id, {Offset position = const Offset(10, 20)}) => LineUpOrigin( id: id, @@ -53,7 +58,8 @@ LineUpGraph _group({ ); Map _described(List changes) => { - for (final change in changes) change.label: change.description, + for (final change in changes) + change.label(_english): change.description(_english), }; void main() { @@ -87,13 +93,15 @@ void main() { ); expect(_described(changes), {'New': 'added', 'B Main 2': 'deleted'}); - expect(changes.last.label, 'B Main 2', reason: 'deletions come last'); + expect(changes.last.label(_english), 'B Main 2', + reason: 'deletions come last'); }); test('a deleted group lists every lineup as deleted', () { final changes = lineupChanges(from: _group(), to: LineUpGraph.empty); - expect(changes.map((change) => change.description).toSet(), {'deleted'}); + expect(changes.map((change) => change.description(_english)).toSet(), + {'deleted'}); expect(changes, hasLength(3)); }); diff --git a/test/offline_add_page_copy_test.dart b/test/offline_add_page_copy_test.dart index 0569fba77..97515264a 100644 --- a/test/offline_add_page_copy_test.dart +++ b/test/offline_add_page_copy_test.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:io'; +import 'dart:ui' show Locale; import 'package:flutter/services.dart'; @@ -7,7 +8,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:icarus/collab/cloud_library_copy.dart'; import 'package:icarus/collab/cloud_strategy_copy_box.dart'; -import 'package:icarus/collab/cloud_sync_error_message.dart'; import 'package:icarus/collab/collab_models.dart'; import 'package:icarus/collab/convex_strategy_repository.dart'; import 'package:icarus/collab/durable_strategy_outbox.dart'; @@ -18,6 +18,8 @@ import 'package:icarus/const/coordinate_system.dart'; import 'package:icarus/const/line_provider.dart'; import 'package:icarus/const/page_copy_id.dart'; import 'package:icarus/const/placed_classes.dart'; +import 'package:icarus/l10n/cloud_sync_error_text.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/agent_provider.dart'; import 'package:icarus/providers/auth_provider.dart'; import 'package:icarus/providers/collab/cloud_copy_scope_provider.dart'; @@ -36,6 +38,8 @@ import 'package:supabase_flutter/supabase_flutter.dart' show User; import 'collab/strategy_copy_fixtures.dart'; import 'collab/subscribe_together_from_each.dart'; +final _english = lookupAppLocalizations(const Locale('en')); + /// "+" in a cloud strategy while the server can't make the copy: this /// computer copies the page, the new page opens and takes edits at once, /// and everything lands once the connection is back. Runs the real queue, @@ -470,7 +474,10 @@ void main() { expect(server.missingPageReads, 0); // Back online, the sync button says why. expect( - friendlyCloudSyncError(app.read(strategyOpQueueProvider).lastError!), + friendlyCloudSyncError( + _english, + app.read(strategyOpQueueProvider).lastError!, + ), "The copy of Page 2 didn't finish saving on this computer, so it " 'was not sent. Copy the page again, or choose Use cloud to ' 'discard it.', diff --git a/test/offline_page_changes_test.dart b/test/offline_page_changes_test.dart index 0fc2d09a6..f8789343b 100644 --- a/test/offline_page_changes_test.dart +++ b/test/offline_page_changes_test.dart @@ -1,10 +1,10 @@ import 'dart:convert'; import 'dart:io'; +import 'dart:ui' show Locale; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hive_ce/hive.dart'; -import 'package:icarus/collab/cloud_sync_error_message.dart'; import 'package:icarus/collab/collab_models.dart'; import 'package:icarus/collab/convex_strategy_repository.dart'; import 'package:icarus/collab/durable_strategy_outbox.dart'; @@ -12,12 +12,16 @@ import 'package:icarus/collab/generated/generated.dart'; import 'package:icarus/collab/page_changes.dart'; import 'package:icarus/collab/transport/convex_transport.dart'; import 'package:icarus/const/hive_boxes.dart'; +import 'package:icarus/l10n/cloud_sync_error_text.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/auth_provider.dart'; import 'package:icarus/providers/collab/active_page_live_sync_models.dart'; import 'package:icarus/providers/collab/convex_connection_provider.dart'; import 'package:icarus/providers/collab/strategy_op_queue_provider.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; +final _english = lookupAppLocalizations(const Locale('en')); + /// Page changes made in a cloud strategy without a connection: each one /// lands, in the order it was made, against a server that applies page /// changes the way convex/ops.ts does. @@ -562,7 +566,7 @@ void main() { expect(queue().queuedByEntityKey, hasLength(1)); expect(queue().lastError, 'Page 2 $pageChangedSinceDeleteReason'); expect( - friendlyCloudSyncError(queue().lastError!), + friendlyCloudSyncError(_english, queue().lastError!), 'Page 2 changed since you deleted it. Review before deleting.', ); }); @@ -760,14 +764,17 @@ void main() { 'Page 2 $pageChangedSinceDeleteReason', ); expect( - friendlyCloudSyncError(queue().lastError!), + friendlyCloudSyncError(_english, queue().lastError!), "Icarus couldn't check Page 2 for a teammate's changes. Review it " 'before deleting.', ); final held = notifier.heldPageDeletes().values.single; expect((held.page, held.checked), ('Page 2', false)); expect( - heldPageDeleteMessage((page: held.page, checked: held.checked)), + heldPageDeleteMessage( + _english, + (page: held.page, checked: held.checked), + ), "Icarus couldn't check Page 2 for a teammate's changes. Review it " 'before deleting.', ); diff --git a/test/page_screenshots_test.dart b/test/page_screenshots_test.dart index bf8e4e207..f8d0e46f0 100644 --- a/test/page_screenshots_test.dart +++ b/test/page_screenshots_test.dart @@ -20,6 +20,7 @@ import 'package:icarus/const/maps.dart'; import 'package:icarus/const/placed_classes.dart'; import 'package:icarus/const/utilities.dart'; import 'package:icarus/hive/hive_registration.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/strategy_page.dart'; import 'package:icarus/providers/strategy_page_session_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; @@ -935,7 +936,7 @@ void main() { return container; } - final refusal = captionsDontFitMessage(null); + final refusal = captionsDontFitMessage(_english, null); bool saveEnabled(WidgetTester tester) => tester .widget(find.ancestor( of: find.text('Save screenshot').last, @@ -1341,3 +1342,5 @@ void main() { expect(archive.files.last.content, [2]); }); } + +final _english = lookupAppLocalizations(const Locale('en')); diff --git a/test/providers/auth_provider_test.dart b/test/providers/auth_provider_test.dart index 26035ce44..57dcce6e7 100644 --- a/test/providers/auth_provider_test.dart +++ b/test/providers/auth_provider_test.dart @@ -7,7 +7,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:hive_ce_flutter/adapters.dart'; import 'package:icarus/collab/cloud_library_copy.dart'; import 'package:icarus/collab/cloud_library_models.dart'; -import 'package:icarus/collab/cloud_sync_error_message.dart'; import 'package:icarus/collab/convex_client.dart'; import 'package:icarus/collab/convex_strategy_repository.dart'; import 'package:icarus/collab/generated/generated.dart'; @@ -16,6 +15,8 @@ import 'package:icarus/const/app_provider_container.dart'; import 'package:icarus/const/hive_boxes.dart'; import 'package:icarus/providers/auth_provider.dart'; import 'package:icarus/const/maps.dart'; +import 'package:icarus/l10n/cloud_sync_error_text.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/collab/client_upgrade_required_provider.dart'; import 'package:icarus/providers/collab/convex_connection_provider.dart'; import 'package:icarus/providers/collab/remote_library_provider.dart'; @@ -27,6 +28,8 @@ import 'package:icarus/strategy/strategy_models.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; +final _english = lookupAppLocalizations(const Locale('en')); + void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -1341,7 +1344,7 @@ void main() { final state = container.read(authProvider); expect(state.convexAuthStatus, ConvexAuthStatus.incident); - expect(state.errorMessage, clientUpgradeRequiredMessage()); + expect(state.errorMessage, clientUpgradeRequiredMessage(_english)); expect(container.read(clientUpgradeRequiredProvider), isTrue); }); diff --git a/test/providers/replay_capture_queue_test.dart b/test/providers/replay_capture_queue_test.dart index 0ff7b1003..235ff8d70 100644 --- a/test/providers/replay_capture_queue_test.dart +++ b/test/providers/replay_capture_queue_test.dart @@ -292,7 +292,7 @@ void main() { final waiting = queued().captures.single; expect(queued().needsUser(waiting), isTrue); - expect(queued().stuck[waiting.id], contains('this device')); + expect(queued().stuck[waiting.id], ReplayCaptureStuckCause.storage); expect(queued().isReliable, isFalse); // Another strategy's capture still goes. diff --git a/test/quicktime_animation_test.dart b/test/quicktime_animation_test.dart index 56328506f..ad57be804 100644 --- a/test/quicktime_animation_test.dart +++ b/test/quicktime_animation_test.dart @@ -2,8 +2,10 @@ import 'dart:convert'; import 'dart:io'; import 'dart:math' as math; import 'dart:typed_data'; +import 'dart:ui' show Locale; import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/services/video_export/quicktime_animation_sink.dart'; import 'package:icarus/services/video_export/video_export_errors.dart'; @@ -84,7 +86,7 @@ void main() { (busy, 3), (base, 1), ]; - final sink = QuickTimeAnimationSink(fps: _fps); + final sink = QuickTimeAnimationSink(fps: _fps, l10n: _english); await sink.start( width: _width, height: _height, @@ -166,7 +168,11 @@ void main() { final path = '${dir.path}${Platform.pathSeparator}strategy.mov'; await File(path).writeAsString('the video already there'); - final sink = QuickTimeAnimationSink(fps: _fps, outputPath: path); + final sink = QuickTimeAnimationSink( + fps: _fps, + outputPath: path, + l10n: _english, + ); await sink.start( width: _width, height: _height, @@ -183,3 +189,5 @@ void main() { expect(dir.listSync(), hasLength(1)); }); } + +final _english = lookupAppLocalizations(const Locale('en')); diff --git a/test/share/share_link_copy_test.dart b/test/share/share_link_copy_test.dart index 4dc986c1b..0f702d5a5 100644 --- a/test/share/share_link_copy_test.dart +++ b/test/share/share_link_copy_test.dart @@ -1,17 +1,24 @@ +import 'dart:ui'; + import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/share/share_link_copy.dart'; void main() { + final l10n = lookupAppLocalizations(const Locale('en')); test('disable copy states the boundary without overpromising', () { - expect(ShareLinkCopy.disableAction, 'Disable link'); - expect(ShareLinkCopy.disableDescription, contains('No one new can join')); + expect(ShareLinkCopy.disableAction(l10n), 'Disable link'); + expect(ShareLinkCopy.disableDescription(l10n), + contains('No one new can join')); + expect(ShareLinkCopy.disableDescription(l10n), + contains('unless you remove them')); expect( - ShareLinkCopy.disableDescription, contains('unless you remove them')); - expect(ShareLinkCopy.disableDescription, contains('or view through it')); + ShareLinkCopy.disableDescription(l10n), contains('or view through it')); for (final targetType in ['strategy', 'folder']) { for (final opensInBrowser in [true, false]) { expect( ShareLinkCopy.dialogDescription( + l10n, targetType, opensInBrowser: opensInBrowser, ), @@ -22,15 +29,15 @@ void main() { // Only a strategy link that opens in a browser promises a view without // an account; a desktop link hands off to an app the reader may lack. expect( - ShareLinkCopy.dialogDescription('strategy', opensInBrowser: true), + ShareLinkCopy.dialogDescription(l10n, 'strategy', opensInBrowser: true), contains('no account needed'), ); expect( - ShareLinkCopy.dialogDescription('strategy', opensInBrowser: false), + ShareLinkCopy.dialogDescription(l10n, 'strategy', opensInBrowser: false), isNot(contains('no account')), ); expect( - ShareLinkCopy.dialogDescription('folder', opensInBrowser: true), + ShareLinkCopy.dialogDescription(l10n, 'folder', opensInBrowser: true), isNot(contains('no account')), ); }); diff --git a/test/shortcut_info_test.dart b/test/shortcut_info_test.dart index c9dfeb4ca..b9433dada 100644 --- a/test/shortcut_info_test.dart +++ b/test/shortcut_info_test.dart @@ -2,6 +2,7 @@ import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:icarus/const/shortcut_info.dart'; +import 'package:icarus/l10n/l10n.dart'; void main() { test('primary modifier displays as the active platform modifier', () { @@ -43,8 +44,9 @@ void main() { customBindings: const {}, ); - expect(undoDuplicate?.title, 'Undo'); - expect(redoDuplicate?.title, 'Redo'); + final l10n = lookupAppLocalizations(const Locale('en')); + expect(undoDuplicate?.title(l10n), 'Undo'); + expect(redoDuplicate?.title(l10n), 'Redo'); }); test('effective bindings ignore custom bindings reserved for undo and redo', @@ -73,6 +75,7 @@ void main() { saveDefinition, 'save', bindings, + lookupAppLocalizations(const Locale('en')), platform: TargetPlatform.windows, ), isTrue, @@ -82,6 +85,7 @@ void main() { saveDefinition, 'ctrl s', bindings, + lookupAppLocalizations(const Locale('en')), platform: TargetPlatform.windows, ), isTrue, diff --git a/test/strategy_op_queue_provider_test.dart b/test/strategy_op_queue_provider_test.dart index d2398e89b..60bd48646 100644 --- a/test/strategy_op_queue_provider_test.dart +++ b/test/strategy_op_queue_provider_test.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import 'dart:ui' show Locale; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -11,6 +12,8 @@ import 'package:icarus/collab/durable_strategy_outbox.dart'; import 'package:icarus/collab/field_merge.dart'; import 'package:icarus/collab/generated/generated.dart'; import 'package:icarus/collab/transport/convex_transport.dart'; +import 'package:icarus/l10n/cloud_sync_error_text.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/auth_provider.dart'; import 'package:icarus/providers/collab/active_page_live_sync_models.dart'; import 'package:icarus/providers/collab/client_upgrade_required_provider.dart'; @@ -19,6 +22,8 @@ import 'package:icarus/providers/collab/convex_connection_provider.dart'; import 'package:icarus/providers/collab/strategy_op_queue_provider.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; +final _english = lookupAppLocalizations(const Locale('en')); + void main() { group('Entity sync revision domains', () { test('page descriptor and page content cannot coalesce together', () { @@ -976,7 +981,7 @@ void main() { final durable = store.load().records.single; expect(durable.status, DurableOutboxStatus.attention); expect( - friendlyCloudSyncError(durable.lastError!), + friendlyCloudSyncError(_english, durable.lastError!), contains('clashes with one on another page'), ); }); @@ -1008,7 +1013,7 @@ void main() { final durable = store.load().records.single; expect(durable.status, DurableOutboxStatus.attention); expect(durable.lastError, lineupOverlapMessage); - expect(friendlyCloudSyncError(durable.lastError!), + expect(friendlyCloudSyncError(_english, durable.lastError!), contains('shares a spot')); }); @@ -1137,7 +1142,10 @@ void main() { final error = container.read(strategyOpQueueProvider).lastError!; expect(error, undrawableWorkMessage); expect(isSpecificAttentionReason(error), isTrue); - expect(friendlyCloudSyncError(error), contains('no longer fits')); + expect( + friendlyCloudSyncError(_english, error), + contains('no longer fits'), + ); }); test('an old-format lineup add is shown, never sent, and kept on restart', @@ -1181,7 +1189,7 @@ void main() { expect(current.lastError, retiredLineupOpMessage); expect(isSpecificAttentionReason(current.lastError!), isTrue); expect( - friendlyCloudSyncError(current.lastError!), + friendlyCloudSyncError(_english, current.lastError!), contains('older version of Icarus'), ); // Only the copy in memory changed: the saved record is as the older @@ -4307,7 +4315,7 @@ void main() { expect(current.pausedByEntityKey.keys, [key]); expect(current.queuedByEntityKey, isEmpty); expect(current.lastError, editAccessRemovedMessage); - expect(friendlyCloudSyncError(current.lastError!), + expect(friendlyCloudSyncError(_english, current.lastError!), editAccessRemovedMessage); // Kept on this device, and not sent again on its own. diff --git a/test/strategy_page_session_provider_test.dart b/test/strategy_page_session_provider_test.dart index 29bc8dcb5..2dfb1f4fd 100644 --- a/test/strategy_page_session_provider_test.dart +++ b/test/strategy_page_session_provider_test.dart @@ -63,6 +63,7 @@ import 'package:icarus/providers/text_provider.dart'; import 'package:icarus/providers/transition_provider.dart' hide PageTransitionState; import 'package:icarus/providers/user_preferences_provider.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/strategy/lineup_group_changes.dart'; import 'package:icarus/strategy/copied_page_check.dart'; import 'package:icarus/strategy/strategy_import_export.dart'; @@ -71,6 +72,8 @@ import 'package:icarus/const/page_copy_id.dart'; import 'package:supabase_flutter/supabase_flutter.dart' show User; import 'package:uuid/uuid.dart'; +final _english = lookupAppLocalizations(const Locale('en')); + class _FakeRemoteEditorNotifier extends RemoteEditorSnapshotNotifier { _FakeRemoteEditorNotifier( this.initialSnapshot, { @@ -8832,7 +8835,7 @@ void main() { /// The changes [changes] lists, each as `label: description`. List described(List changes) => [ for (final change in changes) - '${change.label}: ${change.description}', + '${change.label(_english)}: ${change.description(_english)}', ]; group('merged by field', () { @@ -10370,11 +10373,13 @@ void main() { // change was made from. final conflict = container.read(lineupConflictsProvider)!.single; expect( - [for (final change in conflict.yours) change.description], + [for (final change in conflict.yours) change.description(_english)], ['notes edited', 'notes edited'], ); expect( - [for (final change in conflict.cloud!) change.description], + [ + for (final change in conflict.cloud!) change.description(_english) + ], ['deleted'], ); @@ -10481,7 +10486,7 @@ void main() { undrawableWorkMessage); String listed(List changes) => [ for (final change in changes) - '${change.label}: ${change.description}', + '${change.label(_english)}: ${change.description(_english)}', ].join('; '); final conflict = container.read(lineupConflictsProvider)!.single; expect(listed(conflict.yours), 'Heaven: notes edited'); diff --git a/test/svg_height_app_integration_test.dart b/test/svg_height_app_integration_test.dart index 50a202218..f56fed780 100644 --- a/test/svg_height_app_integration_test.dart +++ b/test/svg_height_app_integration_test.dart @@ -11,6 +11,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:icarus/const/app_provider_container.dart'; import 'package:icarus/const/coordinate_system.dart'; import 'package:icarus/const/maps.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/map_provider.dart'; import 'package:icarus/providers/svg_height_runtime_provider.dart'; import 'package:icarus/providers/view_cone_geometry_provider.dart'; @@ -186,6 +187,7 @@ void main() { final model = SvgHeightVisibility.fromJson(json); double? changed; final item = buildSvgHeightElevationMenuItem( + l10n: lookupAppLocalizations(const Locale('en')), model: model, origin: const Offset(5, 5), selectedElevationCm: 999, diff --git a/test/video_export_navigation_failure_test.dart b/test/video_export_navigation_failure_test.dart index bead67214..c36166dcc 100644 --- a/test/video_export_navigation_failure_test.dart +++ b/test/video_export_navigation_failure_test.dart @@ -4,6 +4,7 @@ import 'package:icarus/const/agents.dart'; import 'package:icarus/const/coordinate_system.dart'; import 'package:icarus/const/maps.dart'; import 'package:icarus/const/placed_classes.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/map_provider.dart'; import 'package:icarus/providers/strategy_page.dart'; import 'package:icarus/providers/strategy_provider.dart'; @@ -50,6 +51,7 @@ void main() { pages: pages, ); final exporter = VideoExporter( + l10n: _english, strategy: strategy, strategyState: StrategyState( isSaved: true, @@ -72,6 +74,7 @@ void main() { binary: 'must-not-run', outputPath: 'must-not-create.mp4', quality: VideoExportQuality.social, + l10n: _english, ), quality: VideoExportQuality.social, onProgress: (_, label) { @@ -90,3 +93,5 @@ void main() { }); } } + +final _english = lookupAppLocalizations(const Locale('en')); diff --git a/test/video_exporter_sink_test.dart b/test/video_exporter_sink_test.dart index 37a3bd5cd..56d42c3bb 100644 --- a/test/video_exporter_sink_test.dart +++ b/test/video_exporter_sink_test.dart @@ -12,6 +12,7 @@ import 'package:icarus/const/image_scale_policy.dart'; import 'package:icarus/const/maps.dart'; import 'package:icarus/const/placed_classes.dart'; import 'package:icarus/hive/hive_registration.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/auth_provider.dart'; import 'package:icarus/providers/map_provider.dart'; import 'package:icarus/providers/strategy_image_source.dart'; @@ -132,6 +133,7 @@ VideoExporter _exporter( bool transparent = false, }) { return VideoExporter( + l10n: _english, strategy: StrategyData( id: 'cloud-strategy', name: 'Cloud strat', @@ -333,6 +335,7 @@ void main() { binary: _ffmpeg!, outputPath: outputPath, quality: VideoExportQuality.social, + l10n: _english, ), ); @@ -387,7 +390,11 @@ void main() { tester, _exporter(pages, transparent: true), pages, - QuickTimeAnimationSink(fps: 30, outputPath: outputPath), + QuickTimeAnimationSink( + fps: 30, + outputPath: outputPath, + l10n: _english, + ), ); expect(outcome, isNull); @@ -417,3 +424,5 @@ void main() { ); }, skip: _ffmpeg == null || _ffprobe == null); } + +final _english = lookupAppLocalizations(const Locale('en')); diff --git a/test/web/web_codecs_video_encoder_test.dart b/test/web/web_codecs_video_encoder_test.dart index 16df44584..1c6096b88 100644 --- a/test/web/web_codecs_video_encoder_test.dart +++ b/test/web/web_codecs_video_encoder_test.dart @@ -4,8 +4,10 @@ library; import 'dart:async'; import 'dart:js_interop'; import 'dart:typed_data'; +import 'dart:ui' show Locale; import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/services/video_export/video_export_errors.dart'; import 'package:icarus/services/video_export/web_codecs_video_encoder.dart'; @@ -45,7 +47,7 @@ void main() { }); test('encodes held frames into an MP4 the browser plays back', () async { - final encoder = WebCodecsMp4Encoder(); + final encoder = WebCodecsMp4Encoder(_english); await encoder.start( inputWidth: 640, inputHeight: 360, @@ -88,7 +90,7 @@ void main() { }); test('scales 1280x720 frames down to a 640x360 video', () async { - final encoder = WebCodecsMp4Encoder(); + final encoder = WebCodecsMp4Encoder(_english); await encoder.start( inputWidth: 1280, inputHeight: 720, @@ -120,7 +122,7 @@ void main() { }); test('carries fractional frame remainders forward', () async { - final encoder = WebCodecsMp4Encoder(); + final encoder = WebCodecsMp4Encoder(_english); await encoder.start( inputWidth: 64, inputHeight: 64, @@ -142,7 +144,7 @@ void main() { }); test('cancel is idempotent and later calls report the cancel', () async { - final encoder = WebCodecsMp4Encoder(); + final encoder = WebCodecsMp4Encoder(_english); await encoder.start( inputWidth: 64, inputHeight: 64, @@ -288,3 +290,5 @@ extension type _Url._(JSObject _) implements JSObject { external static String createObjectURL(_Blob blob); external static void revokeObjectURL(String url); } + +final _english = lookupAppLocalizations(const Locale('en')); diff --git a/test/widgets/auth_dialog_test.dart b/test/widgets/auth_dialog_test.dart index 460529704..6858ab3a4 100644 --- a/test/widgets/auth_dialog_test.dart +++ b/test/widgets/auth_dialog_test.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/auth_provider.dart'; import 'package:icarus/services/auth_callback_uri.dart'; import 'package:icarus/widgets/dialogs/auth/auth_dialog.dart'; @@ -59,13 +60,14 @@ void main() { ), ); await tester.pumpAndSettle(); - expect(find.text(notice.message), findsOneWidget); + expect(find.text(notice.message(_english)), findsOneWidget); expect(find.byIcon(icon), findsOneWidget); } await tester.tap(find.byKey(const ValueKey('auth-mode-switch'))); await tester.pumpAndSettle(); - expect(find.text(AuthLinkNotice.linkExpired.message), findsNothing); + expect( + find.text(AuthLinkNotice.linkExpired.message(_english)), findsNothing); }); testWidgets('a link landing while the dialog is open speaks through it', @@ -78,7 +80,8 @@ void main() { .show(AuthLinkNotice.linkExpired); await tester.pumpAndSettle(); - expect(find.text(AuthLinkNotice.linkExpired.message), findsOneWidget); + expect(find.text(AuthLinkNotice.linkExpired.message(_english)), + findsOneWidget); expect(find.byType(AuthDialog), findsOneWidget); }); @@ -94,7 +97,8 @@ void main() { .show(AuthLinkNotice.emailConfirmed); await tester.pumpAndSettle(); - expect(find.text(AuthLinkNotice.emailConfirmed.message), findsOneWidget); + expect(find.text(AuthLinkNotice.emailConfirmed.message(_english)), + findsOneWidget); expect(find.text('Create account'), findsNothing); expect(find.text('a@b.co'), findsOneWidget); expect( @@ -118,7 +122,8 @@ void main() { find.text('Failed to complete login. Please try again.'), findsOneWidget, ); - expect(find.text(AuthLinkNotice.emailConfirmed.message), findsNothing); + expect(find.text(AuthLinkNotice.emailConfirmed.message(_english)), + findsNothing); }); testWidgets('a reset link that timed out opens on asking for a new one', @@ -140,7 +145,8 @@ void main() { ); await tester.pumpAndSettle(); - expect(find.text(AuthLinkNotice.resetLinkTimedOut.message), findsOneWidget); + expect(find.text(AuthLinkNotice.resetLinkTimedOut.message(_english)), + findsOneWidget); expect(find.text('Send reset link'), findsOneWidget); }); @@ -266,3 +272,5 @@ class _FakeAuthProvider extends AuthProvider { return (message: 'Link sent.', sent: true); } } + +final _english = lookupAppLocalizations(const Locale('en')); diff --git a/test/widgets/cloud_sync_button_test.dart b/test/widgets/cloud_sync_button_test.dart index 3cf7a2575..fdce4e723 100644 --- a/test/widgets/cloud_sync_button_test.dart +++ b/test/widgets/cloud_sync_button_test.dart @@ -9,6 +9,8 @@ import 'package:icarus/collab/convex_strategy_repository.dart'; import 'package:icarus/collab/page_changes.dart'; import 'package:icarus/providers/collab/active_page_live_sync_models.dart'; import 'package:icarus/const/update_checker.dart'; +import 'package:icarus/l10n/cloud_sync_error_text.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/collab/active_page_live_sync_provider.dart'; import 'package:icarus/providers/collab/client_upgrade_required_provider.dart'; import 'package:icarus/providers/collab/cloud_media_upload_queue_provider.dart'; @@ -401,6 +403,8 @@ String _syncTooltip(WidgetTester tester) { return button.tooltip; } +final _english = lookupAppLocalizations(const Locale('en')); + void main() { test('restored active-strategy media renders as syncing', () { final container = _createContainer( @@ -1175,19 +1179,22 @@ void main() { }); group('lineup conflicts', () { - LineupChange change(String label, String description) => - LineupChange(label: label, description: description); + LineupChange change(String name, LineupChangeKind kind) => + LineupChange(name: name, kinds: [kind]); final conflicts = [ LineupGroupConflict( key: const EntitySyncKey.lineup('page-1', 'link-a'), - yours: [change('Heaven', 'notes edited')], + yours: [change('Heaven', LineupChangeKind.notesEdited)], cloud: [ - change('Mid', 'renamed'), - change('Short', 'added'), - change('Long', 'landing moved'), - change('Deep', 'deleted'), - change('Sova lineup', 'images changed'), + change('Mid', LineupChangeKind.renamed), + change('Short', LineupChangeKind.added), + change('Long', LineupChangeKind.landingMoved), + change('Deep', LineupChangeKind.deleted), + const LineupChange( + agent: 'Sova', + kinds: [LineupChangeKind.imagesChanged], + ), ], ), ]; @@ -1227,8 +1234,8 @@ void main() { LineupGroupConflict( key: const EntitySyncKey.lineup('page-1', 'link-a'), yours: [ - change('Heaven', 'notes edited'), - change('Mid', 'notes edited'), + change('Heaven', LineupChangeKind.notesEdited), + change('Mid', LineupChangeKind.notesEdited), ], cloud: null, ), @@ -1236,8 +1243,8 @@ void main() { // the sides' changes apart. LineupGroupConflict( key: const EntitySyncKey.lineup('page-1', 'link-z'), - yours: [change('Long', 'landing moved')], - cloud: [change('Short', 'added')], + yours: [change('Long', LineupChangeKind.landingMoved)], + cloud: [change('Short', LineupChangeKind.added)], ), ], ); @@ -1307,10 +1314,10 @@ void main() { lineupConflicts: [ LineupGroupConflict( key: const EntitySyncKey.lineup('page-1', 'link-a'), - yours: [change('Heaven', 'notes edited')], + yours: [change('Heaven', LineupChangeKind.notesEdited)], cloud: [ - change('Mid', 'notes edited'), - change('Heaven', 'deleted'), + change('Mid', LineupChangeKind.notesEdited), + change('Heaven', LineupChangeKind.deleted), ], ), ], @@ -1823,7 +1830,12 @@ void main() { lineupConflicts: const [ LineupGroupConflict( key: EntitySyncKey.lineup('page-1', 'link-a'), - yours: [LineupChange(label: 'Heaven', description: 'added')], + yours: [ + const LineupChange( + name: 'Heaven', + kinds: [LineupChangeKind.added], + ) + ], cloud: [], ), ], @@ -2028,7 +2040,7 @@ void main() { await tester.pump(const Duration(milliseconds: 300)); expect( - toast(friendlyCloudSyncError(lineupPageMismatchMessage)), + toast(friendlyCloudSyncError(_english, lineupPageMismatchMessage)), findsOneWidget, ); expect( diff --git a/test/widgets/pages_bar_undo_delete_test.dart b/test/widgets/pages_bar_undo_delete_test.dart index 366f88d0b..326910ecc 100644 --- a/test/widgets/pages_bar_undo_delete_test.dart +++ b/test/widgets/pages_bar_undo_delete_test.dart @@ -8,6 +8,7 @@ import 'package:icarus/collab/collab_models.dart'; import 'package:icarus/collab/durable_strategy_outbox.dart'; import 'package:icarus/providers/collab/strategy_op_queue_provider.dart'; import 'package:icarus/const/transition_data.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/collab/remote_strategy_snapshot_provider.dart'; import 'package:icarus/providers/collab/strategy_capabilities_provider.dart'; import 'package:icarus/providers/strategy_page_session_provider.dart'; @@ -458,18 +459,22 @@ void main() { test('Undo failure messages', () { expect( - undoFailedMessage('Page 2', DeletedPageRestore.failed, + undoFailedMessage(lookupAppLocalizations(const Locale('en')), 'Page 2', + DeletedPageRestore.failed, strategyName: null), "Could not confirm that 'Page 2' was restored. Once you're connected, " 'check the pages list or Recently deleted.', ); expect( - undoFailedMessage('Page 2', DeletedPageRestore.failed, strategyName: ''), + undoFailedMessage(lookupAppLocalizations(const Locale('en')), 'Page 2', + DeletedPageRestore.failed, + strategyName: ''), "Could not confirm that 'Page 2' was restored. Once you're connected, " "check the pages list or its strategy's Recently deleted.", ); expect( - undoFailedMessage('Page 2', DeletedPageRestore.gone, + undoFailedMessage(lookupAppLocalizations(const Locale('en')), 'Page 2', + DeletedPageRestore.gone, strategyName: 'Strategy A'), "'Page 2' can no longer be restored.", ); diff --git a/test/widgets/recently_deleted_dialog_test.dart b/test/widgets/recently_deleted_dialog_test.dart index 75b3ed139..3a120b400 100644 --- a/test/widgets/recently_deleted_dialog_test.dart +++ b/test/widgets/recently_deleted_dialog_test.dart @@ -6,6 +6,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:icarus/collab/collab_models.dart'; import 'package:icarus/collab/convex_strategy_repository.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/strategy_page_session_provider.dart'; import 'package:icarus/widgets/dialogs/recently_deleted_dialog.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; @@ -102,6 +103,7 @@ Future _open( void main() { group('labels', () { final now = DateTime(2026, 9, 29, 12); + final l10n = lookupAppLocalizations(const Locale('en')); TrashedPage page({String? by, bool byYou = false, Duration? ago}) => TrashedPage( pageId: 'p', @@ -115,27 +117,31 @@ void main() { ); test('say who deleted the page and when, if known', () { - expect(deletedLabel(page(by: 'Sam'), now), 'Deleted by Sam, 2 days ago'); + expect(deletedLabel(l10n, page(by: 'Sam'), now), + 'Deleted by Sam, 2 days ago'); expect( - deletedLabel( + deletedLabel(l10n, page(by: 'Sam', byYou: true, ago: const Duration(minutes: 5)), now), 'Deleted by you, 5 minutes ago', ); expect( - deletedLabel(page(ago: const Duration(hours: 1)), now), + deletedLabel(l10n, page(ago: const Duration(hours: 1)), now), 'Deleted 1 hour ago', ); - expect(deletedLabel(page(ago: Duration.zero), now), 'Deleted just now'); + expect(deletedLabel(l10n, page(ago: Duration.zero), now), + 'Deleted just now'); }); test('count the days left', () { - expect(timeLeftLabel(page(), now), '28 days left'); + expect(timeLeftLabel(l10n, page(), now), '28 days left'); expect( - timeLeftLabel(page(ago: const Duration(days: 28, hours: 12)), now), + timeLeftLabel( + l10n, page(ago: const Duration(days: 28, hours: 12)), now), '1 day left', ); expect( - timeLeftLabel(page(ago: const Duration(days: 29, hours: 12)), now), + timeLeftLabel( + l10n, page(ago: const Duration(days: 29, hours: 12)), now), 'Less than a day left', ); }); diff --git a/test/widgets/strategy_presence_test.dart b/test/widgets/strategy_presence_test.dart index c67d1cf42..9d5107659 100644 --- a/test/widgets/strategy_presence_test.dart +++ b/test/widgets/strategy_presence_test.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/const/agents.dart'; import 'package:icarus/const/line_provider.dart'; import 'package:icarus/const/placed_classes.dart'; @@ -129,6 +130,8 @@ Widget _app(PresenceRoomState state, Widget child) => ProviderScope( child: ShadApp(home: Scaffold(body: Center(child: child))), ); +final _en = lookupAppLocalizations(const Locale('en')); + void main() { testWidgets('shows nothing when you are alone', (tester) async { await tester.pumpWidget(_app( @@ -204,22 +207,23 @@ void main() { final cy = _peer('s4', 'cy', 'Cy', 'editor'); test('names one editor', () { - expect(lineUpEditorsText([sam]), 'Sam is editing this lineup right now.'); - expect(lineUpEditorsText([sam], lineups: true), + expect(lineUpEditorsText(_en, [sam]), + 'Sam is editing this lineup right now.'); + expect(lineUpEditorsText(_en, [sam], lineups: true), 'Sam is editing these lineups right now.'); }); test('names two editors', () { - expect(lineUpEditorsText([sam, ana]), + expect(lineUpEditorsText(_en, [sam, ana]), 'Sam and Ana are editing this lineup right now.'); - expect(lineUpEditorsText([sam, ana], lineups: true), + expect(lineUpEditorsText(_en, [sam, ana], lineups: true), 'Sam and Ana are editing these lineups right now.'); }); test('counts the rest past one editor when there are three or more', () { - expect(lineUpEditorsText([sam, ana, ben]), + expect(lineUpEditorsText(_en, [sam, ana, ben]), 'Sam and 2 others are editing this lineup right now.'); - expect(lineUpEditorsText([sam, ana, ben, cy], lineups: true), + expect(lineUpEditorsText(_en, [sam, ana, ben, cy], lineups: true), 'Sam and 3 others are editing these lineups right now.'); }); }); diff --git a/test/widgets/web_beta_library_test.dart b/test/widgets/web_beta_library_test.dart index 0b1914f86..a64db120e 100644 --- a/test/widgets/web_beta_library_test.dart +++ b/test/widgets/web_beta_library_test.dart @@ -13,6 +13,8 @@ import 'package:icarus/const/hive_boxes.dart'; import 'package:icarus/const/maps.dart'; import 'package:icarus/const/update_checker.dart'; import 'package:icarus/hive/hive_registration.dart'; +import 'package:icarus/l10n/cloud_sync_error_text.dart'; +import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/providers/auth_provider.dart'; import 'package:icarus/providers/collab/client_upgrade_required_provider.dart'; import 'package:icarus/providers/collab/remote_library_provider.dart'; @@ -39,6 +41,8 @@ Finder _tileInThePool() => find.byKey( ValueKey('create-strategy-map-${Maps.mapNames[Maps.availableMaps.first]}'), ); +final _english = lookupAppLocalizations(const Locale('en')); + void main() { late Directory tempDir; @@ -273,7 +277,7 @@ void main() { tester, policy: PlatformPolicy.webBeta, auth: _unreachable.copyWith( - errorMessage: clientUpgradeRequiredMessage(), + errorMessage: clientUpgradeRequiredMessage(_english), ), ); ProviderScope.containerOf(tester.element(find.byType(FolderNavigator))) @@ -286,6 +290,7 @@ void main() { // Tests run as desktop, where this harness knows of no update yet. expect( find.text(clientUpgradeRequiredMessage( + _english, update: ClientUpdateAvailability.unavailable, )), findsOneWidget, @@ -436,7 +441,8 @@ void main() { test('desktop offers every feature', () { for (final feature in PlatformFeature.values) { expect(PlatformPolicy.desktop.supports(feature), isTrue); - expect(PlatformPolicy.desktop.unavailableMessage(feature), isNull); + expect(PlatformPolicy.desktop.unavailableMessage(_english, feature), + isNull); } expect(PlatformPolicy.desktop.allowsLocalLibrary, isTrue); expect(PlatformPolicy.desktop.requiresSignIn, isFalse); @@ -445,7 +451,7 @@ void main() { test('web beta names the feature and why it is missing', () { const policy = PlatformPolicy.webBeta; expect( - policy.unavailableMessage(PlatformFeature.exportFiles), + policy.unavailableMessage(_english, PlatformFeature.exportFiles), 'Export is desktop-only for now.', ); expect(policy.allowsLocalLibrary, isFalse); @@ -459,7 +465,7 @@ void main() { PlatformFeature.addLineups, ]) { expect(policy.supports(feature), isTrue); - expect(policy.unavailableMessage(feature), isNull); + expect(policy.unavailableMessage(_english, feature), isNull); } expect(policy.comingToWebBeta, isEmpty); }); @@ -474,7 +480,7 @@ void main() { ); expect(policy.supports(PlatformFeature.addLineups), isFalse); expect( - policy.unavailableMessage(PlatformFeature.addLineups), + policy.unavailableMessage(_english, PlatformFeature.addLineups), 'Adding lineups is coming to the web beta.', ); }); diff --git a/tool/fetch_game_names.dart b/tool/fetch_game_names.dart new file mode 100644 index 000000000..05a67ea02 --- /dev/null +++ b/tool/fetch_game_names.dart @@ -0,0 +1,117 @@ +import 'dart:convert'; +import 'dart:io'; + +/// Writes lib/l10n/official_game_names.dart: what Valorant itself calls each +/// agent, ability, role, weapon and map in the languages Icarus has. Players +/// know these names from the game, so Icarus never translates them itself. +/// +/// The names come from valorant-api.com, which republishes the game client's +/// own text. Run this after a new agent, weapon or map ships, or after adding +/// a language to [_languages]: +/// +/// dart run tool/fetch_game_names.dart +/// +/// A name the game has no entry for (Icarus's own names, like "Astra Star") +/// is shown in English. +const _languages = {'ja': 'ja-JP', 'zh': 'zh-CN'}; + +Future main() async { + final client = HttpClient(); + Future>> fetch(String path, String language) async { + final separator = path.contains('?') ? '&' : '?'; + final request = await client.getUrl(Uri.parse( + 'https://valorant-api.com/v1/$path${separator}language=$language', + )); + final response = await request.close(); + if (response.statusCode != 200) { + throw HttpException('$path ($language): ${response.statusCode}'); + } + final body = await response.transform(utf8.decoder).join(); + return ((jsonDecode(body) as Map)['data'] as List) + .cast>(); + } + + // English name (lower case) -> language -> the game's name. + final names = >{}; + final mapNames = >{}; + + final agents = await fetch('agents?isPlayableCharacter=true', 'en-US'); + final weapons = await fetch('weapons', 'en-US'); + final maps = await fetch('maps', 'en-US'); + for (final MapEntry(key: code, value: language) in _languages.entries) { + void add(Map> into, Object? english, + Object? translated) { + if (english is! String || translated is! String) return; + // The game breaks some long names over two lines for its own layout. + final key = english.replaceAll(RegExp(r'\s+'), ' ').trim().toLowerCase(); + into.putIfAbsent(key, () => {})[code] = + translated.replaceAll(RegExp(r'\s*[\r\n]+\s*'), '').trim(); + } + + final translatedAgents = await fetch( + 'agents?isPlayableCharacter=true', + language, + ); + for (final agent in agents) { + final translated = translatedAgents.firstWhere( + (other) => other['uuid'] == agent['uuid'], + ); + add(names, agent['displayName'], translated['displayName']); + add(names, agent['role']?['displayName'], + translated['role']?['displayName']); + final abilities = (agent['abilities'] as List).cast(); + final translatedAbilities = (translated['abilities'] as List).cast(); + for (final ability in abilities) { + final match = translatedAbilities.firstWhere( + (other) => other['slot'] == ability['slot'], + orElse: () => const {}, + ); + add(names, ability['displayName'], match['displayName']); + } + } + final translatedWeapons = await fetch('weapons', language); + for (final weapon in weapons) { + final translated = translatedWeapons.firstWhere( + (other) => other['uuid'] == weapon['uuid'], + ); + add(names, weapon['displayName'], translated['displayName']); + } + final translatedMaps = await fetch('maps', language); + for (final map in maps) { + final translated = translatedMaps.firstWhere( + (other) => other['uuid'] == map['uuid'], + ); + add(mapNames, map['displayName'], translated['displayName']); + } + } + client.close(); + + String table(String name, Map> entries) { + final buffer = + StringBuffer('const $name = >{\n'); + for (final english in entries.keys.toList()..sort()) { + final translations = entries[english]! + .entries + .map((entry) => '${_quote(entry.key)}: ${_quote(entry.value)}') + .join(', '); + buffer.writeln(' ${_quote(english)}: {$translations},'); + } + buffer.writeln('};'); + return buffer.toString(); + } + + File('lib/l10n/official_game_names.dart').writeAsStringSync(''' +// Written by tool/fetch_game_names.dart from the game's own text. Do not edit: +// run the tool again instead. + +/// What Valorant calls an agent, ability, role or weapon, by its English name +/// in lower case, then by language. +${table('officialGameNames', names)} +/// The same for maps. Kept apart because a map and an ability can share an +/// English name. +${table('officialMapNames', mapNames)}'''); + stdout.writeln('${names.length} names, ${mapNames.length} maps'); +} + +String _quote(String text) => + "'${text.replaceAll(r'\', r'\\').replaceAll("'", r"\'").replaceAll(r'$', r'\$')}'";