diff --git a/flutter_app/lib/app.dart b/flutter_app/lib/app.dart index 5732c6a..7baa089 100644 --- a/flutter_app/lib/app.dart +++ b/flutter_app/lib/app.dart @@ -1,6 +1,9 @@ import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:provider/provider.dart'; +import 'l10n/app_localizations.dart'; +import 'providers/locale_provider.dart'; import 'providers/setup_provider.dart'; import 'providers/gateway_provider.dart'; import 'providers/node_provider.dart'; @@ -41,6 +44,7 @@ class OpenClawApp extends StatelessWidget { Widget build(BuildContext context) { return MultiProvider( providers: [ + ChangeNotifierProvider(create: (_) => LocaleProvider()..load()), ChangeNotifierProvider(create: (_) => SetupProvider()), ChangeNotifierProvider(create: (_) => GatewayProvider()), ChangeNotifierProxyProvider( @@ -51,13 +55,66 @@ class OpenClawApp extends StatelessWidget { }, ), ], - child: MaterialApp( - title: 'OpenClaw', - debugShowCheckedModeBanner: false, - theme: _buildLightTheme(), - darkTheme: _buildDarkTheme(), - themeMode: ThemeMode.system, - home: const SplashScreen(), + child: Consumer( + builder: (context, localeProvider, _) => MaterialApp( + debugShowCheckedModeBanner: false, + onGenerateTitle: (context) => context.l10n.t('appName'), + locale: localeProvider.locale, + localeListResolutionCallback: (deviceLocales, supportedLocales) { + if (localeProvider.locale != null) { + return localeProvider.locale; + } + + for (final deviceLocale in deviceLocales ?? const []) { + if (deviceLocale.languageCode == 'zh' && + deviceLocale.scriptCode == null) { + final country = deviceLocale.countryCode?.toUpperCase(); + if (country == 'TW' || country == 'HK' || country == 'MO') { + for (final supportedLocale in supportedLocales) { + if (supportedLocale.languageCode == 'zh' && + supportedLocale.scriptCode == 'Hant') { + return supportedLocale; + } + } + } + } + + for (final supportedLocale in supportedLocales) { + if (supportedLocale.languageCode == deviceLocale.languageCode && + supportedLocale.scriptCode == deviceLocale.scriptCode && + supportedLocale.countryCode == deviceLocale.countryCode) { + return supportedLocale; + } + } + + for (final supportedLocale in supportedLocales) { + if (supportedLocale.languageCode == deviceLocale.languageCode && + supportedLocale.scriptCode == deviceLocale.scriptCode) { + return supportedLocale; + } + } + + for (final supportedLocale in supportedLocales) { + if (supportedLocale.languageCode == deviceLocale.languageCode) { + return supportedLocale; + } + } + } + + return supportedLocales.first; + }, + supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + theme: _buildLightTheme(), + darkTheme: _buildDarkTheme(), + themeMode: ThemeMode.system, + home: const SplashScreen(), + ), ), ); } @@ -95,15 +152,7 @@ class OpenClawApp extends StatelessWidget { color: Colors.white, ), ), - cardTheme: CardTheme( - elevation: 0, - color: AppColors.darkSurface, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - side: const BorderSide(color: AppColors.darkBorder), - ), - margin: const EdgeInsets.symmetric(vertical: 4), - ), + cardColor: AppColors.darkSurface, filledButtonTheme: FilledButtonThemeData( style: FilledButton.styleFrom( backgroundColor: AppColors.accent, @@ -165,13 +214,7 @@ class OpenClawApp extends StatelessWidget { color: AppColors.darkBorder, space: 1, ), - dialogTheme: DialogTheme( - backgroundColor: AppColors.darkSurface, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - side: const BorderSide(color: AppColors.darkBorder), - ), - ), + dialogBackgroundColor: AppColors.darkSurface, snackBarTheme: SnackBarThemeData( backgroundColor: AppColors.darkSurfaceAlt, contentTextStyle: GoogleFonts.inter(color: Colors.white), @@ -226,15 +269,7 @@ class OpenClawApp extends StatelessWidget { color: const Color(0xFF0A0A0A), ), ), - cardTheme: CardTheme( - elevation: 0, - color: AppColors.lightBg, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - side: const BorderSide(color: AppColors.lightBorder), - ), - margin: const EdgeInsets.symmetric(vertical: 4), - ), + cardColor: AppColors.lightBg, filledButtonTheme: FilledButtonThemeData( style: FilledButton.styleFrom( backgroundColor: AppColors.accent, @@ -296,13 +331,7 @@ class OpenClawApp extends StatelessWidget { color: AppColors.lightBorder, space: 1, ), - dialogTheme: DialogTheme( - backgroundColor: AppColors.lightBg, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - side: const BorderSide(color: AppColors.lightBorder), - ), - ), + dialogBackgroundColor: AppColors.lightBg, snackBarTheme: SnackBarThemeData( backgroundColor: const Color(0xFF0A0A0A), contentTextStyle: GoogleFonts.inter(color: Colors.white), diff --git a/flutter_app/lib/l10n/app_localizations.dart b/flutter_app/lib/l10n/app_localizations.dart new file mode 100644 index 0000000..058d41b --- /dev/null +++ b/flutter_app/lib/l10n/app_localizations.dart @@ -0,0 +1,97 @@ +import 'package:flutter/material.dart'; + +import 'app_strings_en.dart'; +import 'app_strings_ja.dart'; +import 'app_strings_zh_hans.dart'; +import 'app_strings_zh_hant.dart'; + +class AppLocalizations { + AppLocalizations(this.locale); + + final Locale locale; + + static const supportedLocales = [ + Locale('en'), + Locale('zh'), + Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant'), + Locale('ja'), + ]; + + static const LocalizationsDelegate delegate = + _AppLocalizationsDelegate(); + + static AppLocalizations of(BuildContext context) { + final localizations = Localizations.of( + context, + AppLocalizations, + ); + assert(localizations != null, 'AppLocalizations not found in context'); + return localizations!; + } + + String t(String key, [Map params = const {}]) { + final localeKey = _localeToKey(locale); + final localized = _localizedValues[localeKey] ?? + _localizedValues[locale.languageCode] ?? + _localizedValues['en']!; + final fallback = _localizedValues['en']!; + + var value = localized[key] ?? fallback[key] ?? key; + for (final entry in params.entries) { + value = value.replaceAll('{${entry.key}}', '${entry.value ?? ''}'); + } + return value; + } + + static bool isLocaleSupported(Locale locale) { + final localeKey = _localeToKey(locale); + if (_localizedValues.containsKey(localeKey)) { + return true; + } + return _localizedValues.containsKey(locale.languageCode); + } + + static String _localeToKey(Locale locale) { + final scriptCode = locale.scriptCode?.toLowerCase(); + final countryCode = locale.countryCode?.toUpperCase(); + + if (locale.languageCode == 'zh') { + if (scriptCode == 'hant') { + return 'zh-Hant'; + } + + if (countryCode == 'TW' || countryCode == 'HK' || countryCode == 'MO') { + return 'zh-Hant'; + } + } + + return locale.languageCode; + } + + static final Map> _localizedValues = { + 'en': appStringsEn, + 'zh': appStringsZhHans, + 'zh-Hant': appStringsZhHant, + 'ja': appStringsJa, + }; +} + +class _AppLocalizationsDelegate + extends LocalizationsDelegate { + const _AppLocalizationsDelegate(); + + @override + bool isSupported(Locale locale) => AppLocalizations.isLocaleSupported(locale); + + @override + Future load(Locale locale) async { + return AppLocalizations(locale); + } + + @override + bool shouldReload(_AppLocalizationsDelegate old) => false; +} + +extension AppLocalizationsContextExtension on BuildContext { + AppLocalizations get l10n => AppLocalizations.of(this); +} diff --git a/flutter_app/lib/l10n/app_strings_en.dart b/flutter_app/lib/l10n/app_strings_en.dart new file mode 100644 index 0000000..0c2f855 --- /dev/null +++ b/flutter_app/lib/l10n/app_strings_en.dart @@ -0,0 +1,295 @@ +const Map appStringsEn = { + 'appName': 'OpenClaw', + 'language': 'Language', + 'languageSystem': 'System default', + 'languageEnglish': 'English', + 'languageChinese': 'Simplified Chinese', + 'languageTraditionalChinese': 'Traditional Chinese', + 'languageJapanese': 'Japanese', + 'commonInstalled': 'Installed', + 'commonNotInstalled': 'Not installed', + 'commonCancel': 'Cancel', + 'commonCopy': 'Copy', + 'commonCopiedToClipboard': 'Copied to clipboard', + 'commonOpen': 'Open', + 'commonPaste': 'Paste', + 'commonRetry': 'Retry', + 'commonDone': 'Done', + 'commonConfigure': 'Configure', + 'commonScreenshot': 'Screenshot', + 'commonSaveFailed': 'Failed to capture screenshot', + 'commonScreenshotSaved': 'Screenshot saved: {fileName}', + 'commonNoUrlFound': 'No URL found in selection', + 'commonOpenLink': 'Open Link', + 'commonLinkCopied': 'Link copied', + 'dashboardQuickActions': 'Quick actions', + 'dashboardTerminalTitle': 'Terminal', + 'dashboardTerminalSubtitle': 'Open Ubuntu shell with OpenClaw', + 'dashboardWebDashboardTitle': 'Web Dashboard', + 'dashboardWebDashboardSubtitle': 'Open OpenClaw dashboard in browser', + 'dashboardStartGatewayFirst': 'Start gateway first', + 'dashboardOnboardingTitle': 'Onboarding', + 'dashboardOnboardingSubtitle': 'Configure API keys and binding', + 'dashboardConfigureTitle': 'Configure', + 'dashboardConfigureSubtitle': 'Manage gateway settings', + 'dashboardProvidersTitle': 'AI Providers', + 'dashboardProvidersSubtitle': 'Configure models and API keys', + 'providersScreenTitle': 'AI Providers', + 'providersScreenActiveModel': 'Active Model', + 'providersScreenIntro': + 'Select a provider to configure its API key, endpoint, and model.', + 'providersStatusActive': 'Active', + 'providersStatusConfigured': 'Configured', + 'providerDetailApiKey': 'API Key', + 'providerDetailApiKeyEmpty': 'API key cannot be empty', + 'providerDetailEndpoint': 'API Base URL', + 'providerDetailEndpointHelper': + 'Override the default endpoint if your account uses a custom or regional API base URL.', + 'providerDetailEndpointInvalid': 'Enter a valid absolute API base URL', + 'providerDetailModel': 'Model', + 'providerDetailModelEmpty': 'Model name cannot be empty', + 'providerDetailCustomModelAction': 'Custom...', + 'providerDetailCustomModelLabel': 'Custom model name', + 'providerDetailCustomModelHint': 'e.g. meta/llama-3.3-70b-instruct', + 'providerDetailSaveAction': 'Save & Activate', + 'providerDetailSaved': '{provider} configured and activated', + 'providerDetailSaveFailed': 'Failed to save: {error}', + 'providerDetailRemoveTitle': 'Remove {provider}?', + 'providerDetailRemoveBody': + 'This will delete the API key, endpoint, and saved model for this provider.', + 'providerDetailRemoveAction': 'Remove', + 'providerDetailRemoveConfiguration': 'Remove Configuration', + 'providerDetailRemoved': '{provider} removed', + 'providerDetailRemoveFailed': 'Failed to remove: {error}', + 'providerNameAnthropic': 'Anthropic', + 'providerDescriptionAnthropic': + 'Claude models for advanced reasoning and coding', + 'providerNameOpenai': 'OpenAI', + 'providerDescriptionOpenai': 'GPT and o-series models', + 'providerNameQwen': 'Qwen', + 'providerDescriptionQwen': + 'Alibaba Cloud Qwen models via DashScope OpenAI-compatible API', + 'providerNameMinimax': 'MiniMax', + 'providerDescriptionMinimax': + 'MiniMax chat models with editable API endpoint support', + 'providerNameDoubao': 'Doubao', + 'providerDescriptionDoubao': + 'Volcengine Ark / Doubao models with official Ark endpoint presets', + 'providerNameGoogle': 'Google Gemini', + 'providerDescriptionGoogle': 'Gemini family of multimodal models', + 'providerNameOpenrouter': 'OpenRouter', + 'providerDescriptionOpenrouter': 'Unified API for hundreds of models', + 'providerNameNvidia': 'NVIDIA NIM', + 'providerDescriptionNvidia': 'GPU-optimized inference endpoints', + 'providerNameDeepseek': 'DeepSeek', + 'providerDescriptionDeepseek': 'High-performance open models', + 'providerNameXai': 'xAI', + 'providerDescriptionXai': 'Grok models from xAI', + 'dashboardPackagesTitle': 'Packages', + 'dashboardPackagesSubtitle': 'Install optional tools (Go, Homebrew, SSH)', + 'dashboardSshTitle': 'SSH Access', + 'dashboardSshSubtitle': 'Remote terminal access via SSH', + 'dashboardLogsTitle': 'Logs', + 'dashboardLogsSubtitle': 'View gateway output and errors', + 'dashboardSnapshotTitle': 'Snapshot', + 'dashboardSnapshotSubtitle': 'Backup or restore your config', + 'dashboardNodeTitle': 'Node', + 'dashboardNodeConnected': 'Connected to gateway', + 'dashboardNodeDisabled': 'Device capabilities for AI', + 'dashboardVersionLabel': 'OpenClaw v{version}', + 'dashboardAuthorLabel': 'by {author} | {org}', + 'gatewayTitle': 'Gateway', + 'gatewayCopyUrl': 'Copy URL', + 'gatewayUrlCopied': 'URL copied to clipboard', + 'gatewayOpenDashboard': 'Open dashboard', + 'gatewayStart': 'Start Gateway', + 'gatewayStop': 'Stop Gateway', + 'gatewayViewLogs': 'View Logs', + 'gatewayStatusRunning': 'Running', + 'gatewayStatusStarting': 'Starting', + 'gatewayStatusError': 'Error', + 'gatewayStatusStopped': 'Stopped', + 'logsTitle': 'Gateway Logs', + 'logsAutoScrollOn': 'Auto-scroll on', + 'logsAutoScrollOff': 'Auto-scroll off', + 'logsCopyAll': 'Copy all logs', + 'logsFilterHint': 'Filter logs...', + 'logsEmpty': 'No logs yet. Start the gateway.', + 'logsNoMatch': 'No matching logs.', + 'logsCopied': 'Logs copied to clipboard', + 'packagesTitle': 'Optional Packages', + 'packagesDescription': + 'Development tools you can install inside the Ubuntu environment.', + 'packagesInstall': 'Install', + 'packagesUninstall': 'Uninstall', + 'packagesUninstallTitle': 'Uninstall {name}?', + 'packagesUninstallDescription': + 'This will remove {name} from the environment.', + 'packageGoDescription': 'Go programming language compiler and tools', + 'packageBrewDescription': 'The missing package manager for Linux', + 'packageSshDescription': 'SSH client and server for secure remote access', + 'setupWizardTitle': 'Setup OpenClaw', + 'setupWizardIntroIdle': + 'This will download Ubuntu, Node.js, and OpenClaw into a self-contained environment.', + 'setupWizardIntroRunning': + 'Setting up the environment. This may take several minutes.', + 'setupWizardConfigureApiKeys': 'Configure API Keys', + 'setupWizardRetry': 'Retry Setup', + 'setupWizardBegin': 'Begin Setup', + 'setupWizardRequirements': + 'Requires ~500MB of storage and an internet connection', + 'setupWizardStorageDialogTitle': 'Grant file access before setup', + 'setupWizardStorageDialogBody': + 'OpenClaw setup needs file management access before installation starts, so the Ubuntu environment can mount shared storage correctly inside proot. The Android permission page will open next.', + 'setupWizardStoragePermissionRequired': + 'File management access is required before setup can start.', + 'setupWizardOptionalPackages': 'OPTIONAL PACKAGES', + 'setupWizardStepDownloadRootfs': 'Download Ubuntu rootfs', + 'setupWizardStepExtractRootfs': 'Extract rootfs', + 'setupWizardStepInstallNode': 'Install Node.js', + 'setupWizardStepInstallOpenClaw': 'Install OpenClaw', + 'setupWizardStepConfigureBypass': 'Configure Bionic Bypass', + 'setupWizardComplete': 'Setup complete!', + 'setupWizardStatusSetupComplete': 'Setup complete', + 'setupWizardStatusSetupRequired': 'Setup required', + 'setupWizardStatusSettingUpDirs': 'Setting up directories...', + 'setupWizardStatusDownloadingUbuntuRootfs': 'Downloading Ubuntu rootfs...', + 'setupWizardStatusDownloadingProgress': + 'Downloading: {current} MB / {total} MB', + 'setupWizardStatusExtractingRootfs': + 'Extracting rootfs (this takes a while)...', + 'setupWizardStatusRootfsExtracted': 'Rootfs extracted', + 'setupWizardStatusFixingPermissions': 'Fixing rootfs permissions...', + 'setupWizardStatusUpdatingPackageLists': 'Updating package lists...', + 'setupWizardStatusInstallingBasePackages': 'Installing base packages...', + 'setupWizardStatusDownloadingNode': 'Downloading Node.js {version}...', + 'setupWizardStatusDownloadingNodeProgress': + 'Downloading Node.js: {current} MB / {total} MB', + 'setupWizardStatusExtractingNode': 'Extracting Node.js...', + 'setupWizardStatusVerifyingNode': 'Verifying Node.js...', + 'setupWizardStatusNodeInstalled': 'Node.js installed', + 'setupWizardStatusInstallingOpenClaw': + 'Installing OpenClaw (this may take a few minutes)...', + 'setupWizardStatusCreatingBinWrappers': 'Creating bin wrappers...', + 'setupWizardStatusVerifyingOpenClaw': 'Verifying OpenClaw...', + 'setupWizardStatusOpenClawInstalled': 'OpenClaw installed', + 'setupWizardStatusBypassConfigured': 'Bionic Bypass configured', + 'setupWizardStatusReady': 'Setup complete! Ready to start the gateway.', + 'onboardingTitle': 'OpenClaw Onboarding', + 'onboardingStarting': 'Starting onboarding...', + 'onboardingGoToDashboard': 'Go to Dashboard', + 'onboardingStartFailed': 'Failed to start onboarding: {error}', + 'configureTitle': 'OpenClaw Configure', + 'configureStarting': 'Starting configure...', + 'configureStartFailed': 'Failed to start configure: {error}', + 'nodeTitle': 'Node', + 'nodeConfigurationTitle': 'Node Configuration', + 'nodeGatewayConnection': 'Gateway connection', + 'nodeLocalGateway': 'Local Gateway', + 'nodeLocalGatewaySubtitle': 'Auto-pair with gateway on this device', + 'nodeRemoteGateway': 'Remote Gateway', + 'nodeRemoteGatewaySubtitle': 'Connect to a gateway on another device', + 'nodeGatewayHost': 'Gateway Host', + 'nodeGatewayPort': 'Gateway Port', + 'nodeGatewayToken': 'Gateway Token', + 'nodeGatewayTokenHint': 'Paste token from gateway dashboard URL', + 'nodeGatewayTokenHelper': 'Found in dashboard URL after #token=', + 'nodeConnect': 'Connect', + 'nodePairing': 'Pairing', + 'nodeApproveCode': 'Approve this code on the gateway:', + 'nodeCapabilities': 'Capabilities', + 'nodeCapabilityCameraTitle': 'Camera', + 'nodeCapabilityCameraSubtitle': 'Capture photos and video clips', + 'nodeCapabilityCanvasTitle': 'Canvas', + 'nodeCapabilityCanvasSubtitle': 'Navigate and interact with web pages', + 'nodeCapabilityLocationTitle': 'Location', + 'nodeCapabilityLocationSubtitle': 'Get device GPS coordinates', + 'nodeCapabilityScreenTitle': 'Screen Recording', + 'nodeCapabilityScreenSubtitle': + 'Record device screen (requires consent each time)', + 'nodeCapabilityFlashlightTitle': 'Flashlight', + 'nodeCapabilityFlashlightSubtitle': 'Toggle device torch on/off', + 'nodeCapabilityVibrationTitle': 'Vibration', + 'nodeCapabilityVibrationSubtitle': + 'Trigger haptic feedback and vibration patterns', + 'nodeCapabilitySensorsTitle': 'Sensors', + 'nodeCapabilitySensorsSubtitle': + 'Read accelerometer, gyroscope, magnetometer, barometer', + 'nodeCapabilitySerialTitle': 'Serial', + 'nodeCapabilitySerialSubtitle': 'Bluetooth and USB serial communication', + 'nodeDeviceInfo': 'Device info', + 'nodeDeviceId': 'Device ID', + 'nodeLogs': 'Node logs', + 'nodeNoLogs': 'No logs yet', + 'nodeConnectedTo': 'Connected to {host}:{port}', + 'nodePairingCode': 'Pairing code: ', + 'nodeEnable': 'Enable Node', + 'nodeDisable': 'Disable Node', + 'nodeReconnect': 'Reconnect', + 'nodeStatusPaired': 'Paired', + 'nodeStatusConnecting': 'Connecting', + 'nodeStatusError': 'Error', + 'nodeStatusDisabled': 'Disabled', + 'nodeStatusDisconnected': 'Disconnected', + 'settingsTitle': 'Settings', + 'settingsGeneral': 'General', + 'settingsAutoStart': 'Auto-start gateway', + 'settingsAutoStartSubtitle': 'Start the gateway when the app opens', + 'settingsBatteryOptimization': 'Battery Optimization', + 'settingsBatteryOptimized': 'Optimized (may kill background sessions)', + 'settingsBatteryUnrestricted': 'Unrestricted (recommended)', + 'settingsStorage': 'Setup Storage', + 'settingsStorageGranted': 'Granted — /sdcard accessible in proot', + 'settingsStorageMissing': 'Allow access to shared storage', + 'settingsStorageDialogTitle': 'Grant file access', + 'settingsStorageDialogBody': + 'OpenClaw needs file management access to read and write snapshot files in shared storage. You will be taken to the system settings page next.', + 'settingsStorageDialogAction': 'Continue', + 'onboardingStorageDialogTitle': 'Grant file access for onboarding', + 'onboardingStorageDialogBody': + 'OpenClaw onboarding needs file management access so Ubuntu can mount shared storage inside proot while you configure API keys and binding. The Android permission page will open next.', + 'onboardingStoragePermissionRequired': + 'File management access is required before onboarding can continue.', + 'configureStorageDialogTitle': 'Grant file access for configuration', + 'configureStorageDialogBody': + 'OpenClaw configuration needs file management access so Ubuntu can mount shared storage inside proot while you manage gateway settings. The Android permission page will open next.', + 'configureStoragePermissionRequired': + 'File management access is required before configuration can continue.', + 'settingsNode': 'Node', + 'settingsEnableNode': 'Enable Node', + 'settingsEnableNodeSubtitle': 'Provide device capabilities to the gateway', + 'settingsNodeConfiguration': 'Node Configuration', + 'settingsNodeConfigurationSubtitle': 'Connection, pairing, and capabilities', + 'settingsSystemInfo': 'System info', + 'settingsArchitecture': 'Architecture', + 'settingsProotPath': 'PRoot path', + 'settingsRootfs': 'Rootfs', + 'settingsNodeJs': 'Node.js', + 'settingsOpenClaw': 'OpenClaw', + 'settingsGo': 'Go (Golang)', + 'settingsHomebrew': 'Homebrew', + 'settingsOpenSsh': 'OpenSSH', + 'settingsMaintenance': 'Maintenance', + 'settingsExportSnapshot': 'Export Snapshot', + 'settingsExportSnapshotSubtitle': 'Backup config to Downloads', + 'settingsImportSnapshot': 'Import Snapshot', + 'settingsImportSnapshotSubtitle': 'Restore config from backup', + 'settingsRerunSetup': 'Re-run setup', + 'settingsRerunSetupSubtitle': 'Reinstall or repair the environment', + 'settingsAbout': 'About', + 'settingsAboutSubtitle': 'AI Gateway for Android\nVersion {version}', + 'settingsDeveloper': 'Developer', + 'settingsGithub': 'GitHub', + 'settingsContact': 'Contact', + 'settingsLicense': 'License', + 'settingsPlayStore': 'Play Store', + 'settingsEmail': 'Email', + 'settingsSnapshotSaved': 'Snapshot saved to {path}', + 'settingsExportFailed': 'Export failed: {error}', + 'settingsSnapshotMissing': 'No snapshot found at {path}', + 'settingsSnapshotRestored': + 'Snapshot restored successfully. Restart the gateway to apply.', + 'settingsImportFailed': 'Import failed: {error}', + 'statusInstalled': 'Installed', + 'statusNotInstalled': 'Not installed', +}; diff --git a/flutter_app/lib/l10n/app_strings_ja.dart b/flutter_app/lib/l10n/app_strings_ja.dart new file mode 100644 index 0000000..7523f84 --- /dev/null +++ b/flutter_app/lib/l10n/app_strings_ja.dart @@ -0,0 +1,276 @@ +const Map appStringsJa = { + 'appName': 'OpenClaw', + 'language': '言語', + 'languageSystem': 'システム既定', + 'languageEnglish': '英語', + 'languageChinese': '簡体字中国語', + 'languageTraditionalChinese': '繁体字中国語', + 'languageJapanese': '日本語', + 'commonInstalled': 'インストール済み', + 'commonNotInstalled': '未インストール', + 'commonCancel': 'キャンセル', + 'commonCopy': 'コピー', + 'commonCopiedToClipboard': 'クリップボードにコピーしました', + 'commonOpen': '開く', + 'commonPaste': '貼り付け', + 'commonRetry': '再試行', + 'commonDone': '完了', + 'commonConfigure': '設定', + 'commonScreenshot': 'スクリーンショット', + 'commonSaveFailed': 'スクリーンショットの保存に失敗しました', + 'commonScreenshotSaved': 'スクリーンショットを保存しました: {fileName}', + 'commonNoUrlFound': '選択範囲に URL が見つかりません', + 'commonOpenLink': 'リンクを開く', + 'commonLinkCopied': 'リンクをコピーしました', + 'dashboardQuickActions': 'クイック操作', + 'dashboardTerminalTitle': 'ターミナル', + 'dashboardTerminalSubtitle': 'Ubuntu シェルで OpenClaw を開く', + 'dashboardWebDashboardTitle': 'Web ダッシュボード', + 'dashboardWebDashboardSubtitle': 'ブラウザで OpenClaw ダッシュボードを開く', + 'dashboardStartGatewayFirst': '先にゲートウェイを起動してください', + 'dashboardOnboardingTitle': 'オンボーディング', + 'dashboardOnboardingSubtitle': 'API キーとバインドを設定', + 'dashboardConfigureTitle': '設定', + 'dashboardConfigureSubtitle': 'ゲートウェイ設定を管理', + 'dashboardProvidersTitle': 'AI プロバイダー', + 'dashboardProvidersSubtitle': 'モデルと API キーを設定', + 'providersScreenTitle': 'AI プロバイダー', + 'providersScreenActiveModel': '現在のモデル', + 'providersScreenIntro': 'プロバイダーを選択し、API キー、エンドポイント、モデルを設定してください。', + 'providersStatusActive': '有効', + 'providersStatusConfigured': '設定済み', + 'providerDetailApiKey': 'API キー', + 'providerDetailApiKeyEmpty': 'API キーは必須です', + 'providerDetailEndpoint': 'API ベース URL', + 'providerDetailEndpointHelper': + 'アカウントがカスタムまたはリージョン専用の API ベース URL を使う場合、既定のエンドポイントを上書きできます。', + 'providerDetailEndpointInvalid': '有効な絶対 API ベース URL を入力してください', + 'providerDetailModel': 'モデル', + 'providerDetailModelEmpty': 'モデル名は必須です', + 'providerDetailCustomModelAction': 'カスタム...', + 'providerDetailCustomModelLabel': 'カスタムモデル名', + 'providerDetailCustomModelHint': '例: meta/llama-3.3-70b-instruct', + 'providerDetailSaveAction': '保存して有効化', + 'providerDetailSaved': '{provider} を設定して有効化しました', + 'providerDetailSaveFailed': '保存に失敗しました: {error}', + 'providerDetailRemoveTitle': '{provider} を削除しますか?', + 'providerDetailRemoveBody': 'このプロバイダーに保存されている API キー、エンドポイント、モデルを削除します。', + 'providerDetailRemoveAction': '削除', + 'providerDetailRemoveConfiguration': '設定を削除', + 'providerDetailRemoved': '{provider} を削除しました', + 'providerDetailRemoveFailed': '削除に失敗しました: {error}', + 'providerNameAnthropic': 'Anthropic', + 'providerDescriptionAnthropic': '高度な推論とコーディング向けの Claude モデル', + 'providerNameOpenai': 'OpenAI', + 'providerDescriptionOpenai': 'GPT と o シリーズのモデル', + 'providerNameQwen': 'Qwen', + 'providerDescriptionQwen': + 'DashScope の OpenAI 互換 API 経由で利用できる Alibaba Cloud Qwen モデル', + 'providerNameMinimax': 'MiniMax', + 'providerDescriptionMinimax': 'API エンドポイントを編集可能な MiniMax チャットモデル', + 'providerNameDoubao': 'Doubao', + 'providerDescriptionDoubao': + '公式 Ark エンドポイントのプリセット付き Volcengine Ark / Doubao モデル', + 'providerNameGoogle': 'Google Gemini', + 'providerDescriptionGoogle': 'Gemini マルチモーダルモデル群', + 'providerNameOpenrouter': 'OpenRouter', + 'providerDescriptionOpenrouter': '数百のモデルを統合的に使える API', + 'providerNameNvidia': 'NVIDIA NIM', + 'providerDescriptionNvidia': 'GPU 推論に最適化されたエンドポイント', + 'providerNameDeepseek': 'DeepSeek', + 'providerDescriptionDeepseek': '高性能なオープンモデル', + 'providerNameXai': 'xAI', + 'providerDescriptionXai': 'xAI の Grok モデル', + 'dashboardPackagesTitle': 'パッケージ', + 'dashboardPackagesSubtitle': 'オプションツールをインストール(Go、Homebrew、SSH)', + 'dashboardSshTitle': 'SSH アクセス', + 'dashboardSshSubtitle': 'SSH 経由でリモートターミナルにアクセス', + 'dashboardLogsTitle': 'ログ', + 'dashboardLogsSubtitle': 'ゲートウェイ出力とエラーを表示', + 'dashboardSnapshotTitle': 'スナップショット', + 'dashboardSnapshotSubtitle': '設定をバックアップまたは復元', + 'dashboardNodeTitle': 'ノード', + 'dashboardNodeConnected': 'ゲートウェイに接続済み', + 'dashboardNodeDisabled': 'AI にデバイス機能を提供', + 'dashboardVersionLabel': 'OpenClaw v{version}', + 'dashboardAuthorLabel': '{author} | {org}', + 'gatewayTitle': 'ゲートウェイ', + 'gatewayCopyUrl': 'URL をコピー', + 'gatewayUrlCopied': 'URL をクリップボードにコピーしました', + 'gatewayOpenDashboard': 'ダッシュボードを開く', + 'gatewayStart': 'ゲートウェイを起動', + 'gatewayStop': 'ゲートウェイを停止', + 'gatewayViewLogs': 'ログを表示', + 'gatewayStatusRunning': '稼働中', + 'gatewayStatusStarting': '起動中', + 'gatewayStatusError': 'エラー', + 'gatewayStatusStopped': '停止', + 'logsTitle': 'ゲートウェイログ', + 'logsAutoScrollOn': '自動スクロール: ON', + 'logsAutoScrollOff': '自動スクロール: OFF', + 'logsCopyAll': 'ログをすべてコピー', + 'logsFilterHint': 'ログを絞り込み...', + 'logsEmpty': 'まだログがありません。ゲートウェイを起動してください。', + 'logsNoMatch': '一致するログがありません。', + 'logsCopied': 'ログをクリップボードにコピーしました', + 'packagesTitle': 'オプションパッケージ', + 'packagesDescription': 'Ubuntu 環境内にインストールできる開発ツールです。', + 'packagesInstall': 'インストール', + 'packagesUninstall': 'アンインストール', + 'packagesUninstallTitle': '{name} をアンインストールしますか?', + 'packagesUninstallDescription': 'これにより環境から {name} が削除されます。', + 'packageGoDescription': 'Go プログラミング言語のコンパイラとツール', + 'packageBrewDescription': 'Linux 向けの定番パッケージマネージャー', + 'packageSshDescription': '安全なリモートアクセス用の SSH クライアント/サーバー', + 'setupWizardTitle': 'OpenClaw セットアップ', + 'setupWizardIntroIdle': 'Ubuntu、Node.js、OpenClaw を自己完結型の環境にダウンロードします。', + 'setupWizardIntroRunning': '環境をセットアップしています。数分かかる場合があります。', + 'setupWizardConfigureApiKeys': 'API キーを設定', + 'setupWizardRetry': '再試行', + 'setupWizardBegin': 'セットアップ開始', + 'setupWizardRequirements': '約 500MB の空き容量とインターネット接続が必要です', + 'setupWizardStorageDialogTitle': 'セットアップ前にファイルアクセスを許可', + 'setupWizardStorageDialogBody': + 'OpenClaw のセットアップ開始前に、ファイル管理アクセスが必要です。これにより Ubuntu 環境が proot 内で共有ストレージを正しくマウントできます。次に Android の権限ページが開きます。', + 'setupWizardStoragePermissionRequired': 'セットアップを開始する前にファイル管理アクセスが必要です。', + 'setupWizardOptionalPackages': 'オプションパッケージ', + 'setupWizardStepDownloadRootfs': 'Ubuntu rootfs をダウンロード', + 'setupWizardStepExtractRootfs': 'rootfs を展開', + 'setupWizardStepInstallNode': 'Node.js をインストール', + 'setupWizardStepInstallOpenClaw': 'OpenClaw をインストール', + 'setupWizardStepConfigureBypass': 'Bionic Bypass を設定', + 'setupWizardComplete': 'セットアップ完了!', + 'setupWizardStatusSetupComplete': 'セットアップ完了', + 'setupWizardStatusSetupRequired': 'セットアップが必要です', + 'setupWizardStatusSettingUpDirs': 'ディレクトリを準備中...', + 'setupWizardStatusDownloadingUbuntuRootfs': 'Ubuntu rootfs をダウンロード中...', + 'setupWizardStatusDownloadingProgress': 'ダウンロード中: {current} MB / {total} MB', + 'setupWizardStatusExtractingRootfs': 'rootfs を展開中(少し時間がかかります)...', + 'setupWizardStatusRootfsExtracted': 'rootfs を展開しました', + 'setupWizardStatusFixingPermissions': 'rootfs の権限を修正中...', + 'setupWizardStatusUpdatingPackageLists': 'パッケージリストを更新中...', + 'setupWizardStatusInstallingBasePackages': '基本パッケージをインストール中...', + 'setupWizardStatusDownloadingNode': 'Node.js {version} をダウンロード中...', + 'setupWizardStatusDownloadingNodeProgress': + 'Node.js をダウンロード中: {current} MB / {total} MB', + 'setupWizardStatusExtractingNode': 'Node.js を展開中...', + 'setupWizardStatusVerifyingNode': 'Node.js を検証中...', + 'setupWizardStatusNodeInstalled': 'Node.js をインストールしました', + 'setupWizardStatusInstallingOpenClaw': 'OpenClaw をインストール中(数分かかる場合があります)...', + 'setupWizardStatusCreatingBinWrappers': 'bin ラッパーを作成中...', + 'setupWizardStatusVerifyingOpenClaw': 'OpenClaw を検証中...', + 'setupWizardStatusOpenClawInstalled': 'OpenClaw をインストールしました', + 'setupWizardStatusBypassConfigured': 'Bionic Bypass を設定しました', + 'setupWizardStatusReady': 'セットアップ完了! ゲートウェイを起動できます。', + 'onboardingTitle': 'OpenClaw オンボーディング', + 'onboardingStarting': 'オンボーディングを開始中...', + 'onboardingGoToDashboard': 'ダッシュボードへ', + 'onboardingStartFailed': 'オンボーディングの開始に失敗しました: {error}', + 'configureTitle': 'OpenClaw 設定', + 'configureStarting': '設定を開始中...', + 'configureStartFailed': '設定の開始に失敗しました: {error}', + 'nodeTitle': 'ノード', + 'nodeConfigurationTitle': 'ノード設定', + 'nodeGatewayConnection': 'ゲートウェイ接続', + 'nodeLocalGateway': 'ローカルゲートウェイ', + 'nodeLocalGatewaySubtitle': 'このデバイス上のゲートウェイと自動ペアリング', + 'nodeRemoteGateway': 'リモートゲートウェイ', + 'nodeRemoteGatewaySubtitle': '別のデバイス上のゲートウェイに接続', + 'nodeGatewayHost': 'ゲートウェイホスト', + 'nodeGatewayPort': 'ゲートウェイポート', + 'nodeGatewayToken': 'ゲートウェイトークン', + 'nodeGatewayTokenHint': 'ゲートウェイダッシュボード URL のトークンを貼り付け', + 'nodeGatewayTokenHelper': 'ダッシュボード URL の #token= の後にあります', + 'nodeConnect': '接続', + 'nodePairing': 'ペアリング', + 'nodeApproveCode': 'ゲートウェイで次のコードを承認してください:', + 'nodeCapabilities': '機能', + 'nodeCapabilityCameraTitle': 'カメラ', + 'nodeCapabilityCameraSubtitle': '写真と動画クリップを撮影', + 'nodeCapabilityCanvasTitle': 'Canvas', + 'nodeCapabilityCanvasSubtitle': 'Web ページを閲覧し操作', + 'nodeCapabilityLocationTitle': '位置情報', + 'nodeCapabilityLocationSubtitle': 'デバイスの GPS 座標を取得', + 'nodeCapabilityScreenTitle': '画面録画', + 'nodeCapabilityScreenSubtitle': 'デバイス画面を録画(毎回の同意が必要)', + 'nodeCapabilityFlashlightTitle': 'ライト', + 'nodeCapabilityFlashlightSubtitle': 'デバイスのライトをオン/オフ', + 'nodeCapabilityVibrationTitle': 'バイブレーション', + 'nodeCapabilityVibrationSubtitle': '触覚フィードバックや振動パターンを実行', + 'nodeCapabilitySensorsTitle': 'センサー', + 'nodeCapabilitySensorsSubtitle': '加速度計、ジャイロ、磁力計、気圧計を読み取り', + 'nodeCapabilitySerialTitle': 'シリアル', + 'nodeCapabilitySerialSubtitle': 'Bluetooth と USB のシリアル通信', + 'nodeDeviceInfo': 'デバイス情報', + 'nodeDeviceId': 'デバイス ID', + 'nodeLogs': 'ノードログ', + 'nodeNoLogs': 'まだログがありません', + 'nodeConnectedTo': '{host}:{port} に接続済み', + 'nodePairingCode': 'ペアリングコード: ', + 'nodeEnable': 'ノードを有効化', + 'nodeDisable': 'ノードを無効化', + 'nodeReconnect': '再接続', + 'nodeStatusPaired': 'ペアリング済み', + 'nodeStatusConnecting': '接続中', + 'nodeStatusError': 'エラー', + 'nodeStatusDisabled': '無効', + 'nodeStatusDisconnected': '未接続', + 'settingsTitle': '設定', + 'settingsGeneral': '一般', + 'settingsAutoStart': 'ゲートウェイを自動起動', + 'settingsAutoStartSubtitle': 'アプリ起動時にゲートウェイを開始', + 'settingsBatteryOptimization': 'バッテリー最適化', + 'settingsBatteryOptimized': '最適化済み(バックグラウンドセッションが終了する場合があります)', + 'settingsBatteryUnrestricted': '制限なし(推奨)', + 'settingsStorage': 'ストレージ設定', + 'settingsStorageGranted': '許可済み - proot で /sdcard にアクセス可能', + 'settingsStorageMissing': '共有ストレージへのアクセスを許可してください', + 'settingsStorageDialogTitle': 'ファイルアクセスを許可', + 'settingsStorageDialogBody': + 'OpenClaw が共有ストレージ内のスナップショットを読み書きするには、ファイル管理アクセスが必要です。次にシステム設定ページを開きます。', + 'settingsStorageDialogAction': '続行', + 'onboardingStorageDialogTitle': 'オンボーディング用にファイルアクセスを許可', + 'onboardingStorageDialogBody': + 'OpenClaw のオンボーディングでは、API キーやバインド設定中に Ubuntu が proot 内で共有ストレージをマウントできるよう、ファイル管理アクセスが必要です。次に Android の権限ページを開きます。', + 'onboardingStoragePermissionRequired': 'オンボーディングを続行する前にファイル管理アクセスが必要です。', + 'configureStorageDialogTitle': '設定用にファイルアクセスを許可', + 'configureStorageDialogBody': + 'OpenClaw の設定では、ゲートウェイ設定の管理中に Ubuntu が proot 内で共有ストレージをマウントできるよう、ファイル管理アクセスが必要です。次に Android の権限ページを開きます。', + 'configureStoragePermissionRequired': '設定を続行する前にファイル管理アクセスが必要です。', + 'settingsNode': 'ノード', + 'settingsEnableNode': 'ノードを有効化', + 'settingsEnableNodeSubtitle': 'ゲートウェイにデバイス機能を提供', + 'settingsNodeConfiguration': 'ノード設定', + 'settingsNodeConfigurationSubtitle': '接続、ペアリング、機能の設定', + 'settingsSystemInfo': 'システム情報', + 'settingsArchitecture': 'アーキテクチャ', + 'settingsProotPath': 'PRoot パス', + 'settingsRootfs': 'Rootfs', + 'settingsNodeJs': 'Node.js', + 'settingsOpenClaw': 'OpenClaw', + 'settingsGo': 'Go(Golang)', + 'settingsHomebrew': 'Homebrew', + 'settingsOpenSsh': 'OpenSSH', + 'settingsMaintenance': 'メンテナンス', + 'settingsExportSnapshot': 'スナップショットをエクスポート', + 'settingsExportSnapshotSubtitle': '設定を Downloads にバックアップ', + 'settingsImportSnapshot': 'スナップショットをインポート', + 'settingsImportSnapshotSubtitle': 'バックアップから設定を復元', + 'settingsRerunSetup': 'セットアップを再実行', + 'settingsRerunSetupSubtitle': '環境を再インストールまたは修復', + 'settingsAbout': '情報', + 'settingsAboutSubtitle': 'Android 向け AI ゲートウェイ\nバージョン {version}', + 'settingsDeveloper': '開発者', + 'settingsGithub': 'GitHub', + 'settingsContact': '連絡先', + 'settingsLicense': 'ライセンス', + 'settingsPlayStore': 'Play ストア', + 'settingsEmail': 'メール', + 'settingsSnapshotSaved': 'スナップショットを {path} に保存しました', + 'settingsExportFailed': 'エクスポートに失敗しました: {error}', + 'settingsSnapshotMissing': '{path} にスナップショットが見つかりません', + 'settingsSnapshotRestored': 'スナップショットを復元しました。反映するにはゲートウェイを再起動してください。', + 'settingsImportFailed': 'インポートに失敗しました: {error}', + 'statusInstalled': 'インストール済み', + 'statusNotInstalled': '未インストール', +}; diff --git a/flutter_app/lib/l10n/app_strings_zh_hans.dart b/flutter_app/lib/l10n/app_strings_zh_hans.dart new file mode 100644 index 0000000..1ad448a --- /dev/null +++ b/flutter_app/lib/l10n/app_strings_zh_hans.dart @@ -0,0 +1,273 @@ +const Map appStringsZhHans = { + 'appName': 'OpenClaw', + 'language': '语言', + 'languageSystem': '跟随系统', + 'languageEnglish': '英语', + 'languageChinese': '简体中文', + 'languageTraditionalChinese': '繁体中文', + 'languageJapanese': '日语', + 'commonInstalled': '已安装', + 'commonNotInstalled': '未安装', + 'commonCancel': '取消', + 'commonCopy': '复制', + 'commonCopiedToClipboard': '已复制到剪贴板', + 'commonOpen': '打开', + 'commonPaste': '粘贴', + 'commonRetry': '重试', + 'commonDone': '完成', + 'commonConfigure': '配置', + 'commonScreenshot': '截图', + 'commonSaveFailed': '保存失败', + 'commonScreenshotSaved': '截图已保存:{fileName}', + 'commonNoUrlFound': '所选内容中未找到 URL', + 'commonOpenLink': '打开链接', + 'commonLinkCopied': '链接已复制', + 'dashboardQuickActions': '快捷操作', + 'dashboardTerminalTitle': '终端', + 'dashboardTerminalSubtitle': '打开 Ubuntu Shell 并使用 OpenClaw', + 'dashboardWebDashboardTitle': 'Web 控制台', + 'dashboardWebDashboardSubtitle': '在浏览器中打开 OpenClaw 控制台', + 'dashboardStartGatewayFirst': '请先启动网关', + 'dashboardOnboardingTitle': '引导配置', + 'dashboardOnboardingSubtitle': '配置 API Key 和绑定信息', + 'dashboardConfigureTitle': '网关配置', + 'dashboardConfigureSubtitle': '管理网关设置', + 'dashboardProvidersTitle': 'AI 提供商', + 'dashboardProvidersSubtitle': '配置模型和 API Key', + 'providersScreenTitle': 'AI 提供商', + 'providersScreenActiveModel': '当前激活模型', + 'providersScreenIntro': '选择一个提供商,配置它的 API Key、端点和模型。', + 'providersStatusActive': '已激活', + 'providersStatusConfigured': '已配置', + 'providerDetailApiKey': 'API Key', + 'providerDetailApiKeyEmpty': 'API Key 不能为空', + 'providerDetailEndpoint': 'API 基础地址', + 'providerDetailEndpointHelper': '如果你的账号使用自定义或区域专属端点,可以在这里覆盖默认地址。', + 'providerDetailEndpointInvalid': '请输入有效的绝对 API 地址', + 'providerDetailModel': '模型', + 'providerDetailModelEmpty': '模型名称不能为空', + 'providerDetailCustomModelAction': '自定义...', + 'providerDetailCustomModelLabel': '自定义模型名', + 'providerDetailCustomModelHint': '例如:meta/llama-3.3-70b-instruct', + 'providerDetailSaveAction': '保存并激活', + 'providerDetailSaved': '已配置并激活 {provider}', + 'providerDetailSaveFailed': '保存失败:{error}', + 'providerDetailRemoveTitle': '移除 {provider}?', + 'providerDetailRemoveBody': '这会删除该提供商保存的 API Key、端点和模型。', + 'providerDetailRemoveAction': '移除', + 'providerDetailRemoveConfiguration': '移除配置', + 'providerDetailRemoved': '已移除 {provider}', + 'providerDetailRemoveFailed': '移除失败:{error}', + 'providerNameAnthropic': 'Anthropic', + 'providerDescriptionAnthropic': 'Claude 系列模型,适合复杂推理与编程', + 'providerNameOpenai': 'OpenAI', + 'providerDescriptionOpenai': 'GPT 与 o 系列模型', + 'providerNameQwen': '通义千问', + 'providerDescriptionQwen': '通过 DashScope OpenAI 兼容接口接入千问模型', + 'providerNameMinimax': 'MiniMax', + 'providerDescriptionMinimax': 'MiniMax 对话模型,支持自定义 API 端点', + 'providerNameDoubao': '豆包', + 'providerDescriptionDoubao': '火山方舟 / 豆包模型,内置官方 Ark 端点预设', + 'providerNameGoogle': 'Google Gemini', + 'providerDescriptionGoogle': 'Gemini 多模态模型家族', + 'providerNameOpenrouter': 'OpenRouter', + 'providerDescriptionOpenrouter': '统一接入数百种模型的 API', + 'providerNameNvidia': 'NVIDIA NIM', + 'providerDescriptionNvidia': '面向 GPU 推理的优化端点', + 'providerNameDeepseek': 'DeepSeek', + 'providerDescriptionDeepseek': '高性能开源模型服务', + 'providerNameXai': 'xAI', + 'providerDescriptionXai': 'xAI 的 Grok 系列模型', + 'dashboardPackagesTitle': '可选组件', + 'dashboardPackagesSubtitle': '安装可选工具(Go、Homebrew、SSH)', + 'dashboardSshTitle': 'SSH 访问', + 'dashboardSshSubtitle': '通过 SSH 远程访问终端', + 'dashboardLogsTitle': '日志', + 'dashboardLogsSubtitle': '查看网关输出和错误', + 'dashboardSnapshotTitle': '快照', + 'dashboardSnapshotSubtitle': '备份或恢复你的配置', + 'dashboardNodeTitle': '节点', + 'dashboardNodeConnected': '已连接到网关', + 'dashboardNodeDisabled': '为 AI 提供设备能力', + 'dashboardVersionLabel': 'OpenClaw v{version}', + 'dashboardAuthorLabel': '作者 {author} | {org}', + 'gatewayTitle': '网关', + 'gatewayCopyUrl': '复制 URL', + 'gatewayUrlCopied': 'URL 已复制到剪贴板', + 'gatewayOpenDashboard': '打开控制台', + 'gatewayStart': '启动网关', + 'gatewayStop': '停止网关', + 'gatewayViewLogs': '查看日志', + 'gatewayStatusRunning': '运行中', + 'gatewayStatusStarting': '启动中', + 'gatewayStatusError': '错误', + 'gatewayStatusStopped': '已停止', + 'logsTitle': '网关日志', + 'logsAutoScrollOn': '自动滚动已开启', + 'logsAutoScrollOff': '自动滚动已关闭', + 'logsCopyAll': '复制全部日志', + 'logsFilterHint': '筛选日志...', + 'logsEmpty': '还没有日志。请先启动网关。', + 'logsNoMatch': '没有匹配的日志。', + 'logsCopied': '日志已复制到剪贴板', + 'packagesTitle': '可选组件', + 'packagesDescription': '可在 Ubuntu 环境内安装的开发工具。', + 'packagesInstall': '安装', + 'packagesUninstall': '卸载', + 'packagesUninstallTitle': '卸载 {name}?', + 'packagesUninstallDescription': '这会将 {name} 从环境中移除。', + 'packageGoDescription': 'Go 编程语言编译器和工具链', + 'packageBrewDescription': 'Linux 上常用的包管理器', + 'packageSshDescription': '用于安全远程访问的 SSH 客户端和服务端', + 'setupWizardTitle': '开始配置 OpenClaw', + 'setupWizardIntroIdle': '这会将 Ubuntu、Node.js 和 OpenClaw 下载到一个自包含环境中。', + 'setupWizardIntroRunning': '正在配置环境,可能需要几分钟。', + 'setupWizardConfigureApiKeys': '配置 API Key', + 'setupWizardRetry': '重新安装', + 'setupWizardBegin': '开始安装', + 'setupWizardRequirements': '需要约 500MB 存储空间和网络连接', + 'setupWizardStorageDialogTitle': '安装前授予文件访问权限', + 'setupWizardStorageDialogBody': + 'OpenClaw 在开始安装前需要文件管理权限,这样 Ubuntu 环境才能在 proot 中正确挂载共享存储。接下来会打开 Android 授权页面。', + 'setupWizardStoragePermissionRequired': '开始安装前,必须先授予文件管理权限。', + 'setupWizardOptionalPackages': '可选组件', + 'setupWizardStepDownloadRootfs': '下载 Ubuntu rootfs', + 'setupWizardStepExtractRootfs': '解压 rootfs', + 'setupWizardStepInstallNode': '安装 Node.js', + 'setupWizardStepInstallOpenClaw': '安装 OpenClaw', + 'setupWizardStepConfigureBypass': '配置 Bionic Bypass', + 'setupWizardComplete': '安装完成!', + 'setupWizardStatusSetupComplete': '安装完成', + 'setupWizardStatusSetupRequired': '需要安装环境', + 'setupWizardStatusSettingUpDirs': '正在准备目录...', + 'setupWizardStatusDownloadingUbuntuRootfs': '正在下载 Ubuntu rootfs...', + 'setupWizardStatusDownloadingProgress': '正在下载:{current} MB / {total} MB', + 'setupWizardStatusExtractingRootfs': '正在解压 rootfs(这会花一点时间)...', + 'setupWizardStatusRootfsExtracted': 'rootfs 已解压', + 'setupWizardStatusFixingPermissions': '正在修复 rootfs 权限...', + 'setupWizardStatusUpdatingPackageLists': '正在更新软件包列表...', + 'setupWizardStatusInstallingBasePackages': '正在安装基础软件包...', + 'setupWizardStatusDownloadingNode': '正在下载 Node.js {version}...', + 'setupWizardStatusDownloadingNodeProgress': + '正在下载 Node.js:{current} MB / {total} MB', + 'setupWizardStatusExtractingNode': '正在解压 Node.js...', + 'setupWizardStatusVerifyingNode': '正在验证 Node.js...', + 'setupWizardStatusNodeInstalled': 'Node.js 已安装', + 'setupWizardStatusInstallingOpenClaw': '正在安装 OpenClaw(这可能需要几分钟)...', + 'setupWizardStatusCreatingBinWrappers': '正在创建命令包装器...', + 'setupWizardStatusVerifyingOpenClaw': '正在验证 OpenClaw...', + 'setupWizardStatusOpenClawInstalled': 'OpenClaw 已安装', + 'setupWizardStatusBypassConfigured': 'Bionic Bypass 已配置', + 'setupWizardStatusReady': '安装完成,可以开始启动网关了。', + 'onboardingTitle': 'OpenClaw 引导配置', + 'onboardingStarting': '正在启动引导配置...', + 'onboardingGoToDashboard': '前往控制台', + 'onboardingStartFailed': '启动引导配置失败:{error}', + 'configureTitle': 'OpenClaw 配置', + 'configureStarting': '正在启动配置...', + 'configureStartFailed': '启动配置失败:{error}', + 'nodeTitle': '节点', + 'nodeConfigurationTitle': '节点配置', + 'nodeGatewayConnection': '网关连接', + 'nodeLocalGateway': '本地网关', + 'nodeLocalGatewaySubtitle': '自动配对本机上的网关', + 'nodeRemoteGateway': '远程网关', + 'nodeRemoteGatewaySubtitle': '连接到其他设备上的网关', + 'nodeGatewayHost': '网关主机', + 'nodeGatewayPort': '网关端口', + 'nodeGatewayToken': '网关令牌', + 'nodeGatewayTokenHint': '粘贴控制台 URL 中的令牌', + 'nodeGatewayTokenHelper': '可在控制台 URL 的 #token= 后找到', + 'nodeConnect': '连接', + 'nodePairing': '配对', + 'nodeApproveCode': '请在网关端确认此代码:', + 'nodeCapabilities': '能力', + 'nodeCapabilityCameraTitle': '相机', + 'nodeCapabilityCameraSubtitle': '拍摄照片和视频片段', + 'nodeCapabilityCanvasTitle': 'Canvas', + 'nodeCapabilityCanvasSubtitle': '浏览并交互网页', + 'nodeCapabilityLocationTitle': '定位', + 'nodeCapabilityLocationSubtitle': '获取设备 GPS 坐标', + 'nodeCapabilityScreenTitle': '录屏', + 'nodeCapabilityScreenSubtitle': '录制设备屏幕(每次都需授权)', + 'nodeCapabilityFlashlightTitle': '手电筒', + 'nodeCapabilityFlashlightSubtitle': '切换设备闪光灯开关', + 'nodeCapabilityVibrationTitle': '震动', + 'nodeCapabilityVibrationSubtitle': '触发触觉反馈和震动模式', + 'nodeCapabilitySensorsTitle': '传感器', + 'nodeCapabilitySensorsSubtitle': '读取加速度计、陀螺仪、磁力计、气压计', + 'nodeCapabilitySerialTitle': '串口', + 'nodeCapabilitySerialSubtitle': '蓝牙和 USB 串口通信', + 'nodeDeviceInfo': '设备信息', + 'nodeDeviceId': '设备 ID', + 'nodeLogs': '节点日志', + 'nodeNoLogs': '还没有日志', + 'nodeConnectedTo': '已连接到 {host}:{port}', + 'nodePairingCode': '配对码:', + 'nodeEnable': '启用节点', + 'nodeDisable': '禁用节点', + 'nodeReconnect': '重新连接', + 'nodeStatusPaired': '已配对', + 'nodeStatusConnecting': '连接中', + 'nodeStatusError': '错误', + 'nodeStatusDisabled': '已禁用', + 'nodeStatusDisconnected': '未连接', + 'settingsTitle': '设置', + 'settingsGeneral': '常规', + 'settingsAutoStart': '自动启动网关', + 'settingsAutoStartSubtitle': '应用打开时自动启动网关', + 'settingsBatteryOptimization': '电池优化', + 'settingsBatteryOptimized': '已优化(可能会杀死后台会话)', + 'settingsBatteryUnrestricted': '不受限制(推荐)', + 'settingsStorage': '存储访问', + 'settingsStorageGranted': '已授权,可在 proot 中访问 /sdcard', + 'settingsStorageMissing': '允许访问共享存储', + 'settingsStorageDialogTitle': '授予文件访问权限', + 'settingsStorageDialogBody': + 'OpenClaw 需要文件管理权限,才能在共享存储中读取和写入快照文件。接下来会跳转到系统设置页面。', + 'settingsStorageDialogAction': '继续', + 'onboardingStorageDialogTitle': '为引导配置授予文件访问权限', + 'onboardingStorageDialogBody': + 'OpenClaw 引导配置需要文件管理权限,Ubuntu 才能在 proot 中挂载共享存储,并完成 API Key 与绑定配置。接下来会打开 Android 授权页面。', + 'onboardingStoragePermissionRequired': '继续引导配置前,必须先授予文件管理权限。', + 'configureStorageDialogTitle': '为配置页面授予文件访问权限', + 'configureStorageDialogBody': + 'OpenClaw 配置页面需要文件管理权限,Ubuntu 才能在 proot 中挂载共享存储并管理网关设置。接下来会打开 Android 授权页面。', + 'configureStoragePermissionRequired': '继续配置前,必须先授予文件管理权限。', + 'settingsNode': '节点', + 'settingsEnableNode': '启用节点', + 'settingsEnableNodeSubtitle': '向网关提供设备能力', + 'settingsNodeConfiguration': '节点配置', + 'settingsNodeConfigurationSubtitle': '连接、配对和能力设置', + 'settingsSystemInfo': '系统信息', + 'settingsArchitecture': '架构', + 'settingsProotPath': 'PRoot 路径', + 'settingsRootfs': 'Rootfs', + 'settingsNodeJs': 'Node.js', + 'settingsOpenClaw': 'OpenClaw', + 'settingsGo': 'Go(Golang)', + 'settingsHomebrew': 'Homebrew', + 'settingsOpenSsh': 'OpenSSH', + 'settingsMaintenance': '维护', + 'settingsExportSnapshot': '导出快照', + 'settingsExportSnapshotSubtitle': '将配置备份到 Downloads', + 'settingsImportSnapshot': '导入快照', + 'settingsImportSnapshotSubtitle': '从备份恢复配置', + 'settingsRerunSetup': '重新运行安装', + 'settingsRerunSetupSubtitle': '重新安装或修复环境', + 'settingsAbout': '关于', + 'settingsAboutSubtitle': 'Android AI 网关\n版本 {version}', + 'settingsDeveloper': '开发者', + 'settingsGithub': 'GitHub', + 'settingsContact': '联系方式', + 'settingsLicense': '许可证', + 'settingsPlayStore': 'Play 商店', + 'settingsEmail': '邮箱', + 'settingsSnapshotSaved': '快照已保存到 {path}', + 'settingsExportFailed': '导出失败:{error}', + 'settingsSnapshotMissing': '在 {path} 未找到快照', + 'settingsSnapshotRestored': '快照已恢复。请重启网关以生效。', + 'settingsImportFailed': '导入失败:{error}', + 'statusInstalled': '已安装', + 'statusNotInstalled': '未安装', +}; diff --git a/flutter_app/lib/l10n/app_strings_zh_hant.dart b/flutter_app/lib/l10n/app_strings_zh_hant.dart new file mode 100644 index 0000000..5e5a671 --- /dev/null +++ b/flutter_app/lib/l10n/app_strings_zh_hant.dart @@ -0,0 +1,273 @@ +const Map appStringsZhHant = { + 'appName': 'OpenClaw', + 'language': '語言', + 'languageSystem': '跟隨系統', + 'languageEnglish': '英語', + 'languageChinese': '簡體中文', + 'languageTraditionalChinese': '繁體中文', + 'languageJapanese': '日語', + 'commonInstalled': '已安裝', + 'commonNotInstalled': '未安裝', + 'commonCancel': '取消', + 'commonCopy': '複製', + 'commonCopiedToClipboard': '已複製到剪貼簿', + 'commonOpen': '打開', + 'commonPaste': '貼上', + 'commonRetry': '重試', + 'commonDone': '完成', + 'commonConfigure': '設定', + 'commonScreenshot': '截圖', + 'commonSaveFailed': '儲存失敗', + 'commonScreenshotSaved': '截圖已儲存:{fileName}', + 'commonNoUrlFound': '所選內容中未找到 URL', + 'commonOpenLink': '打開連結', + 'commonLinkCopied': '連結已複製', + 'dashboardQuickActions': '快捷操作', + 'dashboardTerminalTitle': '終端', + 'dashboardTerminalSubtitle': '打開 Ubuntu Shell 並使用 OpenClaw', + 'dashboardWebDashboardTitle': 'Web 控制台', + 'dashboardWebDashboardSubtitle': '在瀏覽器中打開 OpenClaw 控制台', + 'dashboardStartGatewayFirst': '請先啟動網關', + 'dashboardOnboardingTitle': '引導設定', + 'dashboardOnboardingSubtitle': '配置 API Key 和綁定資訊', + 'dashboardConfigureTitle': '網關設定', + 'dashboardConfigureSubtitle': '管理網關設定', + 'dashboardProvidersTitle': 'AI 供應商', + 'dashboardProvidersSubtitle': '配置模型和 API Key', + 'providersScreenTitle': 'AI 供應商', + 'providersScreenActiveModel': '目前啟用模型', + 'providersScreenIntro': '選擇一個提供商,配置它的 API Key、端點和模型。', + 'providersStatusActive': '已啟用', + 'providersStatusConfigured': '已設定', + 'providerDetailApiKey': 'API Key', + 'providerDetailApiKeyEmpty': 'API Key 不能為空', + 'providerDetailEndpoint': 'API 基礎地址', + 'providerDetailEndpointHelper': '如果你的帳號使用自定義或區域專屬端點,可以在這裡覆蓋預設地址。', + 'providerDetailEndpointInvalid': '請輸入有效的絕對 API 地址', + 'providerDetailModel': '模型', + 'providerDetailModelEmpty': '模型名稱不能為空', + 'providerDetailCustomModelAction': '自定義...', + 'providerDetailCustomModelLabel': '自定義模型名', + 'providerDetailCustomModelHint': '例如:meta/llama-3.3-70b-instruct', + 'providerDetailSaveAction': '儲存並啟用', + 'providerDetailSaved': '已配置並啟用 {provider}', + 'providerDetailSaveFailed': '儲存失敗:{error}', + 'providerDetailRemoveTitle': '移除 {provider}?', + 'providerDetailRemoveBody': '這會刪除該提供商儲存的 API Key、端點和模型。', + 'providerDetailRemoveAction': '移除', + 'providerDetailRemoveConfiguration': '移除配置', + 'providerDetailRemoved': '已移除 {provider}', + 'providerDetailRemoveFailed': '移除失敗:{error}', + 'providerNameAnthropic': 'Anthropic', + 'providerDescriptionAnthropic': 'Claude 系列模型,適合複雜推理與編程', + 'providerNameOpenai': 'OpenAI', + 'providerDescriptionOpenai': 'GPT 與 o 系列模型', + 'providerNameQwen': '通義千問', + 'providerDescriptionQwen': '通過 DashScope OpenAI 相容接口接入千問模型', + 'providerNameMinimax': 'MiniMax', + 'providerDescriptionMinimax': 'MiniMax 對話模型,支援自定義 API 端點', + 'providerNameDoubao': '豆包', + 'providerDescriptionDoubao': '火山方舟 / 豆包模型,內建官方 Ark 端點預設', + 'providerNameGoogle': 'Google Gemini', + 'providerDescriptionGoogle': 'Gemini 多模態模型家族', + 'providerNameOpenrouter': 'OpenRouter', + 'providerDescriptionOpenrouter': '統一接入數百種模型的 API', + 'providerNameNvidia': 'NVIDIA NIM', + 'providerDescriptionNvidia': '面向 GPU 推理的優化端點', + 'providerNameDeepseek': 'DeepSeek', + 'providerDescriptionDeepseek': '高性能開源模型服務', + 'providerNameXai': 'xAI', + 'providerDescriptionXai': 'xAI 的 Grok 系列模型', + 'dashboardPackagesTitle': '可選組件', + 'dashboardPackagesSubtitle': '安裝可選工具(Go、Homebrew、SSH)', + 'dashboardSshTitle': 'SSH 存取', + 'dashboardSshSubtitle': '通過 SSH 遠程存取終端', + 'dashboardLogsTitle': '日誌', + 'dashboardLogsSubtitle': '查看網關輸出和錯誤', + 'dashboardSnapshotTitle': '快照', + 'dashboardSnapshotSubtitle': '備份或恢復你的配置', + 'dashboardNodeTitle': '節點', + 'dashboardNodeConnected': '已連接到網關', + 'dashboardNodeDisabled': '為 AI 提供設備能力', + 'dashboardVersionLabel': 'OpenClaw v{version}', + 'dashboardAuthorLabel': '作者 {author} | {org}', + 'gatewayTitle': '網關', + 'gatewayCopyUrl': '複製 URL', + 'gatewayUrlCopied': 'URL 已複製到剪貼簿', + 'gatewayOpenDashboard': '打開控制台', + 'gatewayStart': '啟動網關', + 'gatewayStop': '停止網關', + 'gatewayViewLogs': '查看日誌', + 'gatewayStatusRunning': '運行中', + 'gatewayStatusStarting': '啟動中', + 'gatewayStatusError': '錯誤', + 'gatewayStatusStopped': '已停止', + 'logsTitle': '網關日誌', + 'logsAutoScrollOn': '自動滾動已開啟', + 'logsAutoScrollOff': '自動滾動已關閉', + 'logsCopyAll': '複製全部日誌', + 'logsFilterHint': '篩選日誌...', + 'logsEmpty': '尚無日誌。請先啟動網關。', + 'logsNoMatch': '沒有匹配的日誌。', + 'logsCopied': '日誌已複製到剪貼簿', + 'packagesTitle': '可選元件', + 'packagesDescription': '可在 Ubuntu 環境內安裝的開發工具。', + 'packagesInstall': '安裝', + 'packagesUninstall': '解除安裝', + 'packagesUninstallTitle': '卸載 {name}?', + 'packagesUninstallDescription': '這會將 {name} 從環境中移除。', + 'packageGoDescription': 'Go 編程語言編譯器和工具鏈', + 'packageBrewDescription': 'Linux 上常用的包管理器', + 'packageSshDescription': '用於安全遠程存取的 SSH 客戶端和服務端', + 'setupWizardTitle': '開始配置 OpenClaw', + 'setupWizardIntroIdle': '這會將 Ubuntu、Node.js 和 OpenClaw 下載到一個自包含環境中。', + 'setupWizardIntroRunning': '正在配置環境,可能需要幾分鐘。', + 'setupWizardConfigureApiKeys': '配置 API Key', + 'setupWizardRetry': '重新安裝', + 'setupWizardBegin': '開始安裝', + 'setupWizardRequirements': '需要約 500MB 存儲空間和網絡連接', + 'setupWizardStorageDialogTitle': '安裝前授予檔案存取權限', + 'setupWizardStorageDialogBody': + 'OpenClaw 在開始安裝前需要檔案管理權限,這樣 Ubuntu 環境才能在 proot 中正確掛載共享存儲。接下來會打開 Android 授權頁面。', + 'setupWizardStoragePermissionRequired': '開始安裝前,必須先授予檔案管理權限。', + 'setupWizardOptionalPackages': '可選組件', + 'setupWizardStepDownloadRootfs': '下載 Ubuntu rootfs', + 'setupWizardStepExtractRootfs': '解壓 rootfs', + 'setupWizardStepInstallNode': '安裝 Node.js', + 'setupWizardStepInstallOpenClaw': '安裝 OpenClaw', + 'setupWizardStepConfigureBypass': '配置 Bionic Bypass', + 'setupWizardComplete': '安裝完成!', + 'setupWizardStatusSetupComplete': '安裝完成', + 'setupWizardStatusSetupRequired': '需要安裝環境', + 'setupWizardStatusSettingUpDirs': '正在準備目錄...', + 'setupWizardStatusDownloadingUbuntuRootfs': '正在下載 Ubuntu rootfs...', + 'setupWizardStatusDownloadingProgress': '正在下載:{current} MB / {total} MB', + 'setupWizardStatusExtractingRootfs': '正在解壓 rootfs(這會花一點時間)...', + 'setupWizardStatusRootfsExtracted': 'rootfs 已解壓', + 'setupWizardStatusFixingPermissions': '正在修復 rootfs 權限...', + 'setupWizardStatusUpdatingPackageLists': '正在更新軟體包列表...', + 'setupWizardStatusInstallingBasePackages': '正在安裝基礎軟體包...', + 'setupWizardStatusDownloadingNode': '正在下載 Node.js {version}...', + 'setupWizardStatusDownloadingNodeProgress': + '正在下載 Node.js:{current} MB / {total} MB', + 'setupWizardStatusExtractingNode': '正在解壓 Node.js...', + 'setupWizardStatusVerifyingNode': '正在驗證 Node.js...', + 'setupWizardStatusNodeInstalled': 'Node.js 已安裝', + 'setupWizardStatusInstallingOpenClaw': '正在安裝 OpenClaw(這可能需要幾分鐘)...', + 'setupWizardStatusCreatingBinWrappers': '正在建立命令包裝器...', + 'setupWizardStatusVerifyingOpenClaw': '正在驗證 OpenClaw...', + 'setupWizardStatusOpenClawInstalled': 'OpenClaw 已安裝', + 'setupWizardStatusBypassConfigured': 'Bionic Bypass 已配置', + 'setupWizardStatusReady': '安裝完成,可以開始啟動網關了。', + 'onboardingTitle': 'OpenClaw 引導配置', + 'onboardingStarting': '正在啟動引導配置...', + 'onboardingGoToDashboard': '前往控制台', + 'onboardingStartFailed': '啟動引導配置失敗:{error}', + 'configureTitle': 'OpenClaw 配置', + 'configureStarting': '正在啟動配置...', + 'configureStartFailed': '啟動配置失敗:{error}', + 'nodeTitle': '節點', + 'nodeConfigurationTitle': '節點配置', + 'nodeGatewayConnection': '網關連接', + 'nodeLocalGateway': '本地網關', + 'nodeLocalGatewaySubtitle': '自動配對本機上的網關', + 'nodeRemoteGateway': '遠程網關', + 'nodeRemoteGatewaySubtitle': '連接到其他設備上的網關', + 'nodeGatewayHost': '網關主機', + 'nodeGatewayPort': '網關端口', + 'nodeGatewayToken': '網關令牌', + 'nodeGatewayTokenHint': '貼上控制台 URL 中的令牌', + 'nodeGatewayTokenHelper': '可在控制台 URL 的 #token= 後找到', + 'nodeConnect': '連接', + 'nodePairing': '配對', + 'nodeApproveCode': '請在網關端確認此代碼:', + 'nodeCapabilities': '能力', + 'nodeCapabilityCameraTitle': '相機', + 'nodeCapabilityCameraSubtitle': '拍攝照片和影片片段', + 'nodeCapabilityCanvasTitle': 'Canvas', + 'nodeCapabilityCanvasSubtitle': '瀏覽並互動網頁', + 'nodeCapabilityLocationTitle': '定位', + 'nodeCapabilityLocationSubtitle': '獲取設備 GPS 坐標', + 'nodeCapabilityScreenTitle': '錄屏', + 'nodeCapabilityScreenSubtitle': '錄製設備螢幕(每次都需授權)', + 'nodeCapabilityFlashlightTitle': '手電筒', + 'nodeCapabilityFlashlightSubtitle': '切換設備閃光燈開關', + 'nodeCapabilityVibrationTitle': '震動', + 'nodeCapabilityVibrationSubtitle': '觸發觸覺回饋和震動模式', + 'nodeCapabilitySensorsTitle': '感測器', + 'nodeCapabilitySensorsSubtitle': '讀取加速度計、陀螺儀、磁力計、氣壓計', + 'nodeCapabilitySerialTitle': '串口', + 'nodeCapabilitySerialSubtitle': '藍牙與 USB 串口通訊', + 'nodeDeviceInfo': '設備資訊', + 'nodeDeviceId': '設備 ID', + 'nodeLogs': '節點日誌', + 'nodeNoLogs': '還沒有日誌', + 'nodeConnectedTo': '已連接到 {host}:{port}', + 'nodePairingCode': '配對碼:', + 'nodeEnable': '啟用節點', + 'nodeDisable': '禁用節點', + 'nodeReconnect': '重新連接', + 'nodeStatusPaired': '已配對', + 'nodeStatusConnecting': '連接中', + 'nodeStatusError': '錯誤', + 'nodeStatusDisabled': '已禁用', + 'nodeStatusDisconnected': '未連接', + 'settingsTitle': '設定', + 'settingsGeneral': '一般', + 'settingsAutoStart': '自動啟動網關', + 'settingsAutoStartSubtitle': '應用打開時自動啟動網關', + 'settingsBatteryOptimization': '電池最佳化', + 'settingsBatteryOptimized': '已優化(可能會終止後台會話)', + 'settingsBatteryUnrestricted': '不受限制(推薦)', + 'settingsStorage': '儲存存取', + 'settingsStorageGranted': '已授權,可在 proot 中存取 /sdcard', + 'settingsStorageMissing': '允許存取共享存儲', + 'settingsStorageDialogTitle': '授予檔案存取權限', + 'settingsStorageDialogBody': + 'OpenClaw 需要檔案管理權限,才能在共享存儲中讀取和寫入快照檔案。接下來會跳轉到系統設定頁面。', + 'settingsStorageDialogAction': '繼續', + 'onboardingStorageDialogTitle': '為引導配置授予檔案存取權限', + 'onboardingStorageDialogBody': + 'OpenClaw 引導配置需要檔案管理權限,Ubuntu 才能在 proot 中掛載共享存儲,並完成 API Key 與綁定配置。接下來會打開 Android 授權頁面。', + 'onboardingStoragePermissionRequired': '繼續引導配置前,必須先授予檔案管理權限。', + 'configureStorageDialogTitle': '為配置頁面授予檔案存取權限', + 'configureStorageDialogBody': + 'OpenClaw 配置頁面需要檔案管理權限,Ubuntu 才能在 proot 中掛載共享存儲並管理網關設定。接下來會打開 Android 授權頁面。', + 'configureStoragePermissionRequired': '繼續配置前,必須先授予檔案管理權限。', + 'settingsNode': '節點', + 'settingsEnableNode': '啟用節點', + 'settingsEnableNodeSubtitle': '向網關提供設備能力', + 'settingsNodeConfiguration': '節點配置', + 'settingsNodeConfigurationSubtitle': '連接、配對和能力設定', + 'settingsSystemInfo': '系統資訊', + 'settingsArchitecture': '架構', + 'settingsProotPath': 'PRoot 路徑', + 'settingsRootfs': 'Rootfs', + 'settingsNodeJs': 'Node.js', + 'settingsOpenClaw': 'OpenClaw', + 'settingsGo': 'Go(Golang)', + 'settingsHomebrew': 'Homebrew', + 'settingsOpenSsh': 'OpenSSH', + 'settingsMaintenance': '維護', + 'settingsExportSnapshot': '匯出快照', + 'settingsExportSnapshotSubtitle': '將配置備份到 Downloads', + 'settingsImportSnapshot': '匯入快照', + 'settingsImportSnapshotSubtitle': '從備份恢復配置', + 'settingsRerunSetup': '重新執行安裝', + 'settingsRerunSetupSubtitle': '重新安裝或修復環境', + 'settingsAbout': '關於', + 'settingsAboutSubtitle': 'Android AI 網關\n版本 {version}', + 'settingsDeveloper': '開發者', + 'settingsGithub': 'GitHub', + 'settingsContact': '聯絡方式', + 'settingsLicense': '授權', + 'settingsPlayStore': 'Play 商店', + 'settingsEmail': '電子郵件', + 'settingsSnapshotSaved': '快照已儲存到 {path}', + 'settingsExportFailed': '匯出失敗:{error}', + 'settingsSnapshotMissing': '在 {path} 未找到快照', + 'settingsSnapshotRestored': '快照已恢復。請重啟網關以生效。', + 'settingsImportFailed': '匯入失敗:{error}', + 'statusInstalled': '已安裝', + 'statusNotInstalled': '未安裝', +}; diff --git a/flutter_app/lib/models/ai_provider.dart b/flutter_app/lib/models/ai_provider.dart index 094d8a5..8beaeac 100644 --- a/flutter_app/lib/models/ai_provider.dart +++ b/flutter_app/lib/models/ai_provider.dart @@ -1,32 +1,44 @@ import 'package:flutter/material.dart'; +import '../l10n/app_localizations.dart'; /// Metadata for an AI model provider that can be configured /// to power the OpenClaw gateway. class AiProvider { final String id; - final String name; - final String description; + final String nameKey; + final String descriptionKey; final IconData icon; final Color color; final String baseUrl; final List defaultModels; final String apiKeyHint; + final bool supportsCustomBaseUrl; const AiProvider({ required this.id, - required this.name, - required this.description, + required this.nameKey, + required this.descriptionKey, required this.icon, required this.color, required this.baseUrl, required this.defaultModels, required this.apiKeyHint, + this.supportsCustomBaseUrl = false, }); + String name(AppLocalizations l10n) => l10n.t(nameKey); + + String description(AppLocalizations l10n) => l10n.t(descriptionKey); + + bool matchesModel(String model) { + return defaultModels.any((candidate) => model.contains(candidate)) || + model.contains(id); + } + static const anthropic = AiProvider( id: 'anthropic', - name: 'Anthropic', - description: 'Claude models — advanced reasoning and coding', + nameKey: 'providerNameAnthropic', + descriptionKey: 'providerDescriptionAnthropic', icon: Icons.psychology, color: Color(0xFFD97706), baseUrl: 'https://api.anthropic.com/v1', @@ -41,8 +53,8 @@ class AiProvider { static const openai = AiProvider( id: 'openai', - name: 'OpenAI', - description: 'GPT and o-series models', + nameKey: 'providerNameOpenai', + descriptionKey: 'providerDescriptionOpenai', icon: Icons.auto_awesome, color: Color(0xFF10A37F), baseUrl: 'https://api.openai.com/v1', @@ -54,12 +66,13 @@ class AiProvider { 'gpt-4-turbo', ], apiKeyHint: 'sk-...', + supportsCustomBaseUrl: true, ); static const google = AiProvider( id: 'google', - name: 'Google Gemini', - description: 'Gemini family of multimodal models', + nameKey: 'providerNameGoogle', + descriptionKey: 'providerDescriptionGoogle', icon: Icons.diamond, color: Color(0xFF4285F4), baseUrl: 'https://generativelanguage.googleapis.com/v1beta', @@ -74,8 +87,8 @@ class AiProvider { static const openrouter = AiProvider( id: 'openrouter', - name: 'OpenRouter', - description: 'Unified API for hundreds of models', + nameKey: 'providerNameOpenrouter', + descriptionKey: 'providerDescriptionOpenrouter', icon: Icons.route, color: Color(0xFF6366F1), baseUrl: 'https://openrouter.ai/api/v1', @@ -90,8 +103,8 @@ class AiProvider { static const nvidia = AiProvider( id: 'nvidia', - name: 'NVIDIA NIM', - description: 'GPU-optimized inference endpoints', + nameKey: 'providerNameNvidia', + descriptionKey: 'providerDescriptionNvidia', icon: Icons.memory, color: Color(0xFF76B900), baseUrl: 'https://integrate.api.nvidia.com/v1', @@ -107,8 +120,8 @@ class AiProvider { static const deepseek = AiProvider( id: 'deepseek', - name: 'DeepSeek', - description: 'High-performance open models', + nameKey: 'providerNameDeepseek', + descriptionKey: 'providerDescriptionDeepseek', icon: Icons.explore, color: Color(0xFF0EA5E9), baseUrl: 'https://api.deepseek.com/v1', @@ -121,8 +134,8 @@ class AiProvider { static const xai = AiProvider( id: 'xai', - name: 'xAI', - description: 'Grok models from xAI', + nameKey: 'providerNameXai', + descriptionKey: 'providerDescriptionXai', icon: Icons.bolt, color: Color(0xFFEF4444), baseUrl: 'https://api.x.ai/v1', @@ -134,6 +147,67 @@ class AiProvider { apiKeyHint: 'xai-...', ); + static const qwen = AiProvider( + id: 'qwen', + nameKey: 'providerNameQwen', + descriptionKey: 'providerDescriptionQwen', + icon: Icons.cloud, + color: Color(0xFF2563EB), + baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1', + defaultModels: [ + 'qwen-max', + 'qwen-plus', + 'qwen-turbo', + 'qwen3-coder-plus', + ], + apiKeyHint: 'sk-...', + supportsCustomBaseUrl: true, + ); + + static const minimax = AiProvider( + id: 'minimax', + nameKey: 'providerNameMinimax', + descriptionKey: 'providerDescriptionMinimax', + icon: Icons.forum, + color: Color(0xFFEC4899), + baseUrl: 'https://api.minimax.chat/v1', + defaultModels: [ + 'MiniMax-Text-01', + 'MiniMax-M1', + 'abab7.5-chat', + ], + apiKeyHint: 'sk-...', + supportsCustomBaseUrl: true, + ); + + static const doubao = AiProvider( + id: 'doubao', + nameKey: 'providerNameDoubao', + descriptionKey: 'providerDescriptionDoubao', + icon: Icons.token, + color: Color(0xFFEA580C), + baseUrl: 'https://ark.cn-beijing.volces.com/api/v3', + defaultModels: [ + 'doubao-seed-2-0-pro-260215', + 'doubao-seed-2-0-lite-260215', + 'doubao-seed-2-0-mini-260215', + 'doubao-seed-1-8-251228', + ], + apiKeyHint: 'ark-...', + supportsCustomBaseUrl: true, + ); + /// All available AI providers. - static const all = [anthropic, openai, google, openrouter, nvidia, deepseek, xai]; + static const all = [ + anthropic, + openai, + qwen, + minimax, + doubao, + google, + openrouter, + nvidia, + deepseek, + xai, + ]; } diff --git a/flutter_app/lib/providers/locale_provider.dart b/flutter_app/lib/providers/locale_provider.dart new file mode 100644 index 0000000..b2bf74b --- /dev/null +++ b/flutter_app/lib/providers/locale_provider.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; + +import '../services/preferences_service.dart'; + +class LocaleProvider extends ChangeNotifier { + final PreferencesService _prefs = PreferencesService(); + + Locale? _locale; + bool _initialized = false; + + Locale? get locale => _locale; + String get localeCode { + final value = _locale; + if (value == null) { + return 'system'; + } + + if (value.languageCode == 'zh' && + value.scriptCode?.toLowerCase() == 'hant') { + return 'zh-Hant'; + } + + return value.languageCode; + } + + Future load() async { + if (_initialized) return; + await _prefs.init(); + _initialized = true; + _locale = _mapCodeToLocale(_prefs.localeCode); + notifyListeners(); + } + + Future setLocaleCode(String code) async { + if (!_initialized) { + await load(); + } + + final normalized = code == 'system' ? null : code; + _prefs.localeCode = normalized; + _locale = _mapCodeToLocale(normalized); + notifyListeners(); + } + + Locale? _mapCodeToLocale(String? code) { + switch (code) { + case 'en': + return const Locale('en'); + case 'zh': + return const Locale('zh'); + case 'zh-Hant': + case 'zh_TW': + case 'zh-HK': + return const Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant'); + case 'ja': + return const Locale('ja'); + default: + return null; + } + } +} diff --git a/flutter_app/lib/screens/configure_screen.dart b/flutter_app/lib/screens/configure_screen.dart index f695700..957bf06 100644 --- a/flutter_app/lib/screens/configure_screen.dart +++ b/flutter_app/lib/screens/configure_screen.dart @@ -5,6 +5,7 @@ import 'package:flutter/services.dart'; import 'package:xterm/xterm.dart'; import 'package:flutter_pty/flutter_pty.dart'; import 'package:url_launcher/url_launcher.dart'; +import '../l10n/app_localizations.dart'; import '../services/native_bridge.dart'; import '../services/screenshot_service.dart'; import '../services/terminal_service.dart'; @@ -60,8 +61,12 @@ class _ConfigureScreenState extends State { _pty = null; try { // Ensure dirs + resolv.conf exist before proot starts (#40). - try { await NativeBridge.setupDirs(); } catch (_) {} - try { await NativeBridge.writeResolv(); } catch (_) {} + try { + await NativeBridge.setupDirs(); + } catch (_) {} + try { + await NativeBridge.writeResolv(); + } catch (_) {} try { final filesDir = await NativeBridge.getFilesDir(); const resolvContent = 'nameserver 8.8.8.8\nnameserver 8.8.4.4\n'; @@ -88,12 +93,13 @@ class _ConfigureScreenState extends State { configureArgs.removeLast(); // remove '-l' configureArgs.removeLast(); // remove '/bin/bash' configureArgs.addAll([ - '/bin/bash', '-lc', + '/bin/bash', + '-lc', 'echo "=== OpenClaw Configure ===" && ' - 'echo "Manage your gateway settings." && ' - 'echo "" && ' - 'openclaw configure; ' - 'echo "" && echo "Configuration complete! You can close this screen."', + 'echo "Manage your gateway settings." && ' + 'echo "" && ' + 'openclaw configure; ' + 'echo "" && echo "Configuration complete! You can close this screen."', ]); _pty = Pty.start( @@ -136,11 +142,14 @@ class _ConfigureScreenState extends State { _pty?.resize(h, w); }; + if (!mounted) return; setState(() => _loading = false); } catch (e) { + if (!mounted) return; + final message = context.l10n.t('configureStartFailed', {'error': '$e'}); setState(() { _loading = false; - _error = 'Failed to start configure: $e'; + _error = message; }); } } @@ -174,7 +183,8 @@ class _ConfigureScreenState extends State { } String? _extractUrl(String text) { - final clean = text.replaceAll(_boxDrawing, '').replaceAll(RegExp(r'\s+'), ''); + final clean = + text.replaceAll(_boxDrawing, '').replaceAll(RegExp(r'\s+'), ''); final parts = clean.split(RegExp(r'(?=https?://)')); String? best; for (final part in parts) { @@ -199,10 +209,10 @@ class _ConfigureScreenState extends State { if (url != null) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: const Text('Copied to clipboard'), + content: Text(context.l10n.t('commonCopiedToClipboard')), duration: const Duration(seconds: 3), action: SnackBarAction( - label: 'Open', + label: context.l10n.t('commonOpen'), onPressed: () { final uri = Uri.tryParse(url); if (uri != null) { @@ -214,9 +224,9 @@ class _ConfigureScreenState extends State { ); } else { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Copied to clipboard'), - duration: Duration(seconds: 1), + SnackBar( + content: Text(context.l10n.t('commonCopiedToClipboard')), + duration: const Duration(seconds: 1), ), ); } @@ -235,9 +245,9 @@ class _ConfigureScreenState extends State { } } ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('No URL found in selection'), - duration: Duration(seconds: 1), + SnackBar( + content: Text(context.l10n.t('commonNoUrlFound')), + duration: const Duration(seconds: 1), ), ); } @@ -250,22 +260,27 @@ class _ConfigureScreenState extends State { } Future _takeScreenshot() async { - final path = await ScreenshotService.capture(_screenshotKey, prefix: 'configure'); + final path = + await ScreenshotService.capture(_screenshotKey, prefix: 'configure'); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(path != null - ? 'Screenshot saved: ${path.split('/').last}' - : 'Failed to capture screenshot'), + ? context.l10n.t('commonScreenshotSaved', { + 'fileName': path.split('/').last, + }) + : context.l10n.t('commonSaveFailed')), ), ); } @override Widget build(BuildContext context) { + final l10n = context.l10n; + return Scaffold( appBar: AppBar( - title: const Text('OpenClaw Configure'), + title: Text(l10n.t('configureTitle')), leading: IconButton( icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.of(context).pop(), @@ -274,22 +289,22 @@ class _ConfigureScreenState extends State { actions: [ IconButton( icon: const Icon(Icons.camera_alt_outlined), - tooltip: 'Screenshot', + tooltip: l10n.t('commonScreenshot'), onPressed: _takeScreenshot, ), IconButton( icon: const Icon(Icons.copy), - tooltip: 'Copy', + tooltip: l10n.t('commonCopy'), onPressed: _copySelection, ), IconButton( icon: const Icon(Icons.open_in_browser), - tooltip: 'Open URL', + tooltip: l10n.t('commonOpen'), onPressed: _openSelection, ), IconButton( icon: const Icon(Icons.paste), - tooltip: 'Paste', + tooltip: l10n.t('commonPaste'), onPressed: _paste, ), ], @@ -297,14 +312,14 @@ class _ConfigureScreenState extends State { body: Column( children: [ if (_loading) - const Expanded( + Expanded( child: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - CircularProgressIndicator(), - SizedBox(height: 16), - Text('Starting configure...'), + const CircularProgressIndicator(), + const SizedBox(height: 16), + Text(l10n.t('configureStarting')), ], ), ), @@ -326,7 +341,8 @@ class _ConfigureScreenState extends State { Text( _error!, textAlign: TextAlign.center, - style: TextStyle(color: Theme.of(context).colorScheme.error), + style: TextStyle( + color: Theme.of(context).colorScheme.error), ), const SizedBox(height: 16), FilledButton.icon( @@ -339,7 +355,7 @@ class _ConfigureScreenState extends State { _startConfigure(); }, icon: const Icon(Icons.refresh), - label: const Text('Retry'), + label: Text(l10n.t('commonRetry')), ), ], ), @@ -376,7 +392,7 @@ class _ConfigureScreenState extends State { child: FilledButton.icon( onPressed: () => Navigator.of(context).pop(), icon: const Icon(Icons.check), - label: const Text('Done'), + label: Text(l10n.t('commonDone')), ), ), ), diff --git a/flutter_app/lib/screens/dashboard_screen.dart b/flutter_app/lib/screens/dashboard_screen.dart index dc8d5c1..f5ec693 100644 --- a/flutter_app/lib/screens/dashboard_screen.dart +++ b/flutter_app/lib/screens/dashboard_screen.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import '../constants.dart'; +import '../l10n/app_localizations.dart'; +import '../models/node_state.dart'; import '../providers/gateway_provider.dart'; import '../providers/node_provider.dart'; import '../widgets/gateway_controls.dart'; @@ -23,10 +25,11 @@ class DashboardScreen extends StatelessWidget { @override Widget build(BuildContext context) { final theme = Theme.of(context); + final l10n = context.l10n; return Scaffold( appBar: AppBar( - title: const Text('OpenClaw'), + title: Text(l10n.t('appName')), actions: [ IconButton( icon: const Icon(Icons.settings), @@ -46,7 +49,7 @@ class DashboardScreen extends StatelessWidget { Padding( padding: const EdgeInsets.only(left: 4, bottom: 8), child: Text( - 'QUICK ACTIONS', + l10n.t('dashboardQuickActions'), style: theme.textTheme.labelSmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, fontWeight: FontWeight.w600, @@ -55,8 +58,8 @@ class DashboardScreen extends StatelessWidget { ), ), StatusCard( - title: 'Terminal', - subtitle: 'Open Ubuntu shell with OpenClaw', + title: l10n.t('dashboardTerminalTitle'), + subtitle: l10n.t('dashboardTerminalSubtitle'), icon: Icons.terminal, trailing: const Icon(Icons.chevron_right), onTap: () => Navigator.of(context).push( @@ -72,10 +75,10 @@ class DashboardScreen extends StatelessWidget { final subtitle = provider.state.isRunning ? (token != null ? 'Token: ${token.substring(0, (token.length > 8 ? 8 : token.length))}...' - : 'Open OpenClaw dashboard in browser') - : 'Start gateway first'; + : l10n.t('dashboardWebDashboardSubtitle')) + : l10n.t('dashboardStartGatewayFirst'); return StatusCard( - title: 'Web Dashboard', + title: l10n.t('dashboardWebDashboardTitle'), subtitle: subtitle, icon: Icons.dashboard, trailing: Row( @@ -88,7 +91,8 @@ class DashboardScreen extends StatelessWidget { onPressed: () { Clipboard.setData(ClipboardData(text: url!)); ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Dashboard URL copied')), + const SnackBar( + content: Text('Dashboard URL copied')), ); }, ), @@ -108,8 +112,8 @@ class DashboardScreen extends StatelessWidget { }, ), StatusCard( - title: 'Onboarding', - subtitle: 'Configure API keys and binding', + title: l10n.t('dashboardOnboardingTitle'), + subtitle: l10n.t('dashboardOnboardingSubtitle'), icon: Icons.vpn_key, trailing: const Icon(Icons.chevron_right), onTap: () => Navigator.of(context).push( @@ -117,8 +121,8 @@ class DashboardScreen extends StatelessWidget { ), ), StatusCard( - title: 'Configure', - subtitle: 'Manage gateway settings', + title: l10n.t('dashboardConfigureTitle'), + subtitle: l10n.t('dashboardConfigureSubtitle'), icon: Icons.tune, trailing: const Icon(Icons.chevron_right), onTap: () => Navigator.of(context).push( @@ -126,8 +130,8 @@ class DashboardScreen extends StatelessWidget { ), ), StatusCard( - title: 'AI Providers', - subtitle: 'Configure models and API keys', + title: l10n.t('dashboardProvidersTitle'), + subtitle: l10n.t('dashboardProvidersSubtitle'), icon: Icons.model_training, trailing: const Icon(Icons.chevron_right), onTap: () => Navigator.of(context).push( @@ -135,8 +139,8 @@ class DashboardScreen extends StatelessWidget { ), ), StatusCard( - title: 'Packages', - subtitle: 'Install optional tools (Go, Homebrew, SSH)', + title: l10n.t('dashboardPackagesTitle'), + subtitle: l10n.t('dashboardPackagesSubtitle'), icon: Icons.extension, trailing: const Icon(Icons.chevron_right), onTap: () => Navigator.of(context).push( @@ -144,8 +148,8 @@ class DashboardScreen extends StatelessWidget { ), ), StatusCard( - title: 'SSH Access', - subtitle: 'Remote terminal access via SSH', + title: l10n.t('dashboardSshTitle'), + subtitle: l10n.t('dashboardSshSubtitle'), icon: Icons.terminal, trailing: const Icon(Icons.chevron_right), onTap: () => Navigator.of(context).push( @@ -153,8 +157,8 @@ class DashboardScreen extends StatelessWidget { ), ), StatusCard( - title: 'Logs', - subtitle: 'View gateway output and errors', + title: l10n.t('dashboardLogsTitle'), + subtitle: l10n.t('dashboardLogsSubtitle'), icon: Icons.article_outlined, trailing: const Icon(Icons.chevron_right), onTap: () => Navigator.of(context).push( @@ -162,8 +166,8 @@ class DashboardScreen extends StatelessWidget { ), ), StatusCard( - title: 'Snapshot', - subtitle: 'Backup or restore your config', + title: l10n.t('dashboardSnapshotTitle'), + subtitle: l10n.t('dashboardSnapshotSubtitle'), icon: Icons.backup, trailing: const Icon(Icons.chevron_right), onTap: () => Navigator.of(context).push( @@ -174,12 +178,12 @@ class DashboardScreen extends StatelessWidget { builder: (context, nodeProvider, _) { final nodeState = nodeProvider.state; return StatusCard( - title: 'Node', + title: l10n.t('dashboardNodeTitle'), subtitle: nodeState.isPaired - ? 'Connected to gateway' + ? l10n.t('dashboardNodeConnected') : nodeState.isDisabled - ? 'Device capabilities for AI' - : nodeState.statusText, + ? l10n.t('dashboardNodeDisabled') + : _nodeStatusText(l10n, nodeState.status), icon: Icons.devices, trailing: const Icon(Icons.chevron_right), onTap: () => Navigator.of(context).push( @@ -193,14 +197,23 @@ class DashboardScreen extends StatelessWidget { child: Column( children: [ Text( - 'OpenClaw v${AppConstants.version}', + l10n.t( + 'dashboardVersionLabel', + {'version': AppConstants.version}, + ), style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), const SizedBox(height: 2), Text( - 'by ${AppConstants.authorName} | ${AppConstants.orgName}', + l10n.t( + 'dashboardAuthorLabel', + { + 'author': AppConstants.authorName, + 'org': AppConstants.orgName, + }, + ), style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), @@ -213,4 +226,21 @@ class DashboardScreen extends StatelessWidget { ), ); } + + String _nodeStatusText(AppLocalizations l10n, NodeStatus status) { + switch (status) { + case NodeStatus.disabled: + return l10n.t('nodeStatusDisabled'); + case NodeStatus.disconnected: + return l10n.t('nodeStatusDisconnected'); + case NodeStatus.connecting: + case NodeStatus.challenging: + case NodeStatus.pairing: + return l10n.t('nodeStatusConnecting'); + case NodeStatus.paired: + return l10n.t('nodeStatusPaired'); + case NodeStatus.error: + return l10n.t('nodeStatusError'); + } + } } diff --git a/flutter_app/lib/screens/logs_screen.dart b/flutter_app/lib/screens/logs_screen.dart index eca00e2..950ef09 100644 --- a/flutter_app/lib/screens/logs_screen.dart +++ b/flutter_app/lib/screens/logs_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import '../app.dart'; +import '../l10n/app_localizations.dart'; import '../providers/gateway_provider.dart'; import '../services/screenshot_service.dart'; @@ -29,26 +30,31 @@ class _LogsScreenState extends State { @override Widget build(BuildContext context) { final theme = Theme.of(context); + final l10n = context.l10n; return Scaffold( appBar: AppBar( - title: const Text('Gateway Logs'), + title: Text(l10n.t('logsTitle')), actions: [ IconButton( icon: const Icon(Icons.camera_alt_outlined), - tooltip: 'Screenshot', + tooltip: l10n.t('commonScreenshot'), onPressed: _takeScreenshot, ), IconButton( icon: Icon( - _autoScroll ? Icons.vertical_align_bottom : Icons.vertical_align_top, + _autoScroll + ? Icons.vertical_align_bottom + : Icons.vertical_align_top, ), - tooltip: _autoScroll ? 'Auto-scroll on' : 'Auto-scroll off', + tooltip: _autoScroll + ? l10n.t('logsAutoScrollOn') + : l10n.t('logsAutoScrollOff'), onPressed: () => setState(() => _autoScroll = !_autoScroll), ), IconButton( icon: const Icon(Icons.copy), - tooltip: 'Copy all logs', + tooltip: l10n.t('logsCopyAll'), onPressed: () => _copyLogs(context), ), ], @@ -60,7 +66,7 @@ class _LogsScreenState extends State { child: TextField( controller: _searchController, decoration: InputDecoration( - hintText: 'Filter logs...', + hintText: l10n.t('logsFilterHint'), prefixIcon: const Icon(Icons.search), isDense: true, contentPadding: const EdgeInsets.symmetric( @@ -84,50 +90,54 @@ class _LogsScreenState extends State { child: RepaintBoundary( key: _screenshotKey, child: Consumer( - builder: (context, provider, _) { - final logs = provider.state.logs; - final filtered = _filter.isEmpty - ? logs - : logs.where((l) => - l.toLowerCase().contains(_filter.toLowerCase())).toList(); + builder: (context, provider, _) { + final logs = provider.state.logs; + final filtered = _filter.isEmpty + ? logs + : logs + .where((l) => + l.toLowerCase().contains(_filter.toLowerCase())) + .toList(); - if (filtered.isEmpty) { - return Center( - child: Text( - logs.isEmpty ? 'No logs yet. Start the gateway.' : 'No matching logs.', - style: theme.textTheme.bodyMedium?.copyWith( - color: theme.colorScheme.onSurfaceVariant, + if (filtered.isEmpty) { + return Center( + child: Text( + logs.isEmpty + ? l10n.t('logsEmpty') + : l10n.t('logsNoMatch'), + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), ), - ), - ); - } - - WidgetsBinding.instance.addPostFrameCallback((_) { - if (_autoScroll && _scrollController.hasClients) { - _scrollController.jumpTo( - _scrollController.position.maxScrollExtent, ); } - }); - return ListView.builder( - controller: _scrollController, - padding: const EdgeInsets.symmetric(horizontal: 8), - itemCount: filtered.length, - itemBuilder: (context, index) { - final line = filtered[index]; - return Text( - line, - style: TextStyle( - fontFamily: 'monospace', - fontSize: 12, - color: _logColor(line, theme), - ), - ); - }, - ); - }, - ), + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_autoScroll && _scrollController.hasClients) { + _scrollController.jumpTo( + _scrollController.position.maxScrollExtent, + ); + } + }); + + return ListView.builder( + controller: _scrollController, + padding: const EdgeInsets.symmetric(horizontal: 8), + itemCount: filtered.length, + itemBuilder: (context, index) { + final line = filtered[index]; + return Text( + line, + style: TextStyle( + fontFamily: 'monospace', + fontSize: 12, + color: _logColor(line, theme), + ), + ); + }, + ); + }, + ), ), ), ], @@ -149,13 +159,16 @@ class _LogsScreenState extends State { } Future _takeScreenshot() async { - final path = await ScreenshotService.capture(_screenshotKey, prefix: 'logs'); + final path = + await ScreenshotService.capture(_screenshotKey, prefix: 'logs'); if (!mounted) return; + final l10n = context.l10n; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(path != null - ? 'Screenshot saved: ${path.split('/').last}' - : 'Failed to capture screenshot'), + ? l10n + .t('commonScreenshotSaved', {'fileName': path.split('/').last}) + : l10n.t('commonSaveFailed')), ), ); } @@ -165,7 +178,7 @@ class _LogsScreenState extends State { final text = provider.state.logs.join('\n'); Clipboard.setData(ClipboardData(text: text)); ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Logs copied to clipboard')), + SnackBar(content: Text(context.l10n.t('logsCopied'))), ); } } diff --git a/flutter_app/lib/screens/node_screen.dart b/flutter_app/lib/screens/node_screen.dart index f9b012d..eee4f68 100644 --- a/flutter_app/lib/screens/node_screen.dart +++ b/flutter_app/lib/screens/node_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../app.dart'; +import '../l10n/app_localizations.dart'; import '../providers/node_provider.dart'; import '../services/preferences_service.dart'; import '../widgets/node_controls.dart'; @@ -51,9 +52,10 @@ class _NodeScreenState extends State { @override Widget build(BuildContext context) { final theme = Theme.of(context); + final l10n = context.l10n; return Scaffold( - appBar: AppBar(title: const Text('Node Configuration')), + appBar: AppBar(title: Text(l10n.t('nodeConfigurationTitle'))), body: _loading ? const Center(child: CircularProgressIndicator()) : Consumer( @@ -67,7 +69,7 @@ class _NodeScreenState extends State { const SizedBox(height: 16), // Gateway Connection - _sectionHeader(theme, 'GATEWAY CONNECTION'), + _sectionHeader(theme, l10n.t('nodeGatewayConnection')), Card( child: Padding( padding: const EdgeInsets.all(16), @@ -75,66 +77,74 @@ class _NodeScreenState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ RadioListTile( - title: const Text('Local Gateway'), - subtitle: const Text('Auto-pair with gateway on this device'), + title: Text(l10n.t('nodeLocalGateway')), + subtitle: Text(l10n.t('nodeLocalGatewaySubtitle')), value: true, groupValue: _isLocal, onChanged: (value) { - setState(() => _isLocal = value!); + if (value != null) { + setState(() => _isLocal = value); + } }, ), RadioListTile( - title: const Text('Remote Gateway'), - subtitle: const Text('Connect to a gateway on another device'), + title: Text(l10n.t('nodeRemoteGateway')), + subtitle: Text(l10n.t('nodeRemoteGatewaySubtitle')), value: false, groupValue: _isLocal, onChanged: (value) { - setState(() => _isLocal = value!); + if (value != null) { + setState(() => _isLocal = value); + } }, ), if (!_isLocal) ...[ - const SizedBox(height: 12), - TextField( - controller: _hostController, - decoration: const InputDecoration( - labelText: 'Gateway Host', - hintText: '192.168.1.100', + const SizedBox(height: 12), + TextField( + controller: _hostController, + decoration: InputDecoration( + labelText: l10n.t('nodeGatewayHost'), + hintText: '192.168.1.100', + ), ), - ), - const SizedBox(height: 12), - TextField( - controller: _portController, - decoration: const InputDecoration( - labelText: 'Gateway Port', - hintText: '18789', + const SizedBox(height: 12), + TextField( + controller: _portController, + decoration: InputDecoration( + labelText: l10n.t('nodeGatewayPort'), + hintText: '18789', + ), + keyboardType: TextInputType.number, ), - keyboardType: TextInputType.number, - ), - const SizedBox(height: 12), - TextField( - controller: _tokenController, - decoration: const InputDecoration( - labelText: 'Gateway Token', - hintText: 'Paste token from gateway dashboard URL', - helperText: 'Found in dashboard URL after #token=', - prefixIcon: Icon(Icons.key), + const SizedBox(height: 12), + TextField( + controller: _tokenController, + decoration: InputDecoration( + labelText: l10n.t('nodeGatewayToken'), + hintText: l10n.t('nodeGatewayTokenHint'), + helperText: + l10n.t('nodeGatewayTokenHelper'), + prefixIcon: const Icon(Icons.key), + ), + obscureText: true, + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: () { + final host = _hostController.text.trim(); + final port = int.tryParse( + _portController.text.trim()) ?? + 18789; + final token = _tokenController.text.trim(); + if (host.isNotEmpty) { + provider.connectRemote(host, port, + token: + token.isNotEmpty ? token : null); + } + }, + icon: const Icon(Icons.link), + label: Text(l10n.t('nodeConnect')), ), - obscureText: true, - ), - const SizedBox(height: 12), - FilledButton.icon( - onPressed: () { - final host = _hostController.text.trim(); - final port = int.tryParse(_portController.text.trim()) ?? 18789; - final token = _tokenController.text.trim(); - if (host.isNotEmpty) { - provider.connectRemote(host, port, - token: token.isNotEmpty ? token : null); - } - }, - icon: const Icon(Icons.link), - label: const Text('Connect'), - ), ], ], ), @@ -144,7 +154,7 @@ class _NodeScreenState extends State { // Pairing Status if (state.pairingCode != null) ...[ - _sectionHeader(theme, 'PAIRING'), + _sectionHeader(theme, l10n.t('nodePairing')), Card( child: Padding( padding: const EdgeInsets.all(16), @@ -153,7 +163,7 @@ class _NodeScreenState extends State { const Icon(Icons.qr_code, size: 48), const SizedBox(height: 8), Text( - 'Approve this code on the gateway:', + l10n.t('nodeApproveCode'), style: theme.textTheme.bodyMedium, ), const SizedBox(height: 8), @@ -173,66 +183,67 @@ class _NodeScreenState extends State { ], // Capabilities - _sectionHeader(theme, 'CAPABILITIES'), + _sectionHeader(theme, l10n.t('nodeCapabilities')), _capabilityTile( theme, - 'Camera', - 'Capture photos and video clips', + l10n.t('nodeCapabilityCameraTitle'), + l10n.t('nodeCapabilityCameraSubtitle'), Icons.camera_alt, ), _capabilityTile( theme, - 'Canvas', - 'Not available on mobile', + l10n.t('nodeCapabilityCanvasTitle'), + l10n.t('nodeCapabilityCanvasSubtitle'), Icons.web, available: false, ), _capabilityTile( theme, - 'Location', - 'Get device GPS coordinates', + l10n.t('nodeCapabilityLocationTitle'), + l10n.t('nodeCapabilityLocationSubtitle'), Icons.location_on, ), _capabilityTile( theme, - 'Screen Recording', - 'Record device screen (requires consent each time)', + l10n.t('nodeCapabilityScreenTitle'), + l10n.t('nodeCapabilityScreenSubtitle'), Icons.screen_share, ), _capabilityTile( theme, - 'Flashlight', - 'Toggle device torch on/off', + l10n.t('nodeCapabilityFlashlightTitle'), + l10n.t('nodeCapabilityFlashlightSubtitle'), Icons.flashlight_on, ), _capabilityTile( theme, - 'Vibration', - 'Trigger haptic feedback and vibration patterns', + l10n.t('nodeCapabilityVibrationTitle'), + l10n.t('nodeCapabilityVibrationSubtitle'), Icons.vibration, ), _capabilityTile( theme, - 'Sensors', - 'Read accelerometer, gyroscope, magnetometer, barometer', + l10n.t('nodeCapabilitySensorsTitle'), + l10n.t('nodeCapabilitySensorsSubtitle'), Icons.sensors, ), _capabilityTile( theme, - 'Serial', - 'Bluetooth and USB serial communication', + l10n.t('nodeCapabilitySerialTitle'), + l10n.t('nodeCapabilitySerialSubtitle'), Icons.usb, ), const SizedBox(height: 16), // Device Info if (state.deviceId != null) ...[ - _sectionHeader(theme, 'DEVICE INFO'), + _sectionHeader(theme, l10n.t('nodeDeviceInfo')), ListTile( - title: const Text('Device ID'), + title: Text(l10n.t('nodeDeviceId')), subtitle: SelectableText( state.deviceId!, - style: const TextStyle(fontFamily: 'monospace', fontSize: 12), + style: const TextStyle( + fontFamily: 'monospace', fontSize: 12), ), leading: const Icon(Icons.fingerprint), ), @@ -240,7 +251,7 @@ class _NodeScreenState extends State { const SizedBox(height: 16), // Logs - _sectionHeader(theme, 'NODE LOGS'), + _sectionHeader(theme, l10n.t('nodeLogs')), Card( child: Container( height: 200, @@ -248,7 +259,7 @@ class _NodeScreenState extends State { child: state.logs.isEmpty ? Center( child: Text( - 'No logs yet', + l10n.t('nodeNoLogs'), style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), @@ -258,7 +269,8 @@ class _NodeScreenState extends State { reverse: true, itemCount: state.logs.length, itemBuilder: (context, index) { - final log = state.logs[state.logs.length - 1 - index]; + final log = + state.logs[state.logs.length - 1 - index]; return Text( log, style: const TextStyle( diff --git a/flutter_app/lib/screens/onboarding_screen.dart b/flutter_app/lib/screens/onboarding_screen.dart index b2e409e..6627d47 100644 --- a/flutter_app/lib/screens/onboarding_screen.dart +++ b/flutter_app/lib/screens/onboarding_screen.dart @@ -6,6 +6,7 @@ import 'package:xterm/xterm.dart'; import 'package:flutter_pty/flutter_pty.dart'; import 'package:url_launcher/url_launcher.dart'; import '../constants.dart'; +import '../l10n/app_localizations.dart'; import '../services/native_bridge.dart'; import '../services/screenshot_service.dart'; import '../services/terminal_service.dart'; @@ -38,8 +39,10 @@ class _OnboardingScreenState extends State { final _altNotifier = ValueNotifier(false); final _screenshotKey = GlobalKey(); static final _anyUrlRegex = RegExp(r'https?://[^\s<>\[\]"' "'" r'\)]+'); - static final _tokenUrlRegex = RegExp(r'https?://(?:localhost|127\.0\.0\.1):18789/#token=[0-9a-f]+'); + static final _tokenUrlRegex = + RegExp(r'https?://(?:localhost|127\.0\.0\.1):18789/#token=[0-9a-f]+'); static final _ansiEscape = AppConstants.ansiEscape; + /// Box-drawing and other TUI characters that break URLs when copied static final _boxDrawing = RegExp(r'[│┤├┬┴┼╮╯╰╭─╌╴╶┌┐└┘◇◆]+'); static final _completionPattern = RegExp( @@ -80,8 +83,12 @@ class _OnboardingScreenState extends State { _pty = null; try { // Ensure dirs + resolv.conf exist before proot starts (#40). - try { await NativeBridge.setupDirs(); } catch (_) {} - try { await NativeBridge.writeResolv(); } catch (_) {} + try { + await NativeBridge.setupDirs(); + } catch (_) {} + try { + await NativeBridge.writeResolv(); + } catch (_) {} try { final filesDir = await NativeBridge.getFilesDir(); const resolvContent = 'nameserver 8.8.8.8\nnameserver 8.8.4.4\n'; @@ -112,13 +119,14 @@ class _OnboardingScreenState extends State { onboardingArgs.removeLast(); // remove '-l' onboardingArgs.removeLast(); // remove '/bin/bash' onboardingArgs.addAll([ - '/bin/bash', '-lc', + '/bin/bash', + '-lc', 'echo "=== OpenClaw Onboarding ===" && ' - 'echo "Configure your API keys and binding settings." && ' - 'echo "TIP: Select Loopback (127.0.0.1) when asked for binding!" && ' - 'echo "" && ' - 'openclaw onboard; ' - 'echo "" && echo "Onboarding complete! You can close this screen."', + 'echo "Configure your API keys and binding settings." && ' + 'echo "TIP: Select Loopback (127.0.0.1) when asked for binding!" && ' + 'echo "" && ' + 'openclaw onboard; ' + 'echo "" && echo "Onboarding complete! You can close this screen."', ]); _pty = Pty.start( @@ -187,11 +195,14 @@ class _OnboardingScreenState extends State { _pty?.resize(h, w); }; + if (!mounted) return; setState(() => _loading = false); } catch (e) { + if (!mounted) return; + final message = context.l10n.t('onboardingStartFailed', {'error': '$e'}); setState(() { _loading = false; - _error = 'Failed to start onboarding: $e'; + _error = message; }); } } @@ -234,7 +245,8 @@ class _OnboardingScreenState extends State { /// chars and rejoining lines, but splitting on `http` boundaries /// so concatenated URLs don't merge into one. String? _extractUrl(String text) { - final clean = text.replaceAll(_boxDrawing, '').replaceAll(RegExp(r'\s+'), ''); + final clean = + text.replaceAll(_boxDrawing, '').replaceAll(RegExp(r'\s+'), ''); // Split before each http(s):// so concatenated URLs become separate final parts = clean.split(RegExp(r'(?=https?://)')); // Return the longest URL match (token URLs are longest) @@ -262,10 +274,10 @@ class _OnboardingScreenState extends State { if (url != null) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: const Text('Copied to clipboard'), + content: Text(context.l10n.t('commonCopiedToClipboard')), duration: const Duration(seconds: 3), action: SnackBarAction( - label: 'Open', + label: context.l10n.t('commonOpen'), onPressed: () { final uri = Uri.tryParse(url); if (uri != null) { @@ -277,9 +289,9 @@ class _OnboardingScreenState extends State { ); } else { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Copied to clipboard'), - duration: Duration(seconds: 1), + SnackBar( + content: Text(context.l10n.t('commonCopiedToClipboard')), + duration: const Duration(seconds: 1), ), ); } @@ -298,9 +310,9 @@ class _OnboardingScreenState extends State { } } ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('No URL found in selection'), - duration: Duration(seconds: 1), + SnackBar( + content: Text(context.l10n.t('commonNoUrlFound')), + duration: const Duration(seconds: 1), ), ); } @@ -313,13 +325,16 @@ class _OnboardingScreenState extends State { } Future _takeScreenshot() async { - final path = await ScreenshotService.capture(_screenshotKey, prefix: 'onboarding'); + final path = + await ScreenshotService.capture(_screenshotKey, prefix: 'onboarding'); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(path != null - ? 'Screenshot saved: ${path.split('/').last}' - : 'Failed to capture screenshot'), + ? context.l10n.t('commonScreenshotSaved', { + 'fileName': path.split('/').last, + }) + : context.l10n.t('commonSaveFailed')), ), ); } @@ -364,29 +379,29 @@ class _OnboardingScreenState extends State { final shouldOpen = await showDialog( context: context, builder: (ctx) => AlertDialog( - title: const Text('Open Link'), + title: Text(context.l10n.t('commonOpenLink')), content: Text(url), actions: [ TextButton( onPressed: () => Navigator.pop(ctx, false), - child: const Text('Cancel'), + child: Text(context.l10n.t('commonCancel')), ), TextButton( onPressed: () { Clipboard.setData(ClipboardData(text: url)); ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Link copied'), - duration: Duration(seconds: 1), + SnackBar( + content: Text(context.l10n.t('commonLinkCopied')), + duration: const Duration(seconds: 1), ), ); Navigator.pop(ctx, false); }, - child: const Text('Copy'), + child: Text(context.l10n.t('commonCopy')), ), FilledButton( onPressed: () => Navigator.pop(ctx, true), - child: const Text('Open'), + child: Text(context.l10n.t('commonOpen')), ), ], ), @@ -413,9 +428,11 @@ class _OnboardingScreenState extends State { @override Widget build(BuildContext context) { + final l10n = context.l10n; + return Scaffold( appBar: AppBar( - title: const Text('OpenClaw Onboarding'), + title: Text(l10n.t('onboardingTitle')), leading: widget.isFirstRun ? null // no back button during first-run : IconButton( @@ -426,22 +443,22 @@ class _OnboardingScreenState extends State { actions: [ IconButton( icon: const Icon(Icons.camera_alt_outlined), - tooltip: 'Screenshot', + tooltip: l10n.t('commonScreenshot'), onPressed: _takeScreenshot, ), IconButton( icon: const Icon(Icons.copy), - tooltip: 'Copy', + tooltip: l10n.t('commonCopy'), onPressed: _copySelection, ), IconButton( icon: const Icon(Icons.open_in_browser), - tooltip: 'Open URL', + tooltip: l10n.t('commonOpen'), onPressed: _openSelection, ), IconButton( icon: const Icon(Icons.paste), - tooltip: 'Paste', + tooltip: l10n.t('commonPaste'), onPressed: _paste, ), ], @@ -449,14 +466,14 @@ class _OnboardingScreenState extends State { body: Column( children: [ if (_loading) - const Expanded( + Expanded( child: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - CircularProgressIndicator(), - SizedBox(height: 16), - Text('Starting onboarding...'), + const CircularProgressIndicator(), + const SizedBox(height: 16), + Text(l10n.t('onboardingStarting')), ], ), ), @@ -478,7 +495,8 @@ class _OnboardingScreenState extends State { Text( _error!, textAlign: TextAlign.center, - style: TextStyle(color: Theme.of(context).colorScheme.error), + style: TextStyle( + color: Theme.of(context).colorScheme.error), ), const SizedBox(height: 16), FilledButton.icon( @@ -491,7 +509,7 @@ class _OnboardingScreenState extends State { _startOnboarding(); }, icon: const Icon(Icons.refresh), - label: const Text('Retry'), + label: Text(l10n.t('commonRetry')), ), ], ), @@ -530,12 +548,11 @@ class _OnboardingScreenState extends State { onPressed: widget.isFirstRun ? _goToDashboard : () => Navigator.of(context).pop(), - icon: Icon(widget.isFirstRun - ? Icons.arrow_forward - : Icons.check), + icon: Icon( + widget.isFirstRun ? Icons.arrow_forward : Icons.check), label: Text(widget.isFirstRun - ? 'Go to Dashboard' - : 'Done'), + ? l10n.t('onboardingGoToDashboard') + : l10n.t('commonDone')), ), ), ), diff --git a/flutter_app/lib/screens/packages_screen.dart b/flutter_app/lib/screens/packages_screen.dart index 9324309..021fffd 100644 --- a/flutter_app/lib/screens/packages_screen.dart +++ b/flutter_app/lib/screens/packages_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import '../app.dart'; +import '../l10n/app_localizations.dart'; import '../models/optional_package.dart'; import '../services/package_service.dart'; import 'package_install_screen.dart'; @@ -50,24 +51,25 @@ class _PackagesScreenState extends State { } void _confirmUninstall(OptionalPackage package) { + final l10n = context.l10n; showDialog( context: context, builder: (ctx) => AlertDialog( - title: Text('Uninstall ${package.name}?'), + title: Text(l10n.t('packagesUninstallTitle', {'name': package.name})), content: Text( - 'This will remove ${package.name} from the environment.', + l10n.t('packagesUninstallDescription', {'name': package.name}), ), actions: [ TextButton( onPressed: () => Navigator.pop(ctx), - child: const Text('Cancel'), + child: Text(l10n.t('commonCancel')), ), FilledButton( onPressed: () { Navigator.pop(ctx); _navigateToInstall(package, isUninstall: true); }, - child: const Text('Uninstall'), + child: Text(l10n.t('packagesUninstall')), ), ], ), @@ -78,29 +80,35 @@ class _PackagesScreenState extends State { Widget build(BuildContext context) { final theme = Theme.of(context); final isDark = theme.brightness == Brightness.dark; + final l10n = context.l10n; return Scaffold( - appBar: AppBar(title: const Text('Optional Packages')), + appBar: AppBar(title: Text(l10n.t('packagesTitle'))), body: _loading ? const Center(child: CircularProgressIndicator()) : ListView( padding: const EdgeInsets.all(16), children: [ Text( - 'Development tools you can install inside the Ubuntu environment.', + l10n.t('packagesDescription'), style: theme.textTheme.bodyMedium?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), const SizedBox(height: 16), for (final pkg in OptionalPackage.all) - _buildPackageCard(theme, pkg, isDark), + _buildPackageCard(theme, l10n, pkg, isDark), ], ), ); } - Widget _buildPackageCard(ThemeData theme, OptionalPackage package, bool isDark) { + Widget _buildPackageCard( + ThemeData theme, + AppLocalizations l10n, + OptionalPackage package, + bool isDark, + ) { final installed = _statuses[package.id] ?? false; final iconBg = isDark ? AppColors.darkSurfaceAlt : const Color(0xFFF3F4F6); @@ -117,7 +125,8 @@ class _PackagesScreenState extends State { color: iconBg, borderRadius: BorderRadius.circular(12), ), - child: Icon(package.icon, color: theme.colorScheme.onSurfaceVariant), + child: + Icon(package.icon, color: theme.colorScheme.onSurfaceVariant), ), const SizedBox(width: 16), Expanded( @@ -144,7 +153,7 @@ class _PackagesScreenState extends State { borderRadius: BorderRadius.circular(12), ), child: Text( - 'Installed', + l10n.t('commonInstalled'), style: theme.textTheme.labelSmall?.copyWith( color: AppColors.statusGreen, fontWeight: FontWeight.w600, @@ -156,7 +165,7 @@ class _PackagesScreenState extends State { ), const SizedBox(height: 4), Text( - package.description, + _packageDescription(l10n, package), style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), @@ -175,15 +184,28 @@ class _PackagesScreenState extends State { installed ? OutlinedButton( onPressed: () => _confirmUninstall(package), - child: const Text('Uninstall'), + child: Text(l10n.t('packagesUninstall')), ) : FilledButton( onPressed: () => _navigateToInstall(package), - child: const Text('Install'), + child: Text(l10n.t('packagesInstall')), ), ], ), ), ); } + + String _packageDescription(AppLocalizations l10n, OptionalPackage package) { + switch (package.id) { + case 'go': + return l10n.t('packageGoDescription'); + case 'brew': + return l10n.t('packageBrewDescription'); + case 'ssh': + return l10n.t('packageSshDescription'); + default: + return package.description; + } + } } diff --git a/flutter_app/lib/screens/provider_detail_screen.dart b/flutter_app/lib/screens/provider_detail_screen.dart index 8601b88..c00962c 100644 --- a/flutter_app/lib/screens/provider_detail_screen.dart +++ b/flutter_app/lib/screens/provider_detail_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import '../app.dart'; +import '../l10n/app_localizations.dart'; import '../models/ai_provider.dart'; import '../services/provider_config_service.dart'; @@ -7,12 +8,14 @@ import '../services/provider_config_service.dart'; class ProviderDetailScreen extends StatefulWidget { final AiProvider provider; final String? existingApiKey; + final String? existingBaseUrl; final String? existingModel; const ProviderDetailScreen({ super.key, required this.provider, this.existingApiKey, + this.existingBaseUrl, this.existingModel, }); @@ -24,6 +27,7 @@ class _ProviderDetailScreenState extends State { static const _customModelSentinel = '__custom__'; late final TextEditingController _apiKeyController; + late final TextEditingController _baseUrlController; late final TextEditingController _customModelController; late String _selectedModel; bool _isCustomModel = false; @@ -31,19 +35,27 @@ class _ProviderDetailScreenState extends State { bool _saving = false; bool _removing = false; - bool get _isConfigured => widget.existingApiKey != null && widget.existingApiKey!.isNotEmpty; + bool get _isConfigured => + widget.existingApiKey != null && widget.existingApiKey!.isNotEmpty; /// Returns the effective model name to save. String get _effectiveModel => _isCustomModel ? _customModelController.text.trim() : _selectedModel; + bool get _supportsCustomBaseUrl => widget.provider.supportsCustomBaseUrl; + @override void initState() { super.initState(); - _apiKeyController = TextEditingController(text: widget.existingApiKey ?? ''); + _apiKeyController = + TextEditingController(text: widget.existingApiKey ?? ''); + _baseUrlController = TextEditingController( + text: widget.existingBaseUrl ?? widget.provider.baseUrl, + ); _customModelController = TextEditingController(); - final existing = widget.existingModel ?? widget.provider.defaultModels.first; + final existing = + widget.existingModel ?? widget.provider.defaultModels.first; if (widget.provider.defaultModels.contains(existing)) { _selectedModel = existing; } else { @@ -57,22 +69,36 @@ class _ProviderDetailScreenState extends State { @override void dispose() { _apiKeyController.dispose(); + _baseUrlController.dispose(); _customModelController.dispose(); super.dispose(); } + bool _isValidBaseUrl(String value) { + final uri = Uri.tryParse(value); + return uri != null && uri.hasScheme && uri.hasAuthority; + } + Future _save() async { + final l10n = context.l10n; final apiKey = _apiKeyController.text.trim(); if (apiKey.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('API key cannot be empty')), + SnackBar(content: Text(l10n.t('providerDetailApiKeyEmpty'))), + ); + return; + } + final baseUrl = _baseUrlController.text.trim(); + if (_supportsCustomBaseUrl && !_isValidBaseUrl(baseUrl)) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l10n.t('providerDetailEndpointInvalid'))), ); return; } final model = _effectiveModel; if (model.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Model name cannot be empty')), + SnackBar(content: Text(l10n.t('providerDetailModelEmpty'))), ); return; } @@ -82,18 +108,27 @@ class _ProviderDetailScreenState extends State { await ProviderConfigService.saveProviderConfig( provider: widget.provider, apiKey: apiKey, + baseUrl: baseUrl, model: model, ); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('${widget.provider.name} configured and activated')), + SnackBar( + content: Text( + l10n.t('providerDetailSaved', { + 'provider': widget.provider.name(l10n), + }), + ), + ), ); Navigator.of(context).pop(true); } } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Failed to save: $e')), + SnackBar( + content: Text(l10n.t('providerDetailSaveFailed', {'error': '$e'})), + ), ); } } finally { @@ -102,19 +137,24 @@ class _ProviderDetailScreenState extends State { } Future _remove() async { + final l10n = context.l10n; final confirmed = await showDialog( context: context, builder: (ctx) => AlertDialog( - title: Text('Remove ${widget.provider.name}?'), - content: const Text('This will delete the API key and deactivate the model.'), + title: Text( + l10n.t('providerDetailRemoveTitle', { + 'provider': widget.provider.name(l10n), + }), + ), + content: Text(l10n.t('providerDetailRemoveBody')), actions: [ TextButton( onPressed: () => Navigator.pop(ctx, false), - child: const Text('Cancel'), + child: Text(l10n.t('commonCancel')), ), FilledButton( onPressed: () => Navigator.pop(ctx, true), - child: const Text('Remove'), + child: Text(l10n.t('providerDetailRemoveAction')), ), ], ), @@ -124,17 +164,28 @@ class _ProviderDetailScreenState extends State { setState(() => _removing = true); try { - await ProviderConfigService.removeProviderConfig(provider: widget.provider); + await ProviderConfigService.removeProviderConfig( + provider: widget.provider); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('${widget.provider.name} removed')), + SnackBar( + content: Text( + l10n.t('providerDetailRemoved', { + 'provider': widget.provider.name(l10n), + }), + ), + ), ); Navigator.of(context).pop(true); } } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Failed to remove: $e')), + SnackBar( + content: Text( + l10n.t('providerDetailRemoveFailed', {'error': '$e'}), + ), + ), ); } } finally { @@ -144,12 +195,13 @@ class _ProviderDetailScreenState extends State { @override Widget build(BuildContext context) { + final l10n = context.l10n; final theme = Theme.of(context); final isDark = theme.brightness == Brightness.dark; final iconBg = isDark ? AppColors.darkSurfaceAlt : const Color(0xFFF3F4F6); return Scaffold( - appBar: AppBar(title: Text(widget.provider.name)), + appBar: AppBar(title: Text(widget.provider.name(l10n))), body: ListView( padding: const EdgeInsets.all(16), children: [ @@ -166,7 +218,8 @@ class _ProviderDetailScreenState extends State { color: iconBg, borderRadius: BorderRadius.circular(12), ), - child: Icon(widget.provider.icon, color: widget.provider.color), + child: Icon(widget.provider.icon, + color: widget.provider.color), ), const SizedBox(width: 16), Expanded( @@ -174,14 +227,14 @@ class _ProviderDetailScreenState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - widget.provider.name, + widget.provider.name(l10n), style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w600, ), ), const SizedBox(height: 4), Text( - widget.provider.description, + widget.provider.description(l10n), style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), @@ -197,8 +250,9 @@ class _ProviderDetailScreenState extends State { // API Key Text( - 'API Key', - style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600), + l10n.t('providerDetailApiKey'), + style: theme.textTheme.titleSmall + ?.copyWith(fontWeight: FontWeight.w600), ), const SizedBox(height: 8), TextField( @@ -207,17 +261,37 @@ class _ProviderDetailScreenState extends State { decoration: InputDecoration( hintText: widget.provider.apiKeyHint, suffixIcon: IconButton( - icon: Icon(_obscureKey ? Icons.visibility_off : Icons.visibility), + icon: + Icon(_obscureKey ? Icons.visibility_off : Icons.visibility), onPressed: () => setState(() => _obscureKey = !_obscureKey), ), ), ), + if (_supportsCustomBaseUrl) ...[ + const SizedBox(height: 24), + Text( + l10n.t('providerDetailEndpoint'), + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + TextField( + controller: _baseUrlController, + keyboardType: TextInputType.url, + decoration: InputDecoration( + hintText: widget.provider.baseUrl, + helperText: l10n.t('providerDetailEndpointHelper'), + ), + ), + ], const SizedBox(height: 24), // Model selection Text( - 'Model', - style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600), + l10n.t('providerDetailModel'), + style: theme.textTheme.titleSmall + ?.copyWith(fontWeight: FontWeight.w600), ), const SizedBox(height: 8), DropdownButtonFormField( @@ -227,9 +301,9 @@ class _ProviderDetailScreenState extends State { items: [ ...widget.provider.defaultModels .map((m) => DropdownMenuItem(value: m, child: Text(m))), - const DropdownMenuItem( + DropdownMenuItem( value: _customModelSentinel, - child: Text('Custom...'), + child: Text(l10n.t('providerDetailCustomModelAction')), ), ], onChanged: (value) { @@ -245,9 +319,9 @@ class _ProviderDetailScreenState extends State { const SizedBox(height: 12), TextField( controller: _customModelController, - decoration: const InputDecoration( - hintText: 'e.g. meta/llama-3.3-70b-instruct', - labelText: 'Custom model name', + decoration: InputDecoration( + hintText: l10n.t('providerDetailCustomModelHint'), + labelText: l10n.t('providerDetailCustomModelLabel'), ), ), ], @@ -260,9 +334,10 @@ class _ProviderDetailScreenState extends State { ? const SizedBox( height: 20, width: 20, - child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), + child: CircularProgressIndicator( + strokeWidth: 2, color: Colors.white), ) - : const Text('Save & Activate'), + : Text(l10n.t('providerDetailSaveAction')), ), if (_isConfigured) ...[ const SizedBox(height: 12), @@ -274,7 +349,7 @@ class _ProviderDetailScreenState extends State { width: 20, child: CircularProgressIndicator(strokeWidth: 2), ) - : const Text('Remove Configuration'), + : Text(l10n.t('providerDetailRemoveConfiguration')), ), ], ], diff --git a/flutter_app/lib/screens/providers_screen.dart b/flutter_app/lib/screens/providers_screen.dart index 3bbb535..96ca083 100644 --- a/flutter_app/lib/screens/providers_screen.dart +++ b/flutter_app/lib/screens/providers_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import '../app.dart'; +import '../l10n/app_localizations.dart'; import '../models/ai_provider.dart'; import '../services/provider_config_service.dart'; import 'provider_detail_screen.dart'; @@ -41,7 +42,8 @@ class _ProvidersScreenState extends State { builder: (_) => ProviderDetailScreen( provider: provider, existingApiKey: providerConfig?['apiKey'] as String?, - existingModel: _activeModel, + existingBaseUrl: providerConfig?['baseUrl'] as String?, + existingModel: providerConfig?['model'] as String? ?? _activeModel, ), ), ); @@ -50,25 +52,36 @@ class _ProvidersScreenState extends State { } } - String _statusLabel(AiProvider provider) { + ({String label, bool isActive}) _statusInfo(AiProvider provider) { + final l10n = context.l10n; final isConfigured = _providers.containsKey(provider.id); - if (!isConfigured) return ''; - // Check if the active model belongs to this provider + if (!isConfigured) { + return (label: '', isActive: false); + } + + final providerConfig = _providers[provider.id] as Map?; + final configuredModel = providerConfig?['model'] as String?; if (_activeModel != null) { - final isActive = provider.defaultModels.any((m) => _activeModel!.contains(m)) || - _activeModel!.contains(provider.id); - if (isActive) return 'Active'; + final isActive = configuredModel == _activeModel || + provider.matchesModel(_activeModel!); + if (isActive) { + return (label: l10n.t('providersStatusActive'), isActive: true); + } } - return 'Configured'; + return ( + label: l10n.t('providersStatusConfigured'), + isActive: false, + ); } @override Widget build(BuildContext context) { + final l10n = context.l10n; final theme = Theme.of(context); final isDark = theme.brightness == Brightness.dark; return Scaffold( - appBar: AppBar(title: const Text('AI Providers')), + appBar: AppBar(title: Text(l10n.t('providersScreenTitle'))), body: _loading ? const Center(child: CircularProgressIndicator()) : ListView( @@ -98,7 +111,7 @@ class _ProvidersScreenState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Active Model', + l10n.t('providersScreenActiveModel'), style: theme.textTheme.labelSmall?.copyWith( color: AppColors.statusGreen, fontWeight: FontWeight.w600, @@ -121,7 +134,7 @@ class _ProvidersScreenState extends State { const SizedBox(height: 16), ], Text( - 'Select a provider to configure its API key and model.', + l10n.t('providersScreenIntro'), style: theme.textTheme.bodyMedium?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), @@ -136,7 +149,8 @@ class _ProvidersScreenState extends State { Widget _buildProviderCard(ThemeData theme, AiProvider provider, bool isDark) { final iconBg = isDark ? AppColors.darkSurfaceAlt : const Color(0xFFF3F4F6); - final status = _statusLabel(provider); + final status = _statusInfo(provider); + final l10n = context.l10n; return Card( margin: const EdgeInsets.only(bottom: 12), @@ -164,12 +178,12 @@ class _ProvidersScreenState extends State { Row( children: [ Text( - provider.name, + provider.name(l10n), style: theme.textTheme.titleSmall?.copyWith( fontWeight: FontWeight.w600, ), ), - if (status.isNotEmpty) ...[ + if (status.label.isNotEmpty) ...[ const SizedBox(width: 8), Container( padding: const EdgeInsets.symmetric( @@ -177,16 +191,16 @@ class _ProvidersScreenState extends State { vertical: 2, ), decoration: BoxDecoration( - color: (status == 'Active' + color: (status.isActive ? AppColors.statusGreen : AppColors.statusAmber) .withAlpha(25), borderRadius: BorderRadius.circular(12), ), child: Text( - status, + status.label, style: theme.textTheme.labelSmall?.copyWith( - color: status == 'Active' + color: status.isActive ? AppColors.statusGreen : AppColors.statusAmber, fontWeight: FontWeight.w600, @@ -198,7 +212,7 @@ class _ProvidersScreenState extends State { ), const SizedBox(height: 4), Text( - provider.description, + provider.description(l10n), style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), diff --git a/flutter_app/lib/screens/settings_screen.dart b/flutter_app/lib/screens/settings_screen.dart index 3aad664..2a883f0 100644 --- a/flutter_app/lib/screens/settings_screen.dart +++ b/flutter_app/lib/screens/settings_screen.dart @@ -6,6 +6,8 @@ import 'package:provider/provider.dart'; import 'package:url_launcher/url_launcher.dart'; import '../app.dart'; import '../constants.dart'; +import '../l10n/app_localizations.dart'; +import '../providers/locale_provider.dart'; import '../providers/node_provider.dart'; import '../services/native_bridge.dart'; import '../services/preferences_service.dart'; @@ -83,32 +85,69 @@ class _SettingsScreenState extends State { @override Widget build(BuildContext context) { final theme = Theme.of(context); + final l10n = context.l10n; + final localeProvider = context.watch(); return Scaffold( - appBar: AppBar(title: const Text('Settings')), + appBar: AppBar(title: Text(l10n.t('settingsTitle'))), body: _loading ? const Center(child: CircularProgressIndicator()) : ListView( children: [ - _sectionHeader(theme, 'GENERAL'), + _sectionHeader(theme, l10n.t('settingsGeneral')), SwitchListTile( - title: const Text('Auto-start gateway'), - subtitle: const Text('Start the gateway when the app opens'), + title: Text(l10n.t('settingsAutoStart')), + subtitle: Text(l10n.t('settingsAutoStartSubtitle')), value: _autoStart, onChanged: (value) { setState(() => _autoStart = value); _prefs.autoStartGateway = value; }, ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), + child: DropdownButtonFormField( + value: localeProvider.localeCode, + decoration: InputDecoration(labelText: l10n.t('language')), + items: [ + DropdownMenuItem( + value: 'system', + child: Text(l10n.t('languageSystem')), + ), + DropdownMenuItem( + value: 'en', + child: Text(l10n.t('languageEnglish')), + ), + DropdownMenuItem( + value: 'zh', + child: Text(l10n.t('languageChinese')), + ), + DropdownMenuItem( + value: 'zh-Hant', + child: Text(l10n.t('languageTraditionalChinese')), + ), + DropdownMenuItem( + value: 'ja', + child: Text(l10n.t('languageJapanese')), + ), + ], + onChanged: (value) { + if (value != null) { + localeProvider.setLocaleCode(value); + } + }, + ), + ), ListTile( - title: const Text('Battery Optimization'), + title: Text(l10n.t('settingsBatteryOptimization')), subtitle: Text(_batteryOptimized - ? 'Optimized (may kill background sessions)' - : 'Unrestricted (recommended)'), + ? l10n.t('settingsBatteryOptimized') + : l10n.t('settingsBatteryUnrestricted')), leading: const Icon(Icons.battery_alert), trailing: _batteryOptimized ? const Icon(Icons.warning, color: AppColors.statusAmber) - : const Icon(Icons.check_circle, color: AppColors.statusGreen), + : const Icon(Icons.check_circle, + color: AppColors.statusGreen), onTap: () async { await NativeBridge.requestBatteryOptimization(); // Refresh status after returning from settings @@ -117,15 +156,40 @@ class _SettingsScreenState extends State { }, ), ListTile( - title: const Text('Setup Storage'), + title: Text(l10n.t('settingsStorage')), subtitle: Text(_storageGranted - ? 'Granted — proot can access /sdcard. Revoke if not needed.' - : 'Allow access to shared storage'), + ? l10n.t('settingsStorageGranted') + : l10n.t('settingsStorageMissing')), leading: const Icon(Icons.sd_storage), trailing: _storageGranted - ? const Icon(Icons.warning_amber, color: AppColors.statusAmber) + ? const Icon(Icons.check_circle, + color: AppColors.statusGreen) : const Icon(Icons.warning, color: AppColors.statusAmber), onTap: () async { + final shouldRequest = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(l10n.t('settingsStorageDialogTitle')), + content: Text(l10n.t('settingsStorageDialogBody')), + actions: [ + TextButton( + onPressed: () => + Navigator.of(dialogContext).pop(false), + child: Text(l10n.t('commonCancel')), + ), + FilledButton( + onPressed: () => + Navigator.of(dialogContext).pop(true), + child: Text(l10n.t('settingsStorageDialogAction')), + ), + ], + ), + ); + + if (shouldRequest != true) { + return; + } + await NativeBridge.requestStoragePermission(); // Refresh after returning from permission screen final granted = await NativeBridge.hasStoragePermission(); @@ -133,10 +197,10 @@ class _SettingsScreenState extends State { }, ), const Divider(), - _sectionHeader(theme, 'NODE'), + _sectionHeader(theme, l10n.t('settingsNode')), SwitchListTile( - title: const Text('Enable Node'), - subtitle: const Text('Provide device capabilities to the gateway'), + title: Text(l10n.t('settingsEnableNode')), + subtitle: Text(l10n.t('settingsEnableNodeSubtitle')), value: _nodeEnabled, onChanged: (value) { setState(() => _nodeEnabled = value); @@ -150,8 +214,8 @@ class _SettingsScreenState extends State { }, ), ListTile( - title: const Text('Node Configuration'), - subtitle: const Text('Connection, pairing, and capabilities'), + title: Text(l10n.t('settingsNodeConfiguration')), + subtitle: Text(l10n.t('settingsNodeConfigurationSubtitle')), leading: const Icon(Icons.devices), trailing: const Icon(Icons.chevron_right), onTap: () => Navigator.of(context).push( @@ -159,78 +223,78 @@ class _SettingsScreenState extends State { ), ), const Divider(), - _sectionHeader(theme, 'SYSTEM INFO'), + _sectionHeader(theme, l10n.t('settingsSystemInfo')), ListTile( - title: const Text('Architecture'), + title: Text(l10n.t('settingsArchitecture')), subtitle: Text(_arch), leading: const Icon(Icons.memory), ), ListTile( - title: const Text('PRoot path'), + title: Text(l10n.t('settingsProotPath')), subtitle: Text(_prootPath), leading: const Icon(Icons.folder), ), ListTile( - title: const Text('Rootfs'), + title: Text(l10n.t('settingsRootfs')), subtitle: Text(_status['rootfsExists'] == true - ? 'Installed' - : 'Not installed'), + ? l10n.t('statusInstalled') + : l10n.t('statusNotInstalled')), leading: const Icon(Icons.storage), ), ListTile( - title: const Text('Node.js'), + title: Text(l10n.t('settingsNodeJs')), subtitle: Text(_status['nodeInstalled'] == true - ? 'Installed' - : 'Not installed'), + ? l10n.t('statusInstalled') + : l10n.t('statusNotInstalled')), leading: const Icon(Icons.code), ), ListTile( - title: const Text('OpenClaw'), + title: Text(l10n.t('settingsOpenClaw')), subtitle: Text(_status['openclawInstalled'] == true - ? 'Installed' - : 'Not installed'), + ? l10n.t('statusInstalled') + : l10n.t('statusNotInstalled')), leading: const Icon(Icons.cloud), ), ListTile( - title: const Text('Go (Golang)'), + title: Text(l10n.t('settingsGo')), subtitle: Text(_goInstalled - ? 'Installed' - : 'Not installed'), + ? l10n.t('statusInstalled') + : l10n.t('statusNotInstalled')), leading: const Icon(Icons.integration_instructions), ), ListTile( - title: const Text('Homebrew'), + title: Text(l10n.t('settingsHomebrew')), subtitle: Text(_brewInstalled - ? 'Installed' - : 'Not installed'), + ? l10n.t('statusInstalled') + : l10n.t('statusNotInstalled')), leading: const Icon(Icons.science), ), ListTile( - title: const Text('OpenSSH'), + title: Text(l10n.t('settingsOpenSsh')), subtitle: Text(_sshInstalled - ? 'Installed' - : 'Not installed'), + ? l10n.t('statusInstalled') + : l10n.t('statusNotInstalled')), leading: const Icon(Icons.vpn_key), ), const Divider(), - _sectionHeader(theme, 'MAINTENANCE'), + _sectionHeader(theme, l10n.t('settingsMaintenance')), ListTile( - title: const Text('Export Snapshot'), - subtitle: const Text('Backup config to Downloads'), + title: Text(l10n.t('settingsExportSnapshot')), + subtitle: Text(l10n.t('settingsExportSnapshotSubtitle')), leading: const Icon(Icons.upload_file), trailing: const Icon(Icons.chevron_right), onTap: _exportSnapshot, ), ListTile( - title: const Text('Import Snapshot'), - subtitle: const Text('Restore config from backup'), + title: Text(l10n.t('settingsImportSnapshot')), + subtitle: Text(l10n.t('settingsImportSnapshotSubtitle')), leading: const Icon(Icons.download), trailing: const Icon(Icons.chevron_right), onTap: _importSnapshot, ), ListTile( - title: const Text('Re-run setup'), - subtitle: const Text('Reinstall or repair the environment'), + title: Text(l10n.t('settingsRerunSetup')), + subtitle: Text(l10n.t('settingsRerunSetupSubtitle')), leading: const Icon(Icons.build), trailing: const Icon(Icons.chevron_right), onTap: () => Navigator.of(context).pushReplacement( @@ -240,15 +304,21 @@ class _SettingsScreenState extends State { ), ), const Divider(), - _sectionHeader(theme, 'ABOUT'), - const ListTile( - title: Text('OpenClaw'), + _sectionHeader(theme, l10n.t('settingsAbout')), + ListTile( + title: Text(l10n.t('settingsOpenClaw')), subtitle: Text( - 'AI Gateway for Android\nVersion ${AppConstants.version}', + l10n.t('settingsAboutSubtitle', + {'version': AppConstants.version}), ), - leading: Icon(Icons.info_outline), + leading: const Icon(Icons.info_outline), isThreeLine: true, ), + ListTile( + title: Text(l10n.t('settingsDeveloper')), + subtitle: const Text(AppConstants.authorName), + leading: const Icon(Icons.person), + ), ListTile( title: const Text('Check for Updates'), subtitle: const Text('Check GitHub for a newer release'), @@ -261,13 +331,8 @@ class _SettingsScreenState extends State { : const Icon(Icons.system_update), onTap: _checkingUpdate ? null : _checkForUpdates, ), - const ListTile( - title: Text('Developer'), - subtitle: Text(AppConstants.authorName), - leading: Icon(Icons.person), - ), ListTile( - title: const Text('GitHub'), + title: Text(l10n.t('settingsGithub')), subtitle: const Text('mithun50/openclaw-termux'), leading: const Icon(Icons.code), trailing: const Icon(Icons.open_in_new, size: 18), @@ -277,7 +342,7 @@ class _SettingsScreenState extends State { ), ), ListTile( - title: const Text('Contact'), + title: Text(l10n.t('settingsContact')), subtitle: const Text(AppConstants.authorEmail), leading: const Icon(Icons.email), trailing: const Icon(Icons.open_in_new, size: 18), @@ -285,10 +350,10 @@ class _SettingsScreenState extends State { Uri.parse('mailto:${AppConstants.authorEmail}'), ), ), - const ListTile( - title: Text('License'), - subtitle: Text(AppConstants.license), - leading: Icon(Icons.description), + ListTile( + title: Text(l10n.t('settingsLicense')), + subtitle: const Text(AppConstants.license), + leading: const Icon(Icons.description), ), const Divider(), _sectionHeader(theme, AppConstants.orgName.toUpperCase()), @@ -313,7 +378,7 @@ class _SettingsScreenState extends State { ), ), ListTile( - title: const Text('Play Store'), + title: Text(l10n.t('settingsPlayStore')), subtitle: const Text('NextGenX Apps'), leading: const Icon(Icons.shop), trailing: const Icon(Icons.open_in_new, size: 18), @@ -323,7 +388,7 @@ class _SettingsScreenState extends State { ), ), ListTile( - title: const Text('Email'), + title: Text(l10n.t('settingsEmail')), subtitle: const Text(AppConstants.orgEmail), leading: const Icon(Icons.email_outlined), trailing: const Icon(Icons.open_in_new, size: 18), @@ -353,7 +418,8 @@ class _SettingsScreenState extends State { Future _exportSnapshot() async { try { - final openclawJson = await NativeBridge.readRootfsFile('root/.openclaw/openclaw.json'); + final openclawJson = + await NativeBridge.readRootfsFile('root/.openclaw/openclaw.json'); final snapshot = { 'version': AppConstants.version, 'timestamp': DateTime.now().toIso8601String(), @@ -369,16 +435,22 @@ class _SettingsScreenState extends State { final path = await _getSnapshotPath(); final file = File(path); - await file.writeAsString(const JsonEncoder.withIndent(' ').convert(snapshot)); + await file + .writeAsString(const JsonEncoder.withIndent(' ').convert(snapshot)); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Snapshot saved to $path')), + SnackBar( + content: + Text(context.l10n.t('settingsSnapshotSaved', {'path': path})), + ), ); } catch (e) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Export failed: $e')), + SnackBar( + content: Text(context.l10n.t('settingsExportFailed', {'error': e})), + ), ); } } @@ -391,7 +463,10 @@ class _SettingsScreenState extends State { if (!await file.exists()) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('No snapshot found at $path')), + SnackBar( + content: + Text(context.l10n.t('settingsSnapshotMissing', {'path': path})), + ), ); return; } @@ -402,7 +477,8 @@ class _SettingsScreenState extends State { // Restore openclaw.json into rootfs final openclawConfig = snapshot['openclawConfig'] as String?; if (openclawConfig != null) { - await NativeBridge.writeRootfsFile('root/.openclaw/openclaw.json', openclawConfig); + await NativeBridge.writeRootfsFile( + 'root/.openclaw/openclaw.json', openclawConfig); } // Restore preferences @@ -433,12 +509,14 @@ class _SettingsScreenState extends State { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Snapshot restored successfully. Restart the gateway to apply.')), + SnackBar(content: Text(context.l10n.t('settingsSnapshotRestored'))), ); } catch (e) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Import failed: $e')), + SnackBar( + content: Text(context.l10n.t('settingsImportFailed', {'error': e})), + ), ); } } diff --git a/flutter_app/lib/screens/setup_wizard_screen.dart b/flutter_app/lib/screens/setup_wizard_screen.dart index e3d396a..46eb17e 100644 --- a/flutter_app/lib/screens/setup_wizard_screen.dart +++ b/flutter_app/lib/screens/setup_wizard_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../app.dart'; import '../constants.dart'; +import '../l10n/app_localizations.dart'; import '../models/setup_state.dart'; import '../models/optional_package.dart'; import '../providers/setup_provider.dart'; @@ -35,10 +36,18 @@ class _SetupWizardScreenState extends State { if (result == true) _refreshPkgStatuses(); } + Future _beginSetup(SetupProvider provider) async { + setState(() { + _started = true; + }); + await provider.runSetup(); + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); final isDark = theme.brightness == Brightness.dark; + final l10n = context.l10n; return Scaffold( body: SafeArea( @@ -64,7 +73,7 @@ class _SetupWizardScreenState extends State { ), const SizedBox(height: 16), Text( - 'Setup OpenClaw', + l10n.t('setupWizardTitle'), style: theme.textTheme.headlineSmall?.copyWith( fontWeight: FontWeight.bold, ), @@ -72,15 +81,15 @@ class _SetupWizardScreenState extends State { const SizedBox(height: 8), Text( _started - ? 'Setting up the environment. This may take several minutes.' - : 'This will download Ubuntu, Node.js, and OpenClaw into a self-contained environment.', + ? l10n.t('setupWizardIntroRunning') + : l10n.t('setupWizardIntroIdle'), style: theme.textTheme.bodyMedium?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), const SizedBox(height: 32), Expanded( - child: _buildSteps(state, theme, isDark), + child: _buildSteps(state, theme, isDark, l10n), ), if (state.hasError) ...[ ConstrainedBox( @@ -94,13 +103,16 @@ class _SetupWizardScreenState extends State { child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon(Icons.error_outline, color: theme.colorScheme.error), + Icon(Icons.error_outline, + color: theme.colorScheme.error), const SizedBox(width: 8), Expanded( child: SingleChildScrollView( child: Text( - state.error ?? 'Unknown error', - style: TextStyle(color: theme.colorScheme.onErrorContainer), + state.error ?? 'Unknown error', + style: TextStyle( + color: + theme.colorScheme.onErrorContainer), ), ), ), @@ -116,7 +128,7 @@ class _SetupWizardScreenState extends State { child: FilledButton.icon( onPressed: () => _goToOnboarding(context), icon: const Icon(Icons.arrow_forward), - label: const Text('Configure API Keys'), + label: Text(l10n.t('setupWizardConfigureApiKeys')), ), ) else if (!_started || state.hasError) @@ -125,19 +137,20 @@ class _SetupWizardScreenState extends State { child: FilledButton.icon( onPressed: provider.isRunning ? null - : () { - setState(() => _started = true); - provider.runSetup(); - }, + : () => _beginSetup(provider), icon: const Icon(Icons.download), - label: Text(_started ? 'Retry Setup' : 'Begin Setup'), + label: Text( + _started + ? l10n.t('setupWizardRetry') + : l10n.t('setupWizardBegin'), + ), ), ), if (!_started) ...[ const SizedBox(height: 8), Center( child: Text( - 'Requires ~500MB of storage and an internet connection', + l10n.t('setupWizardRequirements'), style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), @@ -162,13 +175,26 @@ class _SetupWizardScreenState extends State { ); } - Widget _buildSteps(SetupState state, ThemeData theme, bool isDark) { + Widget _buildSteps( + SetupState state, + ThemeData theme, + bool isDark, + AppLocalizations l10n, + ) { final steps = [ - (1, 'Download Ubuntu rootfs', SetupStep.downloadingRootfs), - (2, 'Extract rootfs', SetupStep.extractingRootfs), - (3, 'Install Node.js', SetupStep.installingNode), - (4, 'Install OpenClaw', SetupStep.installingOpenClaw), - (5, 'Configure Bionic Bypass', SetupStep.configuringBypass), + (1, l10n.t('setupWizardStepDownloadRootfs'), SetupStep.downloadingRootfs), + (2, l10n.t('setupWizardStepExtractRootfs'), SetupStep.extractingRootfs), + (3, l10n.t('setupWizardStepInstallNode'), SetupStep.installingNode), + ( + 4, + l10n.t('setupWizardStepInstallOpenClaw'), + SetupStep.installingOpenClaw + ), + ( + 5, + l10n.t('setupWizardStepConfigureBypass'), + SetupStep.configuringBypass + ), ]; return ListView( @@ -176,23 +202,25 @@ class _SetupWizardScreenState extends State { for (final (num, label, step) in steps) ProgressStep( stepNumber: num, - label: state.step == step ? state.message : label, + label: state.step == step + ? _localizedSetupMessage(l10n, state.message) + : label, isActive: state.step == step, isComplete: state.stepNumber > step.index + 1 || state.isComplete, hasError: state.hasError && state.step == step, progress: state.step == step ? state.progress : null, ), if (state.isComplete) ...[ - const ProgressStep( + ProgressStep( stepNumber: 6, - label: 'Setup complete!', + label: l10n.t('setupWizardComplete'), isComplete: true, ), const SizedBox(height: 24), Padding( padding: const EdgeInsets.symmetric(horizontal: 8), child: Text( - 'OPTIONAL PACKAGES', + l10n.t('setupWizardOptionalPackages'), style: theme.textTheme.labelSmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, fontWeight: FontWeight.w600, @@ -202,13 +230,18 @@ class _SetupWizardScreenState extends State { ), const SizedBox(height: 8), for (final pkg in OptionalPackage.all) - _buildPackageTile(theme, pkg, isDark), + _buildPackageTile(theme, l10n, pkg, isDark), ], ], ); } - Widget _buildPackageTile(ThemeData theme, OptionalPackage package, bool isDark) { + Widget _buildPackageTile( + ThemeData theme, + AppLocalizations l10n, + OptionalPackage package, + bool isDark, + ) { final installed = _pkgStatuses[package.id] ?? false; final iconBg = isDark ? AppColors.darkSurfaceAlt : const Color(0xFFF3F4F6); @@ -222,7 +255,8 @@ class _SetupWizardScreenState extends State { color: iconBg, borderRadius: BorderRadius.circular(10), ), - child: Icon(package.icon, color: theme.colorScheme.onSurfaceVariant, size: 22), + child: Icon(package.icon, + color: theme.colorScheme.onSurfaceVariant, size: 22), ), title: Row( children: [ @@ -231,13 +265,12 @@ class _SetupWizardScreenState extends State { if (installed) ...[ const SizedBox(width: 8), Container( - padding: - const EdgeInsets.symmetric(horizontal: 6, vertical: 1), + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), decoration: BoxDecoration( color: AppColors.statusGreen.withAlpha(25), borderRadius: BorderRadius.circular(8), ), - child: Text('Installed', + child: Text(l10n.t('commonInstalled'), style: theme.textTheme.labelSmall?.copyWith( color: AppColors.statusGreen, fontWeight: FontWeight.w600, @@ -246,17 +279,106 @@ class _SetupWizardScreenState extends State { ], ], ), - subtitle: Text('${package.description} (${package.estimatedSize})'), + subtitle: Text( + '${_packageDescription(l10n, package)} (${package.estimatedSize})'), trailing: installed ? const Icon(Icons.check_circle, color: AppColors.statusGreen) : OutlinedButton( onPressed: () => _installPackage(package), - child: const Text('Install'), + child: Text(l10n.t('packagesInstall')), ), ), ); } + String _packageDescription(AppLocalizations l10n, OptionalPackage package) { + switch (package.id) { + case 'go': + return l10n.t('packageGoDescription'); + case 'brew': + return l10n.t('packageBrewDescription'); + case 'ssh': + return l10n.t('packageSshDescription'); + default: + return package.description; + } + } + + String _localizedSetupMessage(AppLocalizations l10n, String? message) { + if (message == null || message.isEmpty) { + return ''; + } + + final downloadProgress = + RegExp(r'^Downloading: ([0-9.]+) MB / ([0-9.]+) MB$') + .firstMatch(message); + if (downloadProgress != null) { + return l10n.t('setupWizardStatusDownloadingProgress', { + 'current': downloadProgress.group(1), + 'total': downloadProgress.group(2), + }); + } + + final nodeDownloadProgress = + RegExp(r'^Downloading Node\.js: ([0-9.]+) MB / ([0-9.]+) MB$') + .firstMatch(message); + if (nodeDownloadProgress != null) { + return l10n.t('setupWizardStatusDownloadingNodeProgress', { + 'current': nodeDownloadProgress.group(1), + 'total': nodeDownloadProgress.group(2), + }); + } + + final nodeVersionMatch = + RegExp(r'^Downloading Node\.js (.+)\.\.\.$').firstMatch(message); + if (nodeVersionMatch != null) { + return l10n.t('setupWizardStatusDownloadingNode', { + 'version': nodeVersionMatch.group(1), + }); + } + + switch (message) { + case 'Setup complete': + return l10n.t('setupWizardStatusSetupComplete'); + case 'Setup required': + return l10n.t('setupWizardStatusSetupRequired'); + case 'Setting up directories...': + return l10n.t('setupWizardStatusSettingUpDirs'); + case 'Downloading Ubuntu rootfs...': + return l10n.t('setupWizardStatusDownloadingUbuntuRootfs'); + case 'Extracting rootfs (this takes a while)...': + return l10n.t('setupWizardStatusExtractingRootfs'); + case 'Rootfs extracted': + return l10n.t('setupWizardStatusRootfsExtracted'); + case 'Fixing rootfs permissions...': + return l10n.t('setupWizardStatusFixingPermissions'); + case 'Updating package lists...': + return l10n.t('setupWizardStatusUpdatingPackageLists'); + case 'Installing base packages...': + return l10n.t('setupWizardStatusInstallingBasePackages'); + case 'Extracting Node.js...': + return l10n.t('setupWizardStatusExtractingNode'); + case 'Verifying Node.js...': + return l10n.t('setupWizardStatusVerifyingNode'); + case 'Node.js installed': + return l10n.t('setupWizardStatusNodeInstalled'); + case 'Installing OpenClaw (this may take a few minutes)...': + return l10n.t('setupWizardStatusInstallingOpenClaw'); + case 'Creating bin wrappers...': + return l10n.t('setupWizardStatusCreatingBinWrappers'); + case 'Verifying OpenClaw...': + return l10n.t('setupWizardStatusVerifyingOpenClaw'); + case 'OpenClaw installed': + return l10n.t('setupWizardStatusOpenClawInstalled'); + case 'Bionic Bypass configured': + return l10n.t('setupWizardStatusBypassConfigured'); + case 'Setup complete! Ready to start the gateway.': + return l10n.t('setupWizardStatusReady'); + default: + return message; + } + } + void _goToOnboarding(BuildContext context) { Navigator.of(context).pushReplacement( MaterialPageRoute( diff --git a/flutter_app/lib/services/preferences_service.dart b/flutter_app/lib/services/preferences_service.dart index 20418a4..2ad6f7c 100644 --- a/flutter_app/lib/services/preferences_service.dart +++ b/flutter_app/lib/services/preferences_service.dart @@ -5,6 +5,7 @@ class PreferencesService { static const _keySetupComplete = 'setup_complete'; static const _keyFirstRun = 'first_run'; static const _keyDashboardUrl = 'dashboard_url'; + static const _keyLocaleCode = 'locale_code'; static const _keyNodeEnabled = 'node_enabled'; static const _keyNodeDeviceToken = 'node_device_token'; static const _keyNodeGatewayHost = 'node_gateway_host'; @@ -37,6 +38,15 @@ class PreferencesService { } } + String? get localeCode => _prefs.getString(_keyLocaleCode); + set localeCode(String? value) { + if (value != null && value.isNotEmpty) { + _prefs.setString(_keyLocaleCode, value); + } else { + _prefs.remove(_keyLocaleCode); + } + } + bool get nodeEnabled => _prefs.getBool(_keyNodeEnabled) ?? false; set nodeEnabled(bool value) => _prefs.setBool(_keyNodeEnabled, value); @@ -82,6 +92,7 @@ class PreferencesService { final val = _prefs.getInt(_keyNodeGatewayPort); return val; } + set nodeGatewayPort(int? value) { if (value != null) { _prefs.setInt(_keyNodeGatewayPort, value); diff --git a/flutter_app/lib/services/provider_config_service.dart b/flutter_app/lib/services/provider_config_service.dart index 93c1e7d..34c98e2 100644 --- a/flutter_app/lib/services/provider_config_service.dart +++ b/flutter_app/lib/services/provider_config_service.dart @@ -13,7 +13,7 @@ class ProviderConfigService { /// Read the current config and return a map with: /// - `activeModel`: the current primary model string (or null) - /// - `providers`: Map for configured providers + /// - `providers`: `Map` for configured providers static Future> readConfig() async { try { final content = await NativeBridge.readRootfsFile(_configPath); @@ -60,10 +60,14 @@ class ProviderConfigService { required AiProvider provider, required String apiKey, required String model, + String? baseUrl, }) async { + final resolvedBaseUrl = baseUrl != null && baseUrl.trim().isNotEmpty + ? baseUrl.trim() + : provider.baseUrl; final providerJson = jsonEncode({ 'apiKey': apiKey, - 'baseUrl': provider.baseUrl, + 'baseUrl': resolvedBaseUrl, 'models': [model], }); final modelJson = jsonEncode(model); @@ -94,7 +98,7 @@ fs.writeFileSync(p, JSON.stringify(c, null, 2)); await _saveConfigDirect( providerId: provider.id, apiKey: apiKey, - baseUrl: provider.baseUrl, + baseUrl: resolvedBaseUrl, model: model, ); } diff --git a/flutter_app/lib/widgets/gateway_controls.dart b/flutter_app/lib/widgets/gateway_controls.dart index 52c524b..49bc834 100644 --- a/flutter_app/lib/widgets/gateway_controls.dart +++ b/flutter_app/lib/widgets/gateway_controls.dart @@ -3,6 +3,7 @@ import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import '../app.dart'; import '../constants.dart'; +import '../l10n/app_localizations.dart'; import '../models/gateway_state.dart'; import '../providers/gateway_provider.dart'; import '../screens/logs_screen.dart'; @@ -14,6 +15,7 @@ class GatewayControls extends StatelessWidget { @override Widget build(BuildContext context) { final theme = Theme.of(context); + final l10n = context.l10n; return Consumer( builder: (context, provider, _) { @@ -29,13 +31,13 @@ class GatewayControls extends StatelessWidget { children: [ Expanded( child: Text( - 'Gateway', + l10n.t('gatewayTitle'), style: theme.textTheme.titleLarge?.copyWith( fontWeight: FontWeight.w600, ), ), ), - _statusBadge(state.status, theme), + _statusBadge(context, state.status, theme), ], ), const SizedBox(height: 8), @@ -66,21 +68,22 @@ class GatewayControls extends StatelessWidget { ), IconButton( icon: const Icon(Icons.copy, size: 18), - tooltip: 'Copy URL', + tooltip: l10n.t('gatewayCopyUrl'), onPressed: () { - final url = state.dashboardUrl ?? AppConstants.gatewayUrl; + final url = + state.dashboardUrl ?? AppConstants.gatewayUrl; Clipboard.setData(ClipboardData(text: url)); ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('URL copied to clipboard'), - duration: Duration(seconds: 2), + SnackBar( + content: Text(l10n.t('gatewayUrlCopied')), + duration: const Duration(seconds: 2), ), ); }, ), IconButton( icon: const Icon(Icons.open_in_new, size: 18), - tooltip: 'Open dashboard', + tooltip: l10n.t('gatewayOpenDashboard'), onPressed: () { Navigator.of(context).push( MaterialPageRoute( @@ -108,20 +111,21 @@ class GatewayControls extends StatelessWidget { FilledButton.icon( onPressed: () => provider.start(), icon: const Icon(Icons.play_arrow), - label: const Text('Start Gateway'), + label: Text(l10n.t('gatewayStart')), ), - if (state.isRunning || state.status == GatewayStatus.starting) + if (state.isRunning || + state.status == GatewayStatus.starting) OutlinedButton.icon( onPressed: () => provider.stop(), icon: const Icon(Icons.stop), - label: const Text('Stop Gateway'), + label: Text(l10n.t('gatewayStop')), ), OutlinedButton.icon( onPressed: () => Navigator.of(context).push( MaterialPageRoute(builder: (_) => const LogsScreen()), ), icon: const Icon(Icons.article_outlined), - label: const Text('View Logs'), + label: Text(l10n.t('gatewayViewLogs')), ), ], ), @@ -133,7 +137,9 @@ class GatewayControls extends StatelessWidget { ); } - Widget _statusBadge(GatewayStatus status, ThemeData theme) { + Widget _statusBadge( + BuildContext context, GatewayStatus status, ThemeData theme) { + final l10n = context.l10n; Color color; String label; IconData icon; @@ -141,19 +147,19 @@ class GatewayControls extends StatelessWidget { switch (status) { case GatewayStatus.running: color = AppColors.statusGreen; - label = 'Running'; + label = l10n.t('gatewayStatusRunning'); icon = Icons.check_circle_outline; case GatewayStatus.starting: color = AppColors.statusAmber; - label = 'Starting'; + label = l10n.t('gatewayStatusStarting'); icon = Icons.hourglass_top; case GatewayStatus.error: color = AppColors.statusRed; - label = 'Error'; + label = l10n.t('gatewayStatusError'); icon = Icons.error_outline; case GatewayStatus.stopped: color = AppColors.statusGrey; - label = 'Stopped'; + label = l10n.t('gatewayStatusStopped'); icon = Icons.circle_outlined; } diff --git a/flutter_app/lib/widgets/node_controls.dart b/flutter_app/lib/widgets/node_controls.dart index 4a2b719..95eb4e6 100644 --- a/flutter_app/lib/widgets/node_controls.dart +++ b/flutter_app/lib/widgets/node_controls.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../app.dart'; +import '../l10n/app_localizations.dart'; import '../models/node_state.dart'; import '../providers/node_provider.dart'; import '../screens/node_screen.dart'; @@ -11,6 +12,7 @@ class NodeControls extends StatelessWidget { @override Widget build(BuildContext context) { final theme = Theme.of(context); + final l10n = context.l10n; return Consumer( builder: (context, provider, _) { @@ -26,19 +28,25 @@ class NodeControls extends StatelessWidget { children: [ Expanded( child: Text( - 'Node', + l10n.t('nodeTitle'), style: theme.textTheme.titleLarge?.copyWith( fontWeight: FontWeight.w600, ), ), ), - _statusBadge(state.status, theme), + _statusBadge(context, state.status, theme), ], ), const SizedBox(height: 8), if (state.isPaired) ...[ Text( - 'Connected to ${state.gatewayHost}:${state.gatewayPort}', + l10n.t( + 'nodeConnectedTo', + { + 'host': state.gatewayHost, + 'port': state.gatewayPort, + }, + ), style: theme.textTheme.bodyMedium?.copyWith( color: theme.colorScheme.onSurfaceVariant, fontFamily: 'monospace', @@ -50,7 +58,7 @@ class NodeControls extends StatelessWidget { Row( children: [ Text( - 'Pairing code: ', + l10n.t('nodePairingCode'), style: theme.textTheme.bodyMedium, ), SelectableText( @@ -78,20 +86,20 @@ class NodeControls extends StatelessWidget { FilledButton.icon( onPressed: () => provider.enable(), icon: const Icon(Icons.power_settings_new), - label: const Text('Enable Node'), + label: Text(l10n.t('nodeEnable')), ), if (!state.isDisabled) ...[ OutlinedButton.icon( onPressed: () => provider.disable(), icon: const Icon(Icons.stop), - label: const Text('Disable Node'), + label: Text(l10n.t('nodeDisable')), ), if (state.status == NodeStatus.error || state.status == NodeStatus.disconnected) OutlinedButton.icon( onPressed: () => provider.reconnect(), icon: const Icon(Icons.refresh), - label: const Text('Reconnect'), + label: Text(l10n.t('nodeReconnect')), ), ], OutlinedButton.icon( @@ -99,7 +107,7 @@ class NodeControls extends StatelessWidget { MaterialPageRoute(builder: (_) => const NodeScreen()), ), icon: const Icon(Icons.settings), - label: const Text('Configure'), + label: Text(l10n.t('commonConfigure')), ), ], ), @@ -111,7 +119,9 @@ class NodeControls extends StatelessWidget { ); } - Widget _statusBadge(NodeStatus status, ThemeData theme) { + Widget _statusBadge( + BuildContext context, NodeStatus status, ThemeData theme) { + final l10n = context.l10n; Color color; String label; IconData icon; @@ -119,25 +129,25 @@ class NodeControls extends StatelessWidget { switch (status) { case NodeStatus.paired: color = AppColors.statusGreen; - label = 'Paired'; + label = l10n.t('nodeStatusPaired'); icon = Icons.check_circle_outline; case NodeStatus.connecting: case NodeStatus.challenging: case NodeStatus.pairing: color = AppColors.statusAmber; - label = 'Connecting'; + label = l10n.t('nodeStatusConnecting'); icon = Icons.hourglass_top; case NodeStatus.error: color = AppColors.statusRed; - label = 'Error'; + label = l10n.t('nodeStatusError'); icon = Icons.error_outline; case NodeStatus.disabled: color = AppColors.statusGrey; - label = 'Disabled'; + label = l10n.t('nodeStatusDisabled'); icon = Icons.circle_outlined; case NodeStatus.disconnected: color = AppColors.statusGrey; - label = 'Disconnected'; + label = l10n.t('nodeStatusDisconnected'); icon = Icons.link_off; } diff --git a/flutter_app/pubspec.yaml b/flutter_app/pubspec.yaml index 72e19c3..0afdc3d 100644 --- a/flutter_app/pubspec.yaml +++ b/flutter_app/pubspec.yaml @@ -9,6 +9,8 @@ environment: dependencies: flutter: sdk: flutter + flutter_localizations: + sdk: flutter xterm: ^4.0.0 flutter_pty: ^0.4.2 webview_flutter: ^4.4.0 diff --git a/flutter_app/scripts/_expand_l10n.dart b/flutter_app/scripts/_expand_l10n.dart new file mode 100644 index 0000000..e1cb961 --- /dev/null +++ b/flutter_app/scripts/_expand_l10n.dart @@ -0,0 +1,61 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:openclaw/l10n/app_strings_en.dart'; +import 'package:openclaw/l10n/app_strings_ja.dart'; +import 'package:openclaw/l10n/app_strings_zh_hans.dart'; +import 'package:openclaw/l10n/app_strings_zh_hant.dart'; + +String _escape(String value) { + return value + .replaceAll('\\', '\\\\') + .replaceAll("'", "\\'") + .replaceAll('\n', '\\n'); +} + +String _buildMapFile({ + required String variableName, + required Map base, + required Map localized, +}) { + final buffer = StringBuffer(); + buffer.writeln('const Map $variableName = {'); + + final seen = {}; + for (final key in base.keys) { + final value = localized[key] ?? base[key] ?? key; + buffer.writeln(" '$key': '${_escape(value)}',"); + seen.add(key); + } + + for (final entry in localized.entries) { + if (seen.contains(entry.key)) continue; + buffer.writeln(" '${entry.key}': '${_escape(entry.value)}',"); + } + + buffer.writeln('};'); + return buffer.toString(); +} + +void main() { + final jaContent = _buildMapFile( + variableName: 'appStringsJa', + base: appStringsEn, + localized: appStringsJa, + ); + + final zhHantContent = _buildMapFile( + variableName: 'appStringsZhHant', + base: appStringsZhHans, + localized: appStringsZhHant, + ); + + File('lib/l10n/app_strings_ja.dart').writeAsStringSync( + jaContent, + encoding: utf8, + ); + File('lib/l10n/app_strings_zh_hant.dart').writeAsStringSync( + zhHantContent, + encoding: utf8, + ); +}