mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-04-20 22:13:48 +00:00
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:
parent
2495cd840f
commit
b2ce82fe7e
64 changed files with 54716 additions and 1254 deletions
5
l10n.yaml
Normal file
5
l10n.yaml
Normal 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
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
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
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
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
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
1339
lib/l10n/app_it.arb
Normal file
File diff suppressed because it is too large
Load diff
4281
lib/l10n/app_localizations.dart
Normal file
4281
lib/l10n/app_localizations.dart
Normal file
File diff suppressed because it is too large
Load diff
2393
lib/l10n/app_localizations_bg.dart
Normal file
2393
lib/l10n/app_localizations_bg.dart
Normal file
File diff suppressed because it is too large
Load diff
2395
lib/l10n/app_localizations_de.dart
Normal file
2395
lib/l10n/app_localizations_de.dart
Normal file
File diff suppressed because it is too large
Load diff
2357
lib/l10n/app_localizations_en.dart
Normal file
2357
lib/l10n/app_localizations_en.dart
Normal file
File diff suppressed because it is too large
Load diff
2389
lib/l10n/app_localizations_es.dart
Normal file
2389
lib/l10n/app_localizations_es.dart
Normal file
File diff suppressed because it is too large
Load diff
2402
lib/l10n/app_localizations_fr.dart
Normal file
2402
lib/l10n/app_localizations_fr.dart
Normal file
File diff suppressed because it is too large
Load diff
2389
lib/l10n/app_localizations_it.dart
Normal file
2389
lib/l10n/app_localizations_it.dart
Normal file
File diff suppressed because it is too large
Load diff
2382
lib/l10n/app_localizations_nl.dart
Normal file
2382
lib/l10n/app_localizations_nl.dart
Normal file
File diff suppressed because it is too large
Load diff
2387
lib/l10n/app_localizations_pl.dart
Normal file
2387
lib/l10n/app_localizations_pl.dart
Normal file
File diff suppressed because it is too large
Load diff
2389
lib/l10n/app_localizations_pt.dart
Normal file
2389
lib/l10n/app_localizations_pt.dart
Normal file
File diff suppressed because it is too large
Load diff
2378
lib/l10n/app_localizations_sk.dart
Normal file
2378
lib/l10n/app_localizations_sk.dart
Normal file
File diff suppressed because it is too large
Load diff
2383
lib/l10n/app_localizations_sl.dart
Normal file
2383
lib/l10n/app_localizations_sl.dart
Normal file
File diff suppressed because it is too large
Load diff
2366
lib/l10n/app_localizations_sv.dart
Normal file
2366
lib/l10n/app_localizations_sv.dart
Normal file
File diff suppressed because it is too large
Load diff
2263
lib/l10n/app_localizations_zh.dart
Normal file
2263
lib/l10n/app_localizations_zh.dart
Normal file
File diff suppressed because it is too large
Load diff
1339
lib/l10n/app_nl.arb
Normal file
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
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
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
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
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
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
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
6
lib/l10n/l10n.dart
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import 'package:flutter/widgets.dart';
|
||||
import 'app_localizations.dart';
|
||||
|
||||
extension LocalizationExtension on BuildContext {
|
||||
AppLocalizations get l10n => AppLocalizations.of(this);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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]),
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
'$privacy • Unread: $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)),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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}) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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]),
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
723
tools/translate.py
Normal 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())
|
||||
Loading…
Add table
Add a link
Reference in a new issue