Add localization support and translation script

- Introduced a new extension for localization in Flutter with `LocalizationExtension` in `l10n.dart`.
- Added a Python script `translate.py` for translating ARB/JSON localization files using a local Ollama model, preserving keys and placeholders, and handling ICU format rules.
This commit is contained in:
zjs81 2026-01-11 17:13:50 -07:00
parent 2495cd840f
commit b2ce82fe7e
64 changed files with 54716 additions and 1254 deletions

5
l10n.yaml Normal file
View file

@ -0,0 +1,5 @@
arb-dir: lib/l10n
template-arb-file: app_en.arb
output-localization-file: app_localizations.dart
output-class: AppLocalizations
nullable-getter: false

1339
lib/l10n/app_bg.arb Normal file

File diff suppressed because it is too large Load diff

1339
lib/l10n/app_de.arb Normal file

File diff suppressed because it is too large Load diff

1144
lib/l10n/app_en.arb Normal file

File diff suppressed because it is too large Load diff

1339
lib/l10n/app_es.arb Normal file

File diff suppressed because it is too large Load diff

1339
lib/l10n/app_fr.arb Normal file

File diff suppressed because it is too large Load diff

1339
lib/l10n/app_it.arb Normal file

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

1339
lib/l10n/app_nl.arb Normal file

File diff suppressed because it is too large Load diff

1339
lib/l10n/app_pl.arb Normal file

File diff suppressed because it is too large Load diff

1339
lib/l10n/app_pt.arb Normal file

File diff suppressed because it is too large Load diff

1339
lib/l10n/app_sk.arb Normal file

File diff suppressed because it is too large Load diff

1339
lib/l10n/app_sl.arb Normal file

File diff suppressed because it is too large Load diff

1339
lib/l10n/app_sv.arb Normal file

File diff suppressed because it is too large Load diff

1339
lib/l10n/app_zh.arb Normal file

File diff suppressed because it is too large Load diff

6
lib/l10n/l10n.dart Normal file
View file

@ -0,0 +1,6 @@
import 'package:flutter/widgets.dart';
import 'app_localizations.dart';
extension LocalizationExtension on BuildContext {
AppLocalizations get l10n => AppLocalizations.of(this);
}

View file

@ -1,4 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'l10n/app_localizations.dart';
import 'package:provider/provider.dart';
import 'connector/meshcore_connector.dart';
@ -115,6 +117,14 @@ class MeshCoreApp extends StatelessWidget {
return MaterialApp(
title: 'MeshCore Open',
debugShowCheckedModeBanner: false,
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: AppLocalizations.supportedLocales,
locale: _localeFromSetting(settingsService.settings.languageOverride),
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
useMaterial3: true,
@ -144,4 +154,9 @@ class MeshCoreApp extends StatelessWidget {
return ThemeMode.system;
}
}
Locale? _localeFromSetting(String? languageCode) {
if (languageCode == null) return null;
return Locale(languageCode);
}
}

View file

@ -18,6 +18,7 @@ class AppSettings {
final bool notifyOnNewAdvert;
final bool autoRouteRotationEnabled;
final String themeMode;
final String? languageOverride; // null = system default
final bool appDebugLogEnabled;
final Map<String, String> batteryChemistryByDeviceId;
@ -39,6 +40,7 @@ class AppSettings {
this.notifyOnNewAdvert = true,
this.autoRouteRotationEnabled = false,
this.themeMode = 'system',
this.languageOverride,
this.appDebugLogEnabled = false,
Map<String, String>? batteryChemistryByDeviceId,
}) : batteryChemistryByDeviceId = batteryChemistryByDeviceId ?? {};
@ -62,6 +64,7 @@ class AppSettings {
'notify_on_new_advert': notifyOnNewAdvert,
'auto_route_rotation_enabled': autoRouteRotationEnabled,
'theme_mode': themeMode,
'language_override': languageOverride,
'app_debug_log_enabled': appDebugLogEnabled,
'battery_chemistry_by_device_id': batteryChemistryByDeviceId,
};
@ -89,6 +92,7 @@ class AppSettings {
notifyOnNewAdvert: json['notify_on_new_advert'] as bool? ?? true,
autoRouteRotationEnabled: json['auto_route_rotation_enabled'] as bool? ?? false,
themeMode: json['theme_mode'] as String? ?? 'system',
languageOverride: json['language_override'] as String?,
appDebugLogEnabled: json['app_debug_log_enabled'] as bool? ?? false,
batteryChemistryByDeviceId: (json['battery_chemistry_by_device_id'] as Map?)?.map(
(key, value) => MapEntry(key.toString(), value.toString()),
@ -115,6 +119,7 @@ class AppSettings {
bool? notifyOnNewAdvert,
bool? autoRouteRotationEnabled,
String? themeMode,
Object? languageOverride = _unset,
bool? appDebugLogEnabled,
Map<String, String>? batteryChemistryByDeviceId,
}) {
@ -138,6 +143,8 @@ class AppSettings {
notifyOnNewAdvert: notifyOnNewAdvert ?? this.notifyOnNewAdvert,
autoRouteRotationEnabled: autoRouteRotationEnabled ?? this.autoRouteRotationEnabled,
themeMode: themeMode ?? this.themeMode,
languageOverride:
languageOverride == _unset ? this.languageOverride : languageOverride as String?,
appDebugLogEnabled: appDebugLogEnabled ?? this.appDebugLogEnabled,
batteryChemistryByDeviceId: batteryChemistryByDeviceId ?? this.batteryChemistryByDeviceId,
);

View file

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../l10n/l10n.dart';
import '../services/app_debug_log_service.dart';
class AppDebugLogScreen extends StatelessWidget {
@ -16,11 +17,11 @@ class AppDebugLogScreen extends StatelessWidget {
return Scaffold(
appBar: AppBar(
title: const Text('App Debug Log'),
title: Text(context.l10n.debugLog_appTitle),
centerTitle: true,
actions: [
IconButton(
tooltip: 'Copy log',
tooltip: context.l10n.debugLog_copyLog,
icon: const Icon(Icons.copy),
onPressed: hasEntries
? () async {
@ -31,13 +32,13 @@ class AppDebugLogScreen extends StatelessWidget {
await Clipboard.setData(ClipboardData(text: text));
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Debug log copied')),
SnackBar(content: Text(context.l10n.debugLog_copied)),
);
}
: null,
),
IconButton(
tooltip: 'Clear log',
tooltip: context.l10n.debugLog_clearLog,
icon: const Icon(Icons.delete_outline),
onPressed: hasEntries
? () {
@ -76,12 +77,12 @@ class AppDebugLogScreen extends StatelessWidget {
Icon(Icons.bug_report_outlined, size: 64, color: Colors.grey[400]),
const SizedBox(height: 16),
Text(
'No debug logs yet',
context.l10n.debugLog_noEntries,
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
),
const SizedBox(height: 8),
Text(
'Enable app debug logging in settings',
context.l10n.debugLog_enableInSettings,
style: TextStyle(fontSize: 12, color: Colors.grey[500]),
),
],

View file

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart';
import '../l10n/l10n.dart';
import '../services/app_settings_service.dart';
import '../services/notification_service.dart';
import 'map_cache_screen.dart';
@ -13,7 +14,7 @@ class AppSettingsScreen extends StatelessWidget {
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('App Settings'),
title: Text(context.l10n.appSettings_title),
centerTitle: true,
),
body: SafeArea(
@ -47,20 +48,28 @@ class AppSettingsScreen extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
'Appearance',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
context.l10n.appSettings_appearance,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
),
ListTile(
leading: const Icon(Icons.brightness_6_outlined),
title: const Text('Theme'),
subtitle: Text(_themeModeLabel(settingsService.settings.themeMode)),
title: Text(context.l10n.appSettings_theme),
subtitle: Text(_themeModeLabel(context, settingsService.settings.themeMode)),
trailing: const Icon(Icons.chevron_right),
onTap: () => _showThemeModeDialog(context, settingsService),
),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.language_outlined),
title: Text(context.l10n.appSettings_language),
subtitle: Text(_languageLabel(context, settingsService.settings.languageOverride)),
trailing: const Icon(Icons.chevron_right),
onTap: () => _showLanguageDialog(context, settingsService),
),
],
),
);
@ -71,17 +80,17 @@ class AppSettingsScreen extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
'Notifications',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
context.l10n.appSettings_notifications,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
),
SwitchListTile(
secondary: const Icon(Icons.notifications_outlined),
title: const Text('Enable Notifications'),
subtitle: const Text('Receive notifications for messages and adverts'),
title: Text(context.l10n.appSettings_enableNotifications),
subtitle: Text(context.l10n.appSettings_enableNotificationsSubtitle),
value: settingsService.settings.notificationsEnabled,
onChanged: (value) async {
if (value) {
@ -90,9 +99,9 @@ class AppSettingsScreen extends StatelessWidget {
if (!granted) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Notification permission denied'),
duration: Duration(seconds: 2),
SnackBar(
content: Text(context.l10n.appSettings_notificationPermissionDenied),
duration: const Duration(seconds: 2),
),
);
}
@ -105,8 +114,8 @@ class AppSettingsScreen extends StatelessWidget {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(value
? 'Notifications enabled'
: 'Notifications disabled'),
? context.l10n.appSettings_notificationsEnabled
: context.l10n.appSettings_notificationsDisabled),
duration: const Duration(seconds: 2),
),
);
@ -120,13 +129,13 @@ class AppSettingsScreen extends StatelessWidget {
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
),
title: Text(
'Message Notifications',
context.l10n.appSettings_messageNotifications,
style: TextStyle(
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
),
),
subtitle: Text(
'Show notification when receiving new messages',
context.l10n.appSettings_messageNotificationsSubtitle,
style: TextStyle(
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
),
@ -145,13 +154,13 @@ class AppSettingsScreen extends StatelessWidget {
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
),
title: Text(
'Channel Message Notifications',
context.l10n.appSettings_channelMessageNotifications,
style: TextStyle(
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
),
),
subtitle: Text(
'Show notification when receiving channel messages',
context.l10n.appSettings_channelMessageNotificationsSubtitle,
style: TextStyle(
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
),
@ -170,13 +179,13 @@ class AppSettingsScreen extends StatelessWidget {
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
),
title: Text(
'Advertisement Notifications',
context.l10n.appSettings_advertisementNotifications,
style: TextStyle(
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
),
),
subtitle: Text(
'Show notification when new nodes are discovered',
context.l10n.appSettings_advertisementNotificationsSubtitle,
style: TextStyle(
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
),
@ -198,25 +207,25 @@ class AppSettingsScreen extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
'Messaging',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
context.l10n.appSettings_messaging,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
),
SwitchListTile(
secondary: const Icon(Icons.refresh_outlined),
title: const Text('Clear Path on Max Retry'),
subtitle: const Text('Reset contact path after 5 failed send attempts'),
title: Text(context.l10n.appSettings_clearPathOnMaxRetry),
subtitle: Text(context.l10n.appSettings_clearPathOnMaxRetrySubtitle),
value: settingsService.settings.clearPathOnMaxRetry,
onChanged: (value) {
settingsService.setClearPathOnMaxRetry(value);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(value
? 'Paths will be cleared after 5 failed retries'
: 'Paths will not be auto-cleared'),
? context.l10n.appSettings_pathsWillBeCleared
: context.l10n.appSettings_pathsWillNotBeCleared),
duration: const Duration(seconds: 2),
),
);
@ -225,16 +234,16 @@ class AppSettingsScreen extends StatelessWidget {
const Divider(height: 1),
SwitchListTile(
secondary: const Icon(Icons.alt_route),
title: const Text('Auto Route Rotation'),
subtitle: const Text('Cycle between best paths and flood mode'),
title: Text(context.l10n.appSettings_autoRouteRotation),
subtitle: Text(context.l10n.appSettings_autoRouteRotationSubtitle),
value: settingsService.settings.autoRouteRotationEnabled,
onChanged: (value) {
settingsService.setAutoRouteRotationEnabled(value);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(value
? 'Auto route rotation enabled'
: 'Auto route rotation disabled'),
? context.l10n.appSettings_autoRouteRotationEnabled
: context.l10n.appSettings_autoRouteRotationDisabled),
duration: const Duration(seconds: 2),
),
);
@ -250,17 +259,17 @@ class AppSettingsScreen extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
'Map Display',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
context.l10n.appSettings_mapDisplay,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
),
SwitchListTile(
secondary: const Icon(Icons.router_outlined),
title: const Text('Show Repeaters'),
subtitle: const Text('Display repeater nodes on the map'),
title: Text(context.l10n.appSettings_showRepeaters),
subtitle: Text(context.l10n.appSettings_showRepeatersSubtitle),
value: settingsService.settings.mapShowRepeaters,
onChanged: (value) {
settingsService.setMapShowRepeaters(value);
@ -269,8 +278,8 @@ class AppSettingsScreen extends StatelessWidget {
const Divider(height: 1),
SwitchListTile(
secondary: const Icon(Icons.chat_outlined),
title: const Text('Show Chat Nodes'),
subtitle: const Text('Display chat nodes on the map'),
title: Text(context.l10n.appSettings_showChatNodes),
subtitle: Text(context.l10n.appSettings_showChatNodesSubtitle),
value: settingsService.settings.mapShowChatNodes,
onChanged: (value) {
settingsService.setMapShowChatNodes(value);
@ -279,8 +288,8 @@ class AppSettingsScreen extends StatelessWidget {
const Divider(height: 1),
SwitchListTile(
secondary: const Icon(Icons.people_outline),
title: const Text('Show Other Nodes'),
subtitle: const Text('Display other node types on the map'),
title: Text(context.l10n.appSettings_showOtherNodes),
subtitle: Text(context.l10n.appSettings_showOtherNodesSubtitle),
value: settingsService.settings.mapShowOtherNodes,
onChanged: (value) {
settingsService.setMapShowOtherNodes(value);
@ -289,11 +298,11 @@ class AppSettingsScreen extends StatelessWidget {
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.timer_outlined),
title: const Text('Time Filter'),
title: Text(context.l10n.appSettings_timeFilter),
subtitle: Text(
settingsService.settings.mapTimeFilterHours == 0
? 'Show all nodes'
: 'Show nodes from last ${settingsService.settings.mapTimeFilterHours.toInt()} hours',
? context.l10n.appSettings_timeFilterShowAll
: context.l10n.appSettings_timeFilterShowLast(settingsService.settings.mapTimeFilterHours.toInt()),
),
trailing: const Icon(Icons.chevron_right),
onTap: () => _showTimeFilterDialog(context, settingsService),
@ -301,12 +310,14 @@ class AppSettingsScreen extends StatelessWidget {
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.download_outlined),
title: const Text('Offline Map Cache'),
title: Text(context.l10n.appSettings_offlineMapCache),
subtitle: Text(
settingsService.settings.mapCacheBounds == null
? 'No area selected'
: 'Area selected (zoom ${settingsService.settings.mapCacheMinZoom}'
'-${settingsService.settings.mapCacheMaxZoom})',
? context.l10n.appSettings_noAreaSelected
: context.l10n.appSettings_areaSelectedZoom(
settingsService.settings.mapCacheMinZoom,
settingsService.settings.mapCacheMaxZoom,
),
),
trailing: const Icon(Icons.chevron_right),
onTap: () {
@ -335,20 +346,20 @@ class AppSettingsScreen extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
'Battery',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
context.l10n.appSettings_battery,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
),
ListTile(
leading: const Icon(Icons.battery_full),
title: const Text('Battery Chemistry'),
title: Text(context.l10n.appSettings_batteryChemistry),
subtitle: Text(
isConnected
? 'Set per device (${connector.deviceDisplayName})'
: 'Connect to a device to choose',
? context.l10n.appSettings_batteryChemistryPerDevice(connector.deviceDisplayName)
: context.l10n.appSettings_batteryChemistryConnectFirst,
),
trailing: DropdownButton<String>(
value: selection,
@ -359,18 +370,18 @@ class AppSettingsScreen extends StatelessWidget {
}
}
: null,
items: const [
items: [
DropdownMenuItem(
value: 'nmc',
child: Text('18650 NMC (3.0-4.2V)'),
child: Text(context.l10n.appSettings_batteryNmc),
),
DropdownMenuItem(
value: 'lifepo4',
child: Text('LiFePO4 (2.6-3.65V)'),
child: Text(context.l10n.appSettings_batteryLifepo4),
),
DropdownMenuItem(
value: 'lipo',
child: Text('LiPo (3.0-4.2V)'),
child: Text(context.l10n.appSettings_batteryLipo),
),
],
),
@ -384,7 +395,7 @@ class AppSettingsScreen extends StatelessWidget {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Theme'),
title: Text(context.l10n.appSettings_theme),
content: RadioGroup<String>(
groupValue: settingsService.settings.themeMode,
onChanged: (value) {
@ -397,15 +408,15 @@ class AppSettingsScreen extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
children: [
RadioListTile<String>(
title: const Text('System default'),
title: Text(context.l10n.appSettings_themeSystem),
value: 'system',
),
RadioListTile<String>(
title: const Text('Light'),
title: Text(context.l10n.appSettings_themeLight),
value: 'light',
),
RadioListTile<String>(
title: const Text('Dark'),
title: Text(context.l10n.appSettings_themeDark),
value: 'dark',
),
],
@ -414,29 +425,147 @@ class AppSettingsScreen extends StatelessWidget {
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
child: Text(context.l10n.common_close),
),
],
),
);
}
String _themeModeLabel(String value) {
String _themeModeLabel(BuildContext context, String value) {
switch (value) {
case 'light':
return 'Light';
return context.l10n.appSettings_themeLight;
case 'dark':
return 'Dark';
return context.l10n.appSettings_themeDark;
default:
return 'System default';
return context.l10n.appSettings_themeSystem;
}
}
String _languageLabel(BuildContext context, String? languageCode) {
switch (languageCode) {
case 'en':
return context.l10n.appSettings_languageEn;
case 'fr':
return context.l10n.appSettings_languageFr;
case 'es':
return context.l10n.appSettings_languageEs;
case 'de':
return context.l10n.appSettings_languageDe;
case 'pl':
return context.l10n.appSettings_languagePl;
case 'sl':
return context.l10n.appSettings_languageSl;
case 'pt':
return context.l10n.appSettings_languagePt;
case 'it':
return context.l10n.appSettings_languageIt;
case 'zh':
return context.l10n.appSettings_languageZh;
case 'sv':
return context.l10n.appSettings_languageSv;
case 'nl':
return context.l10n.appSettings_languageNl;
case 'sk':
return context.l10n.appSettings_languageSk;
case 'bg':
return context.l10n.appSettings_languageBg;
default:
return context.l10n.appSettings_languageSystem;
}
}
void _showLanguageDialog(BuildContext context, AppSettingsService settingsService) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(context.l10n.appSettings_language),
content: SingleChildScrollView(
child: RadioGroup<String?>(
groupValue: settingsService.settings.languageOverride,
onChanged: (value) {
settingsService.setLanguageOverride(value);
Navigator.pop(context);
},
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
RadioListTile<String?>(
title: Text(context.l10n.appSettings_languageSystem),
value: null,
),
RadioListTile<String?>(
title: Text(context.l10n.appSettings_languageEn),
value: 'en',
),
RadioListTile<String?>(
title: Text(context.l10n.appSettings_languageFr),
value: 'fr',
),
RadioListTile<String?>(
title: Text(context.l10n.appSettings_languageEs),
value: 'es',
),
RadioListTile<String?>(
title: Text(context.l10n.appSettings_languageDe),
value: 'de',
),
RadioListTile<String?>(
title: Text(context.l10n.appSettings_languagePl),
value: 'pl',
),
RadioListTile<String?>(
title: Text(context.l10n.appSettings_languageSl),
value: 'sl',
),
RadioListTile<String?>(
title: Text(context.l10n.appSettings_languagePt),
value: 'pt',
),
RadioListTile<String?>(
title: Text(context.l10n.appSettings_languageIt),
value: 'it',
),
RadioListTile<String?>(
title: Text(context.l10n.appSettings_languageZh),
value: 'zh',
),
RadioListTile<String?>(
title: Text(context.l10n.appSettings_languageSv),
value: 'sv',
),
RadioListTile<String?>(
title: Text(context.l10n.appSettings_languageNl),
value: 'nl',
),
RadioListTile<String?>(
title: Text(context.l10n.appSettings_languageSk),
value: 'sk',
),
RadioListTile<String?>(
title: Text(context.l10n.appSettings_languageBg),
value: 'bg',
),
],
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(context.l10n.common_close),
),
],
),
);
}
void _showTimeFilterDialog(BuildContext context, AppSettingsService settingsService) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Map Time Filter'),
title: Text(context.l10n.appSettings_mapTimeFilter),
content: RadioGroup<double>(
groupValue: settingsService.settings.mapTimeFilterHours,
onChanged: (value) {
@ -448,34 +577,34 @@ class AppSettingsScreen extends StatelessWidget {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('Show nodes discovered within:'),
Text(context.l10n.appSettings_showNodesDiscoveredWithin),
const SizedBox(height: 16),
ListTile(
title: const Text('All time'),
title: Text(context.l10n.appSettings_allTime),
leading: Radio<double>(
value: 0,
),
),
ListTile(
title: const Text('Last hour'),
title: Text(context.l10n.appSettings_lastHour),
leading: Radio<double>(
value: 1,
),
),
ListTile(
title: const Text('Last 6 hours'),
title: Text(context.l10n.appSettings_last6Hours),
leading: Radio<double>(
value: 6,
),
),
ListTile(
title: const Text('Last 24 hours'),
title: Text(context.l10n.appSettings_last24Hours),
leading: Radio<double>(
value: 24,
),
),
ListTile(
title: const Text('Last week'),
title: Text(context.l10n.appSettings_lastWeek),
leading: Radio<double>(
value: 168,
),
@ -486,7 +615,7 @@ class AppSettingsScreen extends StatelessWidget {
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
child: Text(context.l10n.common_close),
),
],
),
@ -498,17 +627,17 @@ class AppSettingsScreen extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
'Debug',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
context.l10n.appSettings_debugCard,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
),
SwitchListTile(
secondary: const Icon(Icons.bug_report_outlined),
title: const Text('App Debug Logging'),
subtitle: const Text('Log app debug messages for troubleshooting'),
title: Text(context.l10n.appSettings_appDebugLogging),
subtitle: Text(context.l10n.appSettings_appDebugLoggingSubtitle),
value: settingsService.settings.appDebugLogEnabled,
onChanged: (value) async {
await settingsService.setAppDebugLogEnabled(value);
@ -516,8 +645,8 @@ class AppSettingsScreen extends StatelessWidget {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(value
? 'App debug logging enabled'
: 'App debug logging disabled'),
? context.l10n.appSettings_appDebugLoggingEnabled
: context.l10n.appSettings_appDebugLoggingDisabled),
duration: const Duration(seconds: 2),
),
);

View file

@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:flutter/services.dart';
import '../l10n/l10n.dart';
import '../services/ble_debug_log_service.dart';
import '../connector/meshcore_protocol.dart';
@ -26,10 +27,10 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
final hasEntries = showingFrames ? entries.isNotEmpty : rawEntries.isNotEmpty;
return Scaffold(
appBar: AppBar(
title: const Text('BLE Debug Log'),
title: Text(context.l10n.debugLog_bleTitle),
actions: [
IconButton(
tooltip: 'Copy log',
tooltip: context.l10n.debugLog_copyLog,
icon: const Icon(Icons.copy),
onPressed: hasEntries
? () async {
@ -43,13 +44,13 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
await Clipboard.setData(ClipboardData(text: text));
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('BLE log copied')),
SnackBar(content: Text(context.l10n.debugLog_bleCopied)),
);
}
: null,
),
IconButton(
tooltip: 'Clear log',
tooltip: context.l10n.debugLog_clearLog,
icon: const Icon(Icons.delete_outline),
onPressed: hasEntries
? () {
@ -66,9 +67,9 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: SegmentedButton<_BleLogView>(
segments: const [
ButtonSegment(value: _BleLogView.frames, label: Text('Frames')),
ButtonSegment(value: _BleLogView.rawLogRx, label: Text('Raw Log-RX')),
segments: [
ButtonSegment(value: _BleLogView.frames, label: Text(context.l10n.debugLog_frames)),
ButtonSegment(value: _BleLogView.rawLogRx, label: Text(context.l10n.debugLog_rawLogRx)),
],
selected: {_view},
onSelectionChanged: (selection) {
@ -113,8 +114,8 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
);
},
)
: const Center(
child: Text('No BLE activity yet'),
: Center(
child: Text(context.l10n.debugLog_noBleActivity),
),
),
],
@ -136,7 +137,7 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
child: Text(context.l10n.common_close),
),
],
),

View file

@ -9,6 +9,7 @@ import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart';
import '../helpers/utf8_length_limiter.dart';
import '../l10n/l10n.dart';
import '../models/channel.dart';
import '../models/channel_message.dart';
import '../utils/emoji_utils.dart';
@ -84,9 +85,9 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
final key = _messageKeys[messageId];
if (key == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Original message not found'),
duration: Duration(seconds: 2),
SnackBar(
content: Text(context.l10n.chat_originalMessageNotFound),
duration: const Duration(seconds: 2),
),
);
return;
@ -120,7 +121,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
children: [
Text(
widget.channel.name.isEmpty
? 'Channel ${widget.channel.index}'
? context.l10n.channels_channelIndex(widget.channel.index)
: widget.channel.name,
style: const TextStyle(fontSize: 16),
),
@ -128,9 +129,9 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
builder: (context, connector, _) {
final unreadCount =
connector.getUnreadCountForChannelIndex(widget.channel.index);
final privacy = widget.channel.isPublicChannel ? 'Public' : 'Private';
final privacy = widget.channel.isPublicChannel ? context.l10n.channels_public : context.l10n.channels_private;
return Text(
'$privacyUnread: $unreadCount',
'$privacy${context.l10n.chat_unread(unreadCount)}',
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 12),
);
@ -170,7 +171,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
),
const SizedBox(height: 16),
Text(
'No messages yet',
context.l10n.chat_noMessages,
style: TextStyle(
fontSize: 16,
color: Colors.grey[600],
@ -178,7 +179,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
),
const SizedBox(height: 8),
Text(
'Send a message to get started',
context.l10n.chat_sendMessageToStart,
style: TextStyle(
fontSize: 14,
color: Colors.grey[500],
@ -372,7 +373,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
children: [
Icon(Icons.location_on_outlined, size: 14, color: previewTextColor),
const SizedBox(width: 4),
Text('Location', style: TextStyle(fontSize: 12, color: previewTextColor)),
Text(context.l10n.chat_location, style: TextStyle(fontSize: 12, color: previewTextColor)),
],
);
} else {
@ -406,7 +407,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Reply to ${message.replyToSenderName}',
context.l10n.chat_replyTo(message.replyToSenderName ?? ''),
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
@ -515,7 +516,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'POI Shared',
context.l10n.chat_poiShared,
style: TextStyle(
color: textColor,
fontWeight: FontWeight.w600,
@ -623,7 +624,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Replying to ${message.senderName}',
context.l10n.chat_replyingTo(message.senderName),
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
@ -678,7 +679,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
IconButton(
icon: const Icon(Icons.gif_box),
onPressed: () => _showGifPicker(context),
tooltip: 'Send GIF',
tooltip: context.l10n.chat_sendGif,
),
Expanded(
child: ValueListenableBuilder<TextEditingValue>(
@ -714,7 +715,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
Utf8LengthLimitingTextInputFormatter(maxBytes),
],
decoration: InputDecoration(
hintText: 'Type a message...',
hintText: context.l10n.chat_typeMessage,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(24),
),
@ -757,7 +758,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
final maxBytes = maxChannelMessageBytes(connector.selfName);
if (utf8.encode(messageText).length > maxBytes) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Message too long (max $maxBytes bytes).')),
SnackBar(content: Text(context.l10n.chat_messageTooLong(maxBytes))),
);
return;
}
@ -796,7 +797,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
children: [
ListTile(
leading: const Icon(Icons.reply),
title: const Text('Reply'),
title: Text(context.l10n.chat_reply),
onTap: () {
Navigator.pop(sheetContext);
_setReplyingTo(message);
@ -804,7 +805,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
),
ListTile(
leading: const Icon(Icons.add_reaction_outlined),
title: const Text('Add Reaction'),
title: Text(context.l10n.chat_addReaction),
onTap: () {
Navigator.pop(sheetContext);
_showEmojiPicker(message);
@ -812,7 +813,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
),
ListTile(
leading: const Icon(Icons.copy),
title: const Text('Copy'),
title: Text(context.l10n.common_copy),
onTap: () {
Navigator.pop(sheetContext);
_copyMessageText(message.text);
@ -820,7 +821,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
),
ListTile(
leading: const Icon(Icons.delete_outline),
title: const Text('Delete'),
title: Text(context.l10n.common_delete),
onTap: () async {
Navigator.pop(sheetContext);
await _deleteMessage(message);
@ -828,7 +829,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
),
ListTile(
leading: const Icon(Icons.close),
title: const Text('Cancel'),
title: Text(context.l10n.common_cancel),
onTap: () => Navigator.pop(sheetContext),
),
],
@ -860,7 +861,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
void _copyMessageText(String text) {
Clipboard.setData(ClipboardData(text: text));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Message copied')),
SnackBar(content: Text(context.l10n.chat_messageCopied)),
);
}
@ -868,7 +869,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
await context.read<MeshCoreConnector>().deleteChannelMessage(message);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Message deleted')),
SnackBar(content: Text(context.l10n.chat_messageDeleted)),
);
}

View file

@ -9,6 +9,8 @@ import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart';
import '../services/map_tile_cache_service.dart';
import '../connector/meshcore_protocol.dart';
import '../l10n/app_localizations.dart';
import '../l10n/l10n.dart';
import '../models/channel_message.dart';
import '../models/contact.dart';
@ -24,22 +26,24 @@ class ChannelMessagePathScreen extends StatelessWidget {
Widget build(BuildContext context) {
return Consumer<MeshCoreConnector>(
builder: (context, connector, _) {
final l10n = context.l10n;
final primaryPath = _selectPrimaryPath(message.pathBytes, message.pathVariants);
final hops = _buildPathHops(primaryPath, connector.contacts);
final hops = _buildPathHops(primaryPath, connector.contacts, l10n);
final hasHopDetails = primaryPath.isNotEmpty;
final observedLabel = _formatObservedHops(
primaryPath.length,
message.pathLength,
l10n,
);
final extraPaths = _otherPaths(primaryPath, message.pathVariants);
return Scaffold(
appBar: AppBar(
title: const Text('Packet Path'),
title: Text(l10n.channelPath_title),
actions: [
IconButton(
icon: const Icon(Icons.map_outlined),
tooltip: 'View map',
tooltip: l10n.channelPath_viewMap,
onPressed: hasHopDetails
? () {
_openPathMap(context);
@ -57,7 +61,7 @@ class ChannelMessagePathScreen extends StatelessWidget {
const SizedBox(height: 16),
if (extraPaths.isNotEmpty) ...[
Text(
'Other Observed Paths',
l10n.channelPath_otherObservedPaths,
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
@ -65,17 +69,17 @@ class ChannelMessagePathScreen extends StatelessWidget {
const SizedBox(height: 16),
],
Text(
'Repeater Hops',
l10n.channelPath_repeaterHops,
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
if (!hasHopDetails)
const Text(
'Hop details are not provided for this packet.',
style: TextStyle(color: Colors.grey),
Text(
l10n.channelPath_noHopDetails,
style: const TextStyle(color: Colors.grey),
)
else
..._buildHopTiles(hops),
..._buildHopTiles(context, hops),
],
),
),
@ -88,6 +92,7 @@ class ChannelMessagePathScreen extends StatelessWidget {
BuildContext context, {
String? observedLabel,
}) {
final l10n = context.l10n;
return Card(
child: Padding(
padding: const EdgeInsets.all(12),
@ -95,16 +100,16 @@ class ChannelMessagePathScreen extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Message Details',
l10n.channelPath_messageDetails,
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
_buildDetailRow('Sender', message.senderName),
_buildDetailRow('Time', _formatTime(message.timestamp)),
_buildDetailRow(l10n.channelPath_senderLabel, message.senderName),
_buildDetailRow(l10n.channelPath_timeLabel, _formatTime(message.timestamp, l10n)),
if (message.repeatCount > 0)
_buildDetailRow('Repeats', message.repeatCount.toString()),
_buildDetailRow('Path', _formatPathLabel(message.pathLength)),
if (observedLabel != null) _buildDetailRow('Observed', observedLabel),
_buildDetailRow(l10n.channelPath_repeatsLabel, message.repeatCount.toString()),
_buildDetailRow(l10n.channelPath_pathLabelTitle, _formatPathLabel(message.pathLength, l10n)),
if (observedLabel != null) _buildDetailRow(l10n.channelPath_observedLabel, observedLabel),
],
),
),
@ -115,6 +120,7 @@ class ChannelMessagePathScreen extends StatelessWidget {
BuildContext context,
List<Uint8List> variants,
) {
final l10n = context.l10n;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@ -124,7 +130,10 @@ class ChannelMessagePathScreen extends StatelessWidget {
child: ListTile(
dense: true,
title: Text(
'Observed path ${i + 1}${_formatHopCount(variants[i].length)}',
l10n.channelPath_observedPathTitle(
i + 1,
_formatHopCount(variants[i].length, l10n),
),
),
subtitle: Text(_formatPathPrefixes(variants[i])),
trailing: const Icon(Icons.map_outlined, size: 20),
@ -135,7 +144,8 @@ class ChannelMessagePathScreen extends StatelessWidget {
);
}
List<Widget> _buildHopTiles(List<_PathHop> hops) {
List<Widget> _buildHopTiles(BuildContext context, List<_PathHop> hops) {
final l10n = context.l10n;
return [
for (final hop in hops)
Card(
@ -154,45 +164,52 @@ class ChannelMessagePathScreen extends StatelessWidget {
hop.hasLocation
? '${hop.position!.latitude.toStringAsFixed(5)}, '
'${hop.position!.longitude.toStringAsFixed(5)}'
: 'No location data',
: l10n.channelPath_noLocationData,
),
),
),
];
}
String _formatTime(DateTime time) {
String _formatTime(DateTime time, AppLocalizations l10n) {
final now = DateTime.now();
final diff = now.difference(time);
if (diff.inDays > 0) {
return '${time.day}/${time.month} '
final timeLabel =
'${time.hour}:${time.minute.toString().padLeft(2, '0')}';
return l10n.channelPath_timeWithDate(time.day, time.month, timeLabel);
}
return '${time.hour}:${time.minute.toString().padLeft(2, '0')}';
return l10n.channelPath_timeOnly(
'${time.hour}:${time.minute.toString().padLeft(2, '0')}',
);
}
String _formatPathLabel(int? pathLength) {
if (pathLength == null) return 'Unknown';
if (pathLength < 0) return 'Flood';
if (pathLength == 0) return 'Direct';
return '$pathLength hops';
String _formatPathLabel(int? pathLength, AppLocalizations l10n) {
if (pathLength == null) return l10n.channelPath_unknownPath;
if (pathLength < 0) return l10n.channelPath_floodPath;
if (pathLength == 0) return l10n.channelPath_directPath;
return l10n.chat_hopsCount(pathLength);
}
String? _formatObservedHops(int observedCount, int? pathLength) {
String? _formatObservedHops(
int observedCount,
int? pathLength,
AppLocalizations l10n,
) {
if (observedCount <= 0 && (pathLength == null || pathLength <= 0)) {
return null;
}
if (pathLength == null || pathLength < 0) {
return observedCount > 0 ? '$observedCount hops' : null;
return observedCount > 0 ? l10n.chat_hopsCount(observedCount) : null;
}
if (observedCount == 0) {
return '0 of $pathLength hops';
return l10n.channelPath_observedZeroOf(pathLength);
}
if (observedCount == pathLength) {
return '$observedCount hops';
return l10n.chat_hopsCount(observedCount);
}
return '$observedCount of $pathLength hops';
return l10n.channelPath_observedSomeOf(observedCount, pathLength);
}
Widget _buildDetailRow(String label, String value) {
@ -274,7 +291,7 @@ class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScree
primaryPath,
);
final selectedIndex = _indexForPath(selectedPath, observedPaths);
final hops = _buildPathHops(selectedPath, connector.contacts);
final hops = _buildPathHops(selectedPath, connector.contacts, context.l10n);
final points = hops
.where((hop) => hop.hasLocation)
.map((hop) => hop.position!)
@ -297,7 +314,7 @@ class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScree
return Scaffold(
appBar: AppBar(
title: const Text('Path Map'),
title: Text(context.l10n.channelPath_mapTitle),
),
body: SafeArea(
top: false,
@ -347,9 +364,9 @@ class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScree
Center(
child: Card(
color: Colors.white.withValues(alpha: 0.9),
child: const Padding(
child: Padding(
padding: EdgeInsets.all(12),
child: Text('No repeater locations available for this path.'),
child: Text(context.l10n.channelPath_noRepeaterLocations),
),
),
),
@ -368,10 +385,11 @@ class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScree
int selectedIndex,
ValueChanged<int> onSelected,
) {
final l10n = context.l10n;
final selectedPath = paths[selectedIndex];
final label = selectedPath.isPrimary
? 'Path ${selectedIndex + 1} (Primary)'
: 'Path ${selectedIndex + 1}';
? l10n.channelPath_primaryPath(selectedIndex + 1)
: l10n.channelPath_pathLabel(selectedIndex + 1);
return Positioned(
left: 16,
right: 16,
@ -383,9 +401,9 @@ class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScree
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Observed Path',
style: TextStyle(fontWeight: FontWeight.w600),
Text(
l10n.channelPath_observedPathHeader,
style: const TextStyle(fontWeight: FontWeight.w600),
),
const SizedBox(height: 4),
DropdownButtonHideUnderline(
@ -397,8 +415,8 @@ class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScree
DropdownMenuItem(
value: i,
child: Text(
'${paths[i].isPrimary ? 'Path ${i + 1} (Primary)' : 'Path ${i + 1}'}'
'${_formatHopCount(paths[i].pathBytes.length)}',
'${paths[i].isPrimary ? l10n.channelPath_primaryPath(i + 1) : l10n.channelPath_pathLabel(i + 1)}'
'${_formatHopCount(paths[i].pathBytes.length, l10n)}',
),
),
],
@ -410,7 +428,10 @@ class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScree
),
const SizedBox(height: 4),
Text(
'$label${_formatPathPrefixes(selectedPath.pathBytes)}',
l10n.channelPath_selectedPathLabel(
label,
_formatPathPrefixes(selectedPath.pathBytes),
),
style: TextStyle(color: Colors.grey[700], fontSize: 12),
),
],
@ -457,6 +478,7 @@ class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScree
}
Widget _buildLegendCard(BuildContext context, List<_PathHop> hops) {
final l10n = context.l10n;
final maxHeight = MediaQuery.of(context).size.height * 0.35;
final estimatedHeight = 72.0 + (hops.length * 56.0);
final cardHeight = max(96.0, min(maxHeight, estimatedHeight));
@ -471,18 +493,18 @@ class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScree
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.all(12),
Padding(
padding: const EdgeInsets.all(12),
child: Text(
'Repeater Hops',
style: TextStyle(fontWeight: FontWeight.w600),
l10n.channelPath_repeaterHops,
style: const TextStyle(fontWeight: FontWeight.w600),
),
),
const Divider(height: 1),
Expanded(
child: hops.isEmpty
? const Center(
child: Text('No hop details available for this packet.'),
? Center(
child: Text(l10n.channelPath_noHopDetailsAvailable),
)
: ListView.separated(
padding: const EdgeInsets.symmetric(vertical: 4),
@ -504,7 +526,7 @@ class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScree
hop.hasLocation
? '${hop.position!.latitude.toStringAsFixed(5)}, '
'${hop.position!.longitude.toStringAsFixed(5)}'
: 'No location data',
: l10n.channelPath_noLocationData,
),
);
},
@ -523,19 +545,21 @@ class _PathHop {
final int prefix;
final Contact? contact;
final LatLng? position;
final AppLocalizations l10n;
const _PathHop({
required this.index,
required this.prefix,
required this.contact,
required this.position,
required this.l10n,
});
bool get hasLocation => position != null;
String get displayLabel {
final prefixLabel = _formatPrefix(prefix);
return '($prefixLabel) ${_resolveName(contact)}';
return '($prefixLabel) ${_resolveName(contact, l10n)}';
}
}
@ -549,7 +573,11 @@ class _ObservedPath {
});
}
List<_PathHop> _buildPathHops(Uint8List pathBytes, List<Contact> contacts) {
List<_PathHop> _buildPathHops(
Uint8List pathBytes,
List<Contact> contacts,
AppLocalizations l10n,
) {
final hops = <_PathHop>[];
for (var i = 0; i < pathBytes.length; i++) {
final prefix = pathBytes[i];
@ -560,6 +588,7 @@ List<_PathHop> _buildPathHops(Uint8List pathBytes, List<Contact> contacts) {
prefix: prefix,
contact: contact,
position: _resolvePosition(contact),
l10n: l10n,
),
);
}
@ -612,15 +641,15 @@ String _formatPathPrefixes(Uint8List pathBytes) {
.join(',');
}
String _formatHopCount(int count) {
return '$count ${count == 1 ? 'hop' : 'hops'}';
String _formatHopCount(int count, AppLocalizations l10n) {
return l10n.chat_hopsCount(count);
}
String _resolveName(Contact? contact) {
if (contact == null) return 'Unknown Repeater';
String _resolveName(Contact? contact, AppLocalizations l10n) {
if (contact == null) return l10n.channelPath_unknownRepeater;
final name = contact.name.trim();
if (name.isEmpty || name.toLowerCase() == 'unknown') {
return 'Unknown Repeater';
return l10n.channelPath_unknownRepeater;
}
return name;
}

View file

@ -6,6 +6,7 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart';
import '../l10n/l10n.dart';
import '../models/channel.dart';
import '../utils/dialog_utils.dart';
import '../utils/disconnect_navigation_mixin.dart';
@ -77,18 +78,18 @@ class _ChannelsScreenState extends State<ChannelsScreen>
child: Scaffold(
appBar: AppBar(
leading: BatteryIndicator(connector: connector),
title: const Text('Channels'),
title: Text(context.l10n.channels_title),
centerTitle: true,
automaticallyImplyLeading: false,
actions: [
IconButton(
icon: const Icon(Icons.bluetooth_disabled),
tooltip: 'Disconnect',
tooltip: context.l10n.common_disconnect,
onPressed: () => _disconnect(context),
),
IconButton(
icon: const Icon(Icons.tune),
tooltip: 'Settings',
tooltip: context.l10n.common_settings,
onPressed: () => Navigator.push(
context,
MaterialPageRoute(builder: (context) => const SettingsScreen()),
@ -114,11 +115,11 @@ class _ChannelsScreenState extends State<ChannelsScreen>
height: MediaQuery.of(context).size.height - 200,
child: EmptyState(
icon: Icons.tag,
title: 'No channels configured',
title: context.l10n.channels_noChannelsConfigured,
action: FilledButton.icon(
onPressed: () => _addPublicChannel(context, connector),
icon: const Icon(Icons.public),
label: const Text('Add Public Channel'),
label: Text(context.l10n.channels_addPublicChannel),
),
),
),
@ -135,7 +136,7 @@ class _ChannelsScreenState extends State<ChannelsScreen>
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Search channels...',
hintText: context.l10n.channels_searchChannels,
prefixIcon: const Icon(Icons.search),
suffixIcon: Row(
mainAxisSize: MainAxisSize.min,
@ -183,7 +184,7 @@ class _ChannelsScreenState extends State<ChannelsScreen>
Icon(Icons.search_off, size: 64, color: Colors.grey[400]),
const SizedBox(height: 16),
Text(
'No channels found',
context.l10n.channels_noChannelsFound,
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
),
],
@ -289,15 +290,15 @@ class _ChannelsScreenState extends State<ChannelsScreen>
),
),
title: Text(
channel.name.isEmpty ? 'Channel ${channel.index}' : channel.name,
channel.name.isEmpty ? context.l10n.channels_channelIndex(channel.index) : channel.name,
style: const TextStyle(fontWeight: FontWeight.w500),
),
subtitle: Text(
channel.name.startsWith('#')
? 'Hashtag channel'
? context.l10n.channels_hashtagChannel
: channel.isPublicChannel
? 'Public channel'
: 'Private channel',
? context.l10n.channels_publicChannel
: context.l10n.channels_privateChannel,
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
@ -346,7 +347,7 @@ class _ChannelsScreenState extends State<ChannelsScreen>
children: [
ListTile(
leading: const Icon(Icons.edit_outlined),
title: const Text('Edit channel'),
title: Text(context.l10n.channels_editChannel),
onTap: () async {
Navigator.pop(context);
await Future.delayed(const Duration(milliseconds: 100));
@ -357,7 +358,7 @@ class _ChannelsScreenState extends State<ChannelsScreen>
),
ListTile(
leading: const Icon(Icons.delete_outline, color: Colors.red),
title: const Text('Delete channel', style: TextStyle(color: Colors.red)),
title: Text(context.l10n.channels_deleteChannel, style: const TextStyle(color: Colors.red)),
onTap: () async {
Navigator.pop(context);
await Future.delayed(const Duration(milliseconds: 100));
@ -406,28 +407,29 @@ class _ChannelsScreenState extends State<ChannelsScreen>
const actionSortUnread = 3;
return SortFilterMenu(
tooltip: context.l10n.listFilter_tooltip,
sections: [
SortFilterMenuSection(
title: 'Sort by',
title: context.l10n.channels_sortBy,
options: [
SortFilterMenuOption(
value: actionSortManual,
label: 'Manual',
label: context.l10n.channels_sortManual,
checked: _sortOption == ChannelSortOption.manual,
),
SortFilterMenuOption(
value: actionSortName,
label: 'A-Z',
label: context.l10n.channels_sortAZ,
checked: _sortOption == ChannelSortOption.name,
),
SortFilterMenuOption(
value: actionSortLatest,
label: 'Latest messages',
label: context.l10n.channels_sortLatestMessages,
checked: _sortOption == ChannelSortOption.latestMessages,
),
SortFilterMenuOption(
value: actionSortUnread,
label: 'Unread',
label: context.l10n.channels_sortUnread,
checked: _sortOption == ChannelSortOption.unread,
),
],
@ -503,7 +505,7 @@ class _ChannelsScreenState extends State<ChannelsScreen>
}
String _normalizeChannelName(Channel channel) {
if (channel.name.isEmpty) return 'Channel ${channel.index}';
if (channel.name.isEmpty) return 'Channel ${channel.index}'; // Fallback for sorting
final trimmed = channel.name.trim();
if (trimmed.startsWith('#') && trimmed.length > 1) {
return trimmed.substring(1);
@ -521,9 +523,9 @@ class _ChannelsScreenState extends State<ChannelsScreen>
showDialog(
context: context,
builder: (context) => StatefulBuilder(
builder: (context, setDialogState) => AlertDialog(
title: const Text('Add Channel'),
builder: (dialogContext) => StatefulBuilder(
builder: (dialogContext, setDialogState) => AlertDialog(
title: Text(dialogContext.l10n.channels_addChannel),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
@ -531,14 +533,14 @@ class _ChannelsScreenState extends State<ChannelsScreen>
children: [
DropdownButtonFormField<int>(
initialValue: selectedIndex,
decoration: const InputDecoration(
labelText: 'Channel Index',
border: OutlineInputBorder(),
decoration: InputDecoration(
labelText: dialogContext.l10n.channels_channelIndexLabel,
border: const OutlineInputBorder(),
),
items: List.generate(maxChannels, (i) => i)
.map((i) => DropdownMenuItem(
value: i,
child: Text('Channel $i'),
child: Text(dialogContext.l10n.channels_channelIndex(i)),
))
.toList(),
onChanged: (value) {
@ -550,16 +552,16 @@ class _ChannelsScreenState extends State<ChannelsScreen>
const SizedBox(height: 16),
TextField(
controller: nameController,
decoration: const InputDecoration(
labelText: 'Channel Name',
border: OutlineInputBorder(),
decoration: InputDecoration(
labelText: dialogContext.l10n.channels_channelName,
border: const OutlineInputBorder(),
),
maxLength: 31,
),
const SizedBox(height: 8),
CheckboxListTile(
title: const Text('Use Public Channel'),
subtitle: const Text('Standard public PSK'),
title: Text(dialogContext.l10n.channels_usePublicChannel),
subtitle: Text(dialogContext.l10n.channels_standardPublicPsk),
value: usePublicPsk,
onChanged: (value) {
setDialogState(() {
@ -578,11 +580,11 @@ class _ChannelsScreenState extends State<ChannelsScreen>
TextField(
controller: pskController,
decoration: InputDecoration(
labelText: 'PSK (Hex)',
labelText: dialogContext.l10n.channels_pskHex,
border: const OutlineInputBorder(),
suffixIcon: IconButton(
icon: const Icon(Icons.casino),
tooltip: 'Generate random PSK',
tooltip: dialogContext.l10n.channels_generateRandomPsk,
onPressed: () {
final random = Random.secure();
final bytes = Uint8List(16);
@ -600,8 +602,8 @@ class _ChannelsScreenState extends State<ChannelsScreen>
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
onPressed: () => Navigator.pop(dialogContext),
child: Text(dialogContext.l10n.common_cancel),
),
FilledButton(
onPressed: () {
@ -611,8 +613,8 @@ class _ChannelsScreenState extends State<ChannelsScreen>
: pskController.text.trim();
if (name.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Please enter a channel name')),
ScaffoldMessenger.of(dialogContext).showSnackBar(
SnackBar(content: Text(dialogContext.l10n.channels_enterChannelName)),
);
return;
}
@ -621,21 +623,21 @@ class _ChannelsScreenState extends State<ChannelsScreen>
try {
psk = Channel.parsePskHex(pskHex);
} on FormatException {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('PSK must be 32 hex characters')),
ScaffoldMessenger.of(dialogContext).showSnackBar(
SnackBar(content: Text(dialogContext.l10n.channels_pskMustBe32Hex)),
);
return;
}
Navigator.pop(context);
Navigator.pop(dialogContext);
connector.setChannel(selectedIndex, name, psk);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Channel "$name" added')),
SnackBar(content: Text(context.l10n.channels_channelAdded(name))),
);
}
},
child: const Text('Add'),
child: Text(dialogContext.l10n.common_add),
),
],
),
@ -654,18 +656,18 @@ class _ChannelsScreenState extends State<ChannelsScreen>
showDialog(
context: context,
builder: (context) => StatefulBuilder(
builder: (context, setState) => AlertDialog(
title: Text('Edit Channel ${channel.index}'),
builder: (dialogContext) => StatefulBuilder(
builder: (dialogContext, setState) => AlertDialog(
title: Text(dialogContext.l10n.channels_editChannelTitle(channel.index)),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: nameController,
decoration: const InputDecoration(
labelText: 'Channel Name',
border: OutlineInputBorder(),
decoration: InputDecoration(
labelText: dialogContext.l10n.channels_channelName,
border: const OutlineInputBorder(),
),
maxLength: 31,
),
@ -673,11 +675,11 @@ class _ChannelsScreenState extends State<ChannelsScreen>
TextField(
controller: pskController,
decoration: InputDecoration(
labelText: 'PSK (Hex)',
labelText: dialogContext.l10n.channels_pskHex,
border: const OutlineInputBorder(),
suffixIcon: IconButton(
icon: const Icon(Icons.casino),
tooltip: 'Generate random PSK',
tooltip: dialogContext.l10n.channels_generateRandomPsk,
onPressed: () {
final random = Random.secure();
final bytes = Uint8List(16);
@ -692,7 +694,7 @@ class _ChannelsScreenState extends State<ChannelsScreen>
const SizedBox(height: 16),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('SMAZ compression'),
title: Text(dialogContext.l10n.channels_smazCompression),
value: smazEnabled,
onChanged: (value) => setState(() => smazEnabled = value),
),
@ -701,8 +703,8 @@ class _ChannelsScreenState extends State<ChannelsScreen>
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
onPressed: () => Navigator.pop(dialogContext),
child: Text(dialogContext.l10n.common_cancel),
),
FilledButton(
onPressed: () {
@ -713,20 +715,20 @@ class _ChannelsScreenState extends State<ChannelsScreen>
try {
psk = Channel.parsePskHex(pskHex);
} on FormatException {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('PSK must be 32 hex characters')),
ScaffoldMessenger.of(dialogContext).showSnackBar(
SnackBar(content: Text(dialogContext.l10n.channels_pskMustBe32Hex)),
);
return;
}
Navigator.pop(context);
Navigator.pop(dialogContext);
connector.setChannel(channel.index, name, psk);
connector.setChannelSmazEnabled(channel.index, smazEnabled);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Channel "$name" updated')),
SnackBar(content: Text(context.l10n.channels_channelUpdated(name))),
);
},
child: const Text('Save'),
child: Text(dialogContext.l10n.common_save),
),
],
),
@ -741,23 +743,23 @@ class _ChannelsScreenState extends State<ChannelsScreen>
) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Delete Channel'),
content: Text('Delete "${channel.name}"? This cannot be undone.'),
builder: (dialogContext) => AlertDialog(
title: Text(dialogContext.l10n.channels_deleteChannel),
content: Text(dialogContext.l10n.channels_deleteChannelConfirm(channel.name)),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
onPressed: () => Navigator.pop(dialogContext),
child: Text(dialogContext.l10n.common_cancel),
),
TextButton(
onPressed: () {
Navigator.pop(context);
Navigator.pop(dialogContext);
connector.deleteChannel(channel.index);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Channel "${channel.name}" deleted')),
SnackBar(content: Text(context.l10n.channels_channelDeleted(channel.name))),
);
},
child: const Text('Delete', style: TextStyle(color: Colors.red)),
child: Text(dialogContext.l10n.common_delete, style: const TextStyle(color: Colors.red)),
),
],
),
@ -768,7 +770,7 @@ class _ChannelsScreenState extends State<ChannelsScreen>
final psk = Channel.parsePskHex(Channel.publicChannelPsk);
connector.setChannel(0, 'Public', psk);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Public channel added')),
SnackBar(content: Text(context.l10n.channels_publicChannelAdded)),
);
}

View file

@ -23,6 +23,7 @@ import '../widgets/gif_message.dart';
import '../widgets/gif_picker.dart';
import '../widgets/path_selection_dialog.dart';
import '../utils/app_logger.dart';
import '../l10n/l10n.dart';
class ChatScreen extends StatefulWidget {
final Contact contact;
@ -67,7 +68,7 @@ class _ChatScreenState extends State<ChatScreen> {
builder: (context, pathService, connector, _) {
final contact = _resolveContact(connector);
final unreadCount = connector.getUnreadCountForContactKey(widget.contact.publicKeyHex);
final unreadLabel = 'Unread: $unreadCount';
final unreadLabel = context.l10n.chat_unread(unreadCount);
final pathLabel = _currentPathLabel(contact);
// Show path details if we have path data (from device or override)
@ -106,7 +107,7 @@ class _ChatScreenState extends State<ChatScreen> {
return PopupMenuButton<String>(
icon: Icon(isFloodMode ? Icons.waves : Icons.route),
tooltip: 'Routing mode',
tooltip: context.l10n.chat_routingMode,
onSelected: (mode) async {
if (mode == 'flood') {
await connector.setPathOverride(contact, pathLen: -1);
@ -122,7 +123,7 @@ class _ChatScreenState extends State<ChatScreen> {
Icon(Icons.auto_mode, size: 20, color: !isFloodMode ? Theme.of(context).primaryColor : null),
const SizedBox(width: 8),
Text(
'Auto (use saved path)',
context.l10n.chat_autoUseSavedPath,
style: TextStyle(
fontWeight: !isFloodMode ? FontWeight.bold : FontWeight.normal,
),
@ -137,7 +138,7 @@ class _ChatScreenState extends State<ChatScreen> {
Icon(Icons.waves, size: 20, color: isFloodMode ? Theme.of(context).primaryColor : null),
const SizedBox(width: 8),
Text(
'Force Flood Mode',
context.l10n.chat_forceFloodMode,
style: TextStyle(
fontWeight: isFloodMode ? FontWeight.bold : FontWeight.normal,
),
@ -151,7 +152,7 @@ class _ChatScreenState extends State<ChatScreen> {
),
IconButton(
icon: const Icon(Icons.timeline),
tooltip: 'Path management',
tooltip: context.l10n.chat_pathManagement,
onPressed: () => _showPathHistory(context),
),
IconButton(
@ -186,12 +187,12 @@ class _ChatScreenState extends State<ChatScreen> {
Icon(Icons.chat_bubble_outline, size: 64, color: Colors.grey[400]),
const SizedBox(height: 16),
Text(
'No messages yet',
context.l10n.chat_noMessages,
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
),
const SizedBox(height: 8),
Text(
'Send a message to ${widget.contact.name}',
context.l10n.chat_sendMessageTo(widget.contact.name),
style: TextStyle(fontSize: 14, color: Colors.grey[500]),
),
],
@ -244,7 +245,7 @@ class _ChatScreenState extends State<ChatScreen> {
IconButton(
icon: const Icon(Icons.gif_box),
onPressed: () => _showGifPicker(context),
tooltip: 'Send GIF',
tooltip: context.l10n.chat_sendGif,
),
Expanded(
child: ValueListenableBuilder<TextEditingValue>(
@ -278,10 +279,10 @@ class _ChatScreenState extends State<ChatScreen> {
inputFormatters: [
Utf8LengthLimitingTextInputFormatter(maxBytes),
],
decoration: const InputDecoration(
hintText: 'Type a message...',
border: OutlineInputBorder(),
contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: InputDecoration(
hintText: context.l10n.chat_typeMessage,
border: const OutlineInputBorder(),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
textInputAction: TextInputAction.send,
onSubmitted: (_) => _sendMessage(connector),
@ -325,7 +326,7 @@ class _ChatScreenState extends State<ChatScreen> {
final maxBytes = maxContactMessageBytes();
if (utf8.encode(text).length > maxBytes) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Message too long (max $maxBytes bytes).')),
SnackBar(content: Text(context.l10n.chat_messageTooLong(maxBytes))),
);
return;
}
@ -357,11 +358,11 @@ class _ChatScreenState extends State<ChatScreen> {
builder: (context, pathService, _) {
final paths = pathService.getRecentPaths(widget.contact.publicKeyHex);
return AlertDialog(
title: const Row(
title: Row(
children: [
Icon(Icons.timeline),
SizedBox(width: 8),
Text('Path Management'),
const Icon(Icons.timeline),
const SizedBox(width: 8),
Text(context.l10n.chat_pathManagement),
],
),
content: SingleChildScrollView(
@ -370,9 +371,9 @@ class _ChatScreenState extends State<ChatScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (paths.isNotEmpty) ...[
const Text(
'Recent ACK Paths (tap to use):',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
Text(
context.l10n.chat_recentAckPaths,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
),
if (paths.length >= 100) ...[
const SizedBox(height: 8),
@ -383,9 +384,9 @@ class _ChatScreenState extends State<ChatScreen> {
color: Colors.amber[100],
borderRadius: BorderRadius.circular(8),
),
child: const Text(
'Path history is full. Remove entries to add new ones.',
style: TextStyle(fontSize: 12),
child: Text(
context.l10n.chat_pathHistoryFull,
style: const TextStyle(fontSize: 12),
),
),
],
@ -404,11 +405,11 @@ class _ChatScreenState extends State<ChatScreen> {
),
),
title: Text(
'${path.hopCount} ${path.hopCount == 1 ? 'hop' : 'hops'}',
'${path.hopCount} ${path.hopCount == 1 ? context.l10n.chat_hopSingular : context.l10n.chat_hopPlural}',
style: const TextStyle(fontSize: 14),
),
subtitle: Text(
'${(path.tripTimeMs / 1000).toStringAsFixed(2)}s • ${_formatRelativeTime(path.timestamp)}${path.successCount} successes',
'${(path.tripTimeMs / 1000).toStringAsFixed(2)}s • ${_formatRelativeTime(path.timestamp)}${path.successCount} ${context.l10n.chat_successes}',
style: const TextStyle(fontSize: 11),
),
trailing: Row(
@ -416,7 +417,7 @@ class _ChatScreenState extends State<ChatScreen> {
children: [
IconButton(
icon: const Icon(Icons.close, size: 16),
tooltip: 'Remove path',
tooltip: context.l10n.chat_removePath,
onPressed: () async {
await pathService.removePathRecord(
widget.contact.publicKeyHex,
@ -433,9 +434,9 @@ class _ChatScreenState extends State<ChatScreen> {
onTap: () async {
if (path.pathBytes.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Path details not available yet. Try sending a message to refresh.'),
duration: Duration(seconds: 2),
SnackBar(
content: Text(context.l10n.chat_pathDetailsNotAvailable),
duration: const Duration(seconds: 2),
),
);
return;
@ -465,13 +466,13 @@ class _ChatScreenState extends State<ChatScreen> {
}),
const Divider(),
] else ...[
const Text('No path history yet.\nSend a message to discover paths.'),
Text(context.l10n.chat_noPathHistoryYet),
const Divider(),
],
const SizedBox(height: 8),
const Text(
'Path Actions:',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
Text(
context.l10n.chat_pathActions,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
),
const SizedBox(height: 8),
ListTile(
@ -481,8 +482,8 @@ class _ChatScreenState extends State<ChatScreen> {
backgroundColor: Colors.purple,
child: Icon(Icons.edit_road, size: 16),
),
title: const Text('Set Custom Path', style: TextStyle(fontSize: 14)),
subtitle: const Text('Manually specify routing path', style: TextStyle(fontSize: 11)),
title: Text(context.l10n.chat_setCustomPath, style: const TextStyle(fontSize: 14)),
subtitle: Text(context.l10n.chat_setCustomPathSubtitle, style: const TextStyle(fontSize: 11)),
onTap: () {
Navigator.pop(context);
_showCustomPathDialog(context);
@ -495,15 +496,15 @@ class _ChatScreenState extends State<ChatScreen> {
backgroundColor: Colors.orange,
child: Icon(Icons.clear_all, size: 16),
),
title: const Text('Clear Path', style: TextStyle(fontSize: 14)),
subtitle: const Text('Force rediscovery on next send', style: TextStyle(fontSize: 11)),
title: Text(context.l10n.chat_clearPath, style: const TextStyle(fontSize: 14)),
subtitle: Text(context.l10n.chat_clearPathSubtitle, style: const TextStyle(fontSize: 11)),
onTap: () async {
await connector.clearContactPath(widget.contact);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Path cleared. Next message will rediscover route.'),
duration: Duration(seconds: 2),
SnackBar(
content: Text(context.l10n.chat_pathCleared),
duration: const Duration(seconds: 2),
),
);
Navigator.pop(context);
@ -516,15 +517,15 @@ class _ChatScreenState extends State<ChatScreen> {
backgroundColor: Colors.blue,
child: Icon(Icons.waves, size: 16),
),
title: const Text('Force Flood Mode', style: TextStyle(fontSize: 14)),
subtitle: const Text('Use routing toggle in app bar', style: TextStyle(fontSize: 11)),
title: Text(context.l10n.chat_forceFloodMode, style: const TextStyle(fontSize: 14)),
subtitle: Text(context.l10n.chat_floodModeSubtitle, style: const TextStyle(fontSize: 11)),
onTap: () async {
await connector.setPathOverride(widget.contact, pathLen: -1);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Flood mode enabled. Toggle back via routing icon in app bar.'),
duration: Duration(seconds: 2),
SnackBar(
content: Text(context.l10n.chat_floodModeEnabled),
duration: const Duration(seconds: 2),
),
);
Navigator.pop(context);
@ -536,7 +537,7 @@ class _ChatScreenState extends State<ChatScreen> {
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
child: Text(context.l10n.common_close),
),
],
);
@ -547,18 +548,18 @@ class _ChatScreenState extends State<ChatScreen> {
String _formatRelativeTime(DateTime time) {
final diff = DateTime.now().difference(time);
if (diff.inSeconds < 60) return 'Just now';
if (diff.inMinutes < 60) return '${diff.inMinutes}m ago';
if (diff.inHours < 24) return '${diff.inHours}h ago';
return '${diff.inDays}d ago';
if (diff.inSeconds < 60) return context.l10n.time_justNow;
if (diff.inMinutes < 60) return context.l10n.time_minutesAgo(diff.inMinutes);
if (diff.inHours < 24) return context.l10n.time_hoursAgo(diff.inHours);
return context.l10n.time_daysAgo(diff.inDays);
}
void _showFullPathDialog(BuildContext context, List<int> pathBytes) {
if (pathBytes.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Path details not available yet. Try sending a message to refresh.'),
duration: Duration(seconds: 2),
SnackBar(
content: Text(context.l10n.chat_pathDetailsNotAvailable),
duration: const Duration(seconds: 2),
),
);
return;
@ -571,12 +572,12 @@ class _ChatScreenState extends State<ChatScreen> {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Full Path'),
title: Text(context.l10n.chat_fullPath),
content: SelectableText(formattedPath),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
child: Text(context.l10n.common_close),
),
],
),
@ -600,15 +601,15 @@ class _ChatScreenState extends State<ChatScreen> {
String _currentPathLabel(Contact contact) {
// Check if user has set a path override
if (contact.pathOverride != null) {
if (contact.pathOverride! < 0) return 'Flood (forced)';
if (contact.pathOverride == 0) return 'Direct (forced)';
return '${contact.pathOverride} hops (forced)';
if (contact.pathOverride! < 0) return context.l10n.chat_floodForced;
if (contact.pathOverride == 0) return context.l10n.chat_directForced;
return context.l10n.chat_hopsForced(contact.pathOverride!);
}
// Use device's path
if (contact.pathLength < 0) return 'Flood (auto)';
if (contact.pathLength == 0) return 'Direct';
return '${contact.pathLength} hops';
if (contact.pathLength < 0) return context.l10n.chat_floodAuto;
if (contact.pathLength == 0) return context.l10n.chat_direct;
return context.l10n.chat_hopsCount(contact.pathLength);
}
Future<void> _notifyPathSet(
@ -623,12 +624,12 @@ class _ChatScreenState extends State<ChatScreen> {
if (!mounted) return;
final status = !connector.isConnected
? 'Saved locally. Connect to sync.'
: (verified ? 'Device confirmed.' : 'Device not confirmed yet.');
? context.l10n.chat_pathSavedLocally
: (verified ? context.l10n.chat_pathDeviceConfirmed : context.l10n.chat_pathDeviceNotConfirmed);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Path set: $hopCount ${hopCount == 1 ? 'hop' : 'hops'} - $status',
context.l10n.chat_pathSetHops(hopCount, status),
),
duration: const Duration(seconds: 3),
),
@ -653,19 +654,19 @@ class _ChatScreenState extends State<ChatScreen> {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildInfoRow('Type', contact.typeLabel),
_buildInfoRow('Path', contact.pathLabel),
_buildInfoRow(context.l10n.chat_type, contact.typeLabel),
_buildInfoRow(context.l10n.chat_path, contact.pathLabel),
if (contact.hasLocation)
_buildInfoRow(
'Location',
context.l10n.chat_location,
'${contact.latitude?.toStringAsFixed(4)}, ${contact.longitude?.toStringAsFixed(4)}',
),
_buildInfoRow('Public Key', '${contact.publicKeyHex.substring(0, 16)}...'),
_buildInfoRow(context.l10n.chat_publicKey, '${contact.publicKeyHex.substring(0, 16)}...'),
const Divider(),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('SMAZ compression'),
subtitle: const Text('Compress outgoing messages'),
title: Text(context.l10n.channels_smazCompression),
subtitle: Text(context.l10n.chat_compressOutgoingMessages),
value: smazEnabled,
onChanged: (value) {
connector.setContactSmazEnabled(contact.publicKeyHex, value);
@ -677,7 +678,7 @@ class _ChatScreenState extends State<ChatScreen> {
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
child: Text(context.l10n.common_close),
),
],
);
@ -731,7 +732,7 @@ class _ChatScreenState extends State<ChatScreen> {
context,
availableContacts: availableContacts,
initialPath: pathForInput.isEmpty ? null : pathForInput,
title: 'Set Custom Path',
title: context.l10n.chat_setCustomPath,
currentPathLabel: currentPathLabel,
onRefresh: connector.isConnected ? connector.getContacts : null,
);
@ -769,7 +770,7 @@ class _ChatScreenState extends State<ChatScreen> {
.toUpperCase();
final String senderName;
if (message.isOutgoing) {
senderName = connector.selfName ?? 'Me';
senderName = connector.selfName ?? context.l10n.chat_me;
} else if (widget.contact.type == advTypeRoom) {
senderName = "${contact.name} [$fourByteHex]";
} else {
@ -803,7 +804,7 @@ class _ChatScreenState extends State<ChatScreen> {
children: [
ListTile(
leading: const Icon(Icons.add_reaction_outlined),
title: const Text('Add Reaction'),
title: Text(context.l10n.chat_addReaction),
onTap: () {
Navigator.pop(sheetContext);
_showEmojiPicker(message);
@ -811,7 +812,7 @@ class _ChatScreenState extends State<ChatScreen> {
),
ListTile(
leading: const Icon(Icons.copy),
title: const Text('Copy'),
title: Text(context.l10n.common_copy),
onTap: () {
Navigator.pop(sheetContext);
_copyMessageText(message.text);
@ -819,7 +820,7 @@ class _ChatScreenState extends State<ChatScreen> {
),
ListTile(
leading: const Icon(Icons.delete_outline),
title: const Text('Delete'),
title: Text(context.l10n.common_delete),
onTap: () async {
Navigator.pop(sheetContext);
await _deleteMessage(message);
@ -829,7 +830,7 @@ class _ChatScreenState extends State<ChatScreen> {
message.status == MessageStatus.failed)
ListTile(
leading: const Icon(Icons.refresh),
title: const Text('Retry'),
title: Text(context.l10n.common_retry),
onTap: () {
Navigator.pop(sheetContext);
_retryMessage(message);
@ -838,7 +839,7 @@ class _ChatScreenState extends State<ChatScreen> {
if (widget.contact.type == advTypeRoom)
ListTile(
leading: const Icon(Icons.chat),
title: const Text('Open Chat'),
title: Text(context.l10n.contacts_openChat),
onTap: () {
Navigator.pop(sheetContext);
_openChat(context, contact);
@ -846,7 +847,7 @@ class _ChatScreenState extends State<ChatScreen> {
),
ListTile(
leading: const Icon(Icons.close),
title: const Text('Cancel'),
title: Text(context.l10n.common_cancel),
onTap: () => Navigator.pop(sheetContext),
),
],
@ -858,7 +859,7 @@ class _ChatScreenState extends State<ChatScreen> {
void _copyMessageText(String text) {
Clipboard.setData(ClipboardData(text: text));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Message copied')),
SnackBar(content: Text(context.l10n.chat_messageCopied)),
);
}
@ -866,7 +867,7 @@ class _ChatScreenState extends State<ChatScreen> {
await context.read<MeshCoreConnector>().deleteMessage(message);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Message deleted')),
SnackBar(content: Text(context.l10n.chat_messageDeleted)),
);
}
@ -878,7 +879,7 @@ class _ChatScreenState extends State<ChatScreen> {
message.text,
);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Retrying message')),
SnackBar(content: Text(context.l10n.chat_retryingMessage)),
);
}
@ -996,7 +997,7 @@ class _MessageBubble extends StatelessWidget {
if (isOutgoing && message.retryCount > 0) ...[
const SizedBox(height: 4),
Text(
'Retry ${message.retryCount}/4',
context.l10n.chat_retryCount(message.retryCount, 4),
style: TextStyle(
fontSize: 10,
color: metaColor,
@ -1106,7 +1107,7 @@ class _MessageBubble extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'POI Shared',
context.l10n.chat_poiShared,
style: TextStyle(
color: textColor,
fontWeight: FontWeight.w600,

View file

@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart';
import '../l10n/l10n.dart';
import '../connector/meshcore_protocol.dart';
import '../models/contact.dart';
import '../models/contact_group.dart';
@ -89,18 +90,18 @@ class _ContactsScreenState extends State<ContactsScreen>
child: Scaffold(
appBar: AppBar(
leading: BatteryIndicator(connector: connector),
title: const Text('Contacts'),
title: Text(context.l10n.contacts_title),
centerTitle: true,
automaticallyImplyLeading: false,
actions: [
IconButton(
icon: const Icon(Icons.bluetooth_disabled),
tooltip: 'Disconnect',
tooltip: context.l10n.common_disconnect,
onPressed: () => _disconnect(context, connector),
),
IconButton(
icon: const Icon(Icons.tune),
tooltip: 'Settings',
tooltip: context.l10n.common_settings,
onPressed: () => Navigator.push(
context,
MaterialPageRoute(builder: (context) => const SettingsScreen()),
@ -159,10 +160,10 @@ class _ContactsScreenState extends State<ContactsScreen>
}
if (contacts.isEmpty && _groups.isEmpty) {
return const EmptyState(
return EmptyState(
icon: Icons.people_outline,
title: 'No contacts yet',
subtitle: 'Contacts will appear when devices advertise',
title: context.l10n.contacts_noContacts,
subtitle: context.l10n.contacts_contactsWillAppear,
);
}
@ -177,7 +178,7 @@ class _ContactsScreenState extends State<ContactsScreen>
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Search contacts...',
hintText: context.l10n.contacts_searchContacts,
prefixIcon: const Icon(Icons.search),
suffixIcon: Row(
mainAxisSize: MainAxisSize.min,
@ -221,8 +222,8 @@ class _ContactsScreenState extends State<ContactsScreen>
const SizedBox(height: 16),
Text(
_showUnreadOnly
? 'No unread contacts'
: 'No contacts or groups found',
? context.l10n.contacts_noUnreadContacts
: context.l10n.contacts_noContactsFound,
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
),
],
@ -341,7 +342,7 @@ class _ContactsScreenState extends State<ContactsScreen>
Widget _buildGroupTile(BuildContext context, ContactGroup group, List<Contact> contacts) {
final memberContacts = _resolveGroupContacts(group, contacts);
final subtitle = _formatGroupMembers(memberContacts);
final subtitle = _formatGroupMembers(context, memberContacts);
return ListTile(
leading: const CircleAvatar(
backgroundColor: Colors.teal,
@ -374,8 +375,8 @@ class _ContactsScreenState extends State<ContactsScreen>
return resolved;
}
String _formatGroupMembers(List<Contact> members) {
if (members.isEmpty) return 'No members';
String _formatGroupMembers(BuildContext context, List<Contact> members) {
if (members.isEmpty) return context.l10n.contacts_noMembers;
final names = members.map((c) => c.name).toList();
if (names.length <= 2) return names.join(', ');
return '${names.take(2).join(', ')} +${names.length - 2}';
@ -469,7 +470,7 @@ class _ContactsScreenState extends State<ContactsScreen>
children: [
ListTile(
leading: const Icon(Icons.edit),
title: const Text('Edit Group'),
title: Text(context.l10n.contacts_editGroup),
onTap: () {
Navigator.pop(sheetContext);
_showGroupEditor(context, contacts, group: group);
@ -477,7 +478,7 @@ class _ContactsScreenState extends State<ContactsScreen>
),
ListTile(
leading: const Icon(Icons.delete, color: Colors.red),
title: const Text('Delete Group', style: TextStyle(color: Colors.red)),
title: Text(context.l10n.contacts_deleteGroup, style: const TextStyle(color: Colors.red)),
onTap: () {
Navigator.pop(sheetContext);
_confirmDeleteGroup(context, group);
@ -506,12 +507,12 @@ class _ContactsScreenState extends State<ContactsScreen>
showDialog(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Delete Group'),
content: Text('Remove "${group.name}"?'),
title: Text(context.l10n.contacts_deleteGroup),
content: Text(context.l10n.contacts_deleteGroupConfirm(group.name)),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: const Text('Cancel'),
child: Text(context.l10n.common_cancel),
),
TextButton(
onPressed: () async {
@ -521,7 +522,7 @@ class _ContactsScreenState extends State<ContactsScreen>
});
await _saveGroups();
},
child: const Text('Delete', style: TextStyle(color: Colors.red)),
child: Text(context.l10n.common_delete, style: const TextStyle(color: Colors.red)),
),
],
),
@ -550,7 +551,7 @@ class _ContactsScreenState extends State<ContactsScreen>
.where((contact) => matchesContactQuery(contact, filterQuery))
.toList();
return AlertDialog(
title: Text(isEditing ? 'Edit Group' : 'New Group'),
title: Text(isEditing ? context.l10n.contacts_editGroup : context.l10n.contacts_newGroup),
content: SizedBox(
width: double.maxFinite,
child: Column(
@ -558,17 +559,17 @@ class _ContactsScreenState extends State<ContactsScreen>
children: [
TextField(
controller: nameController,
decoration: const InputDecoration(
labelText: 'Group name',
border: OutlineInputBorder(),
decoration: InputDecoration(
labelText: context.l10n.contacts_groupName,
border: const OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextField(
decoration: const InputDecoration(
hintText: 'Filter contacts...',
prefixIcon: Icon(Icons.search),
border: OutlineInputBorder(),
decoration: InputDecoration(
hintText: context.l10n.contacts_filterContacts,
prefixIcon: const Icon(Icons.search),
border: const OutlineInputBorder(),
isDense: true,
),
onChanged: (value) {
@ -581,7 +582,7 @@ class _ContactsScreenState extends State<ContactsScreen>
SizedBox(
height: 240,
child: filteredContacts.isEmpty
? const Center(child: Text('No contacts match your filter'))
? Center(child: Text(context.l10n.contacts_noContactsMatchFilter))
: ListView.builder(
itemCount: filteredContacts.length,
itemBuilder: (context, index) {
@ -610,14 +611,14 @@ class _ContactsScreenState extends State<ContactsScreen>
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: const Text('Cancel'),
child: Text(context.l10n.common_cancel),
),
TextButton(
onPressed: () async {
final name = nameController.text.trim();
if (name.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Group name is required')),
SnackBar(content: Text(context.l10n.contacts_groupNameRequired)),
);
return;
}
@ -627,7 +628,7 @@ class _ContactsScreenState extends State<ContactsScreen>
});
if (exists) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Group "$name" already exists')),
SnackBar(content: Text(context.l10n.contacts_groupAlreadyExists(name))),
);
return;
}
@ -649,7 +650,7 @@ class _ContactsScreenState extends State<ContactsScreen>
Navigator.pop(dialogContext);
}
},
child: Text(isEditing ? 'Save' : 'Create'),
child: Text(isEditing ? context.l10n.common_save : context.l10n.common_create),
),
],
);
@ -668,42 +669,42 @@ class _ContactsScreenState extends State<ContactsScreen>
showModalBottomSheet(
context: context,
builder: (context) => SafeArea(
builder: (sheetContext) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (isRepeater)
ListTile(
leading: const Icon(Icons.cell_tower, color: Colors.orange),
title: const Text('Manage Repeater'),
title: Text(context.l10n.contacts_manageRepeater),
onTap: () {
Navigator.pop(context);
Navigator.pop(sheetContext);
_showRepeaterLogin(context, contact);
},
)
else if (isRoom)
ListTile(
leading: const Icon(Icons.room, color: Colors.blue),
title: const Text('Room Login'),
title: Text(context.l10n.contacts_roomLogin),
onTap: () {
Navigator.pop(context);
Navigator.pop(sheetContext);
_showRoomLogin(context, contact);
},
)
else
ListTile(
leading: const Icon(Icons.chat),
title: const Text('Open Chat'),
title: Text(context.l10n.contacts_openChat),
onTap: () {
Navigator.pop(context);
Navigator.pop(sheetContext);
_openChat(context, contact);
},
),
ListTile(
leading: const Icon(Icons.delete, color: Colors.red),
title: const Text('Delete Contact', style: TextStyle(color: Colors.red)),
title: Text(context.l10n.contacts_deleteContact, style: const TextStyle(color: Colors.red)),
onTap: () {
Navigator.pop(context);
Navigator.pop(sheetContext);
_confirmDelete(context, connector, contact);
},
),
@ -720,20 +721,20 @@ class _ContactsScreenState extends State<ContactsScreen>
) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Delete Contact'),
content: Text('Remove ${contact.name} from contacts?'),
builder: (dialogContext) => AlertDialog(
title: Text(context.l10n.contacts_deleteContact),
content: Text(context.l10n.contacts_removeConfirm(contact.name)),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
onPressed: () => Navigator.pop(dialogContext),
child: Text(context.l10n.common_cancel),
),
TextButton(
onPressed: () {
Navigator.pop(context);
Navigator.pop(dialogContext);
connector.removeContact(contact);
},
child: const Text('Delete', style: TextStyle(color: Colors.red)),
child: Text(context.l10n.common_delete, style: const TextStyle(color: Colors.red)),
),
],
),
@ -774,7 +775,7 @@ class _ContactTile extends StatelessWidget {
const SizedBox(height: 4),
],
Text(
_formatLastSeen(lastSeen),
_formatLastSeen(context, lastSeen),
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
),
if (contact.hasLocation)
@ -827,17 +828,17 @@ class _ContactTile extends StatelessWidget {
}
}
String _formatLastSeen(DateTime lastSeen) {
String _formatLastSeen(BuildContext context, DateTime lastSeen) {
final now = DateTime.now();
final diff = now.difference(lastSeen);
if (diff.isNegative || diff.inMinutes < 5) return 'Last seen now';
if (diff.inMinutes < 60) return 'Last seen ${diff.inMinutes} mins ago';
if (diff.isNegative || diff.inMinutes < 5) return context.l10n.contacts_lastSeenNow;
if (diff.inMinutes < 60) return context.l10n.contacts_lastSeenMinsAgo(diff.inMinutes);
if (diff.inHours < 24) {
final hours = diff.inHours;
return hours == 1 ? 'Last seen 1 hour ago' : 'Last seen $hours hours ago';
return hours == 1 ? context.l10n.contacts_lastSeenHourAgo : context.l10n.contacts_lastSeenHoursAgo(hours);
}
final days = diff.inDays;
return days == 1 ? 'Last seen 1 day ago' : 'Last seen $days days ago';
return days == 1 ? context.l10n.contacts_lastSeenDayAgo : context.l10n.contacts_lastSeenDaysAgo(days);
}
}

View file

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart';
import '../l10n/l10n.dart';
import '../utils/dialog_utils.dart';
import '../utils/disconnect_navigation_mixin.dart';
import '../utils/route_transitions.dart';
@ -47,12 +48,12 @@ class _DeviceScreenState extends State<DeviceScreen>
actions: [
IconButton(
icon: const Icon(Icons.bluetooth_disabled),
tooltip: 'Disconnect',
tooltip: context.l10n.common_disconnect,
onPressed: () => _disconnect(context, connector),
),
IconButton(
icon: const Icon(Icons.tune),
tooltip: 'Settings',
tooltip: context.l10n.common_settings,
onPressed: () => Navigator.push(
context,
MaterialPageRoute(
@ -68,7 +69,7 @@ class _DeviceScreenState extends State<DeviceScreen>
children: [
_buildConnectionCard(connector, context),
const SizedBox(height: 16),
_buildSectionLabel(theme, 'Quick switch'),
_buildSectionLabel(theme, context.l10n.device_quickSwitch),
const SizedBox(height: 12),
_buildQuickSwitchBar(context),
],
@ -87,7 +88,7 @@ class _DeviceScreenState extends State<DeviceScreen>
mainAxisSize: MainAxisSize.min,
children: [
Text(
'MeshCore',
context.l10n.device_meshcore,
style: theme.textTheme.labelSmall?.copyWith(
fontWeight: FontWeight.w600,
letterSpacing: 0.8,
@ -180,7 +181,7 @@ class _DeviceScreenState extends State<DeviceScreen>
size: 18,
color: colorScheme.onSecondaryContainer,
),
label: const Text('Connected'),
label: Text(context.l10n.common_connected),
backgroundColor: colorScheme.secondaryContainer,
labelStyle: theme.textTheme.labelMedium?.copyWith(
color: colorScheme.onSecondaryContainer,

View file

@ -3,6 +3,8 @@ import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
import 'package:provider/provider.dart';
import '../l10n/app_localizations.dart';
import '../l10n/l10n.dart';
import '../services/app_settings_service.dart';
import '../services/map_tile_cache_service.dart';
@ -110,14 +112,14 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
final bounds = _selectedBounds;
if (bounds == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Select an area to cache first')),
SnackBar(content: Text(context.l10n.mapCache_selectAreaFirst)),
);
return;
}
if (_estimatedTiles == 0) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('No tiles to download for this area')),
SnackBar(content: Text(context.l10n.mapCache_noTilesToDownload)),
);
return;
}
@ -125,18 +127,18 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Download tiles'),
title: Text(context.l10n.mapCache_downloadTilesTitle),
content: Text(
'Download $_estimatedTiles tiles for offline use?',
context.l10n.mapCache_downloadTilesPrompt(_estimatedTiles),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: const Text('Cancel'),
child: Text(context.l10n.common_cancel),
),
TextButton(
onPressed: () => Navigator.pop(dialogContext, true),
child: const Text('Download'),
child: Text(context.l10n.mapCache_downloadAction),
),
],
),
@ -174,8 +176,11 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
});
final message = result.failed > 0
? 'Cached ${result.downloaded} tiles (${result.failed} failed)'
: 'Cached ${result.downloaded} tiles';
? context.l10n.mapCache_cachedTilesWithFailed(
result.downloaded,
result.failed,
)
: context.l10n.mapCache_cachedTiles(result.downloaded);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message)),
);
@ -185,16 +190,16 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Clear offline cache'),
content: const Text('Remove all cached map tiles?'),
title: Text(context.l10n.mapCache_clearOfflineCacheTitle),
content: Text(context.l10n.mapCache_clearOfflineCachePrompt),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: const Text('Cancel'),
child: Text(context.l10n.common_cancel),
),
TextButton(
onPressed: () => Navigator.pop(dialogContext, true),
child: const Text('Clear'),
child: Text(context.l10n.common_clear),
),
],
),
@ -205,7 +210,7 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
await cacheService.clearCache();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Offline cache cleared')),
SnackBar(content: Text(context.l10n.mapCache_offlineCacheCleared)),
);
}
@ -213,13 +218,14 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
Widget build(BuildContext context) {
final tileCache = context.read<MapTileCacheService>();
final selectedBounds = _selectedBounds;
final l10n = context.l10n;
final progressValue = _estimatedTiles == 0
? 0.0
: (_completedTiles / _estimatedTiles).clamp(0.0, 1.0).toDouble();
return Scaffold(
appBar: AppBar(
title: const Text('Offline Map Cache'),
title: Text(l10n.mapCache_title),
centerTitle: true,
),
body: Column(
@ -264,8 +270,8 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
padding: const EdgeInsets.all(8),
child: Text(
selectedBounds == null
? 'No area selected'
: _formatBounds(selectedBounds),
? l10n.mapCache_noAreaSelected
: _formatBounds(selectedBounds, l10n),
style: const TextStyle(fontSize: 12),
),
),
@ -282,9 +288,9 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Cache Area',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
Text(
l10n.mapCache_cacheArea,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
const SizedBox(height: 8),
Row(
@ -292,7 +298,7 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
Expanded(
child: ElevatedButton.icon(
icon: const Icon(Icons.crop_free),
label: const Text('Use Current View'),
label: Text(l10n.mapCache_useCurrentView),
onPressed: _isDownloading ? null : _setBoundsFromView,
),
),
@ -300,14 +306,14 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
TextButton(
onPressed:
_isDownloading || selectedBounds == null ? null : _clearBounds,
child: const Text('Clear'),
child: Text(l10n.common_clear),
),
],
),
const SizedBox(height: 12),
const Text(
'Zoom Range',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
Text(
l10n.mapCache_zoomRange,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
RangeSlider(
values:
@ -330,12 +336,15 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
_saveZoomRange();
},
),
Text('Estimated tiles: $_estimatedTiles'),
Text(l10n.mapCache_estimatedTiles(_estimatedTiles)),
if (_isDownloading) ...[
const SizedBox(height: 8),
LinearProgressIndicator(value: progressValue),
const SizedBox(height: 4),
Text('Downloaded $_completedTiles / $_estimatedTiles'),
Text(l10n.mapCache_downloadedTiles(
_completedTiles,
_estimatedTiles,
)),
],
const SizedBox(height: 12),
Row(
@ -343,7 +352,7 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
Expanded(
child: ElevatedButton.icon(
icon: const Icon(Icons.download),
label: const Text('Download Tiles'),
label: Text(l10n.mapCache_downloadTilesButton),
onPressed: _isDownloading || selectedBounds == null
? null
: _startDownload,
@ -352,7 +361,7 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
const SizedBox(width: 12),
OutlinedButton(
onPressed: _isDownloading ? null : _clearCache,
child: const Text('Clear Cache'),
child: Text(l10n.mapCache_clearCacheButton),
),
],
),
@ -360,7 +369,7 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
'Failed downloads: $_failedTiles',
l10n.mapCache_failedDownloads(_failedTiles),
style: TextStyle(color: Colors.orange[700]),
),
),
@ -382,10 +391,12 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
];
}
String _formatBounds(LatLngBounds bounds) {
return 'N ${bounds.north.toStringAsFixed(4)}, '
'S ${bounds.south.toStringAsFixed(4)}, '
'E ${bounds.east.toStringAsFixed(4)}, '
'W ${bounds.west.toStringAsFixed(4)}';
String _formatBounds(LatLngBounds bounds, AppLocalizations l10n) {
return l10n.mapCache_boundsLabel(
bounds.north.toStringAsFixed(4),
bounds.south.toStringAsFixed(4),
bounds.east.toStringAsFixed(4),
bounds.west.toStringAsFixed(4),
);
}
}

View file

@ -4,6 +4,7 @@ import 'package:latlong2/latlong.dart';
import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart';
import '../l10n/l10n.dart';
import '../connector/meshcore_protocol.dart';
import '../models/channel.dart';
import '../models/contact.dart';
@ -141,18 +142,18 @@ class _MapScreenState extends State<MapScreen> {
child: Scaffold(
appBar: AppBar(
leading: BatteryIndicator(connector: connector),
title: const Text('Node Map'),
title: Text(context.l10n.map_title),
centerTitle: true,
automaticallyImplyLeading: false,
actions: [
IconButton(
icon: const Icon(Icons.bluetooth_disabled),
tooltip: 'Disconnect',
tooltip: context.l10n.common_disconnect,
onPressed: () => _disconnect(context, connector),
),
IconButton(
icon: const Icon(Icons.tune),
tooltip: 'Settings',
tooltip: context.l10n.common_settings,
onPressed: () => Navigator.push(
context,
MaterialPageRoute(builder: (context) => const SettingsScreen()),
@ -180,7 +181,7 @@ class _MapScreenState extends State<MapScreen> {
context: context,
connector: connector,
position: latLng,
defaultLabel: 'Point of interest',
defaultLabel: context.l10n.map_pointOfInterest,
flags: 'poi',
);
}
@ -194,7 +195,7 @@ class _MapScreenState extends State<MapScreen> {
context: context,
connector: connector,
position: latLng,
defaultLabel: 'Point of interest',
defaultLabel: context.l10n.map_pointOfInterest,
flags: 'poi',
);
return;
@ -265,7 +266,7 @@ class _MapScreenState extends State<MapScreen> {
),
const SizedBox(height: 16),
Text(
'No nodes with location data',
context.l10n.map_noNodesWithLocation,
style: TextStyle(
fontSize: 18,
color: Colors.grey[600],
@ -273,7 +274,7 @@ class _MapScreenState extends State<MapScreen> {
),
const SizedBox(height: 8),
Text(
'Nodes need to share their GPS coordinates\nto appear on the map',
context.l10n.map_nodesNeedGps,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14,
@ -381,27 +382,27 @@ class _MapScreenState extends State<MapScreen> {
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Nodes: $nodeCount',
context.l10n.map_nodesCount(nodeCount),
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
Text(
'Pins: $markerCount',
context.l10n.map_pinsCount(markerCount),
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
const SizedBox(height: 8),
_buildLegendItem(Icons.person, 'Chat', Colors.blue),
_buildLegendItem(Icons.router, 'Repeater', Colors.green),
_buildLegendItem(Icons.meeting_room, 'Room', Colors.purple),
_buildLegendItem(Icons.sensors, 'Sensor', Colors.orange),
_buildLegendItem(Icons.flag, 'Pin (DM)', Colors.blue),
_buildLegendItem(Icons.flag, 'Pin (Private)', Colors.purple),
_buildLegendItem(Icons.flag, 'Pin (Public)', Colors.orange),
_buildLegendItem(Icons.person, context.l10n.map_chat, Colors.blue),
_buildLegendItem(Icons.router, context.l10n.map_repeater, Colors.green),
_buildLegendItem(Icons.meeting_room, context.l10n.map_room, Colors.purple),
_buildLegendItem(Icons.sensors, context.l10n.map_sensor, Colors.orange),
_buildLegendItem(Icons.flag, context.l10n.map_pinDm, Colors.blue),
_buildLegendItem(Icons.flag, context.l10n.map_pinPrivate, Colors.purple),
_buildLegendItem(Icons.flag, context.l10n.map_pinPublic, Colors.orange),
],
),
),
@ -501,7 +502,7 @@ class _MapScreenState extends State<MapScreen> {
final flags = parts.length > 2 ? parts[2].trim() : '';
return _MarkerPayload(
position: LatLng(lat, lon),
label: label.isEmpty ? 'Shared pin' : label,
label: label.isEmpty ? context.l10n.map_sharedPin : label,
flags: flags,
);
}
@ -595,7 +596,7 @@ class _MapScreenState extends State<MapScreen> {
void _showNodeInfo(BuildContext context, Contact contact) {
showDialog(
context: context,
builder: (context) => AlertDialog(
builder: (dialogContext) => AlertDialog(
title: Row(
children: [
Icon(
@ -614,19 +615,19 @@ class _MapScreenState extends State<MapScreen> {
_buildInfoRow('Path', contact.pathLabel),
_buildInfoRow('Location',
'${contact.latitude!.toStringAsFixed(6)}, ${contact.longitude!.toStringAsFixed(6)}'),
_buildInfoRow('Last Seen', _formatLastSeen(contact.lastSeen)),
_buildInfoRow(context.l10n.map_lastSeen, _formatLastSeen(contact.lastSeen)),
_buildInfoRow('Public Key', contact.publicKeyHex),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
onPressed: () => Navigator.pop(dialogContext),
child: Text(context.l10n.common_close),
),
if (contact.type == advTypeChat) // Only show chat button for chat nodes
TextButton(
onPressed: () {
Navigator.pop(context);
Navigator.pop(dialogContext);
Navigator.push(
context,
MaterialPageRoute(
@ -634,23 +635,23 @@ class _MapScreenState extends State<MapScreen> {
),
);
},
child: const Text('Open Chat'),
child: Text(context.l10n.contacts_openChat),
),
if (contact.type == advTypeRepeater)
TextButton(
onPressed: () {
Navigator.pop(context);
Navigator.pop(dialogContext);
_showRepeaterLogin(context, contact);
},
child: const Text('Manage Repeater'),
child: Text(context.l10n.map_manageRepeater),
),
if (contact.type == advTypeRoom)
TextButton(
onPressed: () {
Navigator.pop(context);
Navigator.pop(dialogContext);
_showRoomLogin(context, contact);
},
child: const Text('Join Room'),
child: Text(context.l10n.map_joinRoom),
),
],
),
@ -685,17 +686,17 @@ class _MapScreenState extends State<MapScreen> {
) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Disconnect'),
content: const Text('Are you sure you want to disconnect from this device?'),
builder: (dialogContext) => AlertDialog(
title: Text(context.l10n.common_disconnect),
content: Text(context.l10n.map_disconnectConfirm),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
onPressed: () => Navigator.pop(dialogContext, false),
child: Text(context.l10n.common_cancel),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Disconnect'),
onPressed: () => Navigator.pop(dialogContext, true),
child: Text(context.l10n.common_disconnect),
),
],
),
@ -709,19 +710,19 @@ class _MapScreenState extends State<MapScreen> {
void _showMarkerInfo(_SharedMarker marker) {
showDialog(
context: context,
builder: (context) => AlertDialog(
builder: (dialogContext) => AlertDialog(
title: Text(marker.label),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildInfoRow('From', marker.fromName),
_buildInfoRow('Source', marker.sourceLabel),
_buildInfoRow(context.l10n.map_from, marker.fromName),
_buildInfoRow(context.l10n.map_source, marker.sourceLabel),
_buildInfoRow(
'Location',
'${marker.position.latitude.toStringAsFixed(6)}, ${marker.position.longitude.toStringAsFixed(6)}',
),
if (marker.flags.isNotEmpty) _buildInfoRow('Flags', marker.flags),
if (marker.flags.isNotEmpty) _buildInfoRow(context.l10n.map_flags, marker.flags),
],
),
actions: [
@ -730,9 +731,9 @@ class _MapScreenState extends State<MapScreen> {
setState(() {
_hiddenMarkerIds.add(marker.id);
});
Navigator.pop(context);
Navigator.pop(dialogContext);
},
child: const Text('Hide'),
child: Text(context.l10n.common_hide),
),
TextButton(
onPressed: () async {
@ -741,15 +742,15 @@ class _MapScreenState extends State<MapScreen> {
_removedMarkerIds.add(marker.id);
});
await _markerService.saveRemovedIds(_removedMarkerIds);
if (context.mounted) {
Navigator.pop(context);
if (dialogContext.mounted) {
Navigator.pop(dialogContext);
}
},
child: const Text('Remove'),
child: Text(context.l10n.common_remove),
),
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
onPressed: () => Navigator.pop(dialogContext),
child: Text(context.l10n.common_close),
),
],
),
@ -785,13 +786,13 @@ class _MapScreenState extends State<MapScreen> {
final difference = now.difference(lastSeen);
if (difference.inSeconds < 60) {
return 'Just now';
return context.l10n.time_justNow;
} else if (difference.inMinutes < 60) {
return '${difference.inMinutes}m ago';
return context.l10n.time_minutesAgo(difference.inMinutes);
} else if (difference.inHours < 24) {
return '${difference.inHours}h ago';
return context.l10n.time_hoursAgo(difference.inHours);
} else {
return '${difference.inDays}d ago';
return context.l10n.time_daysAgo(difference.inDays);
}
}
@ -808,21 +809,21 @@ class _MapScreenState extends State<MapScreen> {
children: [
ListTile(
leading: const Icon(Icons.place),
title: const Text('Share marker here'),
title: Text(context.l10n.map_shareMarkerHere),
onTap: () {
Navigator.pop(sheetContext);
_shareMarker(
context: context,
connector: connector,
position: position,
defaultLabel: 'Point of interest',
defaultLabel: context.l10n.map_pointOfInterest,
flags: 'poi',
);
},
),
ListTile(
leading: const Icon(Icons.close),
title: const Text('Cancel'),
title: Text(context.l10n.common_cancel),
onTap: () => Navigator.pop(sheetContext),
),
],
@ -840,7 +841,7 @@ class _MapScreenState extends State<MapScreen> {
}) async {
if (!connector.isConnected) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Connect to a device to share markers')),
SnackBar(content: Text(context.l10n.map_connectToShareMarkers)),
);
return;
}
@ -864,25 +865,25 @@ class _MapScreenState extends State<MapScreen> {
return showDialog<String>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Pin label'),
title: Text(context.l10n.map_pinLabel),
content: TextField(
controller: controller,
decoration: const InputDecoration(
hintText: 'Label',
border: OutlineInputBorder(),
decoration: InputDecoration(
hintText: context.l10n.map_label,
border: const OutlineInputBorder(),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: const Text('Cancel'),
child: Text(context.l10n.common_cancel),
),
TextButton(
onPressed: () {
final label = controller.text.trim().replaceAll('|', '/');
Navigator.pop(dialogContext, label.isEmpty ? defaultLabel : label);
},
child: const Text('Continue'),
child: Text(context.l10n.common_continue),
),
],
),
@ -910,7 +911,7 @@ class _MapScreenState extends State<MapScreen> {
builder: (sheetContext) => StatefulBuilder(
builder: (sheetContext, setSheetState) {
return Consumer<MeshCoreConnector>(
builder: (context, liveConnector, child) {
builder: (consumerContext, liveConnector, child) {
final allContacts = liveConnector.contacts
.where((contact) =>
contact.type != advTypeRepeater && contact.type != advTypeRoom)
@ -921,15 +922,15 @@ class _MapScreenState extends State<MapScreen> {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Text('Send to contact', style: TextStyle(fontWeight: FontWeight.bold)),
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Text(context.l10n.map_sendToContact, style: const TextStyle(fontWeight: FontWeight.bold)),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 8),
child: TextField(
decoration: InputDecoration(
hintText: 'Search contacts...',
hintText: context.l10n.contacts_searchContacts,
prefixIcon: const Icon(Icons.search),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
@ -956,9 +957,9 @@ class _MapScreenState extends State<MapScreen> {
},
);
}),
const Padding(
padding: EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Text('Send to channel', style: TextStyle(fontWeight: FontWeight.bold)),
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Text(context.l10n.map_sendToChannel, style: const TextStyle(fontWeight: FontWeight.bold)),
),
if (liveConnector.isLoadingChannels)
const Padding(
@ -966,9 +967,9 @@ class _MapScreenState extends State<MapScreen> {
child: LinearProgressIndicator(),
)
else if (liveConnector.channels.where((c) => !c.isEmpty).isEmpty)
const Padding(
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text('No channels available'),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text(context.l10n.map_noChannelsAvailable),
)
else
...liveConnector.channels.where((c) => !c.isEmpty).map((channel) {
@ -980,7 +981,7 @@ class _MapScreenState extends State<MapScreen> {
color: isPublic ? Colors.orange : Colors.blue,
),
title: Text(label),
subtitle: isPublic ? const Text('Public channel') : null,
subtitle: isPublic ? Text(context.l10n.channels_publicChannel) : null,
onTap: () async {
Navigator.pop(sheetContext);
final canSend = isPublic
@ -1011,19 +1012,16 @@ class _MapScreenState extends State<MapScreen> {
final result = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Public location share'),
content: Text(
'You are about to share a location in $channelLabel. '
'This channel is public and anyone with the PSK can see it.',
),
title: Text(context.l10n.map_publicLocationShare),
content: Text(context.l10n.map_publicLocationShareConfirm(channelLabel)),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: const Text('Cancel'),
child: Text(context.l10n.common_cancel),
),
TextButton(
onPressed: () => Navigator.pop(dialogContext, true),
child: const Text('Share'),
child: Text(context.l10n.common_share),
),
],
),
@ -1035,26 +1033,26 @@ class _MapScreenState extends State<MapScreen> {
showDialog(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Filter Nodes'),
title: Text(context.l10n.map_filterNodes),
contentPadding: const EdgeInsets.fromLTRB(24, 20, 24, 0),
content: SingleChildScrollView(
child: Consumer<AppSettingsService>(
builder: (context, service, child) {
builder: (consumerContext, service, child) {
final settings = service.settings;
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Node Types',
style: TextStyle(
Text(
context.l10n.map_nodeTypes,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
const SizedBox(height: 8),
CheckboxListTile(
title: const Text('Chat Nodes'),
title: Text(context.l10n.map_chatNodes),
value: settings.mapShowChatNodes,
onChanged: (value) {
service.setMapShowChatNodes(value ?? true);
@ -1062,7 +1060,7 @@ class _MapScreenState extends State<MapScreen> {
contentPadding: EdgeInsets.zero,
),
CheckboxListTile(
title: const Text('Repeaters'),
title: Text(context.l10n.map_repeaters),
value: settings.mapShowRepeaters,
onChanged: (value) {
service.setMapShowRepeaters(value ?? true);
@ -1070,7 +1068,7 @@ class _MapScreenState extends State<MapScreen> {
contentPadding: EdgeInsets.zero,
),
CheckboxListTile(
title: const Text('Other Nodes'),
title: Text(context.l10n.map_otherNodes),
value: settings.mapShowOtherNodes,
onChanged: (value) {
service.setMapShowOtherNodes(value ?? true);
@ -1078,16 +1076,16 @@ class _MapScreenState extends State<MapScreen> {
contentPadding: EdgeInsets.zero,
),
const SizedBox(height: 16),
const Text(
'Key Prefix',
style: TextStyle(
Text(
context.l10n.map_keyPrefix,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
const SizedBox(height: 8),
CheckboxListTile(
title: const Text('Filter by key prefix'),
title: Text(context.l10n.map_filterByKeyPrefix),
value: settings.mapKeyPrefixEnabled,
onChanged: (value) {
service.setMapKeyPrefixEnabled(value ?? false);
@ -1097,10 +1095,10 @@ class _MapScreenState extends State<MapScreen> {
TextFormField(
initialValue: settings.mapKeyPrefix,
enabled: settings.mapKeyPrefixEnabled,
decoration: const InputDecoration(
labelText: 'Public key prefix',
decoration: InputDecoration(
labelText: context.l10n.map_publicKeyPrefix,
hintText: 'e.g. ab12',
border: OutlineInputBorder(),
border: const OutlineInputBorder(),
isDense: true,
),
onChanged: (value) {
@ -1108,16 +1106,16 @@ class _MapScreenState extends State<MapScreen> {
},
),
const SizedBox(height: 16),
const Text(
'Markers',
style: TextStyle(
Text(
context.l10n.map_markers,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
const SizedBox(height: 8),
CheckboxListTile(
title: const Text('Show shared markers'),
title: Text(context.l10n.map_showSharedMarkers),
value: settings.mapShowMarkers,
onChanged: (value) {
service.setMapShowMarkers(value ?? true);
@ -1125,9 +1123,9 @@ class _MapScreenState extends State<MapScreen> {
contentPadding: EdgeInsets.zero,
),
const SizedBox(height: 16),
const Text(
'Last Seen Time',
style: TextStyle(
Text(
context.l10n.map_lastSeenTime,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
@ -1158,7 +1156,7 @@ class _MapScreenState extends State<MapScreen> {
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: const Text('Close'),
child: Text(context.l10n.common_close),
),
],
),
@ -1205,23 +1203,24 @@ class _MapScreenState extends State<MapScreen> {
}
String _getTimeFilterLabel(double hours) {
if (hours == 0) return 'All Time';
if (hours == 0) return context.l10n.time_allTime;
if (hours < 1) {
return '${(hours * 60).round()} minutes';
return '${(hours * 60).round()} ${context.l10n.time_minutes}';
} else if (hours < 24) {
return '${hours.round()} ${hours.round() == 1 ? 'hour' : 'hours'}';
final h = hours.round();
return '$h ${h == 1 ? context.l10n.time_hour : context.l10n.time_hours}';
} else if (hours < 168) {
final days = (hours / 24).round();
return '$days ${days == 1 ? 'day' : 'days'}';
return '$days ${days == 1 ? context.l10n.time_day : context.l10n.time_days}';
} else if (hours < 720) {
final weeks = (hours / 168).round();
return '$weeks ${weeks == 1 ? 'week' : 'weeks'}';
return '$weeks ${weeks == 1 ? context.l10n.time_week : context.l10n.time_weeks}';
} else if (hours < 4380) {
final months = (hours / 730).round();
return '$months ${months == 1 ? 'month' : 'months'}';
return '$months ${months == 1 ? context.l10n.time_month : context.l10n.time_months}';
} else {
return 'All Time';
return context.l10n.time_allTime;
}
}
}

View file

@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../l10n/l10n.dart';
import '../models/contact.dart';
import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart';
@ -33,14 +34,14 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
RepeaterCommandService? _commandService;
// Common commands for quick access
final List<Map<String, String>> _quickCommands = [
{'label': 'Get Name', 'command': 'get name'},
{'label': 'Get Radio', 'command': 'get radio'},
{'label': 'Get TX', 'command': 'get tx'},
{'label': 'Neighbors', 'command': 'neighbors'},
{'label': 'Version', 'command': 'ver'},
{'label': 'Advertise', 'command': 'advert'},
{'label': 'Clock', 'command': 'clock'},
late final List<Map<String, String>> _quickCommands = [
{'labelKey': 'getName', 'command': 'get name'},
{'labelKey': 'getRadio', 'command': 'get radio'},
{'labelKey': 'getTx', 'command': 'get tx'},
{'labelKey': 'neighbors', 'command': 'neighbors'},
{'labelKey': 'version', 'command': 'ver'},
{'labelKey': 'advertise', 'command': 'advert'},
{'labelKey': 'clock', 'command': 'clock'},
];
@override
@ -119,7 +120,7 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
// Show debug info if requested
if (showDebug && mounted) {
final frame = buildSendCliCommandFrame(widget.repeater.publicKey, command);
DebugFrameViewer.showFrameDebug(context, frame, 'CLI Command Frame');
DebugFrameViewer.showFrameDebug(context, frame, context.l10n.repeater_cliCommandFrameTitle);
}
// Send CLI command to repeater with retry
@ -148,7 +149,7 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
setState(() {
_commandHistory.add({
'type': 'response',
'text': 'Error: $e',
'text': context.l10n.repeater_cliCommandError(e.toString()),
'timestamp': DateTime.now().toString(),
});
});
@ -215,6 +216,7 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final connector = context.watch<MeshCoreConnector>();
final repeater = _resolveRepeater(connector);
final isFloodMode = repeater.pathOverride == -1;
@ -225,7 +227,7 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
const Text('Repeater CLI'),
Text(l10n.repeater_cliTitle),
Text(
repeater.name,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.normal),
@ -236,7 +238,7 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
actions: [
PopupMenuButton<String>(
icon: Icon(isFloodMode ? Icons.waves : Icons.route),
tooltip: 'Routing mode',
tooltip: l10n.repeater_routingMode,
onSelected: (mode) async {
if (mode == 'flood') {
await connector.setPathOverride(repeater, pathLen: -1);
@ -252,7 +254,7 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
Icon(Icons.auto_mode, size: 20, color: !isFloodMode ? Theme.of(context).primaryColor : null),
const SizedBox(width: 8),
Text(
'Auto (use saved path)',
l10n.repeater_autoUseSavedPath,
style: TextStyle(
fontWeight: !isFloodMode ? FontWeight.bold : FontWeight.normal,
),
@ -267,7 +269,7 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
Icon(Icons.waves, size: 20, color: isFloodMode ? Theme.of(context).primaryColor : null),
const SizedBox(width: 8),
Text(
'Force Flood Mode',
l10n.repeater_forceFloodMode,
style: TextStyle(
fontWeight: isFloodMode ? FontWeight.bold : FontWeight.normal,
),
@ -279,31 +281,31 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
),
IconButton(
icon: const Icon(Icons.timeline),
tooltip: 'Path management',
tooltip: l10n.repeater_pathManagement,
onPressed: () => PathManagementDialog.show(context, contact: repeater),
),
IconButton(
icon: const Icon(Icons.bug_report),
tooltip: 'Debug Next Command',
tooltip: l10n.repeater_debugNextCommand,
onPressed: () {
// Set a flag or just send next command with debug
if (_commandController.text.trim().isNotEmpty) {
_sendCommand(showDebug: true);
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Enter a command first')),
SnackBar(content: Text(l10n.repeater_enterCommandFirst)),
);
}
},
),
IconButton(
icon: const Icon(Icons.help_outline),
tooltip: 'Command Help',
tooltip: l10n.repeater_commandHelp,
onPressed: () => _showCommandHelp(context),
),
IconButton(
icon: const Icon(Icons.clear_all),
tooltip: 'Clear History',
tooltip: l10n.repeater_clearHistory,
onPressed: _commandHistory.isEmpty ? null : _clearHistory,
),
],
@ -331,10 +333,11 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
scrollDirection: Axis.horizontal,
child: Row(
children: _quickCommands.map((cmd) {
final label = _quickCommandLabel(cmd['labelKey']!);
return Padding(
padding: const EdgeInsets.only(right: 8),
child: ActionChip(
label: Text(cmd['label']!),
label: Text(label),
onPressed: () => _useQuickCommand(cmd['command']!),
avatar: const Icon(Icons.play_arrow, size: 16),
),
@ -345,7 +348,30 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
);
}
String _quickCommandLabel(String key) {
final l10n = context.l10n;
switch (key) {
case 'getName':
return l10n.repeater_cliQuickGetName;
case 'getRadio':
return l10n.repeater_cliQuickGetRadio;
case 'getTx':
return l10n.repeater_cliQuickGetTx;
case 'neighbors':
return l10n.repeater_cliQuickNeighbors;
case 'version':
return l10n.repeater_cliQuickVersion;
case 'advertise':
return l10n.repeater_cliQuickAdvertise;
case 'clock':
return l10n.repeater_cliQuickClock;
default:
return key;
}
}
Widget _buildEmptyState() {
final l10n = context.l10n;
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
@ -353,12 +379,12 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
Icon(Icons.terminal, size: 64, color: Colors.grey[400]),
const SizedBox(height: 16),
Text(
'No commands sent yet',
l10n.repeater_noCommandsSent,
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
),
const SizedBox(height: 8),
Text(
'Type a command below or use quick commands',
l10n.repeater_typeCommandOrUseQuick,
style: TextStyle(fontSize: 14, color: Colors.grey[500]),
),
],
@ -422,6 +448,7 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
}
Widget _buildCommandInput() {
final l10n = context.l10n;
return Container(
padding: const EdgeInsets.all(12),
color: Theme.of(context).colorScheme.surface,
@ -430,12 +457,12 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
children: [
IconButton(
icon: const Icon(Icons.arrow_upward, size: 20),
tooltip: 'Previous command',
tooltip: l10n.repeater_previousCommand,
onPressed: () => _navigateHistory(true),
),
IconButton(
icon: const Icon(Icons.arrow_downward, size: 20),
tooltip: 'Next command',
tooltip: l10n.repeater_nextCommand,
onPressed: () => _navigateHistory(false),
),
const SizedBox(width: 8),
@ -443,10 +470,10 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
child: TextField(
controller: _commandController,
focusNode: _commandFocusNode,
decoration: const InputDecoration(
hintText: 'Enter command...',
border: OutlineInputBorder(),
contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: InputDecoration(
hintText: l10n.repeater_enterCommandHint,
border: const OutlineInputBorder(),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
prefixText: '> ',
),
style: const TextStyle(fontFamily: 'monospace'),
@ -479,312 +506,284 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
}
void _showCommandHelp(BuildContext context) {
final l10n = context.l10n;
final generalCommands = [
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'advert',
description: 'Sends an advertisement packet',
description: l10n.repeater_cliHelpAdvert,
),
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'reboot',
description:
"Reboots the device. (note, you'll prob get 'Timeout' which is normal)",
description: l10n.repeater_cliHelpReboot,
),
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'clock',
description: "Displays current time per device's clock.",
description: l10n.repeater_cliHelpClock,
),
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'password {new-password}',
description: 'Sets a new admin password for the device.',
description: l10n.repeater_cliHelpPassword,
),
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'ver',
description: 'Shows the device version and firmware build date.',
description: l10n.repeater_cliHelpVersion,
),
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'clear stats',
description: 'Resets various stats counters to zero.',
description: l10n.repeater_cliHelpClearStats,
),
];
final settingsCommands = [
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'set af {air-time-factor}',
description: 'Sets the air-time-factor.',
description: l10n.repeater_cliHelpSetAf,
),
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'set tx {tx-power-dbm}',
description: 'Sets LoRa transmit power in dBm. (reboot to apply)',
description: l10n.repeater_cliHelpSetTx,
),
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'set repeat {on|off}',
description: 'Enables or disables the repeater role for this node.',
description: l10n.repeater_cliHelpSetRepeat,
),
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'set allow.read.only {on|off}',
description:
"(Room server) If 'on', then login in blank password will be allowed, but cannot Post to room. (just read only)",
description: l10n.repeater_cliHelpSetAllowReadOnly,
),
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'set flood.max {max-hops}',
description:
'Sets the maximum number of hops of inbound flood packet (if >= max, packet is not forwarded)',
description: l10n.repeater_cliHelpSetFloodMax,
),
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'set int.thresh {db}',
description:
'Sets the Interference Threshold (in DB). Default is 14. Set to 0 to disable channel interference detection.',
description: l10n.repeater_cliHelpSetIntThresh,
),
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'set agc.reset.interval {seconds}',
description:
'Sets the interval to reset the Auto Gain Controller. Set to 0 to disable.',
description: l10n.repeater_cliHelpSetAgcResetInterval,
),
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'set multi.acks {0|1}',
description: "Enables or disables the 'double ACKs' feature.",
description: l10n.repeater_cliHelpSetMultiAcks,
),
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'set advert.interval {minutes}',
description:
'Sets the timer interval in minutes to send a local (zero-hop) advertisement packet. Set to 0 to disable.',
description: l10n.repeater_cliHelpSetAdvertInterval,
),
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'set flood.advert.interval {hours}',
description:
'Sets the timer interval in hours to send a flood advertisement packet. Set to 0 to disable.',
description: l10n.repeater_cliHelpSetFloodAdvertInterval,
),
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'set guest.password {guess-password}',
description:
'Sets/updates the guest password. (for repeaters, guest logins can send the "Get Stats" request)',
description: l10n.repeater_cliHelpSetGuestPassword,
),
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'set name {name}',
description: 'Sets the advertisement name.',
description: l10n.repeater_cliHelpSetName,
),
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'set lat {latitude}',
description: 'Sets the advertisement map latitude. (decimal degrees)',
description: l10n.repeater_cliHelpSetLat,
),
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'set lon {longitude}',
description: 'Sets the advertisement map longitude. (decimal degrees)',
description: l10n.repeater_cliHelpSetLon,
),
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'set radio {freq},{bw},{sf},{cr}',
description:
'Sets completely new radio params, and saves to preferences. Requires a "reboot" command to apply.',
description: l10n.repeater_cliHelpSetRadio,
),
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'set rxdelay {base}',
description:
'Sets (experimental) base (must be > 1 for effect) for applying slight delay to received packets, based on signal strength/score. Set to 0 to disable.',
description: l10n.repeater_cliHelpSetRxDelay,
),
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'set txdelay {factor}',
description:
'Sets a factor multiplied with time-on-air for a flood-mode packet and with a randomized slot system, to delay its forwarding. (to decrease likelihood of collisions)',
description: l10n.repeater_cliHelpSetTxDelay,
),
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'set direct.txdelay {factor}',
description:
'Same as txdelay, but for applying a random delay to the forwarding of direct-mode packets.',
description: l10n.repeater_cliHelpSetDirectTxDelay,
),
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'set bridge.enabled {on|off}',
description: 'Enable/Disable bridge.',
description: l10n.repeater_cliHelpSetBridgeEnabled,
),
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'set bridge.delay {0-10000}',
description: 'Set delay before retransmitting packets.',
description: l10n.repeater_cliHelpSetBridgeDelay,
),
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'set bridge.source {rx|tx}',
description:
'Choose wether the bridge will retransmit received packets or transmitted packets.',
description: l10n.repeater_cliHelpSetBridgeSource,
),
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'set bridge.baud {speed}',
description: 'Set serial link baudrate for rs232 bridges.',
description: l10n.repeater_cliHelpSetBridgeBaud,
),
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'set bridge.secret {shared-secret}',
description: 'Set bridge secret for espnow bridges.',
description: l10n.repeater_cliHelpSetBridgeSecret,
),
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'set adc.multiplier {factor}',
description:
'Sets custom factor to adjust reported battery voltage (only supported on select boards).',
description: l10n.repeater_cliHelpSetAdcMultiplier,
),
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'tempradio {freq},{bw},{sf},{cr},{minutes}',
description:
'Sets temporary radio params for the given number of {minutes}, reverting to original radio params afterward. (does NOT save to preferences).',
description: l10n.repeater_cliHelpTempRadio,
),
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'setperm {pubkey-hex} {permissions}',
description:
'Modifies the ACL. Removes matching entry (by pubkey prefix) if "permissions" is zero. Adds new entry if pubkey-hex is full length and is not currently in ACL. Updates entry by matching pubkey prefix. Permission bits vary per firmware role, but low 2 bits are: 0 (Guest), 1 (Read only), 2 (Read write), 3 (Admin)',
description: l10n.repeater_cliHelpSetPerm,
),
];
final bridgeCommands = [
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'get bridge.type',
description: 'Gets bridge type none, rs232, espnow',
description: l10n.repeater_cliHelpGetBridgeType,
),
];
final loggingCommands = [
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'log start',
description: 'Starts packet logging to file system.',
description: l10n.repeater_cliHelpLogStart,
),
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'log stop',
description: 'Stops packet logging to file system.',
description: l10n.repeater_cliHelpLogStop,
),
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'log erase',
description: 'Erases the packet logs from file system.',
description: l10n.repeater_cliHelpLogErase,
),
];
final neighborCommands = [
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'neighbors',
description:
'Shows a list of other repeater nodes heard via zero-hop adverts. Each line is {id-prefix-hex}:{timestamp}:{snr-times-4}',
description: l10n.repeater_cliHelpNeighbors,
),
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'neighbor.remove {pubkey-prefix}',
description:
'Removes first matching entry (by pubkey prefix (hex)), from neighbors list.',
description: l10n.repeater_cliHelpNeighborRemove,
),
];
final regionCommands = [
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'region',
description:
'(serial only) Lists all defined regions and current flood permissions.',
description: l10n.repeater_cliHelpRegion,
),
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'region load',
description:
'NOTE: this is a special multi-command invocation. Each subsequent command is a region name (indented with spaces to indicate parent hierarchy, with one space at minimum). Terminated by sending a blank line/command.',
description: l10n.repeater_cliHelpRegionLoad,
),
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'region get {* | name-prefix}',
description:
'Searches for region with given name prefix (or "*" for the global scope). Replies with "-> {region-name} ({parent-name}) {\'F\'}"',
description: l10n.repeater_cliHelpRegionGet,
),
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'region put {name} {* | parent-name-prefix}',
description: 'Adds or updates a region definition with given name.',
description: l10n.repeater_cliHelpRegionPut,
),
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'region remove {name}',
description:
'Removes a region definition with given name. (must match exactly, and have no child regions)',
description: l10n.repeater_cliHelpRegionRemove,
),
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'region allowf {* | name-prefix}',
description:
"Sets the 'F'lood permission for the given region. ('*' for the global/legacy scope)",
description: l10n.repeater_cliHelpRegionAllowf,
),
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'region denyf {* | name-prefix}',
description:
"Removes the 'F'lood permission for the given region. (NOTE: at this stage NOT advised to use this on the global/legacy scope!!)",
description: l10n.repeater_cliHelpRegionDenyf,
),
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'region home',
description:
"Replies with the current 'home' region. (Note applied anywhere yet, reserved for future)",
description: l10n.repeater_cliHelpRegionHome,
),
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'region home {* | name-prefix}',
description: "Sets the 'home' region.",
description: l10n.repeater_cliHelpRegionHomeSet,
),
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'region save',
description: 'Persists the region list/map to storage.',
description: l10n.repeater_cliHelpRegionSave,
),
];
final gpsCommands = [
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'gps',
description:
'Gives status of gps. When gps is off, it replies only off, if on it replies with on, {status}, {fix}, {sat count}',
description: l10n.repeater_cliHelpGps,
),
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'gps {on|off}',
description: 'Toggles gps power state.',
description: l10n.repeater_cliHelpGpsOnOff,
),
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'gps sync',
description: 'Syncs node time with gps clock.',
description: l10n.repeater_cliHelpGpsSync,
),
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'gps setloc',
description: "Sets node's position to gps coordinates and save preferences.",
description: l10n.repeater_cliHelpGpsSetLoc,
),
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'gps advert',
description:
"Gives location advert configuration of the node:\n- none: don't include location in adverts\n- share: share gps location (from SensorManager)\n- prefs: advert the location stored in preferences",
description: l10n.repeater_cliHelpGpsAdvert,
),
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'gps advert {none|share|prefs}',
description: 'Sets location advert configuration.',
description: l10n.repeater_cliHelpGpsAdvertSet,
),
];
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Commands List'),
title: Text(l10n.repeater_commandsListTitle),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'NOTE: for the various "set ..." commands, there is also a "get ..." command.',
style: TextStyle(fontSize: 13),
Text(
l10n.repeater_commandsListNote,
style: const TextStyle(fontSize: 13),
),
const SizedBox(height: 16),
_buildHelpSection(context, 'General', generalCommands),
_buildHelpSection(context, l10n.repeater_general, generalCommands),
const SizedBox(height: 16),
_buildHelpSection(context, 'Settings', settingsCommands),
_buildHelpSection(context, l10n.repeater_settingsCategory, settingsCommands),
const SizedBox(height: 16),
_buildHelpSection(context, 'Bridge', bridgeCommands),
_buildHelpSection(context, l10n.repeater_bridge, bridgeCommands),
const SizedBox(height: 16),
_buildHelpSection(context, 'Logging', loggingCommands),
_buildHelpSection(context, l10n.repeater_logging, loggingCommands),
const SizedBox(height: 16),
_buildHelpSection(
context,
'Neighbors (Repeater only)',
l10n.repeater_neighborsRepeaterOnly,
neighborCommands,
),
const SizedBox(height: 16),
_buildHelpSection(
context,
'Region Management (Repeater only)',
l10n.repeater_regionManagementRepeaterOnly,
regionCommands,
note:
'Region commands have been introduced to manage region definitions and permissions.',
note: l10n.repeater_regionNote,
),
const SizedBox(height: 16),
_buildHelpSection(
context,
'GPS Management',
l10n.repeater_gpsManagement,
gpsCommands,
note:
'gps command has been introduced to manage location related topics.',
note: l10n.repeater_gpsNote,
),
],
),
@ -792,7 +791,7 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
child: Text(l10n.common_close),
),
],
),

View file

@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import '../l10n/l10n.dart';
import '../models/contact.dart';
import 'repeater_status_screen.dart';
import 'repeater_cli_screen.dart';
@ -17,13 +18,14 @@ class RepeaterHubScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
return Scaffold(
appBar: AppBar(
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
const Text('Repeater Management'),
Text(l10n.repeater_management),
Text(
repeater.name,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.normal),
@ -77,17 +79,17 @@ class RepeaterHubScreen extends StatelessWidget {
),
),
const SizedBox(height: 24),
const Text(
'Management Tools',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
Text(
l10n.repeater_managementTools,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
// Status button
_buildManagementCard(
context,
icon: Icons.analytics,
title: 'Status',
subtitle: 'View repeater status, stats, and neighbors',
title: l10n.repeater_status,
subtitle: l10n.repeater_statusSubtitle,
color: Colors.blue,
onTap: () {
Navigator.push(
@ -102,12 +104,12 @@ class RepeaterHubScreen extends StatelessWidget {
},
),
const SizedBox(height: 16),
// Status button
// Telemetry button
_buildManagementCard(
context,
icon: Icons.bar_chart_sharp,
title: 'Telemetry',
subtitle: 'View telemetry of sensors and system stats',
title: l10n.repeater_telemetry,
subtitle: l10n.repeater_telemetrySubtitle,
color: Colors.teal,
onTap: () {
Navigator.push(
@ -126,8 +128,8 @@ class RepeaterHubScreen extends StatelessWidget {
_buildManagementCard(
context,
icon: Icons.terminal,
title: 'CLI',
subtitle: 'Send commands to the repeater',
title: l10n.repeater_cli,
subtitle: l10n.repeater_cliSubtitle,
color: Colors.green,
onTap: () {
Navigator.push(
@ -146,8 +148,8 @@ class RepeaterHubScreen extends StatelessWidget {
_buildManagementCard(
context,
icon: Icons.settings,
title: 'Settings',
subtitle: 'Configure repeater parameters',
title: l10n.repeater_settings,
subtitle: l10n.repeater_settingsSubtitle,
color: Colors.orange,
onTap: () {
Navigator.push(

View file

@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../l10n/l10n.dart';
import '../models/contact.dart';
import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart';
@ -32,7 +33,6 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
bool _refreshingLocation = false;
bool _refreshingRepeat = false;
bool _refreshingAllowReadOnly = false;
bool _refreshingPrivacy = false;
bool _refreshingAdvertisement = false;
StreamSubscription<Uint8List>? _frameSubscription;
RepeaterCommandService? _commandService;
@ -246,17 +246,6 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
});
}
bool _isAnySectionRefreshing() {
return _refreshingBasic ||
_refreshingRadio ||
_refreshingTxPower ||
_refreshingLocation ||
_refreshingRepeat ||
_refreshingAllowReadOnly ||
_refreshingPrivacy ||
_refreshingAdvertisement;
}
bool _normalizeOnOff(String value) {
final normalized = value.trim().toLowerCase();
return normalized == 'on' ||
@ -398,6 +387,7 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
required ValueSetter<bool> setRefreshing,
}) async {
if (_commandService == null) return;
final l10n = context.l10n;
setState(() {
setRefreshing(true);
@ -426,14 +416,14 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
if (successCount > 0) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('$label refreshed'),
content: Text(l10n.repeater_refreshed(label)),
backgroundColor: Colors.green,
),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Error refreshing $label'),
content: Text(l10n.repeater_errorRefreshing(label)),
backgroundColor: Colors.red,
),
);
@ -449,64 +439,63 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
}
Future<void> _refreshBasicSettings() async {
final l10n = context.l10n;
await _refreshSection(
label: 'Basic settings',
label: l10n.repeater_basicSettings,
commands: const ['get name'],
setRefreshing: (value) => _refreshingBasic = value,
);
}
Future<void> _refreshRadioSettings() async {
final l10n = context.l10n;
await _refreshSection(
label: 'Radio settings',
label: l10n.repeater_radioSettings,
commands: const ['get radio'],
setRefreshing: (value) => _refreshingRadio = value,
);
}
Future<void> _refreshTxPower() async {
final l10n = context.l10n;
await _refreshSection(
label: 'TX power',
label: l10n.repeater_txPower,
commands: const ['get tx'],
setRefreshing: (value) => _refreshingTxPower = value,
);
}
Future<void> _refreshLocationSettings() async {
final l10n = context.l10n;
await _refreshSection(
label: 'Location settings',
label: l10n.repeater_locationSettings,
commands: const ['get lat', 'get lon'],
setRefreshing: (value) => _refreshingLocation = value,
);
}
Future<void> _refreshRepeat() async {
final l10n = context.l10n;
await _refreshSection(
label: 'Packet forwarding',
label: l10n.repeater_packetForwarding,
commands: const ['get repeat'],
setRefreshing: (value) => _refreshingRepeat = value,
);
}
Future<void> _refreshAllowReadOnly() async {
final l10n = context.l10n;
await _refreshSection(
label: 'Guest access',
label: l10n.repeater_guestAccess,
commands: const ['get allow.read.only'],
setRefreshing: (value) => _refreshingAllowReadOnly = value,
);
}
Future<void> _refreshPrivacy() async {
await _refreshSection(
label: 'Privacy mode',
commands: const ['get privacy'],
setRefreshing: (value) => _refreshingPrivacy = value,
);
}
Future<void> _refreshAdvertisementSettings() async {
final l10n = context.l10n;
await _refreshSection(
label: 'Advertisement settings',
label: l10n.repeater_advertisementSettings,
commands: const [
'get advert.interval',
'get flood.advert.interval',
@ -604,8 +593,8 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Settings saved successfully'),
SnackBar(
content: Text(context.l10n.repeater_settingsSaved),
backgroundColor: Colors.green,
),
);
@ -618,7 +607,7 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Error saving settings: $e'),
content: Text(context.l10n.repeater_errorSavingSettings(e.toString())),
backgroundColor: Colors.red,
),
);
@ -637,6 +626,7 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
Widget _buildSectionHeader({
required IconData icon,
required String title,
required String tooltip,
required bool isRefreshing,
required VoidCallback onRefresh,
}) {
@ -658,7 +648,7 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
)
: const Icon(Icons.refresh),
onPressed: isRefreshing ? null : onRefresh,
tooltip: 'Refresh $title',
tooltip: tooltip,
),
],
);
@ -688,6 +678,7 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final connector = context.watch<MeshCoreConnector>();
final repeater = _resolveRepeater(connector);
final isFloodMode = repeater.pathOverride == -1;
@ -698,7 +689,7 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
const Text('Repeater Settings'),
Text(l10n.repeater_settingsTitle),
Text(
repeater.name,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.normal),
@ -709,7 +700,7 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
actions: [
PopupMenuButton<String>(
icon: Icon(isFloodMode ? Icons.waves : Icons.route),
tooltip: 'Routing mode',
tooltip: l10n.repeater_routingMode,
onSelected: (mode) async {
if (mode == 'flood') {
await connector.setPathOverride(repeater, pathLen: -1);
@ -728,7 +719,7 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
Icon(Icons.auto_mode, size: 20, color: !isFloodMode ? Theme.of(context).primaryColor : null),
const SizedBox(width: 8),
Text(
'Auto (use saved path)',
l10n.repeater_autoUseSavedPath,
style: TextStyle(
fontWeight: !isFloodMode ? FontWeight.bold : FontWeight.normal,
),
@ -743,7 +734,7 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
Icon(Icons.waves, size: 20, color: isFloodMode ? Theme.of(context).primaryColor : null),
const SizedBox(width: 8),
Text(
'Force Flood Mode',
l10n.repeater_forceFloodMode,
style: TextStyle(
fontWeight: isFloodMode ? FontWeight.bold : FontWeight.normal,
),
@ -755,14 +746,14 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
),
IconButton(
icon: const Icon(Icons.timeline),
tooltip: 'Path management',
tooltip: l10n.repeater_pathManagement,
onPressed: () => PathManagementDialog.show(context, contact: repeater),
),
if (_hasChanges)
TextButton.icon(
onPressed: _isLoading ? null : _saveSettings,
icon: const Icon(Icons.save),
label: const Text('Save'),
label: Text(l10n.common_save),
),
],
),
@ -791,6 +782,7 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
}
Widget _buildBasicSettingsCard() {
final l10n = context.l10n;
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
@ -799,27 +791,28 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
children: [
_buildSectionHeader(
icon: Icons.settings,
title: 'Basic Settings',
title: l10n.repeater_basicSettings,
tooltip: l10n.repeater_refreshBasicSettings,
isRefreshing: _refreshingBasic,
onRefresh: _refreshBasicSettings,
),
const Divider(),
TextField(
controller: _nameController,
decoration: const InputDecoration(
labelText: 'Repeater Name',
helperText: 'Display name for this repeater',
border: OutlineInputBorder(),
decoration: InputDecoration(
labelText: l10n.repeater_repeaterName,
helperText: l10n.repeater_repeaterNameHelper,
border: const OutlineInputBorder(),
),
onChanged: (_) => _markChanged(),
),
const SizedBox(height: 16),
TextField(
controller: _passwordController,
decoration: const InputDecoration(
labelText: 'Admin Password',
helperText: 'Full access password',
border: OutlineInputBorder(),
decoration: InputDecoration(
labelText: l10n.repeater_adminPassword,
helperText: l10n.repeater_adminPasswordHelper,
border: const OutlineInputBorder(),
),
obscureText: true,
onChanged: (_) => _markChanged(),
@ -827,10 +820,10 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
const SizedBox(height: 16),
TextField(
controller: _guestPasswordController,
decoration: const InputDecoration(
labelText: 'Guest Password',
helperText: 'Read-only access password',
border: OutlineInputBorder(),
decoration: InputDecoration(
labelText: l10n.repeater_guestPassword,
helperText: l10n.repeater_guestPasswordHelper,
border: const OutlineInputBorder(),
),
obscureText: true,
onChanged: (_) => _markChanged(),
@ -842,6 +835,7 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
}
Widget _buildRadioSettingsCard() {
final l10n = context.l10n;
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
@ -850,17 +844,18 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
children: [
_buildSectionHeader(
icon: Icons.radio,
title: 'Radio Settings',
title: l10n.repeater_radioSettings,
tooltip: l10n.repeater_refreshRadioSettings,
isRefreshing: _refreshingRadio,
onRefresh: _refreshRadioSettings,
),
const Divider(),
TextField(
controller: _freqController,
decoration: const InputDecoration(
labelText: 'Frequency (MHz)',
helperText: '300-2500 MHz',
border: OutlineInputBorder(),
decoration: InputDecoration(
labelText: l10n.repeater_frequencyMhz,
helperText: l10n.repeater_frequencyHelper,
border: const OutlineInputBorder(),
suffixText: 'MHz',
),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
@ -873,10 +868,10 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
Expanded(
child: TextField(
controller: _txPowerController,
decoration: const InputDecoration(
labelText: 'TX Power',
helperText: '1-30 dBm',
border: OutlineInputBorder(),
decoration: InputDecoration(
labelText: l10n.repeater_txPower,
helperText: l10n.repeater_txPowerHelper,
border: const OutlineInputBorder(),
suffixText: 'dBm',
),
keyboardType: TextInputType.number,
@ -887,16 +882,16 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
_buildInlineRefreshButton(
isRefreshing: _refreshingTxPower,
onRefresh: _refreshTxPower,
tooltip: 'Refresh TX power',
tooltip: l10n.repeater_refreshTxPower,
),
],
),
const SizedBox(height: 16),
DropdownButtonFormField<int>(
initialValue: _bandwidth,
decoration: const InputDecoration(
labelText: 'Bandwidth',
border: OutlineInputBorder(),
decoration: InputDecoration(
labelText: l10n.repeater_bandwidth,
border: const OutlineInputBorder(),
),
items: _bandwidthOptions.map((bw) {
return DropdownMenuItem(
@ -916,9 +911,9 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
const SizedBox(height: 16),
DropdownButtonFormField<int>(
initialValue: _spreadingFactor,
decoration: const InputDecoration(
labelText: 'Spreading Factor',
border: OutlineInputBorder(),
decoration: InputDecoration(
labelText: l10n.repeater_spreadingFactor,
border: const OutlineInputBorder(),
),
items: _spreadingFactorOptions.map((sf) {
return DropdownMenuItem(
@ -938,9 +933,9 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
const SizedBox(height: 16),
DropdownButtonFormField<int>(
initialValue: _codingRate,
decoration: const InputDecoration(
labelText: 'Coding Rate',
border: OutlineInputBorder(),
decoration: InputDecoration(
labelText: l10n.repeater_codingRate,
border: const OutlineInputBorder(),
),
items: _codingRateOptions.map((cr) {
return DropdownMenuItem(
@ -964,6 +959,7 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
}
Widget _buildLocationSettingsCard() {
final l10n = context.l10n;
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
@ -972,17 +968,18 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
children: [
_buildSectionHeader(
icon: Icons.location_on,
title: 'Location Settings',
title: l10n.repeater_locationSettings,
tooltip: l10n.repeater_refreshLocationSettings,
isRefreshing: _refreshingLocation,
onRefresh: _refreshLocationSettings,
),
const Divider(),
TextField(
controller: _latController,
decoration: const InputDecoration(
labelText: 'Latitude',
helperText: 'Decimal degrees (e.g., 37.7749)',
border: OutlineInputBorder(),
decoration: InputDecoration(
labelText: l10n.repeater_latitude,
helperText: l10n.repeater_latitudeHelper,
border: const OutlineInputBorder(),
),
keyboardType: const TextInputType.numberWithOptions(decimal: true, signed: true),
onChanged: (_) => _markChanged(),
@ -990,10 +987,10 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
const SizedBox(height: 16),
TextField(
controller: _lonController,
decoration: const InputDecoration(
labelText: 'Longitude',
helperText: 'Decimal degrees (e.g., -122.4194)',
border: OutlineInputBorder(),
decoration: InputDecoration(
labelText: l10n.repeater_longitude,
helperText: l10n.repeater_longitudeHelper,
border: const OutlineInputBorder(),
),
keyboardType: const TextInputType.numberWithOptions(decimal: true, signed: true),
onChanged: (_) => _markChanged(),
@ -1005,6 +1002,7 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
}
Widget _buildFeatureTogglesCard() {
final l10n = context.l10n;
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
@ -1015,16 +1013,16 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
children: [
Icon(Icons.toggle_on, color: Theme.of(context).textTheme.headlineSmall?.color),
const SizedBox(width: 8),
const Text(
'Features',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
Text(
l10n.repeater_features,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
],
),
const Divider(),
_buildFeatureToggleRow(
title: 'Packet Forwarding',
subtitle: 'Enable repeater to forward packets',
title: l10n.repeater_packetForwarding,
subtitle: l10n.repeater_packetForwardingSubtitle,
value: _repeatEnabled,
isRefreshing: _refreshingRepeat,
onChanged: (value) {
@ -1034,10 +1032,11 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
_markChanged();
},
onRefresh: _refreshRepeat,
refreshTooltip: l10n.repeater_refreshPacketForwarding,
),
_buildFeatureToggleRow(
title: 'Guest Access',
subtitle: 'Allow read-only guest access',
title: l10n.repeater_guestAccess,
subtitle: l10n.repeater_guestAccessSubtitle,
value: _allowReadOnly,
isRefreshing: _refreshingAllowReadOnly,
onChanged: (value) {
@ -1047,11 +1046,12 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
_markChanged();
},
onRefresh: _refreshAllowReadOnly,
refreshTooltip: l10n.repeater_refreshGuestAccess,
),
// Privacy mode - hidden until fully implemented
// _buildFeatureToggleRow(
// title: 'Privacy Mode',
// subtitle: 'Hide name/location in advertisements',
// title: l10n.repeater_privacyMode,
// subtitle: l10n.repeater_privacyModeSubtitle,
// value: _privacyMode,
// isRefreshing: _refreshingPrivacy,
// onChanged: (value) {
@ -1061,6 +1061,7 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
// _markChanged();
// },
// onRefresh: _refreshPrivacy,
// refreshTooltip: l10n.repeater_refreshPrivacyMode,
// ),
],
),
@ -1075,6 +1076,7 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
required bool isRefreshing,
required ValueChanged<bool> onChanged,
required VoidCallback onRefresh,
required String refreshTooltip,
}) {
return Row(
children: [
@ -1093,10 +1095,10 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
)
: const Icon(Icons.refresh, size: 20),
onPressed: isRefreshing ? null : onRefresh,
tooltip: 'Refresh $title',
tooltip: refreshTooltip,
visualDensity: VisualDensity.compact,
),
],
@ -1104,6 +1106,7 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
}
Widget _buildAdvertisementSettingsCard() {
final l10n = context.l10n;
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
@ -1112,22 +1115,23 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
children: [
_buildSectionHeader(
icon: Icons.broadcast_on_personal,
title: 'Advertisement Settings',
title: l10n.repeater_advertisementSettings,
tooltip: l10n.repeater_refreshAdvertisementSettings,
isRefreshing: _refreshingAdvertisement,
onRefresh: _refreshAdvertisementSettings,
),
const Divider(),
ListTile(
title: const Text('Local Advertisement Interval'),
subtitle: Text('$_advertInterval minutes'),
trailing: Text('${_advertInterval}m'),
title: Text(l10n.repeater_localAdvertInterval),
subtitle: Text(l10n.repeater_localAdvertIntervalMinutes(_advertInterval)),
trailing: Text(l10n.repeater_localAdvertIntervalMinutes(_advertInterval)),
),
Slider(
value: _advertInterval.toDouble(),
min: 60,
max: 240,
divisions: 18,
label: '${_advertInterval}m',
label: l10n.repeater_localAdvertIntervalMinutes(_advertInterval),
onChanged: (value) {
setState(() {
_advertInterval = value.toInt();
@ -1137,16 +1141,16 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
),
const SizedBox(height: 16),
ListTile(
title: const Text('Flood Advertisement Interval'),
subtitle: Text('$_floodAdvertInterval hours'),
trailing: Text('${_floodAdvertInterval}h'),
title: Text(l10n.repeater_floodAdvertInterval),
subtitle: Text(l10n.repeater_floodAdvertIntervalHours(_floodAdvertInterval)),
trailing: Text(l10n.repeater_floodAdvertIntervalHours(_floodAdvertInterval)),
),
Slider(
value: _floodAdvertInterval.toDouble(),
min: 3,
max: 48,
divisions: 45,
label: '${_floodAdvertInterval}h',
label: l10n.repeater_floodAdvertIntervalHours(_floodAdvertInterval),
onChanged: (value) {
setState(() {
_floodAdvertInterval = value.toInt();
@ -1158,16 +1162,16 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
// if (_privacyMode) ...[
// const SizedBox(height: 16),
// ListTile(
// title: const Text('Encrypted Advertisement Interval'),
// subtitle: Text('$_privAdvertInterval minutes'),
// trailing: Text('${_privAdvertInterval}m'),
// title: Text(l10n.repeater_encryptedAdvertInterval),
// subtitle: Text(l10n.repeater_localAdvertIntervalMinutes(_privAdvertInterval)),
// trailing: Text(l10n.repeater_localAdvertIntervalMinutes(_privAdvertInterval)),
// ),
// Slider(
// value: _privAdvertInterval.toDouble(),
// min: 30,
// max: 240,
// divisions: 21,
// label: '${_privAdvertInterval}m',
// label: l10n.repeater_localAdvertIntervalMinutes(_privAdvertInterval),
// onChanged: (value) {
// setState(() {
// _privAdvertInterval = value.toInt();
@ -1183,6 +1187,7 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
}
Widget _buildDangerZoneCard() {
final l10n = context.l10n;
final colorScheme = Theme.of(context).colorScheme;
return Card(
color: colorScheme.errorContainer,
@ -1196,7 +1201,7 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
Icon(Icons.warning, color: colorScheme.onErrorContainer),
const SizedBox(width: 8),
Text(
'Danger Zone',
l10n.repeater_dangerZone,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
@ -1208,14 +1213,14 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
const Divider(),
ListTile(
leading: Icon(Icons.refresh, color: colorScheme.onErrorContainer),
title: Text('Reboot Repeater', style: TextStyle(color: colorScheme.onErrorContainer)),
title: Text(l10n.repeater_rebootRepeater, style: TextStyle(color: colorScheme.onErrorContainer)),
subtitle: Text(
'Restart the repeater device',
l10n.repeater_rebootRepeaterSubtitle,
style: TextStyle(color: colorScheme.onErrorContainer.withValues(alpha: 0.8)),
),
onTap: () => _confirmAction(
'Reboot Repeater',
'Are you sure you want to reboot this repeater?',
l10n.repeater_rebootRepeater,
l10n.repeater_rebootRepeaterConfirm,
() => _sendDangerCommand('reboot'),
),
),
@ -1235,14 +1240,14 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
// ),
ListTile(
leading: Icon(Icons.delete_forever, color: colorScheme.onErrorContainer),
title: Text('Erase File System', style: TextStyle(color: colorScheme.onErrorContainer)),
title: Text(l10n.repeater_eraseFileSystem, style: TextStyle(color: colorScheme.onErrorContainer)),
subtitle: Text(
'Format the repeater file system',
l10n.repeater_eraseFileSystemSubtitle,
style: TextStyle(color: colorScheme.onErrorContainer.withValues(alpha: 0.8)),
),
onTap: () => _confirmAction(
'Erase File System',
'WARNING: This will erase all data on the repeater. This cannot be undone!',
l10n.repeater_eraseFileSystem,
l10n.repeater_eraseFileSystemConfirm,
() => _sendDangerCommand('erase'),
isDestructive: true,
),
@ -1254,13 +1259,14 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
}
Future<void> _sendDangerCommand(String command) async {
final l10n = context.l10n;
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
final repeater = _resolveRepeater(connector);
if (command == 'erase') {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Erase is only available over serial console.')),
SnackBar(content: Text(l10n.repeater_eraseSerialOnly)),
);
}
return;
@ -1284,14 +1290,14 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Command sent: $command')),
SnackBar(content: Text(l10n.repeater_commandSent(command))),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Error sending command: $e'),
content: Text(l10n.repeater_errorSendingCommand(e.toString())),
backgroundColor: Colors.red,
),
);
@ -1305,6 +1311,7 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
VoidCallback onConfirm, {
bool isDestructive = false,
}) {
final l10n = context.l10n;
showDialog(
context: context,
builder: (context) => AlertDialog(
@ -1313,7 +1320,7 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
child: Text(l10n.common_cancel),
),
FilledButton(
onPressed: () {
@ -1323,7 +1330,7 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
style: isDestructive
? FilledButton.styleFrom(backgroundColor: Colors.red)
: null,
child: const Text('Confirm'),
child: Text(l10n.repeater_confirm),
),
],
),

View file

@ -3,6 +3,7 @@ import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../l10n/l10n.dart';
import '../models/contact.dart';
import '../models/path_selection.dart';
import '../connector/meshcore_connector.dart';
@ -274,8 +275,8 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
_isLoading = false;
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Status request timed out.'),
SnackBar(
content: Text(context.l10n.repeater_statusRequestTimeout),
backgroundColor: Colors.red,
),
);
@ -289,7 +290,7 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Error loading status: $e'),
content: Text(context.l10n.repeater_errorLoadingStatus(e.toString())),
backgroundColor: Colors.red,
),
);
@ -309,6 +310,7 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final connector = context.watch<MeshCoreConnector>();
final repeater = _resolveRepeater(connector);
final isFloodMode = repeater.pathOverride == -1;
@ -319,7 +321,7 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
const Text('Repeater Status'),
Text(l10n.repeater_statusTitle),
Text(
repeater.name,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.normal),
@ -330,7 +332,7 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
actions: [
PopupMenuButton<String>(
icon: Icon(isFloodMode ? Icons.waves : Icons.route),
tooltip: 'Routing mode',
tooltip: l10n.repeater_routingMode,
onSelected: (mode) async {
if (mode == 'flood') {
await connector.setPathOverride(repeater, pathLen: -1);
@ -346,7 +348,7 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
Icon(Icons.auto_mode, size: 20, color: !isFloodMode ? Theme.of(context).primaryColor : null),
const SizedBox(width: 8),
Text(
'Auto (use saved path)',
l10n.repeater_autoUseSavedPath,
style: TextStyle(
fontWeight: !isFloodMode ? FontWeight.bold : FontWeight.normal,
),
@ -361,7 +363,7 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
Icon(Icons.waves, size: 20, color: isFloodMode ? Theme.of(context).primaryColor : null),
const SizedBox(width: 8),
Text(
'Force Flood Mode',
l10n.repeater_forceFloodMode,
style: TextStyle(
fontWeight: isFloodMode ? FontWeight.bold : FontWeight.normal,
),
@ -373,7 +375,7 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
),
IconButton(
icon: const Icon(Icons.timeline),
tooltip: 'Path management',
tooltip: l10n.repeater_pathManagement,
onPressed: () => PathManagementDialog.show(context, contact: repeater),
),
IconButton(
@ -385,7 +387,7 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
)
: const Icon(Icons.refresh),
onPressed: _isLoading ? null : _loadStatus,
tooltip: 'Refresh',
tooltip: l10n.repeater_refresh,
),
],
),
@ -409,6 +411,7 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
}
Widget _buildSystemInfoCard() {
final l10n = context.l10n;
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
@ -419,18 +422,18 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
children: [
Icon(Icons.info_outline, color: Theme.of(context).textTheme.headlineSmall?.color),
const SizedBox(width: 8),
const Text(
'System Information',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
Text(
l10n.repeater_systemInformation,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
],
),
const Divider(),
_buildInfoRow('Battery', _batteryText()),
_buildInfoRow('Clock (at login)', _clockText()),
_buildInfoRow('Uptime', _formatDuration(_uptimeSecs)),
_buildInfoRow('Queue Length', _formatValue(_queueLen)),
_buildInfoRow('Debug Flags', _formatValue(_debugFlags)),
_buildInfoRow(l10n.repeater_battery, _batteryText()),
_buildInfoRow(l10n.repeater_clockAtLogin, _clockText()),
_buildInfoRow(l10n.repeater_uptime, _formatDuration(_uptimeSecs)),
_buildInfoRow(l10n.repeater_queueLength, _formatValue(_queueLen)),
_buildInfoRow(l10n.repeater_debugFlags, _formatValue(_debugFlags)),
],
),
),
@ -438,6 +441,7 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
}
Widget _buildRadioStatsCard() {
final l10n = context.l10n;
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
@ -448,18 +452,18 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
children: [
Icon(Icons.radio, color: Theme.of(context).textTheme.headlineSmall?.color),
const SizedBox(width: 8),
const Text(
'Radio Statistics',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
Text(
l10n.repeater_radioStatistics,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
],
),
const Divider(),
_buildInfoRow('Last RSSI', _formatValue(_lastRssi, suffix: ' dB')),
_buildInfoRow('Last SNR', _formatSnr(_lastSnr)),
_buildInfoRow('Noise Floor', _formatValue(_noiseFloor, suffix: ' dB')),
_buildInfoRow('TX Airtime', _formatDuration(_txAirSecs)),
_buildInfoRow('RX Airtime', _formatDuration(_rxAirSecs)),
_buildInfoRow(l10n.repeater_lastRssi, _formatValue(_lastRssi, suffix: ' dB')),
_buildInfoRow(l10n.repeater_lastSnr, _formatSnr(_lastSnr)),
_buildInfoRow(l10n.repeater_noiseFloor, _formatValue(_noiseFloor, suffix: ' dB')),
_buildInfoRow(l10n.repeater_txAirtime, _formatDuration(_txAirSecs)),
_buildInfoRow(l10n.repeater_rxAirtime, _formatDuration(_rxAirSecs)),
],
),
),
@ -467,6 +471,7 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
}
Widget _buildPacketStatsCard() {
final l10n = context.l10n;
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
@ -477,16 +482,16 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
children: [
Icon(Icons.analytics, color: Theme.of(context).textTheme.headlineSmall?.color),
const SizedBox(width: 8),
const Text(
'Packet Statistics',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
Text(
l10n.repeater_packetStatistics,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
],
),
const Divider(),
_buildInfoRow('Sent', _packetTxText()),
_buildInfoRow('Received', _packetRxText()),
_buildInfoRow('Duplicates', _duplicateText()),
_buildInfoRow(l10n.repeater_sent, _packetTxText()),
_buildInfoRow(l10n.repeater_received, _packetRxText()),
_buildInfoRow(l10n.repeater_duplicates, _duplicateText()),
],
),
),
@ -559,37 +564,41 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
String _formatDuration(int? seconds) {
if (seconds == null) return '';
final l10n = context.l10n;
final days = seconds ~/ 86400;
final hours = (seconds % 86400) ~/ 3600;
final minutes = (seconds % 3600) ~/ 60;
final secs = seconds % 60;
return '$days days ${hours}h ${minutes}m ${secs}s';
return l10n.repeater_daysHoursMinsSecs(days, hours, minutes, secs);
}
String _packetTxText() {
if (_packetsSent == null) return '';
final l10n = context.l10n;
final flood = _formatValue(_floodTx);
final direct = _formatValue(_directTx);
return 'Total: $_packetsSent, Flood: $flood, Direct: $direct';
return l10n.repeater_packetTxTotal(_packetsSent!, flood, direct);
}
String _packetRxText() {
if (_packetsRecv == null) return '';
final l10n = context.l10n;
final flood = _formatValue(_floodRx);
final direct = _formatValue(_directRx);
return 'Total: $_packetsRecv, Flood: $flood, Direct: $direct';
return l10n.repeater_packetRxTotal(_packetsRecv!, flood, direct);
}
String _duplicateText() {
final l10n = context.l10n;
if (_dupFlood != null || _dupDirect != null) {
final flood = _formatValue(_dupFlood);
final direct = _formatValue(_dupDirect);
return 'Flood: $flood, Direct: $direct';
return l10n.repeater_duplicatesFloodDirect(flood, direct);
}
if (_packetsRecv == null || _floodRx == null || _directRx == null) return '';
final dupTotal = _packetsRecv! - _floodRx! - _directRx!;
if (dupTotal < 0) return '';
return 'Total: $dupTotal';
return l10n.repeater_duplicatesTotal(dupTotal);
}
String _formatValue(num? value, {String? suffix}) {

View file

@ -3,6 +3,7 @@ import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart';
import '../l10n/l10n.dart';
import '../widgets/device_tile.dart';
import 'contacts_screen.dart';
@ -14,7 +15,7 @@ class ScannerScreen extends StatelessWidget {
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('MeshCore Open'),
title: Text(context.l10n.scanner_title),
centerTitle: true,
automaticallyImplyLeading: false,
),
@ -58,7 +59,7 @@ class ScannerScreen extends StatelessWidget {
),
)
: const Icon(Icons.bluetooth_searching),
label: Text(isScanning ? 'Stop' : 'Scan'),
label: Text(isScanning ? context.l10n.scanner_stop : context.l10n.scanner_scan),
);
},
),
@ -69,25 +70,26 @@ class ScannerScreen extends StatelessWidget {
String statusText;
Color statusColor;
final l10n = context.l10n;
switch (connector.state) {
case MeshCoreConnectionState.scanning:
statusText = 'Scanning for devices...';
statusText = l10n.scanner_scanning;
statusColor = Colors.blue;
break;
case MeshCoreConnectionState.connecting:
statusText = 'Connecting...';
statusText = l10n.scanner_connecting;
statusColor = Colors.orange;
break;
case MeshCoreConnectionState.connected:
statusText = 'Connected to ${connector.deviceDisplayName}';
statusText = l10n.scanner_connectedTo(connector.deviceDisplayName);
statusColor = Colors.green;
break;
case MeshCoreConnectionState.disconnecting:
statusText = 'Disconnecting...';
statusText = l10n.scanner_disconnecting;
statusColor = Colors.orange;
break;
case MeshCoreConnectionState.disconnected:
statusText = 'Not connected';
statusText = l10n.scanner_notConnected;
statusColor = Colors.grey;
break;
}
@ -123,8 +125,8 @@ class ScannerScreen extends StatelessWidget {
const SizedBox(height: 16),
Text(
connector.state == MeshCoreConnectionState.scanning
? 'Searching for MeshCore devices...'
: 'Tap Scan to find MeshCore devices',
? context.l10n.scanner_searchingDevices
: context.l10n.scanner_tapToScan,
style: TextStyle(
fontSize: 16,
color: Colors.grey[600],
@ -172,7 +174,7 @@ class ScannerScreen extends StatelessWidget {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Connection failed: $e'),
content: Text(context.l10n.scanner_connectionFailed(e.toString())),
backgroundColor: Colors.red,
),
);

View file

@ -4,6 +4,7 @@ import 'package:package_info_plus/package_info_plus.dart';
import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart';
import '../l10n/l10n.dart';
import '../models/radio_settings.dart';
import 'app_settings_screen.dart';
import 'app_debug_log_screen.dart';
@ -18,7 +19,7 @@ class SettingsScreen extends StatefulWidget {
class _SettingsScreenState extends State<SettingsScreen> {
bool _showBatteryVoltage = false;
String _appVersion = '...';
String _appVersion = '';
@override
void initState() {
@ -35,9 +36,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
return Scaffold(
appBar: AppBar(
title: const Text('Settings'),
title: Text(l10n.settings_title),
centerTitle: true,
),
body: SafeArea(
@ -47,7 +49,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
return ListView(
padding: const EdgeInsets.all(16),
children: [
_buildDeviceInfoCard(connector),
_buildDeviceInfoCard(context, connector),
const SizedBox(height: 16),
_buildAppSettingsCard(context),
const SizedBox(height: 16),
@ -66,46 +68,52 @@ class _SettingsScreenState extends State<SettingsScreen> {
);
}
Widget _buildDeviceInfoCard(MeshCoreConnector connector) {
Widget _buildDeviceInfoCard(BuildContext context, MeshCoreConnector connector) {
final l10n = context.l10n;
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Device Info',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
Text(
l10n.settings_deviceInfo,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
_buildInfoRow('Name', connector.deviceDisplayName),
_buildInfoRow('ID', connector.deviceIdLabel),
_buildInfoRow('Status', connector.isConnected ? 'Connected' : 'Disconnected'),
_buildBatteryInfoRow(connector),
_buildInfoRow(l10n.settings_infoName, connector.deviceDisplayName),
_buildInfoRow(l10n.settings_infoId, connector.deviceIdLabel),
_buildInfoRow(l10n.settings_infoStatus, connector.isConnected ? l10n.common_connected : l10n.common_disconnected),
_buildBatteryInfoRow(context, connector),
if (connector.selfName != null)
_buildInfoRow('Node Name', connector.selfName!),
_buildInfoRow(l10n.settings_nodeName, connector.selfName!),
if (connector.selfPublicKey != null)
_buildInfoRow('Public Key', '${pubKeyToHex(connector.selfPublicKey!).substring(0, 16)}...'),
_buildInfoRow('Contacts Count', '${connector.contacts.length}'),
_buildInfoRow('Channel Count', '${connector.channels.length}'),
_buildInfoRow(l10n.settings_infoPublicKey, '${pubKeyToHex(connector.selfPublicKey!).substring(0, 16)}...'),
_buildInfoRow(l10n.settings_infoContactsCount, '${connector.contacts.length}'),
_buildInfoRow(l10n.settings_infoChannelCount, '${connector.channels.length}'),
],
),
),
);
}
Widget _buildBatteryInfoRow(MeshCoreConnector connector) {
Widget _buildBatteryInfoRow(BuildContext context, MeshCoreConnector connector) {
final l10n = context.l10n;
final percent = connector.batteryPercent;
final millivolts = connector.batteryMillivolts;
// figure out display value
final String displayValue;
if (millivolts == null) {
displayValue = '';
displayValue = l10n.common_notAvailable;
} else if (_showBatteryVoltage) {
displayValue = '${(millivolts / 1000.0).toStringAsFixed(2)} V';
displayValue = l10n.common_voltageValue(
(millivolts / 1000.0).toStringAsFixed(2),
);
} else {
displayValue = percent != null ? '$percent%' : '';
displayValue = percent != null
? l10n.common_percentValue(percent)
: l10n.common_notAvailable;
}
final IconData icon;
@ -127,7 +135,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
}
return _buildInfoRow(
'Battery',
l10n.settings_infoBattery,
displayValue,
leading: Icon(icon, size: 18, color: iconColor),
valueColor: valueColor,
@ -142,11 +150,12 @@ class _SettingsScreenState extends State<SettingsScreen> {
}
Widget _buildAppSettingsCard(BuildContext context) {
final l10n = context.l10n;
return Card(
child: ListTile(
leading: const Icon(Icons.settings_outlined),
title: const Text('App Settings'),
subtitle: const Text('Notifications, messaging, and map preferences'),
title: Text(l10n.settings_appSettings),
subtitle: Text(l10n.settings_appSettingsSubtitle),
trailing: const Icon(Icons.chevron_right),
onTap: () {
Navigator.push(
@ -159,45 +168,46 @@ class _SettingsScreenState extends State<SettingsScreen> {
}
Widget _buildNodeSettingsCard(BuildContext context, MeshCoreConnector connector) {
final l10n = context.l10n;
return Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
'Node Settings',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
l10n.settings_nodeSettings,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
),
ListTile(
leading: const Icon(Icons.person_outline),
title: const Text('Node Name'),
subtitle: Text(connector.selfName ?? 'Not set'),
title: Text(l10n.settings_nodeName),
subtitle: Text(connector.selfName ?? l10n.settings_nodeNameNotSet),
trailing: const Icon(Icons.chevron_right),
onTap: () => _editNodeName(context, connector),
),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.radio),
title: const Text('Radio Settings'),
subtitle: const Text('Frequency, power, spreading factor'),
title: Text(l10n.settings_radioSettings),
subtitle: Text(l10n.settings_radioSettingsSubtitle),
trailing: const Icon(Icons.chevron_right),
onTap: () => _showRadioSettings(context, connector),
),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.location_on_outlined),
title: const Text('Location'),
subtitle: const Text('GPS coordinates'),
title: Text(l10n.settings_location),
subtitle: Text(l10n.settings_locationSubtitle),
trailing: const Icon(Icons.chevron_right),
onTap: () => _editLocation(context, connector),
),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.visibility_off_outlined),
title: const Text('Privacy Mode'),
subtitle: const Text('Hide name/location in advertisements'),
title: Text(l10n.settings_privacyMode),
subtitle: Text(l10n.settings_privacyModeSubtitle),
trailing: const Icon(Icons.chevron_right),
onTap: () => _togglePrivacy(context, connector),
),
@ -207,42 +217,43 @@ class _SettingsScreenState extends State<SettingsScreen> {
}
Widget _buildActionsCard(BuildContext context, MeshCoreConnector connector) {
final l10n = context.l10n;
return Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
'Actions',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
l10n.settings_actions,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
),
ListTile(
leading: const Icon(Icons.cell_tower),
title: const Text('Send Advertisement'),
subtitle: const Text('Broadcast presence now'),
title: Text(l10n.settings_sendAdvertisement),
subtitle: Text(l10n.settings_sendAdvertisementSubtitle),
onTap: () => _sendAdvert(context, connector),
),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.sync),
title: const Text('Sync Time'),
subtitle: const Text('Set device clock to phone time'),
title: Text(l10n.settings_syncTime),
subtitle: Text(l10n.settings_syncTimeSubtitle),
onTap: () => _syncTime(context, connector),
),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.refresh),
title: const Text('Refresh Contacts'),
subtitle: const Text('Reload contact list from device'),
title: Text(l10n.settings_refreshContacts),
subtitle: Text(l10n.settings_refreshContactsSubtitle),
onTap: () => connector.getContacts(),
),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.restart_alt, color: Colors.orange),
title: const Text('Reboot Device'),
subtitle: const Text('Restart the MeshCore device'),
title: Text(l10n.settings_rebootDevice),
subtitle: Text(l10n.settings_rebootDeviceSubtitle),
onTap: () => _confirmReboot(context, connector),
),
],
@ -251,32 +262,38 @@ class _SettingsScreenState extends State<SettingsScreen> {
}
Widget _buildAboutCard(BuildContext context) {
final l10n = context.l10n;
return Card(
child: ListTile(
leading: const Icon(Icons.info_outline),
title: const Text('About'),
subtitle: Text('MeshCore Open v$_appVersion'),
title: Text(l10n.settings_about),
subtitle: Text(
l10n.settings_aboutVersion(
_appVersion.isEmpty ? l10n.common_loading : _appVersion,
),
),
onTap: () => _showAbout(context),
),
);
}
Widget _buildDebugCard(BuildContext context) {
final l10n = context.l10n;
return Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
'Debug',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
l10n.settings_debug,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
),
ListTile(
leading: const Icon(Icons.bluetooth_outlined),
title: const Text('BLE Debug Log'),
subtitle: const Text('BLE commands, responses, and raw data'),
title: Text(l10n.settings_bleDebugLog),
subtitle: Text(l10n.settings_bleDebugLogSubtitle),
trailing: const Icon(Icons.chevron_right),
onTap: () {
Navigator.push(
@ -288,8 +305,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.code_outlined),
title: const Text('App Debug Log'),
subtitle: const Text('Application debug messages'),
title: Text(l10n.settings_appDebugLog),
subtitle: Text(l10n.settings_appDebugLogSubtitle),
trailing: const Icon(Icons.chevron_right),
onTap: () {
Navigator.push(
@ -349,23 +366,24 @@ class _SettingsScreenState extends State<SettingsScreen> {
}
void _editNodeName(BuildContext context, MeshCoreConnector connector) {
final l10n = context.l10n;
final controller = TextEditingController(text: connector.selfName ?? '');
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Node Name'),
title: Text(l10n.settings_nodeName),
content: TextField(
controller: controller,
decoration: const InputDecoration(
hintText: 'Enter node name',
border: OutlineInputBorder(),
decoration: InputDecoration(
hintText: l10n.settings_nodeNameHint,
border: const OutlineInputBorder(),
),
maxLength: 31,
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
child: Text(l10n.common_cancel),
),
TextButton(
onPressed: () async {
@ -374,10 +392,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
await connector.refreshDeviceInfo();
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Name updated')),
SnackBar(content: Text(l10n.settings_nodeNameUpdated)),
);
},
child: const Text('Save'),
child: Text(l10n.common_save),
),
],
),
@ -392,29 +410,30 @@ class _SettingsScreenState extends State<SettingsScreen> {
}
void _editLocation(BuildContext context, MeshCoreConnector connector) {
final l10n = context.l10n;
final latController = TextEditingController();
final lonController = TextEditingController();
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Location'),
title: Text(l10n.settings_location),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: latController,
decoration: const InputDecoration(
labelText: 'Latitude',
border: OutlineInputBorder(),
decoration: InputDecoration(
labelText: l10n.settings_latitude,
border: const OutlineInputBorder(),
),
keyboardType: const TextInputType.numberWithOptions(decimal: true, signed: true),
),
const SizedBox(height: 16),
TextField(
controller: lonController,
decoration: const InputDecoration(
labelText: 'Longitude',
border: OutlineInputBorder(),
decoration: InputDecoration(
labelText: l10n.settings_longitude,
border: const OutlineInputBorder(),
),
keyboardType: const TextInputType.numberWithOptions(decimal: true, signed: true),
),
@ -423,7 +442,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
child: Text(l10n.common_cancel),
),
TextButton(
onPressed: () async {
@ -441,14 +460,14 @@ class _SettingsScreenState extends State<SettingsScreen> {
if (lat == null || lon == null) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Enter both latitude and longitude.')),
SnackBar(content: Text(l10n.settings_locationBothRequired)),
);
return;
}
if (lat < -90 || lat > 90 || lon < -180 || lon > 180) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Invalid latitude or longitude.')),
SnackBar(content: Text(l10n.settings_locationInvalid)),
);
return;
}
@ -457,10 +476,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
await connector.refreshDeviceInfo();
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Location updated')),
SnackBar(content: Text(l10n.settings_locationUpdated)),
);
},
child: const Text('Save'),
child: Text(l10n.common_save),
),
],
),
@ -468,15 +487,16 @@ class _SettingsScreenState extends State<SettingsScreen> {
}
void _togglePrivacy(BuildContext context, MeshCoreConnector connector) {
final l10n = context.l10n;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Privacy Mode'),
content: const Text('Toggle privacy mode to hide your name and location in advertisements.'),
title: Text(l10n.settings_privacyMode),
content: Text(l10n.settings_privacyModeToggle),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
child: Text(l10n.common_cancel),
),
TextButton(
onPressed: () async {
@ -485,10 +505,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
await connector.refreshDeviceInfo();
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Privacy mode enabled')),
SnackBar(content: Text(l10n.settings_privacyModeEnabled)),
);
},
child: const Text('Enable'),
child: Text(l10n.common_enable),
),
TextButton(
onPressed: () async {
@ -497,10 +517,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
await connector.refreshDeviceInfo();
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Privacy mode disabled')),
SnackBar(content: Text(l10n.settings_privacyModeDisabled)),
);
},
child: const Text('Disable'),
child: Text(l10n.common_disable),
),
],
),
@ -508,36 +528,39 @@ class _SettingsScreenState extends State<SettingsScreen> {
}
void _sendAdvert(BuildContext context, MeshCoreConnector connector) {
final l10n = context.l10n;
connector.sendSelfAdvert(flood: true);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Advertisement sent')),
SnackBar(content: Text(l10n.settings_advertisementSent)),
);
}
void _syncTime(BuildContext context, MeshCoreConnector connector) {
final l10n = context.l10n;
connector.syncTime();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Time synchronized')),
SnackBar(content: Text(l10n.settings_timeSynchronized)),
);
}
void _confirmReboot(BuildContext context, MeshCoreConnector connector) {
final l10n = context.l10n;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Reboot Device'),
content: const Text('Are you sure you want to reboot the device? You will be disconnected.'),
title: Text(l10n.settings_rebootDevice),
content: Text(l10n.settings_rebootDeviceConfirm),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
child: Text(l10n.common_cancel),
),
TextButton(
onPressed: () {
Navigator.pop(context);
connector.rebootDevice();
},
child: const Text('Reboot', style: TextStyle(color: Colors.orange)),
child: Text(l10n.common_reboot, style: const TextStyle(color: Colors.orange)),
),
],
),
@ -545,16 +568,15 @@ class _SettingsScreenState extends State<SettingsScreen> {
}
void _showAbout(BuildContext context) {
final l10n = context.l10n;
showAboutDialog(
context: context,
applicationName: 'MeshCore Open',
applicationVersion: _appVersion,
applicationLegalese: '2024 MeshCore Open Source Project',
applicationName: l10n.appTitle,
applicationVersion: _appVersion.isEmpty ? l10n.common_loading : _appVersion,
applicationLegalese: l10n.settings_aboutLegalese,
children: [
const SizedBox(height: 16),
const Text(
'An open-source Flutter client for MeshCore LoRa mesh networking devices.',
),
Text(l10n.settings_aboutDescription),
],
);
}
@ -643,19 +665,20 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> {
}
Future<void> _saveSettings() async {
final l10n = context.l10n;
final freqMHz = double.tryParse(_frequencyController.text);
final txPower = int.tryParse(_txPowerController.text);
if (freqMHz == null || freqMHz < 300 || freqMHz > 2500) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Invalid frequency (300-2500 MHz)')),
SnackBar(content: Text(l10n.settings_frequencyInvalid)),
);
return;
}
if (txPower == null || txPower < 0 || txPower > 22) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Invalid TX power (0-22 dBm)')),
SnackBar(content: Text(l10n.settings_txPowerInvalid)),
);
return;
}
@ -673,12 +696,12 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> {
if (!mounted) return;
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Radio settings updated')),
SnackBar(content: Text(l10n.settings_radioSettingsUpdated)),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: $e')),
SnackBar(content: Text(l10n.settings_error(e.toString()))),
);
}
}
@ -696,36 +719,37 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> {
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
return AlertDialog(
title: const Text('Radio Settings'),
title: Text(l10n.settings_radioSettings),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Presets', style: TextStyle(fontWeight: FontWeight.bold)),
Text(l10n.settings_presets, style: const TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: [
_PresetChip(
label: '915 MHz',
label: l10n.settings_preset915Mhz,
onTap: () => _applyPreset(RadioSettings.preset915MHz),
),
_PresetChip(
label: '868 MHz',
label: l10n.settings_preset868Mhz,
onTap: () => _applyPreset(RadioSettings.preset868MHz),
),
_PresetChip(
label: '433 MHz',
label: l10n.settings_preset433Mhz,
onTap: () => _applyPreset(RadioSettings.preset433MHz),
),
_PresetChip(
label: 'Long Range',
label: l10n.settings_longRange,
onTap: () => _applyPreset(RadioSettings.presetLongRange),
),
_PresetChip(
label: 'Fast Speed',
label: l10n.settings_fastSpeed,
onTap: () => _applyPreset(RadioSettings.presetFastSpeed),
),
],
@ -733,19 +757,19 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> {
const SizedBox(height: 24),
TextField(
controller: _frequencyController,
decoration: const InputDecoration(
labelText: 'Frequency (MHz)',
border: OutlineInputBorder(),
helperText: '300.0 - 2500.0',
decoration: InputDecoration(
labelText: l10n.settings_frequency,
border: const OutlineInputBorder(),
helperText: l10n.settings_frequencyHelper,
),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
),
const SizedBox(height: 16),
DropdownButtonFormField<LoRaBandwidth>(
initialValue: _bandwidth,
decoration: const InputDecoration(
labelText: 'Bandwidth',
border: OutlineInputBorder(),
decoration: InputDecoration(
labelText: l10n.settings_bandwidth,
border: const OutlineInputBorder(),
),
items: LoRaBandwidth.values
.map((bw) => DropdownMenuItem(
@ -760,9 +784,9 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> {
const SizedBox(height: 16),
DropdownButtonFormField<LoRaSpreadingFactor>(
initialValue: _spreadingFactor,
decoration: const InputDecoration(
labelText: 'Spreading Factor',
border: OutlineInputBorder(),
decoration: InputDecoration(
labelText: l10n.settings_spreadingFactor,
border: const OutlineInputBorder(),
),
items: LoRaSpreadingFactor.values
.map((sf) => DropdownMenuItem(
@ -777,9 +801,9 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> {
const SizedBox(height: 16),
DropdownButtonFormField<LoRaCodingRate>(
initialValue: _codingRate,
decoration: const InputDecoration(
labelText: 'Coding Rate',
border: OutlineInputBorder(),
decoration: InputDecoration(
labelText: l10n.settings_codingRate,
border: const OutlineInputBorder(),
),
items: LoRaCodingRate.values
.map((cr) => DropdownMenuItem(
@ -794,10 +818,10 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> {
const SizedBox(height: 16),
TextField(
controller: _txPowerController,
decoration: const InputDecoration(
labelText: 'TX Power (dBm)',
border: OutlineInputBorder(),
helperText: '0 - 22',
decoration: InputDecoration(
labelText: l10n.settings_txPower,
border: const OutlineInputBorder(),
helperText: l10n.settings_txPowerHelper,
),
keyboardType: TextInputType.number,
),
@ -807,11 +831,11 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> {
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
child: Text(l10n.common_cancel),
),
FilledButton(
onPressed: _saveSettings,
child: const Text('Save'),
child: Text(l10n.common_save),
),
],
);

View file

@ -5,6 +5,7 @@ import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../l10n/l10n.dart';
import '../models/contact.dart';
import '../models/path_selection.dart';
import '../connector/meshcore_connector.dart';
@ -30,7 +31,8 @@ class TelemetryScreen extends StatefulWidget {
class _TelemetryScreenState extends State<TelemetryScreen> {
static const int _statusPayloadOffset = 8;
static const int _statusStatsSize = 52;
static const int _statusResponseBytes = _statusPayloadOffset + _statusStatsSize;
static const int _statusResponseBytes =
_statusPayloadOffset + _statusStatsSize;
Uint8List _tagData = Uint8List(4);
int _timeEstment = 0;
@ -66,7 +68,8 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
}
// Check if it's a binary response
if (frame[0] == pushCodeBinaryResponse && listEquals(frame.sublist(2, 6), _tagData)) {
if (frame[0] == pushCodeBinaryResponse &&
listEquals(frame.sublist(2, 6), _tagData)) {
_handleStatusResponse(context, frame.sublist(6));
}
});
@ -78,10 +81,10 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Received Telemetry Data'),
SnackBar(
content: Text(context.l10n.telemetry_receivedData),
backgroundColor: Colors.green,
)
),
);
_statusTimeout?.cancel();
if (!mounted) return;
@ -111,7 +114,10 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
final repeater = _resolveRepeater(connector);
final selection = await connector.preparePathForContactSend(repeater);
_pendingStatusSelection = selection;
final frame = buildSendBinaryReq(repeater.publicKey, payload: Uint8List.fromList([reqTypeGetTelemetry]));
final frame = buildSendBinaryReq(
repeater.publicKey,
payload: Uint8List.fromList([reqTypeGetTelemetry]),
);
await connector.sendFrame(frame);
final pathLengthValue = selection.useFlood ? -1 : selection.hopCount;
@ -130,8 +136,8 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
_isLoaded = false;
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Telemetry request timed out.'),
SnackBar(
content: Text(context.l10n.telemetry_requestTimeout),
backgroundColor: Colors.red,
),
);
@ -146,7 +152,7 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Error loading telemetry: $e'),
content: Text(context.l10n.telemetry_errorLoading(e.toString())),
backgroundColor: Colors.red,
),
);
@ -173,6 +179,7 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final connector = context.watch<MeshCoreConnector>();
final repeater = _resolveRepeater(connector);
final isFloodMode = repeater.pathOverride == -1;
@ -183,10 +190,16 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
const Text('Repeater Telemetry', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
Text(
l10n.repeater_telemetry,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
Text(
repeater.name,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.normal),
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.normal,
),
),
],
),
@ -194,7 +207,7 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
actions: [
PopupMenuButton<String>(
icon: Icon(isFloodMode ? Icons.waves : Icons.route),
tooltip: 'Routing mode',
tooltip: l10n.repeater_routingMode,
onSelected: (mode) async {
if (mode == 'flood') {
await connector.setPathOverride(repeater, pathLen: -1);
@ -207,12 +220,20 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
value: 'auto',
child: Row(
children: [
Icon(Icons.auto_mode, size: 20, color: !isFloodMode ? Theme.of(context).primaryColor : null),
Icon(
Icons.auto_mode,
size: 20,
color: !isFloodMode
? Theme.of(context).primaryColor
: null,
),
const SizedBox(width: 8),
Text(
'Auto (use saved path)',
l10n.repeater_autoUseSavedPath,
style: TextStyle(
fontWeight: !isFloodMode ? FontWeight.bold : FontWeight.normal,
fontWeight: !isFloodMode
? FontWeight.bold
: FontWeight.normal,
),
),
],
@ -222,12 +243,20 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
value: 'flood',
child: Row(
children: [
Icon(Icons.waves, size: 20, color: isFloodMode ? Theme.of(context).primaryColor : null),
Icon(
Icons.waves,
size: 20,
color: isFloodMode
? Theme.of(context).primaryColor
: null,
),
const SizedBox(width: 8),
Text(
'Force Flood Mode',
l10n.repeater_forceFloodMode,
style: TextStyle(
fontWeight: isFloodMode ? FontWeight.bold : FontWeight.normal,
fontWeight: isFloodMode
? FontWeight.bold
: FontWeight.normal,
),
),
],
@ -237,8 +266,9 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
),
IconButton(
icon: const Icon(Icons.timeline),
tooltip: 'Path management',
onPressed: () => PathManagementDialog.show(context, contact: repeater),
tooltip: l10n.repeater_pathManagement,
onPressed: () =>
PathManagementDialog.show(context, contact: repeater),
),
IconButton(
icon: _isLoading
@ -249,7 +279,7 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
)
: const Icon(Icons.refresh),
onPressed: _isLoading ? null : _loadTelemetry,
tooltip: 'Refresh',
tooltip: l10n.repeater_refresh,
),
],
),
@ -260,16 +290,24 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
child: ListView(
padding: const EdgeInsets.all(16),
children: [
if (!_isLoaded && !_hasData && (_parsedTelemetry == null || _parsedTelemetry!.isEmpty))
const Center(
if (!_isLoaded &&
!_hasData &&
(_parsedTelemetry == null || _parsedTelemetry!.isEmpty))
Center(
child: Text(
'No telemetry data available.',
style: TextStyle(fontSize: 16, color: Colors.grey),
l10n.telemetry_noData,
style: const TextStyle(fontSize: 16, color: Colors.grey),
),
),
if ((_isLoaded || _hasData) && _parsedTelemetry != null && _parsedTelemetry!.isNotEmpty)
if ((_isLoaded || _hasData) &&
_parsedTelemetry != null &&
_parsedTelemetry!.isNotEmpty)
for (final entry in _parsedTelemetry ?? [])
_buildChannelInfoCard(entry['values'], 'Channel ${entry['channel']}', entry['channel']),
_buildChannelInfoCard(
entry['values'],
l10n.telemetry_channelTitle(entry['channel']),
entry['channel'],
),
],
),
),
@ -277,7 +315,12 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
);
}
Widget _buildChannelInfoCard(Map<String, dynamic> channelData, String title, int channel) {
Widget _buildChannelInfoCard(
Map<String, dynamic> channelData,
String title,
int channel,
) {
final l10n = context.l10n;
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
@ -286,26 +329,47 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
children: [
Row(
children: [
Icon(Icons.info_outline, color: Theme.of(context).textTheme.headlineSmall?.color),
Icon(
Icons.info_outline,
color: Theme.of(context).textTheme.headlineSmall?.color,
),
const SizedBox(width: 8),
Text(
title,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
],
),
const Divider(),
for (final entry in channelData.entries)
if (entry.key == 'voltage' && channel == 1)
_buildInfoRow('Battery', _batteryText(entry.value))
_buildInfoRow(
l10n.telemetry_batteryLabel,
_batteryText(entry.value),
)
else if (entry.key == 'voltage')
_buildInfoRow('Voltage', '${entry.value}V')
_buildInfoRow(
l10n.telemetry_voltageLabel,
l10n.telemetry_voltageValue(entry.value.toString()),
)
else if (entry.key == 'temperature' && channel == 1)
_buildInfoRow('MCU Temperature', _temperatureText(entry.value))
_buildInfoRow(
l10n.telemetry_mcuTemperatureLabel,
_temperatureText(entry.value),
)
else if (entry.key == 'temperature')
_buildInfoRow('Temperature', _temperatureText(entry.value))
_buildInfoRow(
l10n.telemetry_temperatureLabel,
_temperatureText(entry.value),
)
else if (entry.key == 'current' && channel == 1)
_buildInfoRow('Current', '${entry.value}A')
_buildInfoRow(
l10n.telemetry_currentLabel,
l10n.telemetry_currentValue(entry.value.toString()),
)
else
_buildInfoRow(entry.key, entry.value.toString()),
],
@ -341,11 +405,12 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
);
}
String _batteryText(double? _batteryMv) {
if (_batteryMv == null) return '';
final percent = _batteryPercentFromMv(_batteryMv);
final volts = _batteryMv.toStringAsFixed(2);
return '$percent% / ${volts}V';
String _batteryText(double? batteryMv) {
final l10n = context.l10n;
if (batteryMv == null) return l10n.common_notAvailable;
final percent = _batteryPercentFromMv(batteryMv);
final volts = batteryMv.toStringAsFixed(2);
return l10n.telemetry_batteryValue(percent, volts);
}
int _batteryPercentFromMv(double millivolts) {
@ -357,8 +422,12 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
}
String _temperatureText(double? tempC) {
if (tempC == null) return '';
final l10n = context.l10n;
if (tempC == null) return l10n.common_notAvailable;
final tempF = (tempC * 9 / 5) + 32;
return '${tempC.toStringAsFixed(1)}°C / ${tempF.toStringAsFixed(1)}°F';
return l10n.telemetry_temperatureValue(
tempC.toStringAsFixed(1),
tempF.toStringAsFixed(1),
);
}
}
}

View file

@ -113,6 +113,10 @@ class AppSettingsService extends ChangeNotifier {
await updateSettings(_settings.copyWith(themeMode: value));
}
Future<void> setLanguageOverride(String? value) async {
await updateSettings(_settings.copyWith(languageOverride: value));
}
Future<void> setAppDebugLogEnabled(bool value) async {
await updateSettings(_settings.copyWith(appDebugLogEnabled: value));
// Update the global logger

View file

@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import '../connector/meshcore_connector.dart';
import '../l10n/l10n.dart';
/// Shows a confirmation dialog before disconnecting from the device.
/// Returns true if user confirmed and disconnect completed, false otherwise.
@ -7,20 +8,20 @@ Future<bool> showDisconnectDialog(
BuildContext context,
MeshCoreConnector connector,
) async {
final l10n = context.l10n;
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Disconnect'),
content: const Text(
'Are you sure you want to disconnect from this device?'),
title: Text(l10n.dialog_disconnect),
content: Text(l10n.dialog_disconnectConfirm),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
child: Text(l10n.common_cancel),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Disconnect'),
child: Text(l10n.common_disconnect),
),
],
),

View file

@ -1,5 +1,6 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import '../l10n/l10n.dart';
import '../connector/meshcore_protocol.dart';
/// Debug widget to show the hex dump of a frame
@ -10,23 +11,32 @@ class DebugFrameViewer {
.join(' ');
final details = StringBuffer();
details.writeln('Frame Length: ${frame.length} bytes\n');
details.writeln('Command: 0x${frame[0].toRadixString(16).padLeft(2, '0')}');
details.writeln(context.l10n.debugFrame_length(frame.length));
details.writeln('');
details.writeln(
context.l10n.debugFrame_command(frame[0].toRadixString(16).padLeft(2, '0')),
);
if (frame[0] == cmdSendTxtMsg && frame.length > 37) {
details.writeln('\nText Message Frame:');
details.writeln('- Destination PubKey: ${pubKeyToHex(frame.sublist(1, 33))}');
details.writeln('- Timestamp: ${readUint32LE(frame, 33)}');
details.writeln('- Flags: 0x${frame[37].toRadixString(16).padLeft(2, '0')}');
details.writeln('');
details.writeln(context.l10n.debugFrame_textMessageHeader);
details.writeln(context.l10n.debugFrame_destinationPubKey(pubKeyToHex(frame.sublist(1, 33))));
details.writeln(context.l10n.debugFrame_timestamp(readUint32LE(frame, 33)));
details.writeln(
context.l10n.debugFrame_flags(frame[37].toRadixString(16).padLeft(2, '0')),
);
final txtType = (frame[37] >> 2) & 0x03;
details.writeln('- Text Type: $txtType ${txtType == txtTypeCliData ? "(CLI)" : "(Plain)"}');
final typeLabel = txtType == txtTypeCliData
? context.l10n.debugFrame_textTypeCli
: context.l10n.debugFrame_textTypePlain;
details.writeln(context.l10n.debugFrame_textType(txtType, typeLabel));
if (frame.length > 38) {
final textBytes = frame.sublist(38);
final nullIdx = textBytes.indexOf(0);
final text = String.fromCharCodes(
nullIdx >= 0 ? textBytes.sublist(0, nullIdx) : textBytes
);
details.writeln('- Text: "$text"');
details.writeln(context.l10n.debugFrame_text(text));
}
}
@ -44,9 +54,9 @@ class DebugFrameViewer {
style: const TextStyle(fontFamily: 'monospace', fontSize: 12),
),
const Divider(),
const Text(
'Hex Dump:',
style: TextStyle(fontWeight: FontWeight.bold),
Text(
context.l10n.debugFrame_hexDump,
style: const TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Text(
@ -59,7 +69,7 @@ class DebugFrameViewer {
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
child: Text(context.l10n.common_close),
),
],
),

View file

@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import '../l10n/l10n.dart';
/// A reusable tile widget for displaying a MeshCore device in a list
class DeviceTile extends StatelessWidget {
@ -23,13 +24,13 @@ class DeviceTile extends StatelessWidget {
return ListTile(
leading: _buildSignalIcon(rssi),
title: Text(
name.isNotEmpty ? name : 'Unknown Device',
name.isNotEmpty ? name : context.l10n.common_unknownDevice,
style: const TextStyle(fontWeight: FontWeight.w500),
),
subtitle: Text(device.remoteId.toString()),
trailing: ElevatedButton(
onPressed: onTap,
child: const Text('Connect'),
child: Text(context.l10n.common_connect),
),
onTap: onTap,
);

View file

@ -1,4 +1,6 @@
import 'package:flutter/material.dart';
import '../l10n/app_localizations.dart';
import '../l10n/l10n.dart';
class EmojiPicker extends StatelessWidget {
final Function(String) onEmojiSelected;
@ -10,30 +12,39 @@ class EmojiPicker extends StatelessWidget {
static const List<String> quickEmojis = ['👍', '❤️', '😂', '🎉', '👏', '🔥'];
static const Map<String, List<String>> emojiCategories = {
'Smileys': [
static const List<String> _smileys = [
'😀', '😃', '😄', '😁', '😅', '😂', '🤣', '😊', '😇', '🙂', '🙃', '😉', '😌', '😍', '🥰', '😘',
'😗', '😙', '😚', '😋', '😛', '😝', '😜', '🤪', '🤨', '🧐', '🤓', '😎', '🥸', '🤩', '🥳', '😏',
'😒', '😞', '😔', '😟', '😕', '🙁', '😣', '😖', '😫', '😩', '🥺', '😢', '😭', '😤', '😠', '😡',
'🤬', '🤯', '😳', '🥵', '🥶', '😱', '😨', '😰', '😥', '😓', '🤗', '🤔', '🤭', '🤫', '🤥', '😶',
],
'Gestures': [
];
static const List<String> _gestures = [
'👍', '👎', '👊', '', '🤛', '🤜', '🤞', '✌️', '🤟', '🤘', '👌', '🤌', '🤏', '👈', '👉', '👆',
'👇', '☝️', '👋', '🤚', '🖐️', '', '🖖', '👏', '🙌', '👐', '🤲', '🤝', '🙏', '✍️', '💅', '🤳',
],
'Hearts': [
];
static const List<String> _hearts = [
'❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '🤍', '🤎', '💔', '❤️‍🔥', '❤️‍🩹', '💕', '💞', '💓', '💗',
'💖', '💘', '💝', '💟', '💌', '💢', '💥', '💫', '💦', '💨', '🕳️', '💬', '👁️‍🗨️', '🗨️', '🗯️', '💭',
],
'Objects': [
];
static const List<String> _objects = [
'🎉', '🎊', '🎈', '🎁', '🎀', '🪅', '🪆', '🏆', '🥇', '🥈', '🥉', '', '', '🥎', '🏀', '🏐',
'🏈', '🏉', '🎾', '🥏', '🎳', '🏏', '🏑', '🏒', '🥍', '🏓', '🏸', '🥊', '🥋', '🥅', '', '🔥',
'', '🌟', '', '', '💡', '🔦', '🏮', '🪔', '📱', '💻', '', '📷', '📺', '📻', '🎵', '🎶',
],
};
];
Map<String, List<String>> _emojiCategories(AppLocalizations l10n) {
return {
l10n.emojiCategorySmileys: _smileys,
l10n.emojiCategoryGestures: _gestures,
l10n.emojiCategoryHearts: _hearts,
l10n.emojiCategoryObjects: _objects,
};
}
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final emojiCategories = _emojiCategories(l10n);
return Container(
height: MediaQuery.of(context).size.height * 0.5,
decoration: BoxDecoration(
@ -47,9 +58,9 @@ class EmojiPicker extends StatelessWidget {
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Add Reaction',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
Text(
l10n.chat_addReaction,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
IconButton(
icon: const Icon(Icons.close),

View file

@ -2,6 +2,7 @@ import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import '../l10n/l10n.dart';
class GifPicker extends StatefulWidget {
final Function(String gifId) onGifSelected;
@ -57,14 +58,16 @@ class _GifPickerState extends State<GifPicker> {
_isLoading = false;
});
} else {
if (!mounted) return;
setState(() {
_error = 'Failed to load GIFs';
_error = context.l10n.gifPicker_failedLoad;
_isLoading = false;
});
}
} catch (e) {
if (!mounted) return;
setState(() {
_error = 'No internet connection';
_error = context.l10n.gifPicker_noInternet;
_isLoading = false;
});
}
@ -95,14 +98,16 @@ class _GifPickerState extends State<GifPicker> {
_isLoading = false;
});
} else {
if (!mounted) return;
setState(() {
_error = 'Failed to search GIFs';
_error = context.l10n.gifPicker_failedSearch;
_isLoading = false;
});
}
} catch (e) {
if (!mounted) return;
setState(() {
_error = 'No internet connection';
_error = context.l10n.gifPicker_noInternet;
_isLoading = false;
});
}
@ -120,9 +125,9 @@ class _GifPickerState extends State<GifPicker> {
children: [
const Icon(Icons.gif_box, size: 28),
const SizedBox(width: 8),
const Text(
'Choose a GIF',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
Text(
context.l10n.gifPicker_title,
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
const Spacer(),
IconButton(
@ -137,7 +142,7 @@ class _GifPickerState extends State<GifPicker> {
TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Search GIFs...',
hintText: context.l10n.gifPicker_searchHint,
prefixIcon: const Icon(Icons.search),
suffixIcon: _searchController.text.isNotEmpty
? IconButton(
@ -172,7 +177,7 @@ class _GifPickerState extends State<GifPicker> {
// Powered by Giphy attribution
const SizedBox(height: 8),
Text(
'Powered by GIPHY',
context.l10n.gifPicker_poweredBy,
style: TextStyle(
fontSize: 11,
color: Colors.grey[600],
@ -205,7 +210,7 @@ class _GifPickerState extends State<GifPicker> {
ElevatedButton.icon(
onPressed: _loadTrendingGifs,
icon: const Icon(Icons.refresh),
label: const Text('Retry'),
label: Text(context.l10n.common_retry),
),
],
),
@ -220,7 +225,7 @@ class _GifPickerState extends State<GifPicker> {
Icon(Icons.search_off, size: 64, color: Colors.grey[400]),
const SizedBox(height: 16),
Text(
'No GIFs found',
context.l10n.gifPicker_noGifsFound,
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
),
],

View file

@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import '../l10n/l10n.dart';
enum ContactSortOption {
lastSeen,
@ -45,7 +46,7 @@ class SortFilterMenu extends StatelessWidget {
super.key,
required this.sections,
required this.onSelected,
this.tooltip = 'Filter and sort',
required this.tooltip,
this.icon = const Icon(Icons.filter_list_outlined),
});
@ -131,59 +132,61 @@ class ContactsFilterMenu extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
return SortFilterMenu(
tooltip: l10n.listFilter_tooltip,
sections: [
SortFilterMenuSection(
title: 'Sort by',
title: l10n.listFilter_sortBy,
options: [
SortFilterMenuOption(
value: _actionSortRecentMessages,
label: 'Latest messages',
label: l10n.listFilter_latestMessages,
checked: sortOption == ContactSortOption.recentMessages,
),
SortFilterMenuOption(
value: _actionSortLastSeen,
label: 'Heard recently',
label: l10n.listFilter_heardRecently,
checked: sortOption == ContactSortOption.lastSeen,
),
SortFilterMenuOption(
value: _actionSortName,
label: 'A-Z',
label: l10n.listFilter_az,
checked: sortOption == ContactSortOption.name,
),
],
),
SortFilterMenuSection(
title: 'Filters',
title: l10n.listFilter_filters,
options: [
SortFilterMenuOption(
value: _actionFilterAll,
label: 'All',
label: l10n.listFilter_all,
checked: typeFilter == ContactTypeFilter.all,
),
SortFilterMenuOption(
value: _actionFilterUsers,
label: 'Users',
label: l10n.listFilter_users,
checked: typeFilter == ContactTypeFilter.users,
),
SortFilterMenuOption(
value: _actionFilterRepeaters,
label: 'Repeaters',
label: l10n.listFilter_repeaters,
checked: typeFilter == ContactTypeFilter.repeaters,
),
SortFilterMenuOption(
value: _actionFilterRooms,
label: 'Room servers',
label: l10n.listFilter_roomServers,
checked: typeFilter == ContactTypeFilter.rooms,
),
SortFilterMenuOption(
value: _actionToggleUnreadOnly,
label: 'Unread only',
label: l10n.listFilter_unreadOnly,
checked: showUnreadOnly,
),
const SortFilterMenuOption(
SortFilterMenuOption(
value: _actionNewGroup,
label: 'New group',
label: l10n.listFilter_newGroup,
),
],
),

View file

@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart';
import '../l10n/l10n.dart';
import '../models/contact.dart';
import '../services/path_history_service.dart';
import 'path_selection_dialog.dart';
@ -12,13 +13,11 @@ class PathManagementDialog {
static Future<void> show(
BuildContext context, {
required Contact contact,
String title = 'Path Management',
}) {
return showDialog<void>(
context: context,
builder: (context) => _PathManagementDialog(
contact: contact,
title: title,
),
);
}
@ -26,11 +25,9 @@ class PathManagementDialog {
class _PathManagementDialog extends StatelessWidget {
final Contact contact;
final String title;
const _PathManagementDialog({
required this.contact,
required this.title,
});
Contact _resolveContact(MeshCoreConnector connector) {
@ -40,20 +37,22 @@ class _PathManagementDialog extends StatelessWidget {
);
}
String _formatRelativeTime(DateTime time) {
String _formatRelativeTime(BuildContext context, DateTime time) {
final l10n = context.l10n;
final diff = DateTime.now().difference(time);
if (diff.inSeconds < 60) return 'Just now';
if (diff.inMinutes < 60) return '${diff.inMinutes}m ago';
if (diff.inHours < 24) return '${diff.inHours}h ago';
return '${diff.inDays}d ago';
if (diff.inSeconds < 60) return l10n.time_justNow;
if (diff.inMinutes < 60) return l10n.time_minutesAgo(diff.inMinutes);
if (diff.inHours < 24) return l10n.time_hoursAgo(diff.inHours);
return l10n.time_daysAgo(diff.inDays);
}
void _showFullPathDialog(BuildContext context, List<int> pathBytes) {
final l10n = context.l10n;
if (pathBytes.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Path details not available yet. Try sending a message to refresh.'),
duration: Duration(seconds: 2),
SnackBar(
content: Text(l10n.chat_pathDetailsNotAvailable),
duration: const Duration(seconds: 2),
),
);
return;
@ -66,12 +65,12 @@ class _PathManagementDialog extends StatelessWidget {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Full Path'),
title: Text(l10n.chat_fullPath),
content: SelectableText(formattedPath),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
child: Text(l10n.common_close),
),
],
),
@ -83,6 +82,7 @@ class _PathManagementDialog extends StatelessWidget {
MeshCoreConnector connector,
Contact currentContact,
) async {
final l10n = context.l10n;
if (currentContact.pathLength > 0 && currentContact.path.isEmpty && connector.isConnected) {
connector.getContacts();
}
@ -96,7 +96,6 @@ class _PathManagementDialog extends StatelessWidget {
context,
availableContacts: availableContacts,
initialPath: pathForInput.isEmpty ? null : pathForInput,
title: 'Set Custom Path',
currentPathLabel: currentContact.pathLabel,
onRefresh: connector.isConnected ? connector.getContacts : null,
);
@ -111,7 +110,7 @@ class _PathManagementDialog extends StatelessWidget {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Path set: ${result.length} ${result.length == 1 ? "hop" : "hops"}'),
content: Text(l10n.chat_hopsCount(result.length)),
duration: const Duration(seconds: 2),
),
);
@ -120,27 +119,28 @@ class _PathManagementDialog extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
return Consumer2<MeshCoreConnector, PathHistoryService>(
builder: (context, connector, pathService, _) {
final currentContact = _resolveContact(connector);
final paths = pathService.getRecentPaths(currentContact.publicKeyHex);
return AlertDialog(
title: Text(title),
title: Text(l10n.chat_pathManagement),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Current path: ${currentContact.pathLabel}',
l10n.path_currentPath(currentContact.pathLabel),
style: const TextStyle(fontSize: 12, color: Colors.grey),
),
const SizedBox(height: 12),
if (paths.isNotEmpty) ...[
const Text(
'Recent ACK Paths (tap to use):',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
Text(
l10n.chat_recentAckPaths,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
),
if (paths.length >= 100) ...[
const SizedBox(height: 8),
@ -151,9 +151,9 @@ class _PathManagementDialog extends StatelessWidget {
color: Colors.amberAccent,
borderRadius: BorderRadius.circular(8),
),
child: const Text(
'Path history is full. Remove entries to add new ones.',
style: TextStyle(fontSize: 12),
child: Text(
l10n.chat_pathHistoryFull,
style: const TextStyle(fontSize: 12),
),
),
],
@ -172,11 +172,11 @@ class _PathManagementDialog extends StatelessWidget {
),
),
title: Text(
'${path.hopCount} ${path.hopCount == 1 ? 'hop' : 'hops'}',
l10n.chat_hopsCount(path.hopCount),
style: const TextStyle(fontSize: 14),
),
subtitle: Text(
'${(path.tripTimeMs / 1000).toStringAsFixed(2)}s • ${_formatRelativeTime(path.timestamp)}${path.successCount} successes',
'${(path.tripTimeMs / 1000).toStringAsFixed(2)}s • ${_formatRelativeTime(context, path.timestamp)}${path.successCount} ${l10n.chat_successes}',
style: const TextStyle(fontSize: 11),
),
trailing: Row(
@ -184,7 +184,7 @@ class _PathManagementDialog extends StatelessWidget {
children: [
IconButton(
icon: const Icon(Icons.close, size: 16),
tooltip: 'Remove path',
tooltip: l10n.chat_removePath,
onPressed: () async {
await pathService.removePathRecord(
currentContact.publicKeyHex,
@ -201,9 +201,9 @@ class _PathManagementDialog extends StatelessWidget {
onTap: () async {
if (path.pathBytes.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Path details not available yet. Try sending a message to refresh.'),
duration: Duration(seconds: 2),
SnackBar(
content: Text(l10n.chat_pathDetailsNotAvailable),
duration: const Duration(seconds: 2),
),
);
return;
@ -222,7 +222,7 @@ class _PathManagementDialog extends StatelessWidget {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Using ${path.hopCount} ${path.hopCount == 1 ? 'hop' : 'hops'} path'),
content: Text(l10n.path_usingHopsPath(path.hopCount)),
duration: const Duration(seconds: 2),
),
);
@ -232,13 +232,13 @@ class _PathManagementDialog extends StatelessWidget {
}),
const Divider(),
] else ...[
const Text('No path history yet.\nSend a message to discover paths.'),
Text(l10n.chat_noPathHistoryYet),
const Divider(),
],
const SizedBox(height: 8),
const Text(
'Path Actions:',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
Text(
l10n.chat_pathActions,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
),
const SizedBox(height: 8),
ListTile(
@ -248,8 +248,8 @@ class _PathManagementDialog extends StatelessWidget {
backgroundColor: Colors.purple,
child: Icon(Icons.edit_road, size: 16),
),
title: const Text('Set Custom Path', style: TextStyle(fontSize: 14)),
subtitle: const Text('Manually specify routing path', style: TextStyle(fontSize: 11)),
title: Text(l10n.chat_setCustomPath, style: const TextStyle(fontSize: 14)),
subtitle: Text(l10n.chat_setCustomPathSubtitle, style: const TextStyle(fontSize: 11)),
onTap: () async {
await _setCustomPath(context, connector, currentContact);
},
@ -261,15 +261,15 @@ class _PathManagementDialog extends StatelessWidget {
backgroundColor: Colors.orange,
child: Icon(Icons.clear_all, size: 16),
),
title: const Text('Clear Path', style: TextStyle(fontSize: 14)),
subtitle: const Text('Force rediscovery on next send', style: TextStyle(fontSize: 11)),
title: Text(l10n.chat_clearPath, style: const TextStyle(fontSize: 14)),
subtitle: Text(l10n.chat_clearPathSubtitle, style: const TextStyle(fontSize: 11)),
onTap: () async {
await connector.clearContactPath(currentContact);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Path cleared. Next message will rediscover route.'),
duration: Duration(seconds: 2),
SnackBar(
content: Text(l10n.chat_pathCleared),
duration: const Duration(seconds: 2),
),
);
Navigator.pop(context);
@ -282,15 +282,15 @@ class _PathManagementDialog extends StatelessWidget {
backgroundColor: Colors.blue,
child: Icon(Icons.waves, size: 16),
),
title: const Text('Force Flood Mode', style: TextStyle(fontSize: 14)),
subtitle: const Text('Use routing toggle in app bar', style: TextStyle(fontSize: 11)),
title: Text(l10n.chat_forceFloodMode, style: const TextStyle(fontSize: 14)),
subtitle: Text(l10n.chat_floodModeSubtitle, style: const TextStyle(fontSize: 11)),
onTap: () async {
await connector.setPathOverride(currentContact, pathLen: -1);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Flood mode enabled. Toggle back via routing icon in app bar.'),
duration: Duration(seconds: 2),
SnackBar(
content: Text(l10n.chat_floodModeEnabled),
duration: const Duration(seconds: 2),
),
);
Navigator.pop(context);
@ -302,7 +302,7 @@ class _PathManagementDialog extends StatelessWidget {
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
child: Text(l10n.common_close),
),
],
);

View file

@ -1,19 +1,20 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import '../l10n/l10n.dart';
import '../models/contact.dart';
class PathSelectionDialog extends StatefulWidget {
final List<Contact> availableContacts;
final String? initialPath;
final String title;
final String? initialPath;
final String? currentPathLabel;
final VoidCallback? onRefresh;
const PathSelectionDialog({
super.key,
required this.availableContacts,
required this.title,
this.initialPath,
this.title = 'Enter Custom Path',
this.currentPathLabel,
this.onRefresh,
});
@ -24,8 +25,8 @@ class PathSelectionDialog extends StatefulWidget {
static Future<Uint8List?> show(
BuildContext context, {
required List<Contact> availableContacts,
String? title,
String? initialPath,
String title = 'Enter Custom Path',
String? currentPathLabel,
VoidCallback? onRefresh,
}) {
@ -33,8 +34,8 @@ class PathSelectionDialog extends StatefulWidget {
context: context,
builder: (context) => PathSelectionDialog(
availableContacts: availableContacts,
title: title ?? context.l10n.path_enterCustomPath,
initialPath: initialPath,
title: title,
currentPathLabel: currentPathLabel,
onRefresh: onRefresh,
),
@ -98,6 +99,7 @@ class _PathSelectionDialogState extends State<PathSelectionDialog> {
}
Future<void> _validateAndSubmit() async {
final l10n = context.l10n;
final path = _controller.text.trim().toUpperCase();
if (path.isEmpty) {
if (mounted) Navigator.pop(context);
@ -130,7 +132,7 @@ class _PathSelectionDialogState extends State<PathSelectionDialog> {
if (invalidPrefixes.isNotEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Invalid hex prefixes: ${invalidPrefixes.join(", ")}'),
content: Text(l10n.path_invalidHexPrefixes(invalidPrefixes.join(", "))),
duration: const Duration(seconds: 3),
backgroundColor: Colors.red,
),
@ -141,9 +143,9 @@ class _PathSelectionDialogState extends State<PathSelectionDialog> {
// Check max path length (64 hops)
if (pathBytesList.length > 64) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Path too long. Maximum 64 hops allowed.'),
duration: Duration(seconds: 3),
SnackBar(
content: Text(l10n.path_tooLong),
duration: const Duration(seconds: 3),
backgroundColor: Colors.red,
),
);
@ -163,6 +165,7 @@ class _PathSelectionDialogState extends State<PathSelectionDialog> {
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
return AlertDialog(
title: Text(widget.title),
content: SingleChildScrollView(
@ -175,16 +178,16 @@ class _PathSelectionDialogState extends State<PathSelectionDialog> {
if (widget.currentPathLabel != null) ...[
Row(
children: [
const Text(
'Current path',
style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold),
Text(
l10n.path_currentPathLabel,
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold),
),
const Spacer(),
if (widget.onRefresh != null)
TextButton.icon(
onPressed: widget.onRefresh,
icon: const Icon(Icons.refresh, size: 16),
label: const Text('Reload'),
label: Text(l10n.common_reload),
),
],
),
@ -194,23 +197,23 @@ class _PathSelectionDialogState extends State<PathSelectionDialog> {
),
const SizedBox(height: 16),
],
const Text(
'Enter 2-character hex prefixes for each hop, separated by commas.',
style: TextStyle(fontSize: 12, color: Colors.grey),
Text(
l10n.path_hexPrefixInstructions,
style: const TextStyle(fontSize: 12, color: Colors.grey),
),
const SizedBox(height: 8),
const Text(
'Example: A1,F2,3C (each node uses first byte of its public key)',
style: TextStyle(fontSize: 11, color: Colors.grey),
Text(
l10n.path_hexPrefixExample,
style: const TextStyle(fontSize: 11, color: Colors.grey),
),
const SizedBox(height: 16),
TextField(
controller: _controller,
decoration: const InputDecoration(
labelText: 'Path (hex prefixes)',
hintText: 'A1,F2,3C',
border: OutlineInputBorder(),
helperText: 'Max 64 hops. Each prefix is 2 hex characters (1 byte)',
decoration: InputDecoration(
labelText: l10n.path_labelHexPrefixes,
hintText: l10n.path_hexPrefixExample,
border: const OutlineInputBorder(),
helperText: l10n.path_helperMaxHops,
),
textCapitalization: TextCapitalization.characters,
maxLength: 191, // 64 hops * 2 chars + 63 commas
@ -220,36 +223,36 @@ class _PathSelectionDialogState extends State<PathSelectionDialog> {
const SizedBox(height: 8),
Row(
children: [
const Text(
'Or select from contacts:',
style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold),
Text(
l10n.path_selectFromContacts,
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold),
),
const Spacer(),
if (_selectedContacts.isNotEmpty)
TextButton(
onPressed: _clearSelection,
child: const Text('Clear'),
child: Text(l10n.common_clear),
),
],
),
const SizedBox(height: 8),
if (_validContacts.isEmpty) ...[
const Center(
Center(
child: Padding(
padding: EdgeInsets.all(16.0),
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
Icon(Icons.info_outline, size: 48, color: Colors.grey),
SizedBox(height: 16),
const Icon(Icons.info_outline, size: 48, color: Colors.grey),
const SizedBox(height: 16),
Text(
'No repeaters or room servers found.',
style: TextStyle(fontSize: 14),
l10n.path_noRepeatersFound,
style: const TextStyle(fontSize: 14),
textAlign: TextAlign.center,
),
SizedBox(height: 8),
const SizedBox(height: 8),
Text(
'Custom paths require intermediate hops that can relay messages.',
style: TextStyle(fontSize: 12, color: Colors.grey),
l10n.path_customPathsRequire,
style: const TextStyle(fontSize: 12, color: Colors.grey),
textAlign: TextAlign.center,
),
],
@ -300,11 +303,11 @@ class _PathSelectionDialogState extends State<PathSelectionDialog> {
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
child: Text(l10n.common_cancel),
),
TextButton(
onPressed: _validateAndSubmit,
child: const Text('Set Path'),
child: Text(l10n.path_setPath),
),
],
);

View file

@ -1,6 +1,7 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import '../l10n/l10n.dart';
class QuickSwitchBar extends StatelessWidget {
final int selectedIndex;
@ -59,18 +60,18 @@ class QuickSwitchBar extends StatelessWidget {
height: 60,
selectedIndex: selectedIndex,
onDestinationSelected: onDestinationSelected,
destinations: const [
destinations: [
NavigationDestination(
icon: Icon(Icons.people_outline),
label: 'Contacts',
icon: const Icon(Icons.people_outline),
label: context.l10n.nav_contacts,
),
NavigationDestination(
icon: Icon(Icons.tag),
label: 'Channels',
icon: const Icon(Icons.tag),
label: context.l10n.nav_channels,
),
NavigationDestination(
icon: Icon(Icons.map_outlined),
label: 'Map',
icon: const Icon(Icons.map_outlined),
label: context.l10n.nav_map,
),
],
),

View file

@ -3,6 +3,7 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'package:provider/provider.dart';
import '../l10n/l10n.dart';
import '../models/contact.dart';
import '../services/storage_service.dart';
import '../connector/meshcore_connector.dart';
@ -181,7 +182,7 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Login failed: $e'),
content: Text(context.l10n.login_failed(e.toString())),
backgroundColor: Colors.red,
),
);
@ -223,6 +224,7 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final connector = context.watch<MeshCoreConnector>();
final repeater = _resolveRepeater(connector);
final isFloodMode = repeater.pathOverride == -1;
@ -235,7 +237,7 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Repeater Login'),
Text(l10n.login_repeaterLogin),
Text(
repeater.name,
style: TextStyle(
@ -260,17 +262,17 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Enter the repeater password to access settings and status.',
style: TextStyle(fontSize: 14),
Text(
l10n.login_repeaterDescription,
style: const TextStyle(fontSize: 14),
),
const SizedBox(height: 16),
TextField(
controller: _passwordController,
obscureText: _obscurePassword,
decoration: InputDecoration(
labelText: 'Password',
hintText: 'Enter password',
labelText: l10n.login_password,
hintText: l10n.login_enterPassword,
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.lock),
suffixIcon: IconButton(
@ -297,13 +299,13 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
_savePassword = value ?? false;
});
},
title: const Text(
'Save password',
style: TextStyle(fontSize: 14),
title: Text(
l10n.login_savePassword,
style: const TextStyle(fontSize: 14),
),
subtitle: const Text(
'Password will be stored securely on this device',
style: TextStyle(fontSize: 12),
subtitle: Text(
l10n.login_savePasswordSubtitle,
style: const TextStyle(fontSize: 12),
),
controlAffinity: ListTileControlAffinity.leading,
contentPadding: EdgeInsets.zero,
@ -311,14 +313,14 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
const Divider(),
Row(
children: [
const Text(
'Routing',
style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold),
Text(
l10n.login_routing,
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold),
),
const Spacer(),
PopupMenuButton<String>(
icon: Icon(isFloodMode ? Icons.waves : Icons.route),
tooltip: 'Routing mode',
tooltip: l10n.login_routingMode,
onSelected: (mode) async {
if (mode == 'flood') {
await connector.setPathOverride(repeater, pathLen: -1);
@ -334,7 +336,7 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
Icon(Icons.auto_mode, size: 20, color: !isFloodMode ? Theme.of(context).primaryColor : null),
const SizedBox(width: 8),
Text(
'Auto (use saved path)',
l10n.login_autoUseSavedPath,
style: TextStyle(
fontWeight: !isFloodMode ? FontWeight.bold : FontWeight.normal,
),
@ -349,7 +351,7 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
Icon(Icons.waves, size: 20, color: isFloodMode ? Theme.of(context).primaryColor : null),
const SizedBox(width: 8),
Text(
'Force Flood Mode',
l10n.login_forceFloodMode,
style: TextStyle(
fontWeight: isFloodMode ? FontWeight.bold : FontWeight.normal,
),
@ -372,7 +374,7 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
child: TextButton.icon(
onPressed: () => PathManagementDialog.show(context, contact: repeater),
icon: const Icon(Icons.timeline, size: 18),
label: const Text('Manage Paths'),
label: Text(l10n.login_managePaths),
),
),
],
@ -380,7 +382,7 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
child: Text(l10n.common_cancel),
),
if (_isLoggingIn)
SizedBox(
@ -399,7 +401,7 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
),
),
const SizedBox(width: 12),
Text('Attempt $_currentAttempt/$_maxAttempts'),
Text(l10n.login_attempt(_currentAttempt, _maxAttempts)),
],
),
),
@ -408,7 +410,7 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
FilledButton.icon(
onPressed: _isLoading ? null : _handleLogin,
icon: const Icon(Icons.login, size: 18),
label: const Text('Login'),
label: Text(l10n.login_login),
),
],
);

View file

@ -3,6 +3,7 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'package:provider/provider.dart';
import '../l10n/l10n.dart';
import '../models/contact.dart';
import '../services/storage_service.dart';
import '../connector/meshcore_connector.dart';
@ -181,7 +182,7 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Login failed: $e'),
content: Text(context.l10n.login_failed(e.toString())),
backgroundColor: Colors.red,
),
);
@ -223,6 +224,7 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final connector = context.watch<MeshCoreConnector>();
final repeater = _resolveRepeater(connector);
final isFloodMode = repeater.pathOverride == -1;
@ -235,7 +237,7 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Room Login'),
Text(l10n.login_roomLogin),
Text(
repeater.name,
style: TextStyle(
@ -260,17 +262,17 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Enter the room password to access settings and status.',
style: TextStyle(fontSize: 14),
Text(
l10n.login_roomDescription,
style: const TextStyle(fontSize: 14),
),
const SizedBox(height: 16),
TextField(
controller: _passwordController,
obscureText: _obscurePassword,
decoration: InputDecoration(
labelText: 'Password',
hintText: 'Enter password',
labelText: l10n.login_password,
hintText: l10n.login_enterPassword,
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.lock),
suffixIcon: IconButton(
@ -297,13 +299,13 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
_savePassword = value ?? false;
});
},
title: const Text(
'Save password',
style: TextStyle(fontSize: 14),
title: Text(
l10n.login_savePassword,
style: const TextStyle(fontSize: 14),
),
subtitle: const Text(
'Password will be stored securely on this device',
style: TextStyle(fontSize: 12),
subtitle: Text(
l10n.login_savePasswordSubtitle,
style: const TextStyle(fontSize: 12),
),
controlAffinity: ListTileControlAffinity.leading,
contentPadding: EdgeInsets.zero,
@ -311,14 +313,14 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
const Divider(),
Row(
children: [
const Text(
'Routing',
style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold),
Text(
l10n.login_routing,
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold),
),
const Spacer(),
PopupMenuButton<String>(
icon: Icon(isFloodMode ? Icons.waves : Icons.route),
tooltip: 'Routing mode',
tooltip: l10n.login_routingMode,
onSelected: (mode) async {
if (mode == 'flood') {
await connector.setPathOverride(repeater, pathLen: -1);
@ -334,7 +336,7 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
Icon(Icons.auto_mode, size: 20, color: !isFloodMode ? Theme.of(context).primaryColor : null),
const SizedBox(width: 8),
Text(
'Auto (use saved path)',
l10n.login_autoUseSavedPath,
style: TextStyle(
fontWeight: !isFloodMode ? FontWeight.bold : FontWeight.normal,
),
@ -349,7 +351,7 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
Icon(Icons.waves, size: 20, color: isFloodMode ? Theme.of(context).primaryColor : null),
const SizedBox(width: 8),
Text(
'Force Flood Mode',
l10n.login_forceFloodMode,
style: TextStyle(
fontWeight: isFloodMode ? FontWeight.bold : FontWeight.normal,
),
@ -372,7 +374,7 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
child: TextButton.icon(
onPressed: () => PathManagementDialog.show(context, contact: repeater),
icon: const Icon(Icons.timeline, size: 18),
label: const Text('Manage Paths'),
label: Text(l10n.login_managePaths),
),
),
],
@ -380,7 +382,7 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
child: Text(l10n.common_cancel),
),
if (_isLoggingIn)
SizedBox(
@ -399,7 +401,7 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
),
),
const SizedBox(width: 12),
Text('Attempt $_currentAttempt/$_maxAttempts'),
Text(l10n.login_attempt(_currentAttempt, _maxAttempts)),
],
),
),
@ -408,7 +410,7 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
FilledButton.icon(
onPressed: _isLoading ? null : _handleLogin,
icon: const Icon(Icons.login, size: 18),
label: const Text('Login'),
label: Text(l10n.login_login),
),
],
);

View file

@ -294,6 +294,11 @@ packages:
url: "https://pub.dev"
source: hosted
version: "8.0.0"
flutter_localizations:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
flutter_map:
dependency: "direct main"
description:
@ -337,7 +342,7 @@ packages:
source: hosted
version: "4.7.2"
intl:
dependency: transitive
dependency: "direct main"
description:
name: intl
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"

View file

@ -30,6 +30,9 @@ environment:
dependencies:
flutter:
sdk: flutter
flutter_localizations:
sdk: flutter
intl: any
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
@ -68,6 +71,7 @@ dev_dependencies:
# The following section is specific to Flutter packages.
flutter:
generate: true
# The following line ensures that the Material Icons font is
# included with your application, so that you can use the icons in

723
tools/translate.py Normal file
View file

@ -0,0 +1,723 @@
#!/usr/bin/env python3
"""
translate_arb_with_ollama.py
Translates ARB/JSON localization values using a local Ollama model, while:
- preserving keys
- skipping "@@locale" and all "@key" metadata blocks
- preserving placeholders like {deviceName}, {count, plural, ...}
- writing a new file with updated @@locale
- printing progress as it runs
Usage:
python translate_arb_with_ollama.py \
--in /home/zjs81/Desktop/meshcore-open/lib/l10n/app_en.arb \
--out /home/zjs81/Desktop/meshcore-open/lib/l10n/app_es.arb \
--to-locale es \
--model ministral-3:latest \
--temperature 0 \
--concurrency 4
"""
from __future__ import annotations
import argparse
import json
import re
import sys
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass
from typing import Any, Dict, List, Tuple, Optional
from urllib import request
# Simple placeholder like {name}, {count}, {deviceName}
SIMPLE_PLACEHOLDER_RE = re.compile(r"\{(\w+)\}")
# ICU plural/select variable name extraction: {count, plural, ...} or {gender, select, ...}
ICU_VAR_RE = re.compile(r"\{(\w+)\s*,\s*(?:plural|select|selectordinal)\s*,", re.IGNORECASE)
@dataclass
class OllamaConfig:
host: str
model: str
timeout_s: float
temperature: float
num_ctx: int
num_predict: int
top_p: float
def http_post_json(url: str, payload: Dict[str, Any], timeout_s: float) -> Dict[str, Any]:
data = json.dumps(payload).encode("utf-8")
req = request.Request(
url,
data=data,
headers={"Content-Type": "application/json"},
method="POST",
)
with request.urlopen(req, timeout=timeout_s) as resp:
body = resp.read().decode("utf-8")
return json.loads(body)
def strip_markdown(s: str) -> str:
"""Remove common markdown formatting from output."""
# Remove bold/italic markers
s = re.sub(r'\*\*(.+?)\*\*', r'\1', s)
s = re.sub(r'\*(.+?)\*', r'\1', s)
s = re.sub(r'__(.+?)__', r'\1', s)
s = re.sub(r'_(.+?)_', r'\1', s)
# Remove stray asterisks
s = re.sub(r'\*+', '', s)
return s.strip()
def ollama_generate(cfg: OllamaConfig, prompt: str) -> str:
url = cfg.host.rstrip("/") + "/api/generate"
payload = {
"model": cfg.model,
"prompt": prompt,
"stream": False,
"options": {
"temperature": cfg.temperature,
"num_ctx": cfg.num_ctx,
"num_predict": cfg.num_predict,
"top_p": cfg.top_p,
},
}
resp = http_post_json(url, payload, cfg.timeout_s)
out = resp.get("response", "")
# Clean up common LLM artifacts
out = strip_markdown(out)
return out.strip()
def extract_placeholder_names(s: str) -> List[str]:
"""Extract placeholder variable names (not the full braced expression).
For '{name}' returns ['name']
For '{count} {count, plural, =1{hop} other{hops}}' returns ['count']
"""
names = set()
# Get ICU variable names first
for m in ICU_VAR_RE.finditer(s):
names.add(m.group(1))
# Get simple placeholders, but skip if they're inside ICU blocks (text forms like {hop})
# We do this by checking if the name is also an ICU variable - if not, it's a simple placeholder
# unless it looks like a word (ICU text forms are usually short words)
for m in SIMPLE_PLACEHOLDER_RE.finditer(s):
name = m.group(1)
# Check if this appears as a simple {name} placeholder (not inside ICU)
# by looking at what comes after it
full_match = m.group(0)
pos = m.start()
# Look for pattern like {name, plural/select - if found, skip (handled by ICU_VAR_RE)
rest = s[pos:]
if re.match(r"\{\w+\s*,\s*(?:plural|select|selectordinal)", rest, re.IGNORECASE):
continue
# Check if this is likely a text form inside ICU (preceded by =X{ or other{)
before = s[:pos]
if re.search(r"(?:=\d+|zero|one|two|few|many|other)\s*$", before, re.IGNORECASE):
continue # This is a text form like "=1{hop}", skip it
names.add(name)
return sorted(names)
def build_prompt(text: str, target_lang: str, placeholder_names: List[str], has_icu: bool, ask_confidence: bool = False) -> str:
preserve_list = "\n".join(f"- {{{t}}}" for t in placeholder_names) if placeholder_names else "- (none)"
icu_note = ""
if has_icu:
icu_note = (
"ICU FORMAT RULES:\n"
f"- This text uses ICU plural/select format: {{var, plural, =1{{singular}} other{{plural}}}}\n"
"- Keep structure keywords EXACTLY: plural, select, =0, =1, =2, zero, one, two, few, many, other\n"
f"- TRANSLATE the words inside each form to {target_lang}\n"
"- Example: =1{item} other{items} -> translate 'item'/'items' but keep =1{{ }} other{{ }} structure\n\n"
)
if ask_confidence:
return (
f"Translate this UI string to {target_lang}.\n\n"
"RULES:\n"
"- Placeholders like {name}, {count} must appear EXACTLY unchanged.\n"
"- Use infinitive verb forms for buttons (Save, Delete, etc.).\n"
f"- Use natural {target_lang} word order.\n"
"- Keep brand names and technical terms unchanged.\n\n"
f"{icu_note}"
f"Placeholders: {', '.join(f'{{{t}}}' for t in placeholder_names) if placeholder_names else 'none'}\n\n"
f"English: {text}\n\n"
"Respond with EXACTLY two lines:\n"
"1. The translation (no quotes)\n"
"2. Confidence score 1-5 (5=certain, 1=unsure)\n\n"
"Example response:\n"
"Guardar archivo\n"
"5"
)
else:
return (
f"Translate this UI string to {target_lang}. Return ONLY the translation.\n\n"
"RULES:\n"
"- Output the translated text ONLY. No markdown, no quotes, no explanations.\n"
"- Placeholders like {name}, {count} must appear EXACTLY unchanged.\n"
"- Use infinitive verb forms for buttons (Save, Delete, etc.).\n"
f"- Use natural {target_lang} word order.\n"
"- Keep brand names and technical terms unchanged.\n"
"- Translation length should be similar to the original.\n\n"
f"{icu_note}"
f"Placeholders: {', '.join(f'{{{t}}}' for t in placeholder_names) if placeholder_names else 'none'}\n\n"
f"English: {text}\n"
f"{target_lang}:"
)
def parse_confidence_response(response: str) -> Tuple[str, int]:
"""Parse response with translation and confidence score.
Returns (translation, confidence) where confidence is 1-5, or 0 if unparseable.
"""
lines = response.strip().split('\n')
if len(lines) >= 2:
translation = '\n'.join(lines[:-1]).strip() # All but last line
try:
# Try to extract number from last line
last_line = lines[-1].strip()
# Handle formats like "5", "5/5", "Confidence: 5"
match = re.search(r'\b([1-5])\b', last_line)
if match:
confidence = int(match.group(1))
return translation, confidence
except ValueError:
pass
# Fallback: treat whole response as translation with unknown confidence
return strip_markdown(response), 0
def looks_like_translation_failed(src: str, out: str) -> bool:
if not out:
return True
if src.strip() == out.strip() and len(src.strip()) > 8:
return True
# Detect hallucination: output much longer than input (3x+ for short strings, 2x for longer)
src_len = len(src.strip())
out_len = len(out.strip())
if src_len < 50 and out_len > src_len * 3:
return True
if src_len >= 50 and out_len > src_len * 2:
return True
return False
def has_icu_block(s: str) -> bool:
"""Check if string contains ICU plural/select block."""
return bool(ICU_VAR_RE.search(s))
def validate_preserved_tokens(src: str, out: str) -> Tuple[bool, Optional[str]]:
"""Validate that placeholder names are preserved in translation."""
src_names = extract_placeholder_names(src)
# Check each placeholder name appears in output
for name in src_names:
# Look for {name} or {name, plural/select...}
pattern = r"\{" + re.escape(name) + r"(?:\}|\s*,)"
if not re.search(pattern, out):
return False, f"Missing placeholder: {{{name}}}"
# If source has ICU block, output should too
if has_icu_block(src) and not has_icu_block(out):
return False, "ICU plural/select block missing in output"
return True, None
def compute_confidence(src: str, out: str) -> Tuple[float, List[str]]:
"""
Compute confidence score (0.0 to 1.0) for a translation.
Returns (score, list of issues).
"""
issues = []
score = 1.0
src_len = len(src.strip())
out_len = len(out.strip())
# Length ratio check
if src_len > 0:
ratio = out_len / src_len
if ratio < 0.3: # Way too short
score -= 0.4
issues.append("too_short")
elif ratio < 0.5:
score -= 0.2
issues.append("short")
elif ratio > 2.5: # Way too long
score -= 0.4
issues.append("too_long")
elif ratio > 1.8:
score -= 0.2
issues.append("long")
# Contains question mark when source doesn't (model asking questions)
if '?' in out and '?' not in src:
score -= 0.3
issues.append("added_question")
# Contains common LLM artifacts
artifacts = ['```', '**', 'translation:', 'here is', 'certainly', 'i can', 'i\'ll']
out_lower = out.lower()
for artifact in artifacts:
if artifact in out_lower:
score -= 0.3
issues.append(f"artifact:{artifact}")
break
# Output looks like it's in English still (common words)
english_indicators = ['the ', ' is ', ' are ', ' was ', ' were ', ' have ', ' has ', 'you ', ' your ']
english_count = sum(1 for ind in english_indicators if ind in out_lower)
if english_count >= 3 and src_len > 20:
score -= 0.3
issues.append("likely_english")
# Contains newlines when source doesn't
if '\n' in out and '\n' not in src:
score -= 0.2
issues.append("added_newlines")
# ICU/placeholder validation
ok, _ = validate_preserved_tokens(src, out)
if not ok:
score -= 0.3
issues.append("placeholder_error")
return max(0.0, score), issues
# Keys to skip translation (brand names)
SKIP_KEYS = {
"appTitle",
}
# Manual translations for problematic strings (key -> {locale: translation})
MANUAL_TRANSLATIONS: Dict[str, Dict[str, str]] = {
"repeater_daysHoursMinsSecs": {
"es": "{days} días {hours}h {minutes}m {seconds}s",
"fr": "{days} jours {hours}h {minutes}m {seconds}s",
"de": "{days} Tage {hours}h {minutes}m {seconds}s",
"it": "{days} giorni {hours}h {minutes}m {seconds}s",
"pt": "{days} dias {hours}h {minutes}m {seconds}s",
"pl": "{days} dni {hours}h {minutes}m {seconds}s",
"sk": "{days} dní {hours}h {minutes}m {seconds}s",
"sl": "{days} dni {hours}h {minutes}m {seconds}s",
"cs": "{days} dní {hours}h {minutes}m {seconds}s",
"ja": "{days}{hours}時間 {minutes}{seconds}",
"ko": "{days}{hours}시간 {minutes}{seconds}",
"zh": "{days}{hours}小时 {minutes}{seconds}",
"ru": "{days} дней {hours}ч {minutes}м {seconds}с",
"bg": "{days} дни {hours}ч {minutes}м {seconds}с",
"nl": "{days} dagen {hours}u {minutes}m {seconds}s",
"sv": "{days} dagar {hours}t {minutes}m {seconds}s",
},
}
def is_translatable_entry(key: str, value: Any) -> bool:
if key == "@@locale":
return False
if key in SKIP_KEYS:
return False
if key.startswith("@"):
return False
if not isinstance(value, str):
return False
if value.strip() == "":
return False
return True
def translate_one(
key: str,
text: str,
target_lang: str,
cfg: OllamaConfig,
retries: int,
backoff_s: float,
fallback_cfg: Optional[OllamaConfig] = None,
confidence_threshold: float = 0.7,
model_confidence_threshold: int = 4,
ask_model_confidence: bool = True,
) -> Tuple[str, str, Optional[str], bool]:
"""
Translate a single string.
Returns (key, translated_text, error_or_none, used_fallback_model).
"""
placeholder_names = extract_placeholder_names(text)
text_has_icu = has_icu_block(text)
# Ask for confidence if we have a fallback model
should_ask_confidence = ask_model_confidence and fallback_cfg and fallback_cfg.model != cfg.model
prompt = build_prompt(text, target_lang, placeholder_names, text_has_icu, ask_confidence=should_ask_confidence)
used_fallback = False
last_err: Optional[str] = None
for attempt in range(retries + 1):
try:
raw_out = ollama_generate(cfg, prompt)
# Parse confidence if we asked for it
if should_ask_confidence:
out, model_confidence = parse_confidence_response(raw_out)
else:
out = raw_out
model_confidence = 5 # Assume high confidence if not asked
ok, why = validate_preserved_tokens(text, out)
if not ok:
last_err = f"Validation failed: {why}"
# Retry without confidence format for simpler response
prompt = build_prompt(text, target_lang, placeholder_names, text_has_icu, ask_confidence=False)
prompt = (
prompt
+ "\n\nIMPORTANT: You MUST keep every {...} segment exactly unchanged. "
"If you cannot, return the original text unchanged."
)
raise ValueError(last_err)
if looks_like_translation_failed(text, out) and attempt < retries:
last_err = "Output identical/suspicious; retrying"
time.sleep(backoff_s * (attempt + 1))
continue
# Check if model reported low confidence - use fallback
if model_confidence > 0 and model_confidence < model_confidence_threshold and fallback_cfg:
fallback_prompt = build_prompt(text, target_lang, placeholder_names, text_has_icu, ask_confidence=False)
fallback_out = ollama_generate(fallback_cfg, fallback_prompt)
fallback_ok, _ = validate_preserved_tokens(text, fallback_out)
if fallback_ok and not looks_like_translation_failed(text, fallback_out):
return key, fallback_out, None, True
# Also check computed confidence and use fallback model if needed
confidence, issues = compute_confidence(text, out)
if confidence < confidence_threshold and fallback_cfg and fallback_cfg.model != cfg.model:
# Low confidence - try with bigger model
fallback_prompt = build_prompt(text, target_lang, placeholder_names, text_has_icu)
fallback_out = ollama_generate(fallback_cfg, fallback_prompt)
fallback_ok, _ = validate_preserved_tokens(text, fallback_out)
fallback_conf, _ = compute_confidence(text, fallback_out)
if fallback_ok and fallback_conf > confidence:
# Fallback is better
return key, fallback_out, None, True
elif fallback_ok and not ok:
# Original failed validation but fallback passed
return key, fallback_out, None, True
return key, out, None, used_fallback
except Exception as e:
last_err = str(e)
if attempt < retries:
time.sleep(backoff_s * (attempt + 1))
continue
# Last resort: try fallback model
if fallback_cfg and fallback_cfg.model != cfg.model:
try:
fallback_prompt = build_prompt(text, target_lang, placeholder_names, text_has_icu)
fallback_out = ollama_generate(fallback_cfg, fallback_prompt)
fallback_ok, _ = validate_preserved_tokens(text, fallback_out)
if fallback_ok and not looks_like_translation_failed(text, fallback_out):
return key, fallback_out, None, True
except Exception:
pass
return key, text, last_err, False # fallback to original on failure
def fmt_duration(seconds: float) -> str:
if seconds < 60:
return f"{seconds:.1f}s"
m = int(seconds // 60)
s = seconds - 60 * m
if m < 60:
return f"{m}m {s:.0f}s"
h = m // 60
m2 = m % 60
return f"{h}h {m2}m"
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--in", dest="in_path", required=True, help="Input .arb/.json file path")
ap.add_argument("--out", dest="out_path", required=True, help="Output .arb/.json file path")
ap.add_argument("--to-locale", required=True, help="Target locale code, e.g. es, fr, de")
ap.add_argument("--target-lang", default=None, help="Target language name for the model, e.g. Spanish (defaults from locale)")
ap.add_argument("--model", default="gemma3:4b", help="Ollama model name")
ap.add_argument("--fallback-model", default=None, help="Larger model to use for low-confidence translations")
ap.add_argument("--confidence-threshold", type=float, default=0.7, help="Computed confidence threshold to trigger fallback (0.0-1.0)")
ap.add_argument("--model-confidence-threshold", type=int, default=4, help="Model self-reported confidence threshold (1-5, use fallback if below)")
ap.add_argument("--retry-model", default="ministral-3:latest", help="Model to use for end-of-run retries")
ap.add_argument("--host", default="http://localhost:11434", help="Ollama host")
ap.add_argument("--timeout", type=float, default=120.0, help="HTTP timeout seconds")
ap.add_argument("--temperature", type=float, default=0.2, help="Model temperature")
ap.add_argument("--num-ctx", type=int, default=4096, help="Context size")
ap.add_argument("--num-predict", type=int, default=256, help="Max tokens to generate")
ap.add_argument("--top-p", type=float, default=0.9, help="Top-p")
ap.add_argument("--concurrency", type=int, default=4, help="Parallel requests")
ap.add_argument("--retries", type=int, default=2, help="Retries per string")
ap.add_argument("--backoff", type=float, default=0.6, help="Backoff seconds base")
ap.add_argument("--dry-run", action="store_true", help="Do not write file; just print summary")
ap.add_argument("--progress-every", type=int, default=1, help="Print progress every N completed strings (default: 1)")
args = ap.parse_args()
locale_map = {
"es": "Spanish",
"fr": "French",
"de": "German",
"it": "Italian",
"pt": "Portuguese",
"pt-BR": "Brazilian Portuguese",
"ja": "Japanese",
"ko": "Korean",
"zh": "Chinese (Simplified)",
"zh-Hant": "Chinese (Traditional)",
"ru": "Russian",
"uk": "Ukrainian",
"ar": "Arabic",
"hi": "Hindi",
"tr": "Turkish",
"nl": "Dutch",
"sv": "Swedish",
"no": "Norwegian",
"da": "Danish",
"fi": "Finnish",
"pl": "Polish",
"cs": "Czech",
"sk": "Slovak",
"sl": "Slovenian",
"bg": "Bulgarian",
"el": "Greek",
"he": "Hebrew",
"th": "Thai",
"vi": "Vietnamese",
"id": "Indonesian",
}
target_lang = args.target_lang or locale_map.get(args.to_locale, args.to_locale)
try:
with open(args.in_path, "r", encoding="utf-8") as f:
data = json.load(f)
except Exception as e:
print(f"Failed to read input: {e}", file=sys.stderr)
return 2
if not isinstance(data, dict):
print("Input JSON must be an object at top-level.", file=sys.stderr)
return 2
cfg = OllamaConfig(
host=args.host,
model=args.model,
timeout_s=args.timeout,
temperature=args.temperature,
num_ctx=args.num_ctx,
num_predict=args.num_predict,
top_p=args.top_p,
)
# Fallback model for low-confidence translations
fallback_cfg = None
if args.fallback_model:
fallback_cfg = OllamaConfig(
host=args.host,
model=args.fallback_model,
timeout_s=args.timeout,
temperature=args.temperature,
num_ctx=args.num_ctx,
num_predict=args.num_predict,
top_p=args.top_p,
)
out_data: Dict[str, Any] = dict(data)
out_data["@@locale"] = args.to_locale
items: List[Tuple[str, str]] = [(k, v) for k, v in data.items() if is_translatable_entry(k, v)]
# Apply manual translations first
manual_count = 0
items_to_translate: List[Tuple[str, str]] = []
for k, v in items:
if k in MANUAL_TRANSLATIONS and args.to_locale in MANUAL_TRANSLATIONS[k]:
out_data[k] = MANUAL_TRANSLATIONS[k][args.to_locale]
manual_count += 1
else:
items_to_translate.append((k, v))
if manual_count > 0:
print(f"Applied {manual_count} manual translation(s)")
total = len(items_to_translate)
if total == 0 and manual_count == 0:
print("No translatable string entries found (excluding @@locale and @metadata).", file=sys.stderr)
return 1
if total == 0:
print("All strings handled by manual translations.")
else:
fallback_info = f" (fallback: {args.fallback_model})" if args.fallback_model else ""
print(f"Translating {total} strings -> {target_lang} using {cfg.model}{fallback_info} (concurrency={args.concurrency})")
start = time.time()
failures: List[Tuple[str, str]] = []
translated_ok = manual_count # Count manual translations as OK
fallback_used = 0
completed = 0
# Build a lookup for original text by key
items_dict: Dict[str, str] = dict(items_to_translate)
# Submit all tasks up front
if total > 0:
with ThreadPoolExecutor(max_workers=max(1, args.concurrency)) as ex:
future_to_key = {
ex.submit(
translate_one,
key=k,
text=v,
target_lang=target_lang,
cfg=cfg,
retries=args.retries,
backoff_s=args.backoff,
fallback_cfg=fallback_cfg,
confidence_threshold=args.confidence_threshold,
model_confidence_threshold=args.model_confidence_threshold,
ask_model_confidence=bool(args.fallback_model),
): k
for (k, v) in items_to_translate
}
for fut in as_completed(future_to_key):
k, translated, err, used_fallback = fut.result()
out_data[k] = translated
completed += 1
if err:
failures.append((k, err))
status = "FAIL"
else:
translated_ok += 1
if used_fallback:
fallback_used += 1
status = "OK*" # asterisk indicates fallback model was used
else:
status = "OK"
if args.progress_every > 0 and (completed % args.progress_every == 0 or completed == total):
elapsed = time.time() - start
rate = completed / elapsed if elapsed > 0 else 0.0
remaining = (total - completed) / rate if rate > 0 else 0.0
# Keep it single-line friendly but readable.
print(
f"[{completed:>4}/{total}] {status:<4} {k} | "
f"elapsed {fmt_duration(elapsed)} | ETA {fmt_duration(remaining)}"
)
elapsed = time.time() - start
fallback_msg = f", used_fallback_model={fallback_used}" if fallback_used > 0 else ""
print(f"Done in {fmt_duration(elapsed)}. OK={translated_ok}{fallback_msg}, errors={len(failures)}")
# Retry failed translations at the end with increasing temperature
retry_round = 1
max_end_retries = 3
retry_model = args.retry_model
while failures and retry_round <= max_end_retries:
# Increase temperature for each retry round
retry_temp = min(cfg.temperature + (0.2 * retry_round), 1.0)
print(f"\n--- Retry round {retry_round}/{max_end_retries} for {len(failures)} failed key(s) (model={retry_model}, temp={retry_temp:.1f}) ---")
retry_items = [(k, items_dict[k]) for k, _ in failures]
failures = []
retry_completed = 0
retry_total = len(retry_items)
retry_start = time.time()
# Create config with higher temperature (and optionally different model) for retries
retry_cfg = OllamaConfig(
host=cfg.host,
model=retry_model,
timeout_s=cfg.timeout_s,
temperature=retry_temp,
num_ctx=cfg.num_ctx,
num_predict=cfg.num_predict,
top_p=cfg.top_p,
)
with ThreadPoolExecutor(max_workers=max(1, args.concurrency)) as ex:
future_to_key = {
ex.submit(
translate_one,
key=k,
text=v,
target_lang=target_lang,
cfg=retry_cfg,
retries=args.retries,
backoff_s=args.backoff,
): k
for (k, v) in retry_items
}
for fut in as_completed(future_to_key):
k, translated, err, used_fb = fut.result()
out_data[k] = translated
retry_completed += 1
if err:
failures.append((k, err))
status = "FAIL"
else:
translated_ok += 1
status = "OK"
if args.progress_every > 0 and (retry_completed % args.progress_every == 0 or retry_completed == retry_total):
elapsed = time.time() - retry_start
rate = retry_completed / elapsed if elapsed > 0 else 0.0
remaining = (retry_total - retry_completed) / rate if rate > 0 else 0.0
print(
f"[{retry_completed:>4}/{retry_total}] {status:<4} {k} | "
f"elapsed {fmt_duration(elapsed)} | ETA {fmt_duration(remaining)}"
)
retry_elapsed = time.time() - retry_start
print(f"Retry round {retry_round} done in {fmt_duration(retry_elapsed)}. Remaining failures: {len(failures)}")
retry_round += 1
total_elapsed = time.time() - start
print(f"\nTotal time: {fmt_duration(total_elapsed)}. OK={translated_ok}, final fallback={len(failures)}")
if failures:
print("Fallback keys (kept original English due to errors):")
for k, err in failures[:60]:
print(f" - {k}: {err}")
if len(failures) > 60:
print(f" ... and {len(failures) - 60} more")
if args.dry_run:
print("Dry run: not writing output file.")
return 0
try:
with open(args.out_path, "w", encoding="utf-8") as f:
json.dump(out_data, f, ensure_ascii=False, indent=2)
f.write("\n")
except Exception as e:
print(f"Failed to write output: {e}", file=sys.stderr)
return 2
print(f"Wrote: {args.out_path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())