Skip to content

Commit 7295b2b

Browse files
committed
Localize the entire app (English + Korean) behind a language feature flag
Migrate every user-facing string across desktop and mobile to Flutter gen-l10n (lib/l10n/app_en.arb + app_ko.arb, ~1,160 keys) and ship a full Korean translation. English ARB values are byte-identical to the previous hardcoded literals, so English builds render exactly as before. Language selection: - Settings gains a Language option — System (Auto) / English / 한국어 — on desktop (modal) and mobile (sheet). - With no stored preference the app follows the OS locale, clamped to supportedLocales with an English fallback; a saved choice always wins. - localeProvider gains clearToSystem(); a context-free appLocalizationsProvider serves providers and services. Feature flag (kill switch): - VIZOR_LANGUAGE_ENABLED (compile-time dart-define, default true). Building with --dart-define=VIZOR_LANGUAGE_ENABLED=false pins the app to English, ignores stored/OS locale, and hides the Language rows — restoring the pre-i18n behavior verbatim without a revert. The const flag is tree-shaken from release builds. Localization mechanics: - Screens use AppLocalizations.of(context); context-free helpers and friendly-error mappers take an AppLocalizations parameter; Riverpod providers read appLocalizationsProvider (test-safe fallback). - Dates use intl DateFormat guarded by intlSafeLocale(), with locale-aware day/month ordering; hardcoded month tables removed. - Label-text sniffing replaced by typed kinds: SwapStatusDetailRowKind on swap status rows and AddressFormatIssueKind on address findings, so filtering/ordering/tooltips no longer parse English labels. - Rust-thrown error strings remain English as mapper inputs; only mapper output is localized. Persisted data (account names, swap records) stays English. Tooling and tests: - Widgetbook gains a LocalizationAddon with a locale switcher. - ~95 test harnesses receive AppLocalizations delegates (MaterialApp, MaterialApp.router, and bare AppTheme wrappers). - CFBundleLocalizations (en, ko) added to iOS and macOS Info.plists. - Fixed-height cards on the unlock/set-password/intro screens gain 8px headroom so taller Korean type fits. Verification: flutter analyze clean; desktop lane 1422 passed / 1 failed (receive_use_cases_test, pre-existing on main); mobile lane 325 passed / 2 failed (mobile_routes_test, mobile_receive_use_cases_test, both pre-existing on main). Manually verified on macOS: language switching, follow-OS resolution, onboarding/create/import flows, Keystone connect, send end-to-end, activity and swap surfaces in Korean.
1 parent 8749792 commit 7295b2b

311 files changed

Lines changed: 25381 additions & 3156 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

ios/Runner.xcodeproj/project.pbxproj

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
265BA9BA2F7BDB1E00287691 /* SyncWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 265BA9A92F7BDB1D00287691 /* SyncWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
1515
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
1616
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
17+
C0DE1B5001AB12CD34EF0004 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = C0DE1B5001AB12CD34EF0003 /* InfoPlist.strings */; };
1718
42257B77B623F2ABAD2A1912 /* DynamicIslandManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A4D4F9C0A149C751B796E19 /* DynamicIslandManager.swift */; };
1819
5736417E0A4E184E8572693A /* WalletPathResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5736417D0A4E184E8572693A /* WalletPathResolver.swift */; };
1920
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
@@ -106,6 +107,8 @@
106107
97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
107108
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
108109
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
110+
C0DE1B5001AB12CD34EF0001 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/InfoPlist.strings; sourceTree = "<group>"; };
111+
C0DE1B5001AB12CD34EF0002 /* ko */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ko; path = ko.lproj/InfoPlist.strings; sourceTree = "<group>"; };
109112
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
110113
BGSYNC000ED2DC5600515810 /* BackgroundSyncManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BackgroundSyncManager.swift; sourceTree = "<group>"; };
111114
BIOUNLK00ED2DC5600515810 /* BiometricUnlockHandler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BiometricUnlockHandler.swift; sourceTree = "<group>"; };
@@ -248,6 +251,7 @@
248251
8A0F5C502F8B001200A1C001 /* AppIcon.icon */,
249252
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
250253
97C147021CF9000F007C117D /* Info.plist */,
254+
C0DE1B5001AB12CD34EF0003 /* InfoPlist.strings */,
251255
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
252256
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
253257
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
@@ -363,6 +367,7 @@
363367
hasScannedForEncodings = 0;
364368
knownRegions = (
365369
en,
370+
ko,
366371
Base,
367372
);
368373
mainGroup = 97C146E51CF9000F007C117D;
@@ -398,6 +403,7 @@
398403
files = (
399404
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */,
400405
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
406+
C0DE1B5001AB12CD34EF0004 /* InfoPlist.strings in Resources */,
401407
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
402408
8A0F5C512F8B001200A1C001 /* AppIcon.icon in Resources */,
403409
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
@@ -552,6 +558,15 @@
552558
/* End PBXTargetDependency section */
553559

554560
/* Begin PBXVariantGroup section */
561+
C0DE1B5001AB12CD34EF0003 /* InfoPlist.strings */ = {
562+
isa = PBXVariantGroup;
563+
children = (
564+
C0DE1B5001AB12CD34EF0001 /* en */,
565+
C0DE1B5001AB12CD34EF0002 /* ko */,
566+
);
567+
name = InfoPlist.strings;
568+
sourceTree = "<group>";
569+
};
555570
97C146FA1CF9000F007C117D /* Main.storyboard */ = {
556571
isa = PBXVariantGroup;
557572
children = (

ios/Runner/Info.plist

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,11 @@
2323
<true/>
2424
<key>CFBundleDevelopmentRegion</key>
2525
<string>$(DEVELOPMENT_LANGUAGE)</string>
26+
<key>CFBundleLocalizations</key>
27+
<array>
28+
<string>en</string>
29+
<string>ko</string>
30+
</array>
2631
<key>CFBundleDisplayName</key>
2732
<string>Vizor</string>
2833
<key>CFBundleExecutable</key>
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
"NSCameraUsageDescription" = "Vizor uses the camera to scan Keystone QR codes.";
2+
"NSFaceIDUsageDescription" = "Vizor uses Face ID to unlock your wallet and confirm sensitive actions.";
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
"NSCameraUsageDescription" = "Vizor는 Keystone QR 코드를 스캔하기 위해 카메라를 사용합니다.";
2+
"NSFaceIDUsageDescription" = "Vizor는 지갑 잠금 해제와 민감한 작업 확인을 위해 Face ID를 사용합니다.";

l10n.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
arb-dir: lib/l10n
2+
template-arb-file: app_en.arb
3+
output-localization-file: app_localizations.dart
4+
nullable-getter: false

lib/app.dart

Lines changed: 57 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import 'package:go_router/go_router.dart';
88
import 'package:desktop_window_bootstrap/desktop_window_bootstrap.dart';
99
import 'package:url_launcher/url_launcher.dart';
1010

11+
import 'l10n/app_localizations.dart';
1112
import 'src/app_bootstrap.dart';
1213
import 'src/core/config/swap_feature_config.dart';
1314
import 'src/core/layout/app_layout.dart';
@@ -72,6 +73,7 @@ import 'src/features/voting/screens/voting_review_screen.dart';
7273
import 'src/features/voting/screens/voting_software_account_guard.dart';
7374
import 'src/features/voting/screens/voting_status_screen.dart';
7475
import 'src/features/voting/screens/voting_submission_confirmation_screen.dart';
76+
import 'src/providers/locale_provider.dart';
7577
import 'src/providers/theme_mode_provider.dart';
7678
import 'src/providers/app_security_provider.dart';
7779
import 'src/providers/linux_update_provider.dart';
@@ -378,16 +380,18 @@ List<RouteBase> appAuthRoutes(
378380
),
379381
GoRoute(
380382
path: '/terms',
381-
builder: (_, state) => kAppFormFactor == AppFormFactor.mobile
382-
? const MobileLegalScreen(title: 'Terms of Use')
383+
builder: (context, state) => kAppFormFactor == AppFormFactor.mobile
384+
? MobileLegalScreen(title: AppLocalizations.of(context).legalTermsOfUse)
383385
: TermsScreen(
384386
forceFullPane: state.uri.queryParameters['from'] == 'onboarding',
385387
),
386388
),
387389
GoRoute(
388390
path: '/privacy',
389-
builder: (_, state) => kAppFormFactor == AppFormFactor.mobile
390-
? const MobileLegalScreen(title: 'Privacy Policy')
391+
builder: (context, state) => kAppFormFactor == AppFormFactor.mobile
392+
? MobileLegalScreen(
393+
title: AppLocalizations.of(context).aboutPrivacyPolicy,
394+
)
391395
: PrivacyPolicyScreen(
392396
forceFullPane: state.uri.queryParameters['from'] == 'onboarding',
393397
),
@@ -862,6 +866,7 @@ class ZcashWalletApp extends ConsumerWidget {
862866
final appRouter = ref.watch(_routerProvider);
863867
final router = appRouter.router;
864868
final themeMode = ref.watch(themeModeProvider);
869+
final locale = ref.watch(localeProvider);
865870

866871
return MaterialApp.router(
867872
title: 'Vizor',
@@ -874,6 +879,14 @@ class ZcashWalletApp extends ConsumerWidget {
874879
routerDelegate: router.routerDelegate,
875880
backButtonDispatcher: appRouter.backButtonDispatcher,
876881
onNavigationNotification: appRouter.onNavigationNotification,
882+
// No stored preference (null) follows the OS locale, clamped to
883+
// supportedLocales with an English fallback — the whole app is
884+
// translated, so Korean-system users get Korean out of the box
885+
// (Keplr parity). A saved Language choice always wins. The
886+
// kLanguageFeatureEnabled kill switch pins English outright.
887+
locale: kLanguageFeatureEnabled ? locale : kEnglishLocale,
888+
supportedLocales: AppLocalizations.supportedLocales,
889+
localizationsDelegates: AppLocalizations.localizationsDelegates,
877890
builder: (context, child) {
878891
return AppThemeHost(
879892
themeMode: themeMode,
@@ -1110,7 +1123,8 @@ class _WindowsUpdatePrompt extends StatelessWidget {
11101123
Widget build(BuildContext context) {
11111124
final colors = context.colors;
11121125
final isDark = AppTheme.of(context) == AppThemeData.dark;
1113-
final action = _primaryAction();
1126+
final l10n = AppLocalizations.of(context);
1127+
final action = _primaryAction(l10n);
11141128

11151129
return DefaultTextStyle.merge(
11161130
style: const TextStyle(decoration: TextDecoration.none),
@@ -1149,7 +1163,7 @@ class _WindowsUpdatePrompt extends StatelessWidget {
11491163
crossAxisAlignment: CrossAxisAlignment.start,
11501164
children: [
11511165
Text(
1152-
_title(),
1166+
_title(l10n),
11531167
maxLines: 1,
11541168
overflow: TextOverflow.ellipsis,
11551169
style: AppTypography.labelLarge.copyWith(
@@ -1159,7 +1173,7 @@ class _WindowsUpdatePrompt extends StatelessWidget {
11591173
),
11601174
const SizedBox(height: 2),
11611175
Text(
1162-
_message(),
1176+
_message(l10n),
11631177
maxLines: 2,
11641178
overflow: TextOverflow.ellipsis,
11651179
style: AppTypography.bodySmall.copyWith(
@@ -1186,7 +1200,7 @@ class _WindowsUpdatePrompt extends StatelessWidget {
11861200
onPressed: onLater,
11871201
variant: AppButtonVariant.ghost,
11881202
size: AppButtonSize.small,
1189-
child: const Text('Later'),
1203+
child: Text(l10n.updateActionLater),
11901204
),
11911205
const SizedBox(width: AppSpacing.xxs),
11921206
],
@@ -1206,24 +1220,26 @@ class _WindowsUpdatePrompt extends StatelessWidget {
12061220
);
12071221
}
12081222

1209-
String _title() {
1223+
String _title(AppLocalizations l10n) {
12101224
return switch (state.status) {
1211-
WindowsUpdateStatus.available =>
1212-
'Update ${state.availableVersion} available',
1213-
WindowsUpdateStatus.downloading => 'Downloading update',
1214-
WindowsUpdateStatus.ready => 'Update ready',
1215-
WindowsUpdateStatus.applying => 'Restarting Vizor',
1216-
_ => 'Update available',
1225+
WindowsUpdateStatus.available => l10n.updateTitleAvailableVersion(
1226+
state.availableVersion,
1227+
),
1228+
WindowsUpdateStatus.downloading => l10n.updateTitleDownloading,
1229+
WindowsUpdateStatus.ready => l10n.updateTitleReady,
1230+
WindowsUpdateStatus.applying => l10n.updateTitleApplying,
1231+
_ => l10n.updateTitleAvailable,
12171232
};
12181233
}
12191234

1220-
String _message() {
1235+
String _message(AppLocalizations l10n) {
12211236
return switch (state.status) {
1222-
WindowsUpdateStatus.available => 'Download now or keep working.',
1223-
WindowsUpdateStatus.downloading =>
1224-
'${state.downloadProgress}% downloaded.',
1225-
WindowsUpdateStatus.ready => 'Restart when you are ready.',
1226-
WindowsUpdateStatus.applying => 'Applying after Vizor closes.',
1237+
WindowsUpdateStatus.available => l10n.updateBodyAvailable,
1238+
WindowsUpdateStatus.downloading => l10n.updateBodyDownloading(
1239+
state.downloadProgress,
1240+
),
1241+
WindowsUpdateStatus.ready => l10n.updateBodyReady,
1242+
WindowsUpdateStatus.applying => l10n.updateBodyApplying,
12271243
_ => '',
12281244
};
12291245
}
@@ -1233,23 +1249,23 @@ class _WindowsUpdatePrompt extends StatelessWidget {
12331249
state.status == WindowsUpdateStatus.ready;
12341250
}
12351251

1236-
_WindowsUpdatePromptAction _primaryAction() {
1252+
_WindowsUpdatePromptAction _primaryAction(AppLocalizations l10n) {
12371253
return switch (state.status) {
12381254
WindowsUpdateStatus.available => _WindowsUpdatePromptAction(
1239-
label: 'Download',
1255+
label: l10n.updateActionDownload,
12401256
onPressed: onDownload,
12411257
),
12421258
WindowsUpdateStatus.ready => _WindowsUpdatePromptAction(
1243-
label: 'Restart',
1259+
label: l10n.updateActionRestart,
12441260
onPressed: onRestart,
12451261
),
1246-
WindowsUpdateStatus.downloading => const _WindowsUpdatePromptAction(
1247-
label: 'Downloading',
1262+
WindowsUpdateStatus.downloading => _WindowsUpdatePromptAction(
1263+
label: l10n.updateActionDownloading,
12481264
),
1249-
WindowsUpdateStatus.applying => const _WindowsUpdatePromptAction(
1250-
label: 'Restarting',
1265+
WindowsUpdateStatus.applying => _WindowsUpdatePromptAction(
1266+
label: l10n.updateActionRestarting,
12511267
),
1252-
_ => const _WindowsUpdatePromptAction(label: 'Update'),
1268+
_ => _WindowsUpdatePromptAction(label: l10n.updateActionUpdate),
12531269
};
12541270
}
12551271
}
@@ -1338,10 +1354,14 @@ class _LinuxUpdateNoticeListener extends ConsumerWidget {
13381354
messenger.hideCurrentSnackBar();
13391355
messenger.showSnackBar(
13401356
SnackBar(
1341-
content: Text('Vizor ${update.assetVersion} is available.'),
1357+
content: Text(
1358+
AppLocalizations.of(
1359+
context,
1360+
).updateLinuxAvailable(update.assetVersion),
1361+
),
13421362
duration: const Duration(seconds: 8),
13431363
action: SnackBarAction(
1344-
label: 'View Release',
1364+
label: AppLocalizations.of(context).updateViewRelease,
13451365
onPressed: () => unawaited(_openLinuxUpdateRelease(update)),
13461366
),
13471367
),
@@ -1401,7 +1421,12 @@ class _RpcEndpointFailoverToastBridge extends ConsumerWidget {
14011421
if (!context.mounted) return;
14021422
showNetworkFallbackToast(
14031423
context,
1404-
next.message,
1424+
switch (next.kind) {
1425+
RpcEndpointFailoverEventKind.switchedToFallback =>
1426+
AppLocalizations.of(context).endpointFailoverSwitched,
1427+
RpcEndpointFailoverEventKind.switchedToPrimary =>
1428+
AppLocalizations.of(context).endpointFailoverRecovered,
1429+
},
14051430
duration: const Duration(seconds: 4),
14061431
);
14071432
});

0 commit comments

Comments
 (0)