Add localization support and translation script

- Introduced a new extension for localization in Flutter with `LocalizationExtension` in `l10n.dart`.
- Added a Python script `translate.py` for translating ARB/JSON localization files using a local Ollama model, preserving keys and placeholders, and handling ICU format rules.
This commit is contained in:
zjs81
2026-01-11 17:13:50 -07:00
parent 2495cd840f
commit b2ce82fe7e
64 changed files with 54716 additions and 1254 deletions
+7 -6
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../l10n/l10n.dart';
import '../services/app_debug_log_service.dart';
class AppDebugLogScreen extends StatelessWidget {
@@ -16,11 +17,11 @@ class AppDebugLogScreen extends StatelessWidget {
return Scaffold(
appBar: AppBar(
title: const Text('App Debug Log'),
title: Text(context.l10n.debugLog_appTitle),
centerTitle: true,
actions: [
IconButton(
tooltip: 'Copy log',
tooltip: context.l10n.debugLog_copyLog,
icon: const Icon(Icons.copy),
onPressed: hasEntries
? () async {
@@ -31,13 +32,13 @@ class AppDebugLogScreen extends StatelessWidget {
await Clipboard.setData(ClipboardData(text: text));
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Debug log copied')),
SnackBar(content: Text(context.l10n.debugLog_copied)),
);
}
: null,
),
IconButton(
tooltip: 'Clear log',
tooltip: context.l10n.debugLog_clearLog,
icon: const Icon(Icons.delete_outline),
onPressed: hasEntries
? () {
@@ -76,12 +77,12 @@ class AppDebugLogScreen extends StatelessWidget {
Icon(Icons.bug_report_outlined, size: 64, color: Colors.grey[400]),
const SizedBox(height: 16),
Text(
'No debug logs yet',
context.l10n.debugLog_noEntries,
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
),
const SizedBox(height: 8),
Text(
'Enable app debug logging in settings',
context.l10n.debugLog_enableInSettings,
style: TextStyle(fontSize: 12, color: Colors.grey[500]),
),
],
+218 -89
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart';
import '../l10n/l10n.dart';
import '../services/app_settings_service.dart';
import '../services/notification_service.dart';
import 'map_cache_screen.dart';
@@ -13,7 +14,7 @@ class AppSettingsScreen extends StatelessWidget {
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('App Settings'),
title: Text(context.l10n.appSettings_title),
centerTitle: true,
),
body: SafeArea(
@@ -47,20 +48,28 @@ class AppSettingsScreen extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
'Appearance',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
context.l10n.appSettings_appearance,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
),
ListTile(
leading: const Icon(Icons.brightness_6_outlined),
title: const Text('Theme'),
subtitle: Text(_themeModeLabel(settingsService.settings.themeMode)),
title: Text(context.l10n.appSettings_theme),
subtitle: Text(_themeModeLabel(context, settingsService.settings.themeMode)),
trailing: const Icon(Icons.chevron_right),
onTap: () => _showThemeModeDialog(context, settingsService),
),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.language_outlined),
title: Text(context.l10n.appSettings_language),
subtitle: Text(_languageLabel(context, settingsService.settings.languageOverride)),
trailing: const Icon(Icons.chevron_right),
onTap: () => _showLanguageDialog(context, settingsService),
),
],
),
);
@@ -71,17 +80,17 @@ class AppSettingsScreen extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
'Notifications',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
context.l10n.appSettings_notifications,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
),
SwitchListTile(
secondary: const Icon(Icons.notifications_outlined),
title: const Text('Enable Notifications'),
subtitle: const Text('Receive notifications for messages and adverts'),
title: Text(context.l10n.appSettings_enableNotifications),
subtitle: Text(context.l10n.appSettings_enableNotificationsSubtitle),
value: settingsService.settings.notificationsEnabled,
onChanged: (value) async {
if (value) {
@@ -90,9 +99,9 @@ class AppSettingsScreen extends StatelessWidget {
if (!granted) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Notification permission denied'),
duration: Duration(seconds: 2),
SnackBar(
content: Text(context.l10n.appSettings_notificationPermissionDenied),
duration: const Duration(seconds: 2),
),
);
}
@@ -105,8 +114,8 @@ class AppSettingsScreen extends StatelessWidget {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(value
? 'Notifications enabled'
: 'Notifications disabled'),
? context.l10n.appSettings_notificationsEnabled
: context.l10n.appSettings_notificationsDisabled),
duration: const Duration(seconds: 2),
),
);
@@ -120,13 +129,13 @@ class AppSettingsScreen extends StatelessWidget {
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
),
title: Text(
'Message Notifications',
context.l10n.appSettings_messageNotifications,
style: TextStyle(
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
),
),
subtitle: Text(
'Show notification when receiving new messages',
context.l10n.appSettings_messageNotificationsSubtitle,
style: TextStyle(
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
),
@@ -145,13 +154,13 @@ class AppSettingsScreen extends StatelessWidget {
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
),
title: Text(
'Channel Message Notifications',
context.l10n.appSettings_channelMessageNotifications,
style: TextStyle(
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
),
),
subtitle: Text(
'Show notification when receiving channel messages',
context.l10n.appSettings_channelMessageNotificationsSubtitle,
style: TextStyle(
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
),
@@ -170,13 +179,13 @@ class AppSettingsScreen extends StatelessWidget {
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
),
title: Text(
'Advertisement Notifications',
context.l10n.appSettings_advertisementNotifications,
style: TextStyle(
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
),
),
subtitle: Text(
'Show notification when new nodes are discovered',
context.l10n.appSettings_advertisementNotificationsSubtitle,
style: TextStyle(
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
),
@@ -198,25 +207,25 @@ class AppSettingsScreen extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
'Messaging',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
context.l10n.appSettings_messaging,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
),
SwitchListTile(
secondary: const Icon(Icons.refresh_outlined),
title: const Text('Clear Path on Max Retry'),
subtitle: const Text('Reset contact path after 5 failed send attempts'),
title: Text(context.l10n.appSettings_clearPathOnMaxRetry),
subtitle: Text(context.l10n.appSettings_clearPathOnMaxRetrySubtitle),
value: settingsService.settings.clearPathOnMaxRetry,
onChanged: (value) {
settingsService.setClearPathOnMaxRetry(value);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(value
? 'Paths will be cleared after 5 failed retries'
: 'Paths will not be auto-cleared'),
? context.l10n.appSettings_pathsWillBeCleared
: context.l10n.appSettings_pathsWillNotBeCleared),
duration: const Duration(seconds: 2),
),
);
@@ -225,16 +234,16 @@ class AppSettingsScreen extends StatelessWidget {
const Divider(height: 1),
SwitchListTile(
secondary: const Icon(Icons.alt_route),
title: const Text('Auto Route Rotation'),
subtitle: const Text('Cycle between best paths and flood mode'),
title: Text(context.l10n.appSettings_autoRouteRotation),
subtitle: Text(context.l10n.appSettings_autoRouteRotationSubtitle),
value: settingsService.settings.autoRouteRotationEnabled,
onChanged: (value) {
settingsService.setAutoRouteRotationEnabled(value);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(value
? 'Auto route rotation enabled'
: 'Auto route rotation disabled'),
? context.l10n.appSettings_autoRouteRotationEnabled
: context.l10n.appSettings_autoRouteRotationDisabled),
duration: const Duration(seconds: 2),
),
);
@@ -250,17 +259,17 @@ class AppSettingsScreen extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
'Map Display',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
context.l10n.appSettings_mapDisplay,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
),
SwitchListTile(
secondary: const Icon(Icons.router_outlined),
title: const Text('Show Repeaters'),
subtitle: const Text('Display repeater nodes on the map'),
title: Text(context.l10n.appSettings_showRepeaters),
subtitle: Text(context.l10n.appSettings_showRepeatersSubtitle),
value: settingsService.settings.mapShowRepeaters,
onChanged: (value) {
settingsService.setMapShowRepeaters(value);
@@ -269,8 +278,8 @@ class AppSettingsScreen extends StatelessWidget {
const Divider(height: 1),
SwitchListTile(
secondary: const Icon(Icons.chat_outlined),
title: const Text('Show Chat Nodes'),
subtitle: const Text('Display chat nodes on the map'),
title: Text(context.l10n.appSettings_showChatNodes),
subtitle: Text(context.l10n.appSettings_showChatNodesSubtitle),
value: settingsService.settings.mapShowChatNodes,
onChanged: (value) {
settingsService.setMapShowChatNodes(value);
@@ -279,8 +288,8 @@ class AppSettingsScreen extends StatelessWidget {
const Divider(height: 1),
SwitchListTile(
secondary: const Icon(Icons.people_outline),
title: const Text('Show Other Nodes'),
subtitle: const Text('Display other node types on the map'),
title: Text(context.l10n.appSettings_showOtherNodes),
subtitle: Text(context.l10n.appSettings_showOtherNodesSubtitle),
value: settingsService.settings.mapShowOtherNodes,
onChanged: (value) {
settingsService.setMapShowOtherNodes(value);
@@ -289,11 +298,11 @@ class AppSettingsScreen extends StatelessWidget {
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.timer_outlined),
title: const Text('Time Filter'),
title: Text(context.l10n.appSettings_timeFilter),
subtitle: Text(
settingsService.settings.mapTimeFilterHours == 0
? 'Show all nodes'
: 'Show nodes from last ${settingsService.settings.mapTimeFilterHours.toInt()} hours',
? context.l10n.appSettings_timeFilterShowAll
: context.l10n.appSettings_timeFilterShowLast(settingsService.settings.mapTimeFilterHours.toInt()),
),
trailing: const Icon(Icons.chevron_right),
onTap: () => _showTimeFilterDialog(context, settingsService),
@@ -301,12 +310,14 @@ class AppSettingsScreen extends StatelessWidget {
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.download_outlined),
title: const Text('Offline Map Cache'),
title: Text(context.l10n.appSettings_offlineMapCache),
subtitle: Text(
settingsService.settings.mapCacheBounds == null
? 'No area selected'
: 'Area selected (zoom ${settingsService.settings.mapCacheMinZoom}'
'-${settingsService.settings.mapCacheMaxZoom})',
? context.l10n.appSettings_noAreaSelected
: context.l10n.appSettings_areaSelectedZoom(
settingsService.settings.mapCacheMinZoom,
settingsService.settings.mapCacheMaxZoom,
),
),
trailing: const Icon(Icons.chevron_right),
onTap: () {
@@ -335,20 +346,20 @@ class AppSettingsScreen extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
'Battery',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
context.l10n.appSettings_battery,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
),
ListTile(
leading: const Icon(Icons.battery_full),
title: const Text('Battery Chemistry'),
title: Text(context.l10n.appSettings_batteryChemistry),
subtitle: Text(
isConnected
? 'Set per device (${connector.deviceDisplayName})'
: 'Connect to a device to choose',
? context.l10n.appSettings_batteryChemistryPerDevice(connector.deviceDisplayName)
: context.l10n.appSettings_batteryChemistryConnectFirst,
),
trailing: DropdownButton<String>(
value: selection,
@@ -359,18 +370,18 @@ class AppSettingsScreen extends StatelessWidget {
}
}
: null,
items: const [
items: [
DropdownMenuItem(
value: 'nmc',
child: Text('18650 NMC (3.0-4.2V)'),
child: Text(context.l10n.appSettings_batteryNmc),
),
DropdownMenuItem(
value: 'lifepo4',
child: Text('LiFePO4 (2.6-3.65V)'),
child: Text(context.l10n.appSettings_batteryLifepo4),
),
DropdownMenuItem(
value: 'lipo',
child: Text('LiPo (3.0-4.2V)'),
child: Text(context.l10n.appSettings_batteryLipo),
),
],
),
@@ -384,7 +395,7 @@ class AppSettingsScreen extends StatelessWidget {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Theme'),
title: Text(context.l10n.appSettings_theme),
content: RadioGroup<String>(
groupValue: settingsService.settings.themeMode,
onChanged: (value) {
@@ -397,15 +408,15 @@ class AppSettingsScreen extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
children: [
RadioListTile<String>(
title: const Text('System default'),
title: Text(context.l10n.appSettings_themeSystem),
value: 'system',
),
RadioListTile<String>(
title: const Text('Light'),
title: Text(context.l10n.appSettings_themeLight),
value: 'light',
),
RadioListTile<String>(
title: const Text('Dark'),
title: Text(context.l10n.appSettings_themeDark),
value: 'dark',
),
],
@@ -414,29 +425,147 @@ class AppSettingsScreen extends StatelessWidget {
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
child: Text(context.l10n.common_close),
),
],
),
);
}
String _themeModeLabel(String value) {
String _themeModeLabel(BuildContext context, String value) {
switch (value) {
case 'light':
return 'Light';
return context.l10n.appSettings_themeLight;
case 'dark':
return 'Dark';
return context.l10n.appSettings_themeDark;
default:
return 'System default';
return context.l10n.appSettings_themeSystem;
}
}
String _languageLabel(BuildContext context, String? languageCode) {
switch (languageCode) {
case 'en':
return context.l10n.appSettings_languageEn;
case 'fr':
return context.l10n.appSettings_languageFr;
case 'es':
return context.l10n.appSettings_languageEs;
case 'de':
return context.l10n.appSettings_languageDe;
case 'pl':
return context.l10n.appSettings_languagePl;
case 'sl':
return context.l10n.appSettings_languageSl;
case 'pt':
return context.l10n.appSettings_languagePt;
case 'it':
return context.l10n.appSettings_languageIt;
case 'zh':
return context.l10n.appSettings_languageZh;
case 'sv':
return context.l10n.appSettings_languageSv;
case 'nl':
return context.l10n.appSettings_languageNl;
case 'sk':
return context.l10n.appSettings_languageSk;
case 'bg':
return context.l10n.appSettings_languageBg;
default:
return context.l10n.appSettings_languageSystem;
}
}
void _showLanguageDialog(BuildContext context, AppSettingsService settingsService) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(context.l10n.appSettings_language),
content: SingleChildScrollView(
child: RadioGroup<String?>(
groupValue: settingsService.settings.languageOverride,
onChanged: (value) {
settingsService.setLanguageOverride(value);
Navigator.pop(context);
},
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
RadioListTile<String?>(
title: Text(context.l10n.appSettings_languageSystem),
value: null,
),
RadioListTile<String?>(
title: Text(context.l10n.appSettings_languageEn),
value: 'en',
),
RadioListTile<String?>(
title: Text(context.l10n.appSettings_languageFr),
value: 'fr',
),
RadioListTile<String?>(
title: Text(context.l10n.appSettings_languageEs),
value: 'es',
),
RadioListTile<String?>(
title: Text(context.l10n.appSettings_languageDe),
value: 'de',
),
RadioListTile<String?>(
title: Text(context.l10n.appSettings_languagePl),
value: 'pl',
),
RadioListTile<String?>(
title: Text(context.l10n.appSettings_languageSl),
value: 'sl',
),
RadioListTile<String?>(
title: Text(context.l10n.appSettings_languagePt),
value: 'pt',
),
RadioListTile<String?>(
title: Text(context.l10n.appSettings_languageIt),
value: 'it',
),
RadioListTile<String?>(
title: Text(context.l10n.appSettings_languageZh),
value: 'zh',
),
RadioListTile<String?>(
title: Text(context.l10n.appSettings_languageSv),
value: 'sv',
),
RadioListTile<String?>(
title: Text(context.l10n.appSettings_languageNl),
value: 'nl',
),
RadioListTile<String?>(
title: Text(context.l10n.appSettings_languageSk),
value: 'sk',
),
RadioListTile<String?>(
title: Text(context.l10n.appSettings_languageBg),
value: 'bg',
),
],
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(context.l10n.common_close),
),
],
),
);
}
void _showTimeFilterDialog(BuildContext context, AppSettingsService settingsService) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Map Time Filter'),
title: Text(context.l10n.appSettings_mapTimeFilter),
content: RadioGroup<double>(
groupValue: settingsService.settings.mapTimeFilterHours,
onChanged: (value) {
@@ -448,34 +577,34 @@ class AppSettingsScreen extends StatelessWidget {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('Show nodes discovered within:'),
Text(context.l10n.appSettings_showNodesDiscoveredWithin),
const SizedBox(height: 16),
ListTile(
title: const Text('All time'),
title: Text(context.l10n.appSettings_allTime),
leading: Radio<double>(
value: 0,
),
),
ListTile(
title: const Text('Last hour'),
title: Text(context.l10n.appSettings_lastHour),
leading: Radio<double>(
value: 1,
),
),
ListTile(
title: const Text('Last 6 hours'),
title: Text(context.l10n.appSettings_last6Hours),
leading: Radio<double>(
value: 6,
),
),
ListTile(
title: const Text('Last 24 hours'),
title: Text(context.l10n.appSettings_last24Hours),
leading: Radio<double>(
value: 24,
),
),
ListTile(
title: const Text('Last week'),
title: Text(context.l10n.appSettings_lastWeek),
leading: Radio<double>(
value: 168,
),
@@ -486,7 +615,7 @@ class AppSettingsScreen extends StatelessWidget {
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
child: Text(context.l10n.common_close),
),
],
),
@@ -498,17 +627,17 @@ class AppSettingsScreen extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
'Debug',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
context.l10n.appSettings_debugCard,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
),
SwitchListTile(
secondary: const Icon(Icons.bug_report_outlined),
title: const Text('App Debug Logging'),
subtitle: const Text('Log app debug messages for troubleshooting'),
title: Text(context.l10n.appSettings_appDebugLogging),
subtitle: Text(context.l10n.appSettings_appDebugLoggingSubtitle),
value: settingsService.settings.appDebugLogEnabled,
onChanged: (value) async {
await settingsService.setAppDebugLogEnabled(value);
@@ -516,8 +645,8 @@ class AppSettingsScreen extends StatelessWidget {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(value
? 'App debug logging enabled'
: 'App debug logging disabled'),
? context.l10n.appSettings_appDebugLoggingEnabled
: context.l10n.appSettings_appDebugLoggingDisabled),
duration: const Duration(seconds: 2),
),
);
+11 -10
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:flutter/services.dart';
import '../l10n/l10n.dart';
import '../services/ble_debug_log_service.dart';
import '../connector/meshcore_protocol.dart';
@@ -26,10 +27,10 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
final hasEntries = showingFrames ? entries.isNotEmpty : rawEntries.isNotEmpty;
return Scaffold(
appBar: AppBar(
title: const Text('BLE Debug Log'),
title: Text(context.l10n.debugLog_bleTitle),
actions: [
IconButton(
tooltip: 'Copy log',
tooltip: context.l10n.debugLog_copyLog,
icon: const Icon(Icons.copy),
onPressed: hasEntries
? () async {
@@ -43,13 +44,13 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
await Clipboard.setData(ClipboardData(text: text));
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('BLE log copied')),
SnackBar(content: Text(context.l10n.debugLog_bleCopied)),
);
}
: null,
),
IconButton(
tooltip: 'Clear log',
tooltip: context.l10n.debugLog_clearLog,
icon: const Icon(Icons.delete_outline),
onPressed: hasEntries
? () {
@@ -66,9 +67,9 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: SegmentedButton<_BleLogView>(
segments: const [
ButtonSegment(value: _BleLogView.frames, label: Text('Frames')),
ButtonSegment(value: _BleLogView.rawLogRx, label: Text('Raw Log-RX')),
segments: [
ButtonSegment(value: _BleLogView.frames, label: Text(context.l10n.debugLog_frames)),
ButtonSegment(value: _BleLogView.rawLogRx, label: Text(context.l10n.debugLog_rawLogRx)),
],
selected: {_view},
onSelectionChanged: (selection) {
@@ -113,8 +114,8 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
);
},
)
: const Center(
child: Text('No BLE activity yet'),
: Center(
child: Text(context.l10n.debugLog_noBleActivity),
),
),
],
@@ -136,7 +137,7 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
child: Text(context.l10n.common_close),
),
],
),
+23 -22
View File
@@ -9,6 +9,7 @@ import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart';
import '../helpers/utf8_length_limiter.dart';
import '../l10n/l10n.dart';
import '../models/channel.dart';
import '../models/channel_message.dart';
import '../utils/emoji_utils.dart';
@@ -84,9 +85,9 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
final key = _messageKeys[messageId];
if (key == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Original message not found'),
duration: Duration(seconds: 2),
SnackBar(
content: Text(context.l10n.chat_originalMessageNotFound),
duration: const Duration(seconds: 2),
),
);
return;
@@ -120,7 +121,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
children: [
Text(
widget.channel.name.isEmpty
? 'Channel ${widget.channel.index}'
? context.l10n.channels_channelIndex(widget.channel.index)
: widget.channel.name,
style: const TextStyle(fontSize: 16),
),
@@ -128,9 +129,9 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
builder: (context, connector, _) {
final unreadCount =
connector.getUnreadCountForChannelIndex(widget.channel.index);
final privacy = widget.channel.isPublicChannel ? 'Public' : 'Private';
final privacy = widget.channel.isPublicChannel ? context.l10n.channels_public : context.l10n.channels_private;
return Text(
'$privacyUnread: $unreadCount',
'$privacy${context.l10n.chat_unread(unreadCount)}',
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 12),
);
@@ -170,7 +171,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
),
const SizedBox(height: 16),
Text(
'No messages yet',
context.l10n.chat_noMessages,
style: TextStyle(
fontSize: 16,
color: Colors.grey[600],
@@ -178,7 +179,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
),
const SizedBox(height: 8),
Text(
'Send a message to get started',
context.l10n.chat_sendMessageToStart,
style: TextStyle(
fontSize: 14,
color: Colors.grey[500],
@@ -372,7 +373,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
children: [
Icon(Icons.location_on_outlined, size: 14, color: previewTextColor),
const SizedBox(width: 4),
Text('Location', style: TextStyle(fontSize: 12, color: previewTextColor)),
Text(context.l10n.chat_location, style: TextStyle(fontSize: 12, color: previewTextColor)),
],
);
} else {
@@ -406,7 +407,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Reply to ${message.replyToSenderName}',
context.l10n.chat_replyTo(message.replyToSenderName ?? ''),
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
@@ -515,7 +516,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'POI Shared',
context.l10n.chat_poiShared,
style: TextStyle(
color: textColor,
fontWeight: FontWeight.w600,
@@ -623,7 +624,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Replying to ${message.senderName}',
context.l10n.chat_replyingTo(message.senderName),
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
@@ -678,7 +679,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
IconButton(
icon: const Icon(Icons.gif_box),
onPressed: () => _showGifPicker(context),
tooltip: 'Send GIF',
tooltip: context.l10n.chat_sendGif,
),
Expanded(
child: ValueListenableBuilder<TextEditingValue>(
@@ -714,7 +715,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
Utf8LengthLimitingTextInputFormatter(maxBytes),
],
decoration: InputDecoration(
hintText: 'Type a message...',
hintText: context.l10n.chat_typeMessage,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(24),
),
@@ -757,7 +758,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
final maxBytes = maxChannelMessageBytes(connector.selfName);
if (utf8.encode(messageText).length > maxBytes) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Message too long (max $maxBytes bytes).')),
SnackBar(content: Text(context.l10n.chat_messageTooLong(maxBytes))),
);
return;
}
@@ -796,7 +797,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
children: [
ListTile(
leading: const Icon(Icons.reply),
title: const Text('Reply'),
title: Text(context.l10n.chat_reply),
onTap: () {
Navigator.pop(sheetContext);
_setReplyingTo(message);
@@ -804,7 +805,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
),
ListTile(
leading: const Icon(Icons.add_reaction_outlined),
title: const Text('Add Reaction'),
title: Text(context.l10n.chat_addReaction),
onTap: () {
Navigator.pop(sheetContext);
_showEmojiPicker(message);
@@ -812,7 +813,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
),
ListTile(
leading: const Icon(Icons.copy),
title: const Text('Copy'),
title: Text(context.l10n.common_copy),
onTap: () {
Navigator.pop(sheetContext);
_copyMessageText(message.text);
@@ -820,7 +821,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
),
ListTile(
leading: const Icon(Icons.delete_outline),
title: const Text('Delete'),
title: Text(context.l10n.common_delete),
onTap: () async {
Navigator.pop(sheetContext);
await _deleteMessage(message);
@@ -828,7 +829,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
),
ListTile(
leading: const Icon(Icons.close),
title: const Text('Cancel'),
title: Text(context.l10n.common_cancel),
onTap: () => Navigator.pop(sheetContext),
),
],
@@ -860,7 +861,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
void _copyMessageText(String text) {
Clipboard.setData(ClipboardData(text: text));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Message copied')),
SnackBar(content: Text(context.l10n.chat_messageCopied)),
);
}
@@ -868,7 +869,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
await context.read<MeshCoreConnector>().deleteChannelMessage(message);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Message deleted')),
SnackBar(content: Text(context.l10n.chat_messageDeleted)),
);
}
+86 -57
View File
@@ -9,6 +9,8 @@ import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart';
import '../services/map_tile_cache_service.dart';
import '../connector/meshcore_protocol.dart';
import '../l10n/app_localizations.dart';
import '../l10n/l10n.dart';
import '../models/channel_message.dart';
import '../models/contact.dart';
@@ -24,22 +26,24 @@ class ChannelMessagePathScreen extends StatelessWidget {
Widget build(BuildContext context) {
return Consumer<MeshCoreConnector>(
builder: (context, connector, _) {
final l10n = context.l10n;
final primaryPath = _selectPrimaryPath(message.pathBytes, message.pathVariants);
final hops = _buildPathHops(primaryPath, connector.contacts);
final hops = _buildPathHops(primaryPath, connector.contacts, l10n);
final hasHopDetails = primaryPath.isNotEmpty;
final observedLabel = _formatObservedHops(
primaryPath.length,
message.pathLength,
l10n,
);
final extraPaths = _otherPaths(primaryPath, message.pathVariants);
return Scaffold(
appBar: AppBar(
title: const Text('Packet Path'),
title: Text(l10n.channelPath_title),
actions: [
IconButton(
icon: const Icon(Icons.map_outlined),
tooltip: 'View map',
tooltip: l10n.channelPath_viewMap,
onPressed: hasHopDetails
? () {
_openPathMap(context);
@@ -57,7 +61,7 @@ class ChannelMessagePathScreen extends StatelessWidget {
const SizedBox(height: 16),
if (extraPaths.isNotEmpty) ...[
Text(
'Other Observed Paths',
l10n.channelPath_otherObservedPaths,
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
@@ -65,17 +69,17 @@ class ChannelMessagePathScreen extends StatelessWidget {
const SizedBox(height: 16),
],
Text(
'Repeater Hops',
l10n.channelPath_repeaterHops,
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
if (!hasHopDetails)
const Text(
'Hop details are not provided for this packet.',
style: TextStyle(color: Colors.grey),
Text(
l10n.channelPath_noHopDetails,
style: const TextStyle(color: Colors.grey),
)
else
..._buildHopTiles(hops),
..._buildHopTiles(context, hops),
],
),
),
@@ -88,6 +92,7 @@ class ChannelMessagePathScreen extends StatelessWidget {
BuildContext context, {
String? observedLabel,
}) {
final l10n = context.l10n;
return Card(
child: Padding(
padding: const EdgeInsets.all(12),
@@ -95,16 +100,16 @@ class ChannelMessagePathScreen extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Message Details',
l10n.channelPath_messageDetails,
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
_buildDetailRow('Sender', message.senderName),
_buildDetailRow('Time', _formatTime(message.timestamp)),
_buildDetailRow(l10n.channelPath_senderLabel, message.senderName),
_buildDetailRow(l10n.channelPath_timeLabel, _formatTime(message.timestamp, l10n)),
if (message.repeatCount > 0)
_buildDetailRow('Repeats', message.repeatCount.toString()),
_buildDetailRow('Path', _formatPathLabel(message.pathLength)),
if (observedLabel != null) _buildDetailRow('Observed', observedLabel),
_buildDetailRow(l10n.channelPath_repeatsLabel, message.repeatCount.toString()),
_buildDetailRow(l10n.channelPath_pathLabelTitle, _formatPathLabel(message.pathLength, l10n)),
if (observedLabel != null) _buildDetailRow(l10n.channelPath_observedLabel, observedLabel),
],
),
),
@@ -115,6 +120,7 @@ class ChannelMessagePathScreen extends StatelessWidget {
BuildContext context,
List<Uint8List> variants,
) {
final l10n = context.l10n;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -124,7 +130,10 @@ class ChannelMessagePathScreen extends StatelessWidget {
child: ListTile(
dense: true,
title: Text(
'Observed path ${i + 1}${_formatHopCount(variants[i].length)}',
l10n.channelPath_observedPathTitle(
i + 1,
_formatHopCount(variants[i].length, l10n),
),
),
subtitle: Text(_formatPathPrefixes(variants[i])),
trailing: const Icon(Icons.map_outlined, size: 20),
@@ -135,7 +144,8 @@ class ChannelMessagePathScreen extends StatelessWidget {
);
}
List<Widget> _buildHopTiles(List<_PathHop> hops) {
List<Widget> _buildHopTiles(BuildContext context, List<_PathHop> hops) {
final l10n = context.l10n;
return [
for (final hop in hops)
Card(
@@ -154,45 +164,52 @@ class ChannelMessagePathScreen extends StatelessWidget {
hop.hasLocation
? '${hop.position!.latitude.toStringAsFixed(5)}, '
'${hop.position!.longitude.toStringAsFixed(5)}'
: 'No location data',
: l10n.channelPath_noLocationData,
),
),
),
];
}
String _formatTime(DateTime time) {
String _formatTime(DateTime time, AppLocalizations l10n) {
final now = DateTime.now();
final diff = now.difference(time);
if (diff.inDays > 0) {
return '${time.day}/${time.month} '
final timeLabel =
'${time.hour}:${time.minute.toString().padLeft(2, '0')}';
return l10n.channelPath_timeWithDate(time.day, time.month, timeLabel);
}
return '${time.hour}:${time.minute.toString().padLeft(2, '0')}';
return l10n.channelPath_timeOnly(
'${time.hour}:${time.minute.toString().padLeft(2, '0')}',
);
}
String _formatPathLabel(int? pathLength) {
if (pathLength == null) return 'Unknown';
if (pathLength < 0) return 'Flood';
if (pathLength == 0) return 'Direct';
return '$pathLength hops';
String _formatPathLabel(int? pathLength, AppLocalizations l10n) {
if (pathLength == null) return l10n.channelPath_unknownPath;
if (pathLength < 0) return l10n.channelPath_floodPath;
if (pathLength == 0) return l10n.channelPath_directPath;
return l10n.chat_hopsCount(pathLength);
}
String? _formatObservedHops(int observedCount, int? pathLength) {
String? _formatObservedHops(
int observedCount,
int? pathLength,
AppLocalizations l10n,
) {
if (observedCount <= 0 && (pathLength == null || pathLength <= 0)) {
return null;
}
if (pathLength == null || pathLength < 0) {
return observedCount > 0 ? '$observedCount hops' : null;
return observedCount > 0 ? l10n.chat_hopsCount(observedCount) : null;
}
if (observedCount == 0) {
return '0 of $pathLength hops';
return l10n.channelPath_observedZeroOf(pathLength);
}
if (observedCount == pathLength) {
return '$observedCount hops';
return l10n.chat_hopsCount(observedCount);
}
return '$observedCount of $pathLength hops';
return l10n.channelPath_observedSomeOf(observedCount, pathLength);
}
Widget _buildDetailRow(String label, String value) {
@@ -274,7 +291,7 @@ class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScree
primaryPath,
);
final selectedIndex = _indexForPath(selectedPath, observedPaths);
final hops = _buildPathHops(selectedPath, connector.contacts);
final hops = _buildPathHops(selectedPath, connector.contacts, context.l10n);
final points = hops
.where((hop) => hop.hasLocation)
.map((hop) => hop.position!)
@@ -297,7 +314,7 @@ class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScree
return Scaffold(
appBar: AppBar(
title: const Text('Path Map'),
title: Text(context.l10n.channelPath_mapTitle),
),
body: SafeArea(
top: false,
@@ -347,9 +364,9 @@ class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScree
Center(
child: Card(
color: Colors.white.withValues(alpha: 0.9),
child: const Padding(
child: Padding(
padding: EdgeInsets.all(12),
child: Text('No repeater locations available for this path.'),
child: Text(context.l10n.channelPath_noRepeaterLocations),
),
),
),
@@ -368,10 +385,11 @@ class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScree
int selectedIndex,
ValueChanged<int> onSelected,
) {
final l10n = context.l10n;
final selectedPath = paths[selectedIndex];
final label = selectedPath.isPrimary
? 'Path ${selectedIndex + 1} (Primary)'
: 'Path ${selectedIndex + 1}';
? l10n.channelPath_primaryPath(selectedIndex + 1)
: l10n.channelPath_pathLabel(selectedIndex + 1);
return Positioned(
left: 16,
right: 16,
@@ -383,9 +401,9 @@ class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScree
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Observed Path',
style: TextStyle(fontWeight: FontWeight.w600),
Text(
l10n.channelPath_observedPathHeader,
style: const TextStyle(fontWeight: FontWeight.w600),
),
const SizedBox(height: 4),
DropdownButtonHideUnderline(
@@ -397,8 +415,8 @@ class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScree
DropdownMenuItem(
value: i,
child: Text(
'${paths[i].isPrimary ? 'Path ${i + 1} (Primary)' : 'Path ${i + 1}'}'
'${_formatHopCount(paths[i].pathBytes.length)}',
'${paths[i].isPrimary ? l10n.channelPath_primaryPath(i + 1) : l10n.channelPath_pathLabel(i + 1)}'
'${_formatHopCount(paths[i].pathBytes.length, l10n)}',
),
),
],
@@ -410,7 +428,10 @@ class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScree
),
const SizedBox(height: 4),
Text(
'$label${_formatPathPrefixes(selectedPath.pathBytes)}',
l10n.channelPath_selectedPathLabel(
label,
_formatPathPrefixes(selectedPath.pathBytes),
),
style: TextStyle(color: Colors.grey[700], fontSize: 12),
),
],
@@ -457,6 +478,7 @@ class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScree
}
Widget _buildLegendCard(BuildContext context, List<_PathHop> hops) {
final l10n = context.l10n;
final maxHeight = MediaQuery.of(context).size.height * 0.35;
final estimatedHeight = 72.0 + (hops.length * 56.0);
final cardHeight = max(96.0, min(maxHeight, estimatedHeight));
@@ -471,18 +493,18 @@ class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScree
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.all(12),
Padding(
padding: const EdgeInsets.all(12),
child: Text(
'Repeater Hops',
style: TextStyle(fontWeight: FontWeight.w600),
l10n.channelPath_repeaterHops,
style: const TextStyle(fontWeight: FontWeight.w600),
),
),
const Divider(height: 1),
Expanded(
child: hops.isEmpty
? const Center(
child: Text('No hop details available for this packet.'),
? Center(
child: Text(l10n.channelPath_noHopDetailsAvailable),
)
: ListView.separated(
padding: const EdgeInsets.symmetric(vertical: 4),
@@ -504,7 +526,7 @@ class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScree
hop.hasLocation
? '${hop.position!.latitude.toStringAsFixed(5)}, '
'${hop.position!.longitude.toStringAsFixed(5)}'
: 'No location data',
: l10n.channelPath_noLocationData,
),
);
},
@@ -523,19 +545,21 @@ class _PathHop {
final int prefix;
final Contact? contact;
final LatLng? position;
final AppLocalizations l10n;
const _PathHop({
required this.index,
required this.prefix,
required this.contact,
required this.position,
required this.l10n,
});
bool get hasLocation => position != null;
String get displayLabel {
final prefixLabel = _formatPrefix(prefix);
return '($prefixLabel) ${_resolveName(contact)}';
return '($prefixLabel) ${_resolveName(contact, l10n)}';
}
}
@@ -549,7 +573,11 @@ class _ObservedPath {
});
}
List<_PathHop> _buildPathHops(Uint8List pathBytes, List<Contact> contacts) {
List<_PathHop> _buildPathHops(
Uint8List pathBytes,
List<Contact> contacts,
AppLocalizations l10n,
) {
final hops = <_PathHop>[];
for (var i = 0; i < pathBytes.length; i++) {
final prefix = pathBytes[i];
@@ -560,6 +588,7 @@ List<_PathHop> _buildPathHops(Uint8List pathBytes, List<Contact> contacts) {
prefix: prefix,
contact: contact,
position: _resolvePosition(contact),
l10n: l10n,
),
);
}
@@ -612,15 +641,15 @@ String _formatPathPrefixes(Uint8List pathBytes) {
.join(',');
}
String _formatHopCount(int count) {
return '$count ${count == 1 ? 'hop' : 'hops'}';
String _formatHopCount(int count, AppLocalizations l10n) {
return l10n.chat_hopsCount(count);
}
String _resolveName(Contact? contact) {
if (contact == null) return 'Unknown Repeater';
String _resolveName(Contact? contact, AppLocalizations l10n) {
if (contact == null) return l10n.channelPath_unknownRepeater;
final name = contact.name.trim();
if (name.isEmpty || name.toLowerCase() == 'unknown') {
return 'Unknown Repeater';
return l10n.channelPath_unknownRepeater;
}
return name;
}
+69 -67
View File
@@ -6,6 +6,7 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart';
import '../l10n/l10n.dart';
import '../models/channel.dart';
import '../utils/dialog_utils.dart';
import '../utils/disconnect_navigation_mixin.dart';
@@ -77,18 +78,18 @@ class _ChannelsScreenState extends State<ChannelsScreen>
child: Scaffold(
appBar: AppBar(
leading: BatteryIndicator(connector: connector),
title: const Text('Channels'),
title: Text(context.l10n.channels_title),
centerTitle: true,
automaticallyImplyLeading: false,
actions: [
IconButton(
icon: const Icon(Icons.bluetooth_disabled),
tooltip: 'Disconnect',
tooltip: context.l10n.common_disconnect,
onPressed: () => _disconnect(context),
),
IconButton(
icon: const Icon(Icons.tune),
tooltip: 'Settings',
tooltip: context.l10n.common_settings,
onPressed: () => Navigator.push(
context,
MaterialPageRoute(builder: (context) => const SettingsScreen()),
@@ -114,11 +115,11 @@ class _ChannelsScreenState extends State<ChannelsScreen>
height: MediaQuery.of(context).size.height - 200,
child: EmptyState(
icon: Icons.tag,
title: 'No channels configured',
title: context.l10n.channels_noChannelsConfigured,
action: FilledButton.icon(
onPressed: () => _addPublicChannel(context, connector),
icon: const Icon(Icons.public),
label: const Text('Add Public Channel'),
label: Text(context.l10n.channels_addPublicChannel),
),
),
),
@@ -135,7 +136,7 @@ class _ChannelsScreenState extends State<ChannelsScreen>
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Search channels...',
hintText: context.l10n.channels_searchChannels,
prefixIcon: const Icon(Icons.search),
suffixIcon: Row(
mainAxisSize: MainAxisSize.min,
@@ -183,7 +184,7 @@ class _ChannelsScreenState extends State<ChannelsScreen>
Icon(Icons.search_off, size: 64, color: Colors.grey[400]),
const SizedBox(height: 16),
Text(
'No channels found',
context.l10n.channels_noChannelsFound,
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
),
],
@@ -289,15 +290,15 @@ class _ChannelsScreenState extends State<ChannelsScreen>
),
),
title: Text(
channel.name.isEmpty ? 'Channel ${channel.index}' : channel.name,
channel.name.isEmpty ? context.l10n.channels_channelIndex(channel.index) : channel.name,
style: const TextStyle(fontWeight: FontWeight.w500),
),
subtitle: Text(
channel.name.startsWith('#')
? 'Hashtag channel'
? context.l10n.channels_hashtagChannel
: channel.isPublicChannel
? 'Public channel'
: 'Private channel',
? context.l10n.channels_publicChannel
: context.l10n.channels_privateChannel,
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
@@ -346,7 +347,7 @@ class _ChannelsScreenState extends State<ChannelsScreen>
children: [
ListTile(
leading: const Icon(Icons.edit_outlined),
title: const Text('Edit channel'),
title: Text(context.l10n.channels_editChannel),
onTap: () async {
Navigator.pop(context);
await Future.delayed(const Duration(milliseconds: 100));
@@ -357,7 +358,7 @@ class _ChannelsScreenState extends State<ChannelsScreen>
),
ListTile(
leading: const Icon(Icons.delete_outline, color: Colors.red),
title: const Text('Delete channel', style: TextStyle(color: Colors.red)),
title: Text(context.l10n.channels_deleteChannel, style: const TextStyle(color: Colors.red)),
onTap: () async {
Navigator.pop(context);
await Future.delayed(const Duration(milliseconds: 100));
@@ -406,28 +407,29 @@ class _ChannelsScreenState extends State<ChannelsScreen>
const actionSortUnread = 3;
return SortFilterMenu(
tooltip: context.l10n.listFilter_tooltip,
sections: [
SortFilterMenuSection(
title: 'Sort by',
title: context.l10n.channels_sortBy,
options: [
SortFilterMenuOption(
value: actionSortManual,
label: 'Manual',
label: context.l10n.channels_sortManual,
checked: _sortOption == ChannelSortOption.manual,
),
SortFilterMenuOption(
value: actionSortName,
label: 'A-Z',
label: context.l10n.channels_sortAZ,
checked: _sortOption == ChannelSortOption.name,
),
SortFilterMenuOption(
value: actionSortLatest,
label: 'Latest messages',
label: context.l10n.channels_sortLatestMessages,
checked: _sortOption == ChannelSortOption.latestMessages,
),
SortFilterMenuOption(
value: actionSortUnread,
label: 'Unread',
label: context.l10n.channels_sortUnread,
checked: _sortOption == ChannelSortOption.unread,
),
],
@@ -503,7 +505,7 @@ class _ChannelsScreenState extends State<ChannelsScreen>
}
String _normalizeChannelName(Channel channel) {
if (channel.name.isEmpty) return 'Channel ${channel.index}';
if (channel.name.isEmpty) return 'Channel ${channel.index}'; // Fallback for sorting
final trimmed = channel.name.trim();
if (trimmed.startsWith('#') && trimmed.length > 1) {
return trimmed.substring(1);
@@ -521,9 +523,9 @@ class _ChannelsScreenState extends State<ChannelsScreen>
showDialog(
context: context,
builder: (context) => StatefulBuilder(
builder: (context, setDialogState) => AlertDialog(
title: const Text('Add Channel'),
builder: (dialogContext) => StatefulBuilder(
builder: (dialogContext, setDialogState) => AlertDialog(
title: Text(dialogContext.l10n.channels_addChannel),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
@@ -531,14 +533,14 @@ class _ChannelsScreenState extends State<ChannelsScreen>
children: [
DropdownButtonFormField<int>(
initialValue: selectedIndex,
decoration: const InputDecoration(
labelText: 'Channel Index',
border: OutlineInputBorder(),
decoration: InputDecoration(
labelText: dialogContext.l10n.channels_channelIndexLabel,
border: const OutlineInputBorder(),
),
items: List.generate(maxChannels, (i) => i)
.map((i) => DropdownMenuItem(
value: i,
child: Text('Channel $i'),
child: Text(dialogContext.l10n.channels_channelIndex(i)),
))
.toList(),
onChanged: (value) {
@@ -550,16 +552,16 @@ class _ChannelsScreenState extends State<ChannelsScreen>
const SizedBox(height: 16),
TextField(
controller: nameController,
decoration: const InputDecoration(
labelText: 'Channel Name',
border: OutlineInputBorder(),
decoration: InputDecoration(
labelText: dialogContext.l10n.channels_channelName,
border: const OutlineInputBorder(),
),
maxLength: 31,
),
const SizedBox(height: 8),
CheckboxListTile(
title: const Text('Use Public Channel'),
subtitle: const Text('Standard public PSK'),
title: Text(dialogContext.l10n.channels_usePublicChannel),
subtitle: Text(dialogContext.l10n.channels_standardPublicPsk),
value: usePublicPsk,
onChanged: (value) {
setDialogState(() {
@@ -578,11 +580,11 @@ class _ChannelsScreenState extends State<ChannelsScreen>
TextField(
controller: pskController,
decoration: InputDecoration(
labelText: 'PSK (Hex)',
labelText: dialogContext.l10n.channels_pskHex,
border: const OutlineInputBorder(),
suffixIcon: IconButton(
icon: const Icon(Icons.casino),
tooltip: 'Generate random PSK',
tooltip: dialogContext.l10n.channels_generateRandomPsk,
onPressed: () {
final random = Random.secure();
final bytes = Uint8List(16);
@@ -600,8 +602,8 @@ class _ChannelsScreenState extends State<ChannelsScreen>
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
onPressed: () => Navigator.pop(dialogContext),
child: Text(dialogContext.l10n.common_cancel),
),
FilledButton(
onPressed: () {
@@ -611,8 +613,8 @@ class _ChannelsScreenState extends State<ChannelsScreen>
: pskController.text.trim();
if (name.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Please enter a channel name')),
ScaffoldMessenger.of(dialogContext).showSnackBar(
SnackBar(content: Text(dialogContext.l10n.channels_enterChannelName)),
);
return;
}
@@ -621,21 +623,21 @@ class _ChannelsScreenState extends State<ChannelsScreen>
try {
psk = Channel.parsePskHex(pskHex);
} on FormatException {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('PSK must be 32 hex characters')),
ScaffoldMessenger.of(dialogContext).showSnackBar(
SnackBar(content: Text(dialogContext.l10n.channels_pskMustBe32Hex)),
);
return;
}
Navigator.pop(context);
Navigator.pop(dialogContext);
connector.setChannel(selectedIndex, name, psk);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Channel "$name" added')),
SnackBar(content: Text(context.l10n.channels_channelAdded(name))),
);
}
},
child: const Text('Add'),
child: Text(dialogContext.l10n.common_add),
),
],
),
@@ -654,18 +656,18 @@ class _ChannelsScreenState extends State<ChannelsScreen>
showDialog(
context: context,
builder: (context) => StatefulBuilder(
builder: (context, setState) => AlertDialog(
title: Text('Edit Channel ${channel.index}'),
builder: (dialogContext) => StatefulBuilder(
builder: (dialogContext, setState) => AlertDialog(
title: Text(dialogContext.l10n.channels_editChannelTitle(channel.index)),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: nameController,
decoration: const InputDecoration(
labelText: 'Channel Name',
border: OutlineInputBorder(),
decoration: InputDecoration(
labelText: dialogContext.l10n.channels_channelName,
border: const OutlineInputBorder(),
),
maxLength: 31,
),
@@ -673,11 +675,11 @@ class _ChannelsScreenState extends State<ChannelsScreen>
TextField(
controller: pskController,
decoration: InputDecoration(
labelText: 'PSK (Hex)',
labelText: dialogContext.l10n.channels_pskHex,
border: const OutlineInputBorder(),
suffixIcon: IconButton(
icon: const Icon(Icons.casino),
tooltip: 'Generate random PSK',
tooltip: dialogContext.l10n.channels_generateRandomPsk,
onPressed: () {
final random = Random.secure();
final bytes = Uint8List(16);
@@ -692,7 +694,7 @@ class _ChannelsScreenState extends State<ChannelsScreen>
const SizedBox(height: 16),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('SMAZ compression'),
title: Text(dialogContext.l10n.channels_smazCompression),
value: smazEnabled,
onChanged: (value) => setState(() => smazEnabled = value),
),
@@ -701,8 +703,8 @@ class _ChannelsScreenState extends State<ChannelsScreen>
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
onPressed: () => Navigator.pop(dialogContext),
child: Text(dialogContext.l10n.common_cancel),
),
FilledButton(
onPressed: () {
@@ -713,20 +715,20 @@ class _ChannelsScreenState extends State<ChannelsScreen>
try {
psk = Channel.parsePskHex(pskHex);
} on FormatException {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('PSK must be 32 hex characters')),
ScaffoldMessenger.of(dialogContext).showSnackBar(
SnackBar(content: Text(dialogContext.l10n.channels_pskMustBe32Hex)),
);
return;
}
Navigator.pop(context);
Navigator.pop(dialogContext);
connector.setChannel(channel.index, name, psk);
connector.setChannelSmazEnabled(channel.index, smazEnabled);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Channel "$name" updated')),
SnackBar(content: Text(context.l10n.channels_channelUpdated(name))),
);
},
child: const Text('Save'),
child: Text(dialogContext.l10n.common_save),
),
],
),
@@ -741,23 +743,23 @@ class _ChannelsScreenState extends State<ChannelsScreen>
) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Delete Channel'),
content: Text('Delete "${channel.name}"? This cannot be undone.'),
builder: (dialogContext) => AlertDialog(
title: Text(dialogContext.l10n.channels_deleteChannel),
content: Text(dialogContext.l10n.channels_deleteChannelConfirm(channel.name)),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
onPressed: () => Navigator.pop(dialogContext),
child: Text(dialogContext.l10n.common_cancel),
),
TextButton(
onPressed: () {
Navigator.pop(context);
Navigator.pop(dialogContext);
connector.deleteChannel(channel.index);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Channel "${channel.name}" deleted')),
SnackBar(content: Text(context.l10n.channels_channelDeleted(channel.name))),
);
},
child: const Text('Delete', style: TextStyle(color: Colors.red)),
child: Text(dialogContext.l10n.common_delete, style: const TextStyle(color: Colors.red)),
),
],
),
@@ -768,7 +770,7 @@ class _ChannelsScreenState extends State<ChannelsScreen>
final psk = Channel.parsePskHex(Channel.publicChannelPsk);
connector.setChannel(0, 'Public', psk);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Public channel added')),
SnackBar(content: Text(context.l10n.channels_publicChannelAdded)),
);
}
+85 -84
View File
@@ -23,6 +23,7 @@ import '../widgets/gif_message.dart';
import '../widgets/gif_picker.dart';
import '../widgets/path_selection_dialog.dart';
import '../utils/app_logger.dart';
import '../l10n/l10n.dart';
class ChatScreen extends StatefulWidget {
final Contact contact;
@@ -67,7 +68,7 @@ class _ChatScreenState extends State<ChatScreen> {
builder: (context, pathService, connector, _) {
final contact = _resolveContact(connector);
final unreadCount = connector.getUnreadCountForContactKey(widget.contact.publicKeyHex);
final unreadLabel = 'Unread: $unreadCount';
final unreadLabel = context.l10n.chat_unread(unreadCount);
final pathLabel = _currentPathLabel(contact);
// Show path details if we have path data (from device or override)
@@ -106,7 +107,7 @@ class _ChatScreenState extends State<ChatScreen> {
return PopupMenuButton<String>(
icon: Icon(isFloodMode ? Icons.waves : Icons.route),
tooltip: 'Routing mode',
tooltip: context.l10n.chat_routingMode,
onSelected: (mode) async {
if (mode == 'flood') {
await connector.setPathOverride(contact, pathLen: -1);
@@ -122,7 +123,7 @@ class _ChatScreenState extends State<ChatScreen> {
Icon(Icons.auto_mode, size: 20, color: !isFloodMode ? Theme.of(context).primaryColor : null),
const SizedBox(width: 8),
Text(
'Auto (use saved path)',
context.l10n.chat_autoUseSavedPath,
style: TextStyle(
fontWeight: !isFloodMode ? FontWeight.bold : FontWeight.normal,
),
@@ -137,7 +138,7 @@ class _ChatScreenState extends State<ChatScreen> {
Icon(Icons.waves, size: 20, color: isFloodMode ? Theme.of(context).primaryColor : null),
const SizedBox(width: 8),
Text(
'Force Flood Mode',
context.l10n.chat_forceFloodMode,
style: TextStyle(
fontWeight: isFloodMode ? FontWeight.bold : FontWeight.normal,
),
@@ -151,7 +152,7 @@ class _ChatScreenState extends State<ChatScreen> {
),
IconButton(
icon: const Icon(Icons.timeline),
tooltip: 'Path management',
tooltip: context.l10n.chat_pathManagement,
onPressed: () => _showPathHistory(context),
),
IconButton(
@@ -186,12 +187,12 @@ class _ChatScreenState extends State<ChatScreen> {
Icon(Icons.chat_bubble_outline, size: 64, color: Colors.grey[400]),
const SizedBox(height: 16),
Text(
'No messages yet',
context.l10n.chat_noMessages,
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
),
const SizedBox(height: 8),
Text(
'Send a message to ${widget.contact.name}',
context.l10n.chat_sendMessageTo(widget.contact.name),
style: TextStyle(fontSize: 14, color: Colors.grey[500]),
),
],
@@ -244,7 +245,7 @@ class _ChatScreenState extends State<ChatScreen> {
IconButton(
icon: const Icon(Icons.gif_box),
onPressed: () => _showGifPicker(context),
tooltip: 'Send GIF',
tooltip: context.l10n.chat_sendGif,
),
Expanded(
child: ValueListenableBuilder<TextEditingValue>(
@@ -278,10 +279,10 @@ class _ChatScreenState extends State<ChatScreen> {
inputFormatters: [
Utf8LengthLimitingTextInputFormatter(maxBytes),
],
decoration: const InputDecoration(
hintText: 'Type a message...',
border: OutlineInputBorder(),
contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: InputDecoration(
hintText: context.l10n.chat_typeMessage,
border: const OutlineInputBorder(),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
textInputAction: TextInputAction.send,
onSubmitted: (_) => _sendMessage(connector),
@@ -325,7 +326,7 @@ class _ChatScreenState extends State<ChatScreen> {
final maxBytes = maxContactMessageBytes();
if (utf8.encode(text).length > maxBytes) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Message too long (max $maxBytes bytes).')),
SnackBar(content: Text(context.l10n.chat_messageTooLong(maxBytes))),
);
return;
}
@@ -357,11 +358,11 @@ class _ChatScreenState extends State<ChatScreen> {
builder: (context, pathService, _) {
final paths = pathService.getRecentPaths(widget.contact.publicKeyHex);
return AlertDialog(
title: const Row(
title: Row(
children: [
Icon(Icons.timeline),
SizedBox(width: 8),
Text('Path Management'),
const Icon(Icons.timeline),
const SizedBox(width: 8),
Text(context.l10n.chat_pathManagement),
],
),
content: SingleChildScrollView(
@@ -370,9 +371,9 @@ class _ChatScreenState extends State<ChatScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (paths.isNotEmpty) ...[
const Text(
'Recent ACK Paths (tap to use):',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
Text(
context.l10n.chat_recentAckPaths,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
),
if (paths.length >= 100) ...[
const SizedBox(height: 8),
@@ -383,9 +384,9 @@ class _ChatScreenState extends State<ChatScreen> {
color: Colors.amber[100],
borderRadius: BorderRadius.circular(8),
),
child: const Text(
'Path history is full. Remove entries to add new ones.',
style: TextStyle(fontSize: 12),
child: Text(
context.l10n.chat_pathHistoryFull,
style: const TextStyle(fontSize: 12),
),
),
],
@@ -404,11 +405,11 @@ class _ChatScreenState extends State<ChatScreen> {
),
),
title: Text(
'${path.hopCount} ${path.hopCount == 1 ? 'hop' : 'hops'}',
'${path.hopCount} ${path.hopCount == 1 ? context.l10n.chat_hopSingular : context.l10n.chat_hopPlural}',
style: const TextStyle(fontSize: 14),
),
subtitle: Text(
'${(path.tripTimeMs / 1000).toStringAsFixed(2)}s • ${_formatRelativeTime(path.timestamp)}${path.successCount} successes',
'${(path.tripTimeMs / 1000).toStringAsFixed(2)}s • ${_formatRelativeTime(path.timestamp)}${path.successCount} ${context.l10n.chat_successes}',
style: const TextStyle(fontSize: 11),
),
trailing: Row(
@@ -416,7 +417,7 @@ class _ChatScreenState extends State<ChatScreen> {
children: [
IconButton(
icon: const Icon(Icons.close, size: 16),
tooltip: 'Remove path',
tooltip: context.l10n.chat_removePath,
onPressed: () async {
await pathService.removePathRecord(
widget.contact.publicKeyHex,
@@ -433,9 +434,9 @@ class _ChatScreenState extends State<ChatScreen> {
onTap: () async {
if (path.pathBytes.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Path details not available yet. Try sending a message to refresh.'),
duration: Duration(seconds: 2),
SnackBar(
content: Text(context.l10n.chat_pathDetailsNotAvailable),
duration: const Duration(seconds: 2),
),
);
return;
@@ -465,13 +466,13 @@ class _ChatScreenState extends State<ChatScreen> {
}),
const Divider(),
] else ...[
const Text('No path history yet.\nSend a message to discover paths.'),
Text(context.l10n.chat_noPathHistoryYet),
const Divider(),
],
const SizedBox(height: 8),
const Text(
'Path Actions:',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
Text(
context.l10n.chat_pathActions,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
),
const SizedBox(height: 8),
ListTile(
@@ -481,8 +482,8 @@ class _ChatScreenState extends State<ChatScreen> {
backgroundColor: Colors.purple,
child: Icon(Icons.edit_road, size: 16),
),
title: const Text('Set Custom Path', style: TextStyle(fontSize: 14)),
subtitle: const Text('Manually specify routing path', style: TextStyle(fontSize: 11)),
title: Text(context.l10n.chat_setCustomPath, style: const TextStyle(fontSize: 14)),
subtitle: Text(context.l10n.chat_setCustomPathSubtitle, style: const TextStyle(fontSize: 11)),
onTap: () {
Navigator.pop(context);
_showCustomPathDialog(context);
@@ -495,15 +496,15 @@ class _ChatScreenState extends State<ChatScreen> {
backgroundColor: Colors.orange,
child: Icon(Icons.clear_all, size: 16),
),
title: const Text('Clear Path', style: TextStyle(fontSize: 14)),
subtitle: const Text('Force rediscovery on next send', style: TextStyle(fontSize: 11)),
title: Text(context.l10n.chat_clearPath, style: const TextStyle(fontSize: 14)),
subtitle: Text(context.l10n.chat_clearPathSubtitle, style: const TextStyle(fontSize: 11)),
onTap: () async {
await connector.clearContactPath(widget.contact);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Path cleared. Next message will rediscover route.'),
duration: Duration(seconds: 2),
SnackBar(
content: Text(context.l10n.chat_pathCleared),
duration: const Duration(seconds: 2),
),
);
Navigator.pop(context);
@@ -516,15 +517,15 @@ class _ChatScreenState extends State<ChatScreen> {
backgroundColor: Colors.blue,
child: Icon(Icons.waves, size: 16),
),
title: const Text('Force Flood Mode', style: TextStyle(fontSize: 14)),
subtitle: const Text('Use routing toggle in app bar', style: TextStyle(fontSize: 11)),
title: Text(context.l10n.chat_forceFloodMode, style: const TextStyle(fontSize: 14)),
subtitle: Text(context.l10n.chat_floodModeSubtitle, style: const TextStyle(fontSize: 11)),
onTap: () async {
await connector.setPathOverride(widget.contact, pathLen: -1);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Flood mode enabled. Toggle back via routing icon in app bar.'),
duration: Duration(seconds: 2),
SnackBar(
content: Text(context.l10n.chat_floodModeEnabled),
duration: const Duration(seconds: 2),
),
);
Navigator.pop(context);
@@ -536,7 +537,7 @@ class _ChatScreenState extends State<ChatScreen> {
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
child: Text(context.l10n.common_close),
),
],
);
@@ -547,18 +548,18 @@ class _ChatScreenState extends State<ChatScreen> {
String _formatRelativeTime(DateTime time) {
final diff = DateTime.now().difference(time);
if (diff.inSeconds < 60) return 'Just now';
if (diff.inMinutes < 60) return '${diff.inMinutes}m ago';
if (diff.inHours < 24) return '${diff.inHours}h ago';
return '${diff.inDays}d ago';
if (diff.inSeconds < 60) return context.l10n.time_justNow;
if (diff.inMinutes < 60) return context.l10n.time_minutesAgo(diff.inMinutes);
if (diff.inHours < 24) return context.l10n.time_hoursAgo(diff.inHours);
return context.l10n.time_daysAgo(diff.inDays);
}
void _showFullPathDialog(BuildContext context, List<int> pathBytes) {
if (pathBytes.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Path details not available yet. Try sending a message to refresh.'),
duration: Duration(seconds: 2),
SnackBar(
content: Text(context.l10n.chat_pathDetailsNotAvailable),
duration: const Duration(seconds: 2),
),
);
return;
@@ -571,12 +572,12 @@ class _ChatScreenState extends State<ChatScreen> {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Full Path'),
title: Text(context.l10n.chat_fullPath),
content: SelectableText(formattedPath),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
child: Text(context.l10n.common_close),
),
],
),
@@ -600,15 +601,15 @@ class _ChatScreenState extends State<ChatScreen> {
String _currentPathLabel(Contact contact) {
// Check if user has set a path override
if (contact.pathOverride != null) {
if (contact.pathOverride! < 0) return 'Flood (forced)';
if (contact.pathOverride == 0) return 'Direct (forced)';
return '${contact.pathOverride} hops (forced)';
if (contact.pathOverride! < 0) return context.l10n.chat_floodForced;
if (contact.pathOverride == 0) return context.l10n.chat_directForced;
return context.l10n.chat_hopsForced(contact.pathOverride!);
}
// Use device's path
if (contact.pathLength < 0) return 'Flood (auto)';
if (contact.pathLength == 0) return 'Direct';
return '${contact.pathLength} hops';
if (contact.pathLength < 0) return context.l10n.chat_floodAuto;
if (contact.pathLength == 0) return context.l10n.chat_direct;
return context.l10n.chat_hopsCount(contact.pathLength);
}
Future<void> _notifyPathSet(
@@ -623,12 +624,12 @@ class _ChatScreenState extends State<ChatScreen> {
if (!mounted) return;
final status = !connector.isConnected
? 'Saved locally. Connect to sync.'
: (verified ? 'Device confirmed.' : 'Device not confirmed yet.');
? context.l10n.chat_pathSavedLocally
: (verified ? context.l10n.chat_pathDeviceConfirmed : context.l10n.chat_pathDeviceNotConfirmed);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Path set: $hopCount ${hopCount == 1 ? 'hop' : 'hops'} - $status',
context.l10n.chat_pathSetHops(hopCount, status),
),
duration: const Duration(seconds: 3),
),
@@ -653,19 +654,19 @@ class _ChatScreenState extends State<ChatScreen> {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildInfoRow('Type', contact.typeLabel),
_buildInfoRow('Path', contact.pathLabel),
_buildInfoRow(context.l10n.chat_type, contact.typeLabel),
_buildInfoRow(context.l10n.chat_path, contact.pathLabel),
if (contact.hasLocation)
_buildInfoRow(
'Location',
context.l10n.chat_location,
'${contact.latitude?.toStringAsFixed(4)}, ${contact.longitude?.toStringAsFixed(4)}',
),
_buildInfoRow('Public Key', '${contact.publicKeyHex.substring(0, 16)}...'),
_buildInfoRow(context.l10n.chat_publicKey, '${contact.publicKeyHex.substring(0, 16)}...'),
const Divider(),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('SMAZ compression'),
subtitle: const Text('Compress outgoing messages'),
title: Text(context.l10n.channels_smazCompression),
subtitle: Text(context.l10n.chat_compressOutgoingMessages),
value: smazEnabled,
onChanged: (value) {
connector.setContactSmazEnabled(contact.publicKeyHex, value);
@@ -677,7 +678,7 @@ class _ChatScreenState extends State<ChatScreen> {
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
child: Text(context.l10n.common_close),
),
],
);
@@ -731,7 +732,7 @@ class _ChatScreenState extends State<ChatScreen> {
context,
availableContacts: availableContacts,
initialPath: pathForInput.isEmpty ? null : pathForInput,
title: 'Set Custom Path',
title: context.l10n.chat_setCustomPath,
currentPathLabel: currentPathLabel,
onRefresh: connector.isConnected ? connector.getContacts : null,
);
@@ -769,7 +770,7 @@ class _ChatScreenState extends State<ChatScreen> {
.toUpperCase();
final String senderName;
if (message.isOutgoing) {
senderName = connector.selfName ?? 'Me';
senderName = connector.selfName ?? context.l10n.chat_me;
} else if (widget.contact.type == advTypeRoom) {
senderName = "${contact.name} [$fourByteHex]";
} else {
@@ -803,7 +804,7 @@ class _ChatScreenState extends State<ChatScreen> {
children: [
ListTile(
leading: const Icon(Icons.add_reaction_outlined),
title: const Text('Add Reaction'),
title: Text(context.l10n.chat_addReaction),
onTap: () {
Navigator.pop(sheetContext);
_showEmojiPicker(message);
@@ -811,7 +812,7 @@ class _ChatScreenState extends State<ChatScreen> {
),
ListTile(
leading: const Icon(Icons.copy),
title: const Text('Copy'),
title: Text(context.l10n.common_copy),
onTap: () {
Navigator.pop(sheetContext);
_copyMessageText(message.text);
@@ -819,7 +820,7 @@ class _ChatScreenState extends State<ChatScreen> {
),
ListTile(
leading: const Icon(Icons.delete_outline),
title: const Text('Delete'),
title: Text(context.l10n.common_delete),
onTap: () async {
Navigator.pop(sheetContext);
await _deleteMessage(message);
@@ -829,7 +830,7 @@ class _ChatScreenState extends State<ChatScreen> {
message.status == MessageStatus.failed)
ListTile(
leading: const Icon(Icons.refresh),
title: const Text('Retry'),
title: Text(context.l10n.common_retry),
onTap: () {
Navigator.pop(sheetContext);
_retryMessage(message);
@@ -838,7 +839,7 @@ class _ChatScreenState extends State<ChatScreen> {
if (widget.contact.type == advTypeRoom)
ListTile(
leading: const Icon(Icons.chat),
title: const Text('Open Chat'),
title: Text(context.l10n.contacts_openChat),
onTap: () {
Navigator.pop(sheetContext);
_openChat(context, contact);
@@ -846,7 +847,7 @@ class _ChatScreenState extends State<ChatScreen> {
),
ListTile(
leading: const Icon(Icons.close),
title: const Text('Cancel'),
title: Text(context.l10n.common_cancel),
onTap: () => Navigator.pop(sheetContext),
),
],
@@ -858,7 +859,7 @@ class _ChatScreenState extends State<ChatScreen> {
void _copyMessageText(String text) {
Clipboard.setData(ClipboardData(text: text));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Message copied')),
SnackBar(content: Text(context.l10n.chat_messageCopied)),
);
}
@@ -866,7 +867,7 @@ class _ChatScreenState extends State<ChatScreen> {
await context.read<MeshCoreConnector>().deleteMessage(message);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Message deleted')),
SnackBar(content: Text(context.l10n.chat_messageDeleted)),
);
}
@@ -878,7 +879,7 @@ class _ChatScreenState extends State<ChatScreen> {
message.text,
);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Retrying message')),
SnackBar(content: Text(context.l10n.chat_retryingMessage)),
);
}
@@ -996,7 +997,7 @@ class _MessageBubble extends StatelessWidget {
if (isOutgoing && message.retryCount > 0) ...[
const SizedBox(height: 4),
Text(
'Retry ${message.retryCount}/4',
context.l10n.chat_retryCount(message.retryCount, 4),
style: TextStyle(
fontSize: 10,
color: metaColor,
@@ -1106,7 +1107,7 @@ class _MessageBubble extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'POI Shared',
context.l10n.chat_poiShared,
style: TextStyle(
color: textColor,
fontWeight: FontWeight.w600,
+54 -53
View File
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart';
import '../l10n/l10n.dart';
import '../connector/meshcore_protocol.dart';
import '../models/contact.dart';
import '../models/contact_group.dart';
@@ -89,18 +90,18 @@ class _ContactsScreenState extends State<ContactsScreen>
child: Scaffold(
appBar: AppBar(
leading: BatteryIndicator(connector: connector),
title: const Text('Contacts'),
title: Text(context.l10n.contacts_title),
centerTitle: true,
automaticallyImplyLeading: false,
actions: [
IconButton(
icon: const Icon(Icons.bluetooth_disabled),
tooltip: 'Disconnect',
tooltip: context.l10n.common_disconnect,
onPressed: () => _disconnect(context, connector),
),
IconButton(
icon: const Icon(Icons.tune),
tooltip: 'Settings',
tooltip: context.l10n.common_settings,
onPressed: () => Navigator.push(
context,
MaterialPageRoute(builder: (context) => const SettingsScreen()),
@@ -159,10 +160,10 @@ class _ContactsScreenState extends State<ContactsScreen>
}
if (contacts.isEmpty && _groups.isEmpty) {
return const EmptyState(
return EmptyState(
icon: Icons.people_outline,
title: 'No contacts yet',
subtitle: 'Contacts will appear when devices advertise',
title: context.l10n.contacts_noContacts,
subtitle: context.l10n.contacts_contactsWillAppear,
);
}
@@ -177,7 +178,7 @@ class _ContactsScreenState extends State<ContactsScreen>
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Search contacts...',
hintText: context.l10n.contacts_searchContacts,
prefixIcon: const Icon(Icons.search),
suffixIcon: Row(
mainAxisSize: MainAxisSize.min,
@@ -221,8 +222,8 @@ class _ContactsScreenState extends State<ContactsScreen>
const SizedBox(height: 16),
Text(
_showUnreadOnly
? 'No unread contacts'
: 'No contacts or groups found',
? context.l10n.contacts_noUnreadContacts
: context.l10n.contacts_noContactsFound,
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
),
],
@@ -341,7 +342,7 @@ class _ContactsScreenState extends State<ContactsScreen>
Widget _buildGroupTile(BuildContext context, ContactGroup group, List<Contact> contacts) {
final memberContacts = _resolveGroupContacts(group, contacts);
final subtitle = _formatGroupMembers(memberContacts);
final subtitle = _formatGroupMembers(context, memberContacts);
return ListTile(
leading: const CircleAvatar(
backgroundColor: Colors.teal,
@@ -374,8 +375,8 @@ class _ContactsScreenState extends State<ContactsScreen>
return resolved;
}
String _formatGroupMembers(List<Contact> members) {
if (members.isEmpty) return 'No members';
String _formatGroupMembers(BuildContext context, List<Contact> members) {
if (members.isEmpty) return context.l10n.contacts_noMembers;
final names = members.map((c) => c.name).toList();
if (names.length <= 2) return names.join(', ');
return '${names.take(2).join(', ')} +${names.length - 2}';
@@ -469,7 +470,7 @@ class _ContactsScreenState extends State<ContactsScreen>
children: [
ListTile(
leading: const Icon(Icons.edit),
title: const Text('Edit Group'),
title: Text(context.l10n.contacts_editGroup),
onTap: () {
Navigator.pop(sheetContext);
_showGroupEditor(context, contacts, group: group);
@@ -477,7 +478,7 @@ class _ContactsScreenState extends State<ContactsScreen>
),
ListTile(
leading: const Icon(Icons.delete, color: Colors.red),
title: const Text('Delete Group', style: TextStyle(color: Colors.red)),
title: Text(context.l10n.contacts_deleteGroup, style: const TextStyle(color: Colors.red)),
onTap: () {
Navigator.pop(sheetContext);
_confirmDeleteGroup(context, group);
@@ -506,12 +507,12 @@ class _ContactsScreenState extends State<ContactsScreen>
showDialog(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Delete Group'),
content: Text('Remove "${group.name}"?'),
title: Text(context.l10n.contacts_deleteGroup),
content: Text(context.l10n.contacts_deleteGroupConfirm(group.name)),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: const Text('Cancel'),
child: Text(context.l10n.common_cancel),
),
TextButton(
onPressed: () async {
@@ -521,7 +522,7 @@ class _ContactsScreenState extends State<ContactsScreen>
});
await _saveGroups();
},
child: const Text('Delete', style: TextStyle(color: Colors.red)),
child: Text(context.l10n.common_delete, style: const TextStyle(color: Colors.red)),
),
],
),
@@ -550,7 +551,7 @@ class _ContactsScreenState extends State<ContactsScreen>
.where((contact) => matchesContactQuery(contact, filterQuery))
.toList();
return AlertDialog(
title: Text(isEditing ? 'Edit Group' : 'New Group'),
title: Text(isEditing ? context.l10n.contacts_editGroup : context.l10n.contacts_newGroup),
content: SizedBox(
width: double.maxFinite,
child: Column(
@@ -558,17 +559,17 @@ class _ContactsScreenState extends State<ContactsScreen>
children: [
TextField(
controller: nameController,
decoration: const InputDecoration(
labelText: 'Group name',
border: OutlineInputBorder(),
decoration: InputDecoration(
labelText: context.l10n.contacts_groupName,
border: const OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextField(
decoration: const InputDecoration(
hintText: 'Filter contacts...',
prefixIcon: Icon(Icons.search),
border: OutlineInputBorder(),
decoration: InputDecoration(
hintText: context.l10n.contacts_filterContacts,
prefixIcon: const Icon(Icons.search),
border: const OutlineInputBorder(),
isDense: true,
),
onChanged: (value) {
@@ -581,7 +582,7 @@ class _ContactsScreenState extends State<ContactsScreen>
SizedBox(
height: 240,
child: filteredContacts.isEmpty
? const Center(child: Text('No contacts match your filter'))
? Center(child: Text(context.l10n.contacts_noContactsMatchFilter))
: ListView.builder(
itemCount: filteredContacts.length,
itemBuilder: (context, index) {
@@ -610,14 +611,14 @@ class _ContactsScreenState extends State<ContactsScreen>
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: const Text('Cancel'),
child: Text(context.l10n.common_cancel),
),
TextButton(
onPressed: () async {
final name = nameController.text.trim();
if (name.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Group name is required')),
SnackBar(content: Text(context.l10n.contacts_groupNameRequired)),
);
return;
}
@@ -627,7 +628,7 @@ class _ContactsScreenState extends State<ContactsScreen>
});
if (exists) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Group "$name" already exists')),
SnackBar(content: Text(context.l10n.contacts_groupAlreadyExists(name))),
);
return;
}
@@ -649,7 +650,7 @@ class _ContactsScreenState extends State<ContactsScreen>
Navigator.pop(dialogContext);
}
},
child: Text(isEditing ? 'Save' : 'Create'),
child: Text(isEditing ? context.l10n.common_save : context.l10n.common_create),
),
],
);
@@ -668,42 +669,42 @@ class _ContactsScreenState extends State<ContactsScreen>
showModalBottomSheet(
context: context,
builder: (context) => SafeArea(
builder: (sheetContext) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (isRepeater)
ListTile(
leading: const Icon(Icons.cell_tower, color: Colors.orange),
title: const Text('Manage Repeater'),
title: Text(context.l10n.contacts_manageRepeater),
onTap: () {
Navigator.pop(context);
Navigator.pop(sheetContext);
_showRepeaterLogin(context, contact);
},
)
else if (isRoom)
ListTile(
leading: const Icon(Icons.room, color: Colors.blue),
title: const Text('Room Login'),
title: Text(context.l10n.contacts_roomLogin),
onTap: () {
Navigator.pop(context);
Navigator.pop(sheetContext);
_showRoomLogin(context, contact);
},
)
else
ListTile(
leading: const Icon(Icons.chat),
title: const Text('Open Chat'),
title: Text(context.l10n.contacts_openChat),
onTap: () {
Navigator.pop(context);
Navigator.pop(sheetContext);
_openChat(context, contact);
},
),
ListTile(
leading: const Icon(Icons.delete, color: Colors.red),
title: const Text('Delete Contact', style: TextStyle(color: Colors.red)),
title: Text(context.l10n.contacts_deleteContact, style: const TextStyle(color: Colors.red)),
onTap: () {
Navigator.pop(context);
Navigator.pop(sheetContext);
_confirmDelete(context, connector, contact);
},
),
@@ -720,20 +721,20 @@ class _ContactsScreenState extends State<ContactsScreen>
) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Delete Contact'),
content: Text('Remove ${contact.name} from contacts?'),
builder: (dialogContext) => AlertDialog(
title: Text(context.l10n.contacts_deleteContact),
content: Text(context.l10n.contacts_removeConfirm(contact.name)),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
onPressed: () => Navigator.pop(dialogContext),
child: Text(context.l10n.common_cancel),
),
TextButton(
onPressed: () {
Navigator.pop(context);
Navigator.pop(dialogContext);
connector.removeContact(contact);
},
child: const Text('Delete', style: TextStyle(color: Colors.red)),
child: Text(context.l10n.common_delete, style: const TextStyle(color: Colors.red)),
),
],
),
@@ -774,7 +775,7 @@ class _ContactTile extends StatelessWidget {
const SizedBox(height: 4),
],
Text(
_formatLastSeen(lastSeen),
_formatLastSeen(context, lastSeen),
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
),
if (contact.hasLocation)
@@ -827,17 +828,17 @@ class _ContactTile extends StatelessWidget {
}
}
String _formatLastSeen(DateTime lastSeen) {
String _formatLastSeen(BuildContext context, DateTime lastSeen) {
final now = DateTime.now();
final diff = now.difference(lastSeen);
if (diff.isNegative || diff.inMinutes < 5) return 'Last seen now';
if (diff.inMinutes < 60) return 'Last seen ${diff.inMinutes} mins ago';
if (diff.isNegative || diff.inMinutes < 5) return context.l10n.contacts_lastSeenNow;
if (diff.inMinutes < 60) return context.l10n.contacts_lastSeenMinsAgo(diff.inMinutes);
if (diff.inHours < 24) {
final hours = diff.inHours;
return hours == 1 ? 'Last seen 1 hour ago' : 'Last seen $hours hours ago';
return hours == 1 ? context.l10n.contacts_lastSeenHourAgo : context.l10n.contacts_lastSeenHoursAgo(hours);
}
final days = diff.inDays;
return days == 1 ? 'Last seen 1 day ago' : 'Last seen $days days ago';
return days == 1 ? context.l10n.contacts_lastSeenDayAgo : context.l10n.contacts_lastSeenDaysAgo(days);
}
}
+6 -5
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart';
import '../l10n/l10n.dart';
import '../utils/dialog_utils.dart';
import '../utils/disconnect_navigation_mixin.dart';
import '../utils/route_transitions.dart';
@@ -47,12 +48,12 @@ class _DeviceScreenState extends State<DeviceScreen>
actions: [
IconButton(
icon: const Icon(Icons.bluetooth_disabled),
tooltip: 'Disconnect',
tooltip: context.l10n.common_disconnect,
onPressed: () => _disconnect(context, connector),
),
IconButton(
icon: const Icon(Icons.tune),
tooltip: 'Settings',
tooltip: context.l10n.common_settings,
onPressed: () => Navigator.push(
context,
MaterialPageRoute(
@@ -68,7 +69,7 @@ class _DeviceScreenState extends State<DeviceScreen>
children: [
_buildConnectionCard(connector, context),
const SizedBox(height: 16),
_buildSectionLabel(theme, 'Quick switch'),
_buildSectionLabel(theme, context.l10n.device_quickSwitch),
const SizedBox(height: 12),
_buildQuickSwitchBar(context),
],
@@ -87,7 +88,7 @@ class _DeviceScreenState extends State<DeviceScreen>
mainAxisSize: MainAxisSize.min,
children: [
Text(
'MeshCore',
context.l10n.device_meshcore,
style: theme.textTheme.labelSmall?.copyWith(
fontWeight: FontWeight.w600,
letterSpacing: 0.8,
@@ -180,7 +181,7 @@ class _DeviceScreenState extends State<DeviceScreen>
size: 18,
color: colorScheme.onSecondaryContainer,
),
label: const Text('Connected'),
label: Text(context.l10n.common_connected),
backgroundColor: colorScheme.secondaryContainer,
labelStyle: theme.textTheme.labelMedium?.copyWith(
color: colorScheme.onSecondaryContainer,
+45 -34
View File
@@ -3,6 +3,8 @@ import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
import 'package:provider/provider.dart';
import '../l10n/app_localizations.dart';
import '../l10n/l10n.dart';
import '../services/app_settings_service.dart';
import '../services/map_tile_cache_service.dart';
@@ -110,14 +112,14 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
final bounds = _selectedBounds;
if (bounds == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Select an area to cache first')),
SnackBar(content: Text(context.l10n.mapCache_selectAreaFirst)),
);
return;
}
if (_estimatedTiles == 0) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('No tiles to download for this area')),
SnackBar(content: Text(context.l10n.mapCache_noTilesToDownload)),
);
return;
}
@@ -125,18 +127,18 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Download tiles'),
title: Text(context.l10n.mapCache_downloadTilesTitle),
content: Text(
'Download $_estimatedTiles tiles for offline use?',
context.l10n.mapCache_downloadTilesPrompt(_estimatedTiles),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: const Text('Cancel'),
child: Text(context.l10n.common_cancel),
),
TextButton(
onPressed: () => Navigator.pop(dialogContext, true),
child: const Text('Download'),
child: Text(context.l10n.mapCache_downloadAction),
),
],
),
@@ -174,8 +176,11 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
});
final message = result.failed > 0
? 'Cached ${result.downloaded} tiles (${result.failed} failed)'
: 'Cached ${result.downloaded} tiles';
? context.l10n.mapCache_cachedTilesWithFailed(
result.downloaded,
result.failed,
)
: context.l10n.mapCache_cachedTiles(result.downloaded);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message)),
);
@@ -185,16 +190,16 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Clear offline cache'),
content: const Text('Remove all cached map tiles?'),
title: Text(context.l10n.mapCache_clearOfflineCacheTitle),
content: Text(context.l10n.mapCache_clearOfflineCachePrompt),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: const Text('Cancel'),
child: Text(context.l10n.common_cancel),
),
TextButton(
onPressed: () => Navigator.pop(dialogContext, true),
child: const Text('Clear'),
child: Text(context.l10n.common_clear),
),
],
),
@@ -205,7 +210,7 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
await cacheService.clearCache();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Offline cache cleared')),
SnackBar(content: Text(context.l10n.mapCache_offlineCacheCleared)),
);
}
@@ -213,13 +218,14 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
Widget build(BuildContext context) {
final tileCache = context.read<MapTileCacheService>();
final selectedBounds = _selectedBounds;
final l10n = context.l10n;
final progressValue = _estimatedTiles == 0
? 0.0
: (_completedTiles / _estimatedTiles).clamp(0.0, 1.0).toDouble();
return Scaffold(
appBar: AppBar(
title: const Text('Offline Map Cache'),
title: Text(l10n.mapCache_title),
centerTitle: true,
),
body: Column(
@@ -264,8 +270,8 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
padding: const EdgeInsets.all(8),
child: Text(
selectedBounds == null
? 'No area selected'
: _formatBounds(selectedBounds),
? l10n.mapCache_noAreaSelected
: _formatBounds(selectedBounds, l10n),
style: const TextStyle(fontSize: 12),
),
),
@@ -282,9 +288,9 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Cache Area',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
Text(
l10n.mapCache_cacheArea,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
const SizedBox(height: 8),
Row(
@@ -292,7 +298,7 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
Expanded(
child: ElevatedButton.icon(
icon: const Icon(Icons.crop_free),
label: const Text('Use Current View'),
label: Text(l10n.mapCache_useCurrentView),
onPressed: _isDownloading ? null : _setBoundsFromView,
),
),
@@ -300,14 +306,14 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
TextButton(
onPressed:
_isDownloading || selectedBounds == null ? null : _clearBounds,
child: const Text('Clear'),
child: Text(l10n.common_clear),
),
],
),
const SizedBox(height: 12),
const Text(
'Zoom Range',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
Text(
l10n.mapCache_zoomRange,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
RangeSlider(
values:
@@ -330,12 +336,15 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
_saveZoomRange();
},
),
Text('Estimated tiles: $_estimatedTiles'),
Text(l10n.mapCache_estimatedTiles(_estimatedTiles)),
if (_isDownloading) ...[
const SizedBox(height: 8),
LinearProgressIndicator(value: progressValue),
const SizedBox(height: 4),
Text('Downloaded $_completedTiles / $_estimatedTiles'),
Text(l10n.mapCache_downloadedTiles(
_completedTiles,
_estimatedTiles,
)),
],
const SizedBox(height: 12),
Row(
@@ -343,7 +352,7 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
Expanded(
child: ElevatedButton.icon(
icon: const Icon(Icons.download),
label: const Text('Download Tiles'),
label: Text(l10n.mapCache_downloadTilesButton),
onPressed: _isDownloading || selectedBounds == null
? null
: _startDownload,
@@ -352,7 +361,7 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
const SizedBox(width: 12),
OutlinedButton(
onPressed: _isDownloading ? null : _clearCache,
child: const Text('Clear Cache'),
child: Text(l10n.mapCache_clearCacheButton),
),
],
),
@@ -360,7 +369,7 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
'Failed downloads: $_failedTiles',
l10n.mapCache_failedDownloads(_failedTiles),
style: TextStyle(color: Colors.orange[700]),
),
),
@@ -382,10 +391,12 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
];
}
String _formatBounds(LatLngBounds bounds) {
return 'N ${bounds.north.toStringAsFixed(4)}, '
'S ${bounds.south.toStringAsFixed(4)}, '
'E ${bounds.east.toStringAsFixed(4)}, '
'W ${bounds.west.toStringAsFixed(4)}';
String _formatBounds(LatLngBounds bounds, AppLocalizations l10n) {
return l10n.mapCache_boundsLabel(
bounds.north.toStringAsFixed(4),
bounds.south.toStringAsFixed(4),
bounds.east.toStringAsFixed(4),
bounds.west.toStringAsFixed(4),
);
}
}
+107 -108
View File
@@ -4,6 +4,7 @@ import 'package:latlong2/latlong.dart';
import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart';
import '../l10n/l10n.dart';
import '../connector/meshcore_protocol.dart';
import '../models/channel.dart';
import '../models/contact.dart';
@@ -141,18 +142,18 @@ class _MapScreenState extends State<MapScreen> {
child: Scaffold(
appBar: AppBar(
leading: BatteryIndicator(connector: connector),
title: const Text('Node Map'),
title: Text(context.l10n.map_title),
centerTitle: true,
automaticallyImplyLeading: false,
actions: [
IconButton(
icon: const Icon(Icons.bluetooth_disabled),
tooltip: 'Disconnect',
tooltip: context.l10n.common_disconnect,
onPressed: () => _disconnect(context, connector),
),
IconButton(
icon: const Icon(Icons.tune),
tooltip: 'Settings',
tooltip: context.l10n.common_settings,
onPressed: () => Navigator.push(
context,
MaterialPageRoute(builder: (context) => const SettingsScreen()),
@@ -180,7 +181,7 @@ class _MapScreenState extends State<MapScreen> {
context: context,
connector: connector,
position: latLng,
defaultLabel: 'Point of interest',
defaultLabel: context.l10n.map_pointOfInterest,
flags: 'poi',
);
}
@@ -194,7 +195,7 @@ class _MapScreenState extends State<MapScreen> {
context: context,
connector: connector,
position: latLng,
defaultLabel: 'Point of interest',
defaultLabel: context.l10n.map_pointOfInterest,
flags: 'poi',
);
return;
@@ -265,7 +266,7 @@ class _MapScreenState extends State<MapScreen> {
),
const SizedBox(height: 16),
Text(
'No nodes with location data',
context.l10n.map_noNodesWithLocation,
style: TextStyle(
fontSize: 18,
color: Colors.grey[600],
@@ -273,7 +274,7 @@ class _MapScreenState extends State<MapScreen> {
),
const SizedBox(height: 8),
Text(
'Nodes need to share their GPS coordinates\nto appear on the map',
context.l10n.map_nodesNeedGps,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14,
@@ -381,27 +382,27 @@ class _MapScreenState extends State<MapScreen> {
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Nodes: $nodeCount',
context.l10n.map_nodesCount(nodeCount),
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
Text(
'Pins: $markerCount',
context.l10n.map_pinsCount(markerCount),
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
const SizedBox(height: 8),
_buildLegendItem(Icons.person, 'Chat', Colors.blue),
_buildLegendItem(Icons.router, 'Repeater', Colors.green),
_buildLegendItem(Icons.meeting_room, 'Room', Colors.purple),
_buildLegendItem(Icons.sensors, 'Sensor', Colors.orange),
_buildLegendItem(Icons.flag, 'Pin (DM)', Colors.blue),
_buildLegendItem(Icons.flag, 'Pin (Private)', Colors.purple),
_buildLegendItem(Icons.flag, 'Pin (Public)', Colors.orange),
_buildLegendItem(Icons.person, context.l10n.map_chat, Colors.blue),
_buildLegendItem(Icons.router, context.l10n.map_repeater, Colors.green),
_buildLegendItem(Icons.meeting_room, context.l10n.map_room, Colors.purple),
_buildLegendItem(Icons.sensors, context.l10n.map_sensor, Colors.orange),
_buildLegendItem(Icons.flag, context.l10n.map_pinDm, Colors.blue),
_buildLegendItem(Icons.flag, context.l10n.map_pinPrivate, Colors.purple),
_buildLegendItem(Icons.flag, context.l10n.map_pinPublic, Colors.orange),
],
),
),
@@ -501,7 +502,7 @@ class _MapScreenState extends State<MapScreen> {
final flags = parts.length > 2 ? parts[2].trim() : '';
return _MarkerPayload(
position: LatLng(lat, lon),
label: label.isEmpty ? 'Shared pin' : label,
label: label.isEmpty ? context.l10n.map_sharedPin : label,
flags: flags,
);
}
@@ -595,7 +596,7 @@ class _MapScreenState extends State<MapScreen> {
void _showNodeInfo(BuildContext context, Contact contact) {
showDialog(
context: context,
builder: (context) => AlertDialog(
builder: (dialogContext) => AlertDialog(
title: Row(
children: [
Icon(
@@ -614,19 +615,19 @@ class _MapScreenState extends State<MapScreen> {
_buildInfoRow('Path', contact.pathLabel),
_buildInfoRow('Location',
'${contact.latitude!.toStringAsFixed(6)}, ${contact.longitude!.toStringAsFixed(6)}'),
_buildInfoRow('Last Seen', _formatLastSeen(contact.lastSeen)),
_buildInfoRow(context.l10n.map_lastSeen, _formatLastSeen(contact.lastSeen)),
_buildInfoRow('Public Key', contact.publicKeyHex),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
onPressed: () => Navigator.pop(dialogContext),
child: Text(context.l10n.common_close),
),
if (contact.type == advTypeChat) // Only show chat button for chat nodes
TextButton(
onPressed: () {
Navigator.pop(context);
Navigator.pop(dialogContext);
Navigator.push(
context,
MaterialPageRoute(
@@ -634,23 +635,23 @@ class _MapScreenState extends State<MapScreen> {
),
);
},
child: const Text('Open Chat'),
child: Text(context.l10n.contacts_openChat),
),
if (contact.type == advTypeRepeater)
TextButton(
onPressed: () {
Navigator.pop(context);
Navigator.pop(dialogContext);
_showRepeaterLogin(context, contact);
},
child: const Text('Manage Repeater'),
child: Text(context.l10n.map_manageRepeater),
),
if (contact.type == advTypeRoom)
TextButton(
onPressed: () {
Navigator.pop(context);
Navigator.pop(dialogContext);
_showRoomLogin(context, contact);
},
child: const Text('Join Room'),
child: Text(context.l10n.map_joinRoom),
),
],
),
@@ -685,17 +686,17 @@ class _MapScreenState extends State<MapScreen> {
) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Disconnect'),
content: const Text('Are you sure you want to disconnect from this device?'),
builder: (dialogContext) => AlertDialog(
title: Text(context.l10n.common_disconnect),
content: Text(context.l10n.map_disconnectConfirm),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
onPressed: () => Navigator.pop(dialogContext, false),
child: Text(context.l10n.common_cancel),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Disconnect'),
onPressed: () => Navigator.pop(dialogContext, true),
child: Text(context.l10n.common_disconnect),
),
],
),
@@ -709,19 +710,19 @@ class _MapScreenState extends State<MapScreen> {
void _showMarkerInfo(_SharedMarker marker) {
showDialog(
context: context,
builder: (context) => AlertDialog(
builder: (dialogContext) => AlertDialog(
title: Text(marker.label),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildInfoRow('From', marker.fromName),
_buildInfoRow('Source', marker.sourceLabel),
_buildInfoRow(context.l10n.map_from, marker.fromName),
_buildInfoRow(context.l10n.map_source, marker.sourceLabel),
_buildInfoRow(
'Location',
'${marker.position.latitude.toStringAsFixed(6)}, ${marker.position.longitude.toStringAsFixed(6)}',
),
if (marker.flags.isNotEmpty) _buildInfoRow('Flags', marker.flags),
if (marker.flags.isNotEmpty) _buildInfoRow(context.l10n.map_flags, marker.flags),
],
),
actions: [
@@ -730,9 +731,9 @@ class _MapScreenState extends State<MapScreen> {
setState(() {
_hiddenMarkerIds.add(marker.id);
});
Navigator.pop(context);
Navigator.pop(dialogContext);
},
child: const Text('Hide'),
child: Text(context.l10n.common_hide),
),
TextButton(
onPressed: () async {
@@ -741,15 +742,15 @@ class _MapScreenState extends State<MapScreen> {
_removedMarkerIds.add(marker.id);
});
await _markerService.saveRemovedIds(_removedMarkerIds);
if (context.mounted) {
Navigator.pop(context);
if (dialogContext.mounted) {
Navigator.pop(dialogContext);
}
},
child: const Text('Remove'),
child: Text(context.l10n.common_remove),
),
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
onPressed: () => Navigator.pop(dialogContext),
child: Text(context.l10n.common_close),
),
],
),
@@ -785,13 +786,13 @@ class _MapScreenState extends State<MapScreen> {
final difference = now.difference(lastSeen);
if (difference.inSeconds < 60) {
return 'Just now';
return context.l10n.time_justNow;
} else if (difference.inMinutes < 60) {
return '${difference.inMinutes}m ago';
return context.l10n.time_minutesAgo(difference.inMinutes);
} else if (difference.inHours < 24) {
return '${difference.inHours}h ago';
return context.l10n.time_hoursAgo(difference.inHours);
} else {
return '${difference.inDays}d ago';
return context.l10n.time_daysAgo(difference.inDays);
}
}
@@ -808,21 +809,21 @@ class _MapScreenState extends State<MapScreen> {
children: [
ListTile(
leading: const Icon(Icons.place),
title: const Text('Share marker here'),
title: Text(context.l10n.map_shareMarkerHere),
onTap: () {
Navigator.pop(sheetContext);
_shareMarker(
context: context,
connector: connector,
position: position,
defaultLabel: 'Point of interest',
defaultLabel: context.l10n.map_pointOfInterest,
flags: 'poi',
);
},
),
ListTile(
leading: const Icon(Icons.close),
title: const Text('Cancel'),
title: Text(context.l10n.common_cancel),
onTap: () => Navigator.pop(sheetContext),
),
],
@@ -840,7 +841,7 @@ class _MapScreenState extends State<MapScreen> {
}) async {
if (!connector.isConnected) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Connect to a device to share markers')),
SnackBar(content: Text(context.l10n.map_connectToShareMarkers)),
);
return;
}
@@ -864,25 +865,25 @@ class _MapScreenState extends State<MapScreen> {
return showDialog<String>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Pin label'),
title: Text(context.l10n.map_pinLabel),
content: TextField(
controller: controller,
decoration: const InputDecoration(
hintText: 'Label',
border: OutlineInputBorder(),
decoration: InputDecoration(
hintText: context.l10n.map_label,
border: const OutlineInputBorder(),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: const Text('Cancel'),
child: Text(context.l10n.common_cancel),
),
TextButton(
onPressed: () {
final label = controller.text.trim().replaceAll('|', '/');
Navigator.pop(dialogContext, label.isEmpty ? defaultLabel : label);
},
child: const Text('Continue'),
child: Text(context.l10n.common_continue),
),
],
),
@@ -910,7 +911,7 @@ class _MapScreenState extends State<MapScreen> {
builder: (sheetContext) => StatefulBuilder(
builder: (sheetContext, setSheetState) {
return Consumer<MeshCoreConnector>(
builder: (context, liveConnector, child) {
builder: (consumerContext, liveConnector, child) {
final allContacts = liveConnector.contacts
.where((contact) =>
contact.type != advTypeRepeater && contact.type != advTypeRoom)
@@ -921,15 +922,15 @@ class _MapScreenState extends State<MapScreen> {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Text('Send to contact', style: TextStyle(fontWeight: FontWeight.bold)),
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Text(context.l10n.map_sendToContact, style: const TextStyle(fontWeight: FontWeight.bold)),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 8),
child: TextField(
decoration: InputDecoration(
hintText: 'Search contacts...',
hintText: context.l10n.contacts_searchContacts,
prefixIcon: const Icon(Icons.search),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
@@ -956,9 +957,9 @@ class _MapScreenState extends State<MapScreen> {
},
);
}),
const Padding(
padding: EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Text('Send to channel', style: TextStyle(fontWeight: FontWeight.bold)),
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Text(context.l10n.map_sendToChannel, style: const TextStyle(fontWeight: FontWeight.bold)),
),
if (liveConnector.isLoadingChannels)
const Padding(
@@ -966,9 +967,9 @@ class _MapScreenState extends State<MapScreen> {
child: LinearProgressIndicator(),
)
else if (liveConnector.channels.where((c) => !c.isEmpty).isEmpty)
const Padding(
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text('No channels available'),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text(context.l10n.map_noChannelsAvailable),
)
else
...liveConnector.channels.where((c) => !c.isEmpty).map((channel) {
@@ -980,7 +981,7 @@ class _MapScreenState extends State<MapScreen> {
color: isPublic ? Colors.orange : Colors.blue,
),
title: Text(label),
subtitle: isPublic ? const Text('Public channel') : null,
subtitle: isPublic ? Text(context.l10n.channels_publicChannel) : null,
onTap: () async {
Navigator.pop(sheetContext);
final canSend = isPublic
@@ -1011,19 +1012,16 @@ class _MapScreenState extends State<MapScreen> {
final result = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Public location share'),
content: Text(
'You are about to share a location in $channelLabel. '
'This channel is public and anyone with the PSK can see it.',
),
title: Text(context.l10n.map_publicLocationShare),
content: Text(context.l10n.map_publicLocationShareConfirm(channelLabel)),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: const Text('Cancel'),
child: Text(context.l10n.common_cancel),
),
TextButton(
onPressed: () => Navigator.pop(dialogContext, true),
child: const Text('Share'),
child: Text(context.l10n.common_share),
),
],
),
@@ -1035,26 +1033,26 @@ class _MapScreenState extends State<MapScreen> {
showDialog(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Filter Nodes'),
title: Text(context.l10n.map_filterNodes),
contentPadding: const EdgeInsets.fromLTRB(24, 20, 24, 0),
content: SingleChildScrollView(
child: Consumer<AppSettingsService>(
builder: (context, service, child) {
builder: (consumerContext, service, child) {
final settings = service.settings;
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Node Types',
style: TextStyle(
Text(
context.l10n.map_nodeTypes,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
const SizedBox(height: 8),
CheckboxListTile(
title: const Text('Chat Nodes'),
title: Text(context.l10n.map_chatNodes),
value: settings.mapShowChatNodes,
onChanged: (value) {
service.setMapShowChatNodes(value ?? true);
@@ -1062,7 +1060,7 @@ class _MapScreenState extends State<MapScreen> {
contentPadding: EdgeInsets.zero,
),
CheckboxListTile(
title: const Text('Repeaters'),
title: Text(context.l10n.map_repeaters),
value: settings.mapShowRepeaters,
onChanged: (value) {
service.setMapShowRepeaters(value ?? true);
@@ -1070,7 +1068,7 @@ class _MapScreenState extends State<MapScreen> {
contentPadding: EdgeInsets.zero,
),
CheckboxListTile(
title: const Text('Other Nodes'),
title: Text(context.l10n.map_otherNodes),
value: settings.mapShowOtherNodes,
onChanged: (value) {
service.setMapShowOtherNodes(value ?? true);
@@ -1078,16 +1076,16 @@ class _MapScreenState extends State<MapScreen> {
contentPadding: EdgeInsets.zero,
),
const SizedBox(height: 16),
const Text(
'Key Prefix',
style: TextStyle(
Text(
context.l10n.map_keyPrefix,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
const SizedBox(height: 8),
CheckboxListTile(
title: const Text('Filter by key prefix'),
title: Text(context.l10n.map_filterByKeyPrefix),
value: settings.mapKeyPrefixEnabled,
onChanged: (value) {
service.setMapKeyPrefixEnabled(value ?? false);
@@ -1097,10 +1095,10 @@ class _MapScreenState extends State<MapScreen> {
TextFormField(
initialValue: settings.mapKeyPrefix,
enabled: settings.mapKeyPrefixEnabled,
decoration: const InputDecoration(
labelText: 'Public key prefix',
decoration: InputDecoration(
labelText: context.l10n.map_publicKeyPrefix,
hintText: 'e.g. ab12',
border: OutlineInputBorder(),
border: const OutlineInputBorder(),
isDense: true,
),
onChanged: (value) {
@@ -1108,16 +1106,16 @@ class _MapScreenState extends State<MapScreen> {
},
),
const SizedBox(height: 16),
const Text(
'Markers',
style: TextStyle(
Text(
context.l10n.map_markers,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
const SizedBox(height: 8),
CheckboxListTile(
title: const Text('Show shared markers'),
title: Text(context.l10n.map_showSharedMarkers),
value: settings.mapShowMarkers,
onChanged: (value) {
service.setMapShowMarkers(value ?? true);
@@ -1125,9 +1123,9 @@ class _MapScreenState extends State<MapScreen> {
contentPadding: EdgeInsets.zero,
),
const SizedBox(height: 16),
const Text(
'Last Seen Time',
style: TextStyle(
Text(
context.l10n.map_lastSeenTime,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
@@ -1158,7 +1156,7 @@ class _MapScreenState extends State<MapScreen> {
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: const Text('Close'),
child: Text(context.l10n.common_close),
),
],
),
@@ -1205,23 +1203,24 @@ class _MapScreenState extends State<MapScreen> {
}
String _getTimeFilterLabel(double hours) {
if (hours == 0) return 'All Time';
if (hours == 0) return context.l10n.time_allTime;
if (hours < 1) {
return '${(hours * 60).round()} minutes';
return '${(hours * 60).round()} ${context.l10n.time_minutes}';
} else if (hours < 24) {
return '${hours.round()} ${hours.round() == 1 ? 'hour' : 'hours'}';
final h = hours.round();
return '$h ${h == 1 ? context.l10n.time_hour : context.l10n.time_hours}';
} else if (hours < 168) {
final days = (hours / 24).round();
return '$days ${days == 1 ? 'day' : 'days'}';
return '$days ${days == 1 ? context.l10n.time_day : context.l10n.time_days}';
} else if (hours < 720) {
final weeks = (hours / 168).round();
return '$weeks ${weeks == 1 ? 'week' : 'weeks'}';
return '$weeks ${weeks == 1 ? context.l10n.time_week : context.l10n.time_weeks}';
} else if (hours < 4380) {
final months = (hours / 730).round();
return '$months ${months == 1 ? 'month' : 'months'}';
return '$months ${months == 1 ? context.l10n.time_month : context.l10n.time_months}';
} else {
return 'All Time';
return context.l10n.time_allTime;
}
}
}
+178 -179
View File
@@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../l10n/l10n.dart';
import '../models/contact.dart';
import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart';
@@ -33,14 +34,14 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
RepeaterCommandService? _commandService;
// Common commands for quick access
final List<Map<String, String>> _quickCommands = [
{'label': 'Get Name', 'command': 'get name'},
{'label': 'Get Radio', 'command': 'get radio'},
{'label': 'Get TX', 'command': 'get tx'},
{'label': 'Neighbors', 'command': 'neighbors'},
{'label': 'Version', 'command': 'ver'},
{'label': 'Advertise', 'command': 'advert'},
{'label': 'Clock', 'command': 'clock'},
late final List<Map<String, String>> _quickCommands = [
{'labelKey': 'getName', 'command': 'get name'},
{'labelKey': 'getRadio', 'command': 'get radio'},
{'labelKey': 'getTx', 'command': 'get tx'},
{'labelKey': 'neighbors', 'command': 'neighbors'},
{'labelKey': 'version', 'command': 'ver'},
{'labelKey': 'advertise', 'command': 'advert'},
{'labelKey': 'clock', 'command': 'clock'},
];
@override
@@ -119,7 +120,7 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
// Show debug info if requested
if (showDebug && mounted) {
final frame = buildSendCliCommandFrame(widget.repeater.publicKey, command);
DebugFrameViewer.showFrameDebug(context, frame, 'CLI Command Frame');
DebugFrameViewer.showFrameDebug(context, frame, context.l10n.repeater_cliCommandFrameTitle);
}
// Send CLI command to repeater with retry
@@ -148,7 +149,7 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
setState(() {
_commandHistory.add({
'type': 'response',
'text': 'Error: $e',
'text': context.l10n.repeater_cliCommandError(e.toString()),
'timestamp': DateTime.now().toString(),
});
});
@@ -215,6 +216,7 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final connector = context.watch<MeshCoreConnector>();
final repeater = _resolveRepeater(connector);
final isFloodMode = repeater.pathOverride == -1;
@@ -225,7 +227,7 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
const Text('Repeater CLI'),
Text(l10n.repeater_cliTitle),
Text(
repeater.name,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.normal),
@@ -236,7 +238,7 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
actions: [
PopupMenuButton<String>(
icon: Icon(isFloodMode ? Icons.waves : Icons.route),
tooltip: 'Routing mode',
tooltip: l10n.repeater_routingMode,
onSelected: (mode) async {
if (mode == 'flood') {
await connector.setPathOverride(repeater, pathLen: -1);
@@ -252,7 +254,7 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
Icon(Icons.auto_mode, size: 20, color: !isFloodMode ? Theme.of(context).primaryColor : null),
const SizedBox(width: 8),
Text(
'Auto (use saved path)',
l10n.repeater_autoUseSavedPath,
style: TextStyle(
fontWeight: !isFloodMode ? FontWeight.bold : FontWeight.normal,
),
@@ -267,7 +269,7 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
Icon(Icons.waves, size: 20, color: isFloodMode ? Theme.of(context).primaryColor : null),
const SizedBox(width: 8),
Text(
'Force Flood Mode',
l10n.repeater_forceFloodMode,
style: TextStyle(
fontWeight: isFloodMode ? FontWeight.bold : FontWeight.normal,
),
@@ -279,31 +281,31 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
),
IconButton(
icon: const Icon(Icons.timeline),
tooltip: 'Path management',
tooltip: l10n.repeater_pathManagement,
onPressed: () => PathManagementDialog.show(context, contact: repeater),
),
IconButton(
icon: const Icon(Icons.bug_report),
tooltip: 'Debug Next Command',
tooltip: l10n.repeater_debugNextCommand,
onPressed: () {
// Set a flag or just send next command with debug
if (_commandController.text.trim().isNotEmpty) {
_sendCommand(showDebug: true);
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Enter a command first')),
SnackBar(content: Text(l10n.repeater_enterCommandFirst)),
);
}
},
),
IconButton(
icon: const Icon(Icons.help_outline),
tooltip: 'Command Help',
tooltip: l10n.repeater_commandHelp,
onPressed: () => _showCommandHelp(context),
),
IconButton(
icon: const Icon(Icons.clear_all),
tooltip: 'Clear History',
tooltip: l10n.repeater_clearHistory,
onPressed: _commandHistory.isEmpty ? null : _clearHistory,
),
],
@@ -331,10 +333,11 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
scrollDirection: Axis.horizontal,
child: Row(
children: _quickCommands.map((cmd) {
final label = _quickCommandLabel(cmd['labelKey']!);
return Padding(
padding: const EdgeInsets.only(right: 8),
child: ActionChip(
label: Text(cmd['label']!),
label: Text(label),
onPressed: () => _useQuickCommand(cmd['command']!),
avatar: const Icon(Icons.play_arrow, size: 16),
),
@@ -345,7 +348,30 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
);
}
String _quickCommandLabel(String key) {
final l10n = context.l10n;
switch (key) {
case 'getName':
return l10n.repeater_cliQuickGetName;
case 'getRadio':
return l10n.repeater_cliQuickGetRadio;
case 'getTx':
return l10n.repeater_cliQuickGetTx;
case 'neighbors':
return l10n.repeater_cliQuickNeighbors;
case 'version':
return l10n.repeater_cliQuickVersion;
case 'advertise':
return l10n.repeater_cliQuickAdvertise;
case 'clock':
return l10n.repeater_cliQuickClock;
default:
return key;
}
}
Widget _buildEmptyState() {
final l10n = context.l10n;
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
@@ -353,12 +379,12 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
Icon(Icons.terminal, size: 64, color: Colors.grey[400]),
const SizedBox(height: 16),
Text(
'No commands sent yet',
l10n.repeater_noCommandsSent,
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
),
const SizedBox(height: 8),
Text(
'Type a command below or use quick commands',
l10n.repeater_typeCommandOrUseQuick,
style: TextStyle(fontSize: 14, color: Colors.grey[500]),
),
],
@@ -422,6 +448,7 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
}
Widget _buildCommandInput() {
final l10n = context.l10n;
return Container(
padding: const EdgeInsets.all(12),
color: Theme.of(context).colorScheme.surface,
@@ -430,12 +457,12 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
children: [
IconButton(
icon: const Icon(Icons.arrow_upward, size: 20),
tooltip: 'Previous command',
tooltip: l10n.repeater_previousCommand,
onPressed: () => _navigateHistory(true),
),
IconButton(
icon: const Icon(Icons.arrow_downward, size: 20),
tooltip: 'Next command',
tooltip: l10n.repeater_nextCommand,
onPressed: () => _navigateHistory(false),
),
const SizedBox(width: 8),
@@ -443,10 +470,10 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
child: TextField(
controller: _commandController,
focusNode: _commandFocusNode,
decoration: const InputDecoration(
hintText: 'Enter command...',
border: OutlineInputBorder(),
contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: InputDecoration(
hintText: l10n.repeater_enterCommandHint,
border: const OutlineInputBorder(),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
prefixText: '> ',
),
style: const TextStyle(fontFamily: 'monospace'),
@@ -479,312 +506,284 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
}
void _showCommandHelp(BuildContext context) {
final l10n = context.l10n;
final generalCommands = [
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'advert',
description: 'Sends an advertisement packet',
description: l10n.repeater_cliHelpAdvert,
),
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'reboot',
description:
"Reboots the device. (note, you'll prob get 'Timeout' which is normal)",
description: l10n.repeater_cliHelpReboot,
),
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'clock',
description: "Displays current time per device's clock.",
description: l10n.repeater_cliHelpClock,
),
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'password {new-password}',
description: 'Sets a new admin password for the device.',
description: l10n.repeater_cliHelpPassword,
),
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'ver',
description: 'Shows the device version and firmware build date.',
description: l10n.repeater_cliHelpVersion,
),
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'clear stats',
description: 'Resets various stats counters to zero.',
description: l10n.repeater_cliHelpClearStats,
),
];
final settingsCommands = [
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'set af {air-time-factor}',
description: 'Sets the air-time-factor.',
description: l10n.repeater_cliHelpSetAf,
),
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'set tx {tx-power-dbm}',
description: 'Sets LoRa transmit power in dBm. (reboot to apply)',
description: l10n.repeater_cliHelpSetTx,
),
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'set repeat {on|off}',
description: 'Enables or disables the repeater role for this node.',
description: l10n.repeater_cliHelpSetRepeat,
),
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'set allow.read.only {on|off}',
description:
"(Room server) If 'on', then login in blank password will be allowed, but cannot Post to room. (just read only)",
description: l10n.repeater_cliHelpSetAllowReadOnly,
),
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'set flood.max {max-hops}',
description:
'Sets the maximum number of hops of inbound flood packet (if >= max, packet is not forwarded)',
description: l10n.repeater_cliHelpSetFloodMax,
),
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'set int.thresh {db}',
description:
'Sets the Interference Threshold (in DB). Default is 14. Set to 0 to disable channel interference detection.',
description: l10n.repeater_cliHelpSetIntThresh,
),
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'set agc.reset.interval {seconds}',
description:
'Sets the interval to reset the Auto Gain Controller. Set to 0 to disable.',
description: l10n.repeater_cliHelpSetAgcResetInterval,
),
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'set multi.acks {0|1}',
description: "Enables or disables the 'double ACKs' feature.",
description: l10n.repeater_cliHelpSetMultiAcks,
),
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'set advert.interval {minutes}',
description:
'Sets the timer interval in minutes to send a local (zero-hop) advertisement packet. Set to 0 to disable.',
description: l10n.repeater_cliHelpSetAdvertInterval,
),
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'set flood.advert.interval {hours}',
description:
'Sets the timer interval in hours to send a flood advertisement packet. Set to 0 to disable.',
description: l10n.repeater_cliHelpSetFloodAdvertInterval,
),
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'set guest.password {guess-password}',
description:
'Sets/updates the guest password. (for repeaters, guest logins can send the "Get Stats" request)',
description: l10n.repeater_cliHelpSetGuestPassword,
),
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'set name {name}',
description: 'Sets the advertisement name.',
description: l10n.repeater_cliHelpSetName,
),
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'set lat {latitude}',
description: 'Sets the advertisement map latitude. (decimal degrees)',
description: l10n.repeater_cliHelpSetLat,
),
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'set lon {longitude}',
description: 'Sets the advertisement map longitude. (decimal degrees)',
description: l10n.repeater_cliHelpSetLon,
),
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'set radio {freq},{bw},{sf},{cr}',
description:
'Sets completely new radio params, and saves to preferences. Requires a "reboot" command to apply.',
description: l10n.repeater_cliHelpSetRadio,
),
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'set rxdelay {base}',
description:
'Sets (experimental) base (must be > 1 for effect) for applying slight delay to received packets, based on signal strength/score. Set to 0 to disable.',
description: l10n.repeater_cliHelpSetRxDelay,
),
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'set txdelay {factor}',
description:
'Sets a factor multiplied with time-on-air for a flood-mode packet and with a randomized slot system, to delay its forwarding. (to decrease likelihood of collisions)',
description: l10n.repeater_cliHelpSetTxDelay,
),
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'set direct.txdelay {factor}',
description:
'Same as txdelay, but for applying a random delay to the forwarding of direct-mode packets.',
description: l10n.repeater_cliHelpSetDirectTxDelay,
),
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'set bridge.enabled {on|off}',
description: 'Enable/Disable bridge.',
description: l10n.repeater_cliHelpSetBridgeEnabled,
),
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'set bridge.delay {0-10000}',
description: 'Set delay before retransmitting packets.',
description: l10n.repeater_cliHelpSetBridgeDelay,
),
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'set bridge.source {rx|tx}',
description:
'Choose wether the bridge will retransmit received packets or transmitted packets.',
description: l10n.repeater_cliHelpSetBridgeSource,
),
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'set bridge.baud {speed}',
description: 'Set serial link baudrate for rs232 bridges.',
description: l10n.repeater_cliHelpSetBridgeBaud,
),
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'set bridge.secret {shared-secret}',
description: 'Set bridge secret for espnow bridges.',
description: l10n.repeater_cliHelpSetBridgeSecret,
),
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'set adc.multiplier {factor}',
description:
'Sets custom factor to adjust reported battery voltage (only supported on select boards).',
description: l10n.repeater_cliHelpSetAdcMultiplier,
),
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'tempradio {freq},{bw},{sf},{cr},{minutes}',
description:
'Sets temporary radio params for the given number of {minutes}, reverting to original radio params afterward. (does NOT save to preferences).',
description: l10n.repeater_cliHelpTempRadio,
),
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'setperm {pubkey-hex} {permissions}',
description:
'Modifies the ACL. Removes matching entry (by pubkey prefix) if "permissions" is zero. Adds new entry if pubkey-hex is full length and is not currently in ACL. Updates entry by matching pubkey prefix. Permission bits vary per firmware role, but low 2 bits are: 0 (Guest), 1 (Read only), 2 (Read write), 3 (Admin)',
description: l10n.repeater_cliHelpSetPerm,
),
];
final bridgeCommands = [
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'get bridge.type',
description: 'Gets bridge type none, rs232, espnow',
description: l10n.repeater_cliHelpGetBridgeType,
),
];
final loggingCommands = [
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'log start',
description: 'Starts packet logging to file system.',
description: l10n.repeater_cliHelpLogStart,
),
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'log stop',
description: 'Stops packet logging to file system.',
description: l10n.repeater_cliHelpLogStop,
),
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'log erase',
description: 'Erases the packet logs from file system.',
description: l10n.repeater_cliHelpLogErase,
),
];
final neighborCommands = [
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'neighbors',
description:
'Shows a list of other repeater nodes heard via zero-hop adverts. Each line is {id-prefix-hex}:{timestamp}:{snr-times-4}',
description: l10n.repeater_cliHelpNeighbors,
),
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'neighbor.remove {pubkey-prefix}',
description:
'Removes first matching entry (by pubkey prefix (hex)), from neighbors list.',
description: l10n.repeater_cliHelpNeighborRemove,
),
];
final regionCommands = [
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'region',
description:
'(serial only) Lists all defined regions and current flood permissions.',
description: l10n.repeater_cliHelpRegion,
),
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'region load',
description:
'NOTE: this is a special multi-command invocation. Each subsequent command is a region name (indented with spaces to indicate parent hierarchy, with one space at minimum). Terminated by sending a blank line/command.',
description: l10n.repeater_cliHelpRegionLoad,
),
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'region get {* | name-prefix}',
description:
'Searches for region with given name prefix (or "*" for the global scope). Replies with "-> {region-name} ({parent-name}) {\'F\'}"',
description: l10n.repeater_cliHelpRegionGet,
),
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'region put {name} {* | parent-name-prefix}',
description: 'Adds or updates a region definition with given name.',
description: l10n.repeater_cliHelpRegionPut,
),
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'region remove {name}',
description:
'Removes a region definition with given name. (must match exactly, and have no child regions)',
description: l10n.repeater_cliHelpRegionRemove,
),
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'region allowf {* | name-prefix}',
description:
"Sets the 'F'lood permission for the given region. ('*' for the global/legacy scope)",
description: l10n.repeater_cliHelpRegionAllowf,
),
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'region denyf {* | name-prefix}',
description:
"Removes the 'F'lood permission for the given region. (NOTE: at this stage NOT advised to use this on the global/legacy scope!!)",
description: l10n.repeater_cliHelpRegionDenyf,
),
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'region home',
description:
"Replies with the current 'home' region. (Note applied anywhere yet, reserved for future)",
description: l10n.repeater_cliHelpRegionHome,
),
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'region home {* | name-prefix}',
description: "Sets the 'home' region.",
description: l10n.repeater_cliHelpRegionHomeSet,
),
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'region save',
description: 'Persists the region list/map to storage.',
description: l10n.repeater_cliHelpRegionSave,
),
];
final gpsCommands = [
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'gps',
description:
'Gives status of gps. When gps is off, it replies only off, if on it replies with on, {status}, {fix}, {sat count}',
description: l10n.repeater_cliHelpGps,
),
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'gps {on|off}',
description: 'Toggles gps power state.',
description: l10n.repeater_cliHelpGpsOnOff,
),
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'gps sync',
description: 'Syncs node time with gps clock.',
description: l10n.repeater_cliHelpGpsSync,
),
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'gps setloc',
description: "Sets node's position to gps coordinates and save preferences.",
description: l10n.repeater_cliHelpGpsSetLoc,
),
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'gps advert',
description:
"Gives location advert configuration of the node:\n- none: don't include location in adverts\n- share: share gps location (from SensorManager)\n- prefs: advert the location stored in preferences",
description: l10n.repeater_cliHelpGpsAdvert,
),
const _CommandHelpEntry(
_CommandHelpEntry(
command: 'gps advert {none|share|prefs}',
description: 'Sets location advert configuration.',
description: l10n.repeater_cliHelpGpsAdvertSet,
),
];
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Commands List'),
title: Text(l10n.repeater_commandsListTitle),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'NOTE: for the various "set ..." commands, there is also a "get ..." command.',
style: TextStyle(fontSize: 13),
Text(
l10n.repeater_commandsListNote,
style: const TextStyle(fontSize: 13),
),
const SizedBox(height: 16),
_buildHelpSection(context, 'General', generalCommands),
_buildHelpSection(context, l10n.repeater_general, generalCommands),
const SizedBox(height: 16),
_buildHelpSection(context, 'Settings', settingsCommands),
_buildHelpSection(context, l10n.repeater_settingsCategory, settingsCommands),
const SizedBox(height: 16),
_buildHelpSection(context, 'Bridge', bridgeCommands),
_buildHelpSection(context, l10n.repeater_bridge, bridgeCommands),
const SizedBox(height: 16),
_buildHelpSection(context, 'Logging', loggingCommands),
_buildHelpSection(context, l10n.repeater_logging, loggingCommands),
const SizedBox(height: 16),
_buildHelpSection(
context,
'Neighbors (Repeater only)',
l10n.repeater_neighborsRepeaterOnly,
neighborCommands,
),
const SizedBox(height: 16),
_buildHelpSection(
context,
'Region Management (Repeater only)',
l10n.repeater_regionManagementRepeaterOnly,
regionCommands,
note:
'Region commands have been introduced to manage region definitions and permissions.',
note: l10n.repeater_regionNote,
),
const SizedBox(height: 16),
_buildHelpSection(
context,
'GPS Management',
l10n.repeater_gpsManagement,
gpsCommands,
note:
'gps command has been introduced to manage location related topics.',
note: l10n.repeater_gpsNote,
),
],
),
@@ -792,7 +791,7 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
child: Text(l10n.common_close),
),
],
),
+15 -13
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import '../l10n/l10n.dart';
import '../models/contact.dart';
import 'repeater_status_screen.dart';
import 'repeater_cli_screen.dart';
@@ -17,13 +18,14 @@ class RepeaterHubScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
return Scaffold(
appBar: AppBar(
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
const Text('Repeater Management'),
Text(l10n.repeater_management),
Text(
repeater.name,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.normal),
@@ -77,17 +79,17 @@ class RepeaterHubScreen extends StatelessWidget {
),
),
const SizedBox(height: 24),
const Text(
'Management Tools',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
Text(
l10n.repeater_managementTools,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
// Status button
_buildManagementCard(
context,
icon: Icons.analytics,
title: 'Status',
subtitle: 'View repeater status, stats, and neighbors',
title: l10n.repeater_status,
subtitle: l10n.repeater_statusSubtitle,
color: Colors.blue,
onTap: () {
Navigator.push(
@@ -102,12 +104,12 @@ class RepeaterHubScreen extends StatelessWidget {
},
),
const SizedBox(height: 16),
// Status button
// Telemetry button
_buildManagementCard(
context,
icon: Icons.bar_chart_sharp,
title: 'Telemetry',
subtitle: 'View telemetry of sensors and system stats',
title: l10n.repeater_telemetry,
subtitle: l10n.repeater_telemetrySubtitle,
color: Colors.teal,
onTap: () {
Navigator.push(
@@ -126,8 +128,8 @@ class RepeaterHubScreen extends StatelessWidget {
_buildManagementCard(
context,
icon: Icons.terminal,
title: 'CLI',
subtitle: 'Send commands to the repeater',
title: l10n.repeater_cli,
subtitle: l10n.repeater_cliSubtitle,
color: Colors.green,
onTap: () {
Navigator.push(
@@ -146,8 +148,8 @@ class RepeaterHubScreen extends StatelessWidget {
_buildManagementCard(
context,
icon: Icons.settings,
title: 'Settings',
subtitle: 'Configure repeater parameters',
title: l10n.repeater_settings,
subtitle: l10n.repeater_settingsSubtitle,
color: Colors.orange,
onTap: () {
Navigator.push(
+125 -118
View File
@@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../l10n/l10n.dart';
import '../models/contact.dart';
import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart';
@@ -32,7 +33,6 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
bool _refreshingLocation = false;
bool _refreshingRepeat = false;
bool _refreshingAllowReadOnly = false;
bool _refreshingPrivacy = false;
bool _refreshingAdvertisement = false;
StreamSubscription<Uint8List>? _frameSubscription;
RepeaterCommandService? _commandService;
@@ -246,17 +246,6 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
});
}
bool _isAnySectionRefreshing() {
return _refreshingBasic ||
_refreshingRadio ||
_refreshingTxPower ||
_refreshingLocation ||
_refreshingRepeat ||
_refreshingAllowReadOnly ||
_refreshingPrivacy ||
_refreshingAdvertisement;
}
bool _normalizeOnOff(String value) {
final normalized = value.trim().toLowerCase();
return normalized == 'on' ||
@@ -398,6 +387,7 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
required ValueSetter<bool> setRefreshing,
}) async {
if (_commandService == null) return;
final l10n = context.l10n;
setState(() {
setRefreshing(true);
@@ -426,14 +416,14 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
if (successCount > 0) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('$label refreshed'),
content: Text(l10n.repeater_refreshed(label)),
backgroundColor: Colors.green,
),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Error refreshing $label'),
content: Text(l10n.repeater_errorRefreshing(label)),
backgroundColor: Colors.red,
),
);
@@ -449,64 +439,63 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
}
Future<void> _refreshBasicSettings() async {
final l10n = context.l10n;
await _refreshSection(
label: 'Basic settings',
label: l10n.repeater_basicSettings,
commands: const ['get name'],
setRefreshing: (value) => _refreshingBasic = value,
);
}
Future<void> _refreshRadioSettings() async {
final l10n = context.l10n;
await _refreshSection(
label: 'Radio settings',
label: l10n.repeater_radioSettings,
commands: const ['get radio'],
setRefreshing: (value) => _refreshingRadio = value,
);
}
Future<void> _refreshTxPower() async {
final l10n = context.l10n;
await _refreshSection(
label: 'TX power',
label: l10n.repeater_txPower,
commands: const ['get tx'],
setRefreshing: (value) => _refreshingTxPower = value,
);
}
Future<void> _refreshLocationSettings() async {
final l10n = context.l10n;
await _refreshSection(
label: 'Location settings',
label: l10n.repeater_locationSettings,
commands: const ['get lat', 'get lon'],
setRefreshing: (value) => _refreshingLocation = value,
);
}
Future<void> _refreshRepeat() async {
final l10n = context.l10n;
await _refreshSection(
label: 'Packet forwarding',
label: l10n.repeater_packetForwarding,
commands: const ['get repeat'],
setRefreshing: (value) => _refreshingRepeat = value,
);
}
Future<void> _refreshAllowReadOnly() async {
final l10n = context.l10n;
await _refreshSection(
label: 'Guest access',
label: l10n.repeater_guestAccess,
commands: const ['get allow.read.only'],
setRefreshing: (value) => _refreshingAllowReadOnly = value,
);
}
Future<void> _refreshPrivacy() async {
await _refreshSection(
label: 'Privacy mode',
commands: const ['get privacy'],
setRefreshing: (value) => _refreshingPrivacy = value,
);
}
Future<void> _refreshAdvertisementSettings() async {
final l10n = context.l10n;
await _refreshSection(
label: 'Advertisement settings',
label: l10n.repeater_advertisementSettings,
commands: const [
'get advert.interval',
'get flood.advert.interval',
@@ -604,8 +593,8 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Settings saved successfully'),
SnackBar(
content: Text(context.l10n.repeater_settingsSaved),
backgroundColor: Colors.green,
),
);
@@ -618,7 +607,7 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Error saving settings: $e'),
content: Text(context.l10n.repeater_errorSavingSettings(e.toString())),
backgroundColor: Colors.red,
),
);
@@ -637,6 +626,7 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
Widget _buildSectionHeader({
required IconData icon,
required String title,
required String tooltip,
required bool isRefreshing,
required VoidCallback onRefresh,
}) {
@@ -658,7 +648,7 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
)
: const Icon(Icons.refresh),
onPressed: isRefreshing ? null : onRefresh,
tooltip: 'Refresh $title',
tooltip: tooltip,
),
],
);
@@ -688,6 +678,7 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final connector = context.watch<MeshCoreConnector>();
final repeater = _resolveRepeater(connector);
final isFloodMode = repeater.pathOverride == -1;
@@ -698,7 +689,7 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
const Text('Repeater Settings'),
Text(l10n.repeater_settingsTitle),
Text(
repeater.name,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.normal),
@@ -709,7 +700,7 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
actions: [
PopupMenuButton<String>(
icon: Icon(isFloodMode ? Icons.waves : Icons.route),
tooltip: 'Routing mode',
tooltip: l10n.repeater_routingMode,
onSelected: (mode) async {
if (mode == 'flood') {
await connector.setPathOverride(repeater, pathLen: -1);
@@ -728,7 +719,7 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
Icon(Icons.auto_mode, size: 20, color: !isFloodMode ? Theme.of(context).primaryColor : null),
const SizedBox(width: 8),
Text(
'Auto (use saved path)',
l10n.repeater_autoUseSavedPath,
style: TextStyle(
fontWeight: !isFloodMode ? FontWeight.bold : FontWeight.normal,
),
@@ -743,7 +734,7 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
Icon(Icons.waves, size: 20, color: isFloodMode ? Theme.of(context).primaryColor : null),
const SizedBox(width: 8),
Text(
'Force Flood Mode',
l10n.repeater_forceFloodMode,
style: TextStyle(
fontWeight: isFloodMode ? FontWeight.bold : FontWeight.normal,
),
@@ -755,14 +746,14 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
),
IconButton(
icon: const Icon(Icons.timeline),
tooltip: 'Path management',
tooltip: l10n.repeater_pathManagement,
onPressed: () => PathManagementDialog.show(context, contact: repeater),
),
if (_hasChanges)
TextButton.icon(
onPressed: _isLoading ? null : _saveSettings,
icon: const Icon(Icons.save),
label: const Text('Save'),
label: Text(l10n.common_save),
),
],
),
@@ -791,6 +782,7 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
}
Widget _buildBasicSettingsCard() {
final l10n = context.l10n;
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
@@ -799,27 +791,28 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
children: [
_buildSectionHeader(
icon: Icons.settings,
title: 'Basic Settings',
title: l10n.repeater_basicSettings,
tooltip: l10n.repeater_refreshBasicSettings,
isRefreshing: _refreshingBasic,
onRefresh: _refreshBasicSettings,
),
const Divider(),
TextField(
controller: _nameController,
decoration: const InputDecoration(
labelText: 'Repeater Name',
helperText: 'Display name for this repeater',
border: OutlineInputBorder(),
decoration: InputDecoration(
labelText: l10n.repeater_repeaterName,
helperText: l10n.repeater_repeaterNameHelper,
border: const OutlineInputBorder(),
),
onChanged: (_) => _markChanged(),
),
const SizedBox(height: 16),
TextField(
controller: _passwordController,
decoration: const InputDecoration(
labelText: 'Admin Password',
helperText: 'Full access password',
border: OutlineInputBorder(),
decoration: InputDecoration(
labelText: l10n.repeater_adminPassword,
helperText: l10n.repeater_adminPasswordHelper,
border: const OutlineInputBorder(),
),
obscureText: true,
onChanged: (_) => _markChanged(),
@@ -827,10 +820,10 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
const SizedBox(height: 16),
TextField(
controller: _guestPasswordController,
decoration: const InputDecoration(
labelText: 'Guest Password',
helperText: 'Read-only access password',
border: OutlineInputBorder(),
decoration: InputDecoration(
labelText: l10n.repeater_guestPassword,
helperText: l10n.repeater_guestPasswordHelper,
border: const OutlineInputBorder(),
),
obscureText: true,
onChanged: (_) => _markChanged(),
@@ -842,6 +835,7 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
}
Widget _buildRadioSettingsCard() {
final l10n = context.l10n;
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
@@ -850,17 +844,18 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
children: [
_buildSectionHeader(
icon: Icons.radio,
title: 'Radio Settings',
title: l10n.repeater_radioSettings,
tooltip: l10n.repeater_refreshRadioSettings,
isRefreshing: _refreshingRadio,
onRefresh: _refreshRadioSettings,
),
const Divider(),
TextField(
controller: _freqController,
decoration: const InputDecoration(
labelText: 'Frequency (MHz)',
helperText: '300-2500 MHz',
border: OutlineInputBorder(),
decoration: InputDecoration(
labelText: l10n.repeater_frequencyMhz,
helperText: l10n.repeater_frequencyHelper,
border: const OutlineInputBorder(),
suffixText: 'MHz',
),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
@@ -873,10 +868,10 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
Expanded(
child: TextField(
controller: _txPowerController,
decoration: const InputDecoration(
labelText: 'TX Power',
helperText: '1-30 dBm',
border: OutlineInputBorder(),
decoration: InputDecoration(
labelText: l10n.repeater_txPower,
helperText: l10n.repeater_txPowerHelper,
border: const OutlineInputBorder(),
suffixText: 'dBm',
),
keyboardType: TextInputType.number,
@@ -887,16 +882,16 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
_buildInlineRefreshButton(
isRefreshing: _refreshingTxPower,
onRefresh: _refreshTxPower,
tooltip: 'Refresh TX power',
tooltip: l10n.repeater_refreshTxPower,
),
],
),
const SizedBox(height: 16),
DropdownButtonFormField<int>(
initialValue: _bandwidth,
decoration: const InputDecoration(
labelText: 'Bandwidth',
border: OutlineInputBorder(),
decoration: InputDecoration(
labelText: l10n.repeater_bandwidth,
border: const OutlineInputBorder(),
),
items: _bandwidthOptions.map((bw) {
return DropdownMenuItem(
@@ -916,9 +911,9 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
const SizedBox(height: 16),
DropdownButtonFormField<int>(
initialValue: _spreadingFactor,
decoration: const InputDecoration(
labelText: 'Spreading Factor',
border: OutlineInputBorder(),
decoration: InputDecoration(
labelText: l10n.repeater_spreadingFactor,
border: const OutlineInputBorder(),
),
items: _spreadingFactorOptions.map((sf) {
return DropdownMenuItem(
@@ -938,9 +933,9 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
const SizedBox(height: 16),
DropdownButtonFormField<int>(
initialValue: _codingRate,
decoration: const InputDecoration(
labelText: 'Coding Rate',
border: OutlineInputBorder(),
decoration: InputDecoration(
labelText: l10n.repeater_codingRate,
border: const OutlineInputBorder(),
),
items: _codingRateOptions.map((cr) {
return DropdownMenuItem(
@@ -964,6 +959,7 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
}
Widget _buildLocationSettingsCard() {
final l10n = context.l10n;
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
@@ -972,17 +968,18 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
children: [
_buildSectionHeader(
icon: Icons.location_on,
title: 'Location Settings',
title: l10n.repeater_locationSettings,
tooltip: l10n.repeater_refreshLocationSettings,
isRefreshing: _refreshingLocation,
onRefresh: _refreshLocationSettings,
),
const Divider(),
TextField(
controller: _latController,
decoration: const InputDecoration(
labelText: 'Latitude',
helperText: 'Decimal degrees (e.g., 37.7749)',
border: OutlineInputBorder(),
decoration: InputDecoration(
labelText: l10n.repeater_latitude,
helperText: l10n.repeater_latitudeHelper,
border: const OutlineInputBorder(),
),
keyboardType: const TextInputType.numberWithOptions(decimal: true, signed: true),
onChanged: (_) => _markChanged(),
@@ -990,10 +987,10 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
const SizedBox(height: 16),
TextField(
controller: _lonController,
decoration: const InputDecoration(
labelText: 'Longitude',
helperText: 'Decimal degrees (e.g., -122.4194)',
border: OutlineInputBorder(),
decoration: InputDecoration(
labelText: l10n.repeater_longitude,
helperText: l10n.repeater_longitudeHelper,
border: const OutlineInputBorder(),
),
keyboardType: const TextInputType.numberWithOptions(decimal: true, signed: true),
onChanged: (_) => _markChanged(),
@@ -1005,6 +1002,7 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
}
Widget _buildFeatureTogglesCard() {
final l10n = context.l10n;
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
@@ -1015,16 +1013,16 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
children: [
Icon(Icons.toggle_on, color: Theme.of(context).textTheme.headlineSmall?.color),
const SizedBox(width: 8),
const Text(
'Features',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
Text(
l10n.repeater_features,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
],
),
const Divider(),
_buildFeatureToggleRow(
title: 'Packet Forwarding',
subtitle: 'Enable repeater to forward packets',
title: l10n.repeater_packetForwarding,
subtitle: l10n.repeater_packetForwardingSubtitle,
value: _repeatEnabled,
isRefreshing: _refreshingRepeat,
onChanged: (value) {
@@ -1034,10 +1032,11 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
_markChanged();
},
onRefresh: _refreshRepeat,
refreshTooltip: l10n.repeater_refreshPacketForwarding,
),
_buildFeatureToggleRow(
title: 'Guest Access',
subtitle: 'Allow read-only guest access',
title: l10n.repeater_guestAccess,
subtitle: l10n.repeater_guestAccessSubtitle,
value: _allowReadOnly,
isRefreshing: _refreshingAllowReadOnly,
onChanged: (value) {
@@ -1047,11 +1046,12 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
_markChanged();
},
onRefresh: _refreshAllowReadOnly,
refreshTooltip: l10n.repeater_refreshGuestAccess,
),
// Privacy mode - hidden until fully implemented
// _buildFeatureToggleRow(
// title: 'Privacy Mode',
// subtitle: 'Hide name/location in advertisements',
// title: l10n.repeater_privacyMode,
// subtitle: l10n.repeater_privacyModeSubtitle,
// value: _privacyMode,
// isRefreshing: _refreshingPrivacy,
// onChanged: (value) {
@@ -1061,6 +1061,7 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
// _markChanged();
// },
// onRefresh: _refreshPrivacy,
// refreshTooltip: l10n.repeater_refreshPrivacyMode,
// ),
],
),
@@ -1075,6 +1076,7 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
required bool isRefreshing,
required ValueChanged<bool> onChanged,
required VoidCallback onRefresh,
required String refreshTooltip,
}) {
return Row(
children: [
@@ -1093,10 +1095,10 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
)
: const Icon(Icons.refresh, size: 20),
onPressed: isRefreshing ? null : onRefresh,
tooltip: 'Refresh $title',
tooltip: refreshTooltip,
visualDensity: VisualDensity.compact,
),
],
@@ -1104,6 +1106,7 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
}
Widget _buildAdvertisementSettingsCard() {
final l10n = context.l10n;
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
@@ -1112,22 +1115,23 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
children: [
_buildSectionHeader(
icon: Icons.broadcast_on_personal,
title: 'Advertisement Settings',
title: l10n.repeater_advertisementSettings,
tooltip: l10n.repeater_refreshAdvertisementSettings,
isRefreshing: _refreshingAdvertisement,
onRefresh: _refreshAdvertisementSettings,
),
const Divider(),
ListTile(
title: const Text('Local Advertisement Interval'),
subtitle: Text('$_advertInterval minutes'),
trailing: Text('${_advertInterval}m'),
title: Text(l10n.repeater_localAdvertInterval),
subtitle: Text(l10n.repeater_localAdvertIntervalMinutes(_advertInterval)),
trailing: Text(l10n.repeater_localAdvertIntervalMinutes(_advertInterval)),
),
Slider(
value: _advertInterval.toDouble(),
min: 60,
max: 240,
divisions: 18,
label: '${_advertInterval}m',
label: l10n.repeater_localAdvertIntervalMinutes(_advertInterval),
onChanged: (value) {
setState(() {
_advertInterval = value.toInt();
@@ -1137,16 +1141,16 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
),
const SizedBox(height: 16),
ListTile(
title: const Text('Flood Advertisement Interval'),
subtitle: Text('$_floodAdvertInterval hours'),
trailing: Text('${_floodAdvertInterval}h'),
title: Text(l10n.repeater_floodAdvertInterval),
subtitle: Text(l10n.repeater_floodAdvertIntervalHours(_floodAdvertInterval)),
trailing: Text(l10n.repeater_floodAdvertIntervalHours(_floodAdvertInterval)),
),
Slider(
value: _floodAdvertInterval.toDouble(),
min: 3,
max: 48,
divisions: 45,
label: '${_floodAdvertInterval}h',
label: l10n.repeater_floodAdvertIntervalHours(_floodAdvertInterval),
onChanged: (value) {
setState(() {
_floodAdvertInterval = value.toInt();
@@ -1158,16 +1162,16 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
// if (_privacyMode) ...[
// const SizedBox(height: 16),
// ListTile(
// title: const Text('Encrypted Advertisement Interval'),
// subtitle: Text('$_privAdvertInterval minutes'),
// trailing: Text('${_privAdvertInterval}m'),
// title: Text(l10n.repeater_encryptedAdvertInterval),
// subtitle: Text(l10n.repeater_localAdvertIntervalMinutes(_privAdvertInterval)),
// trailing: Text(l10n.repeater_localAdvertIntervalMinutes(_privAdvertInterval)),
// ),
// Slider(
// value: _privAdvertInterval.toDouble(),
// min: 30,
// max: 240,
// divisions: 21,
// label: '${_privAdvertInterval}m',
// label: l10n.repeater_localAdvertIntervalMinutes(_privAdvertInterval),
// onChanged: (value) {
// setState(() {
// _privAdvertInterval = value.toInt();
@@ -1183,6 +1187,7 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
}
Widget _buildDangerZoneCard() {
final l10n = context.l10n;
final colorScheme = Theme.of(context).colorScheme;
return Card(
color: colorScheme.errorContainer,
@@ -1196,7 +1201,7 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
Icon(Icons.warning, color: colorScheme.onErrorContainer),
const SizedBox(width: 8),
Text(
'Danger Zone',
l10n.repeater_dangerZone,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
@@ -1208,14 +1213,14 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
const Divider(),
ListTile(
leading: Icon(Icons.refresh, color: colorScheme.onErrorContainer),
title: Text('Reboot Repeater', style: TextStyle(color: colorScheme.onErrorContainer)),
title: Text(l10n.repeater_rebootRepeater, style: TextStyle(color: colorScheme.onErrorContainer)),
subtitle: Text(
'Restart the repeater device',
l10n.repeater_rebootRepeaterSubtitle,
style: TextStyle(color: colorScheme.onErrorContainer.withValues(alpha: 0.8)),
),
onTap: () => _confirmAction(
'Reboot Repeater',
'Are you sure you want to reboot this repeater?',
l10n.repeater_rebootRepeater,
l10n.repeater_rebootRepeaterConfirm,
() => _sendDangerCommand('reboot'),
),
),
@@ -1235,14 +1240,14 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
// ),
ListTile(
leading: Icon(Icons.delete_forever, color: colorScheme.onErrorContainer),
title: Text('Erase File System', style: TextStyle(color: colorScheme.onErrorContainer)),
title: Text(l10n.repeater_eraseFileSystem, style: TextStyle(color: colorScheme.onErrorContainer)),
subtitle: Text(
'Format the repeater file system',
l10n.repeater_eraseFileSystemSubtitle,
style: TextStyle(color: colorScheme.onErrorContainer.withValues(alpha: 0.8)),
),
onTap: () => _confirmAction(
'Erase File System',
'WARNING: This will erase all data on the repeater. This cannot be undone!',
l10n.repeater_eraseFileSystem,
l10n.repeater_eraseFileSystemConfirm,
() => _sendDangerCommand('erase'),
isDestructive: true,
),
@@ -1254,13 +1259,14 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
}
Future<void> _sendDangerCommand(String command) async {
final l10n = context.l10n;
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
final repeater = _resolveRepeater(connector);
if (command == 'erase') {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Erase is only available over serial console.')),
SnackBar(content: Text(l10n.repeater_eraseSerialOnly)),
);
}
return;
@@ -1284,14 +1290,14 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Command sent: $command')),
SnackBar(content: Text(l10n.repeater_commandSent(command))),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Error sending command: $e'),
content: Text(l10n.repeater_errorSendingCommand(e.toString())),
backgroundColor: Colors.red,
),
);
@@ -1305,6 +1311,7 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
VoidCallback onConfirm, {
bool isDestructive = false,
}) {
final l10n = context.l10n;
showDialog(
context: context,
builder: (context) => AlertDialog(
@@ -1313,7 +1320,7 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
child: Text(l10n.common_cancel),
),
FilledButton(
onPressed: () {
@@ -1323,7 +1330,7 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
style: isDestructive
? FilledButton.styleFrom(backgroundColor: Colors.red)
: null,
child: const Text('Confirm'),
child: Text(l10n.repeater_confirm),
),
],
),
+45 -36
View File
@@ -3,6 +3,7 @@ import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../l10n/l10n.dart';
import '../models/contact.dart';
import '../models/path_selection.dart';
import '../connector/meshcore_connector.dart';
@@ -274,8 +275,8 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
_isLoading = false;
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Status request timed out.'),
SnackBar(
content: Text(context.l10n.repeater_statusRequestTimeout),
backgroundColor: Colors.red,
),
);
@@ -289,7 +290,7 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Error loading status: $e'),
content: Text(context.l10n.repeater_errorLoadingStatus(e.toString())),
backgroundColor: Colors.red,
),
);
@@ -309,6 +310,7 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final connector = context.watch<MeshCoreConnector>();
final repeater = _resolveRepeater(connector);
final isFloodMode = repeater.pathOverride == -1;
@@ -319,7 +321,7 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
const Text('Repeater Status'),
Text(l10n.repeater_statusTitle),
Text(
repeater.name,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.normal),
@@ -330,7 +332,7 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
actions: [
PopupMenuButton<String>(
icon: Icon(isFloodMode ? Icons.waves : Icons.route),
tooltip: 'Routing mode',
tooltip: l10n.repeater_routingMode,
onSelected: (mode) async {
if (mode == 'flood') {
await connector.setPathOverride(repeater, pathLen: -1);
@@ -346,7 +348,7 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
Icon(Icons.auto_mode, size: 20, color: !isFloodMode ? Theme.of(context).primaryColor : null),
const SizedBox(width: 8),
Text(
'Auto (use saved path)',
l10n.repeater_autoUseSavedPath,
style: TextStyle(
fontWeight: !isFloodMode ? FontWeight.bold : FontWeight.normal,
),
@@ -361,7 +363,7 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
Icon(Icons.waves, size: 20, color: isFloodMode ? Theme.of(context).primaryColor : null),
const SizedBox(width: 8),
Text(
'Force Flood Mode',
l10n.repeater_forceFloodMode,
style: TextStyle(
fontWeight: isFloodMode ? FontWeight.bold : FontWeight.normal,
),
@@ -373,7 +375,7 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
),
IconButton(
icon: const Icon(Icons.timeline),
tooltip: 'Path management',
tooltip: l10n.repeater_pathManagement,
onPressed: () => PathManagementDialog.show(context, contact: repeater),
),
IconButton(
@@ -385,7 +387,7 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
)
: const Icon(Icons.refresh),
onPressed: _isLoading ? null : _loadStatus,
tooltip: 'Refresh',
tooltip: l10n.repeater_refresh,
),
],
),
@@ -409,6 +411,7 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
}
Widget _buildSystemInfoCard() {
final l10n = context.l10n;
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
@@ -419,18 +422,18 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
children: [
Icon(Icons.info_outline, color: Theme.of(context).textTheme.headlineSmall?.color),
const SizedBox(width: 8),
const Text(
'System Information',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
Text(
l10n.repeater_systemInformation,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
],
),
const Divider(),
_buildInfoRow('Battery', _batteryText()),
_buildInfoRow('Clock (at login)', _clockText()),
_buildInfoRow('Uptime', _formatDuration(_uptimeSecs)),
_buildInfoRow('Queue Length', _formatValue(_queueLen)),
_buildInfoRow('Debug Flags', _formatValue(_debugFlags)),
_buildInfoRow(l10n.repeater_battery, _batteryText()),
_buildInfoRow(l10n.repeater_clockAtLogin, _clockText()),
_buildInfoRow(l10n.repeater_uptime, _formatDuration(_uptimeSecs)),
_buildInfoRow(l10n.repeater_queueLength, _formatValue(_queueLen)),
_buildInfoRow(l10n.repeater_debugFlags, _formatValue(_debugFlags)),
],
),
),
@@ -438,6 +441,7 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
}
Widget _buildRadioStatsCard() {
final l10n = context.l10n;
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
@@ -448,18 +452,18 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
children: [
Icon(Icons.radio, color: Theme.of(context).textTheme.headlineSmall?.color),
const SizedBox(width: 8),
const Text(
'Radio Statistics',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
Text(
l10n.repeater_radioStatistics,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
],
),
const Divider(),
_buildInfoRow('Last RSSI', _formatValue(_lastRssi, suffix: ' dB')),
_buildInfoRow('Last SNR', _formatSnr(_lastSnr)),
_buildInfoRow('Noise Floor', _formatValue(_noiseFloor, suffix: ' dB')),
_buildInfoRow('TX Airtime', _formatDuration(_txAirSecs)),
_buildInfoRow('RX Airtime', _formatDuration(_rxAirSecs)),
_buildInfoRow(l10n.repeater_lastRssi, _formatValue(_lastRssi, suffix: ' dB')),
_buildInfoRow(l10n.repeater_lastSnr, _formatSnr(_lastSnr)),
_buildInfoRow(l10n.repeater_noiseFloor, _formatValue(_noiseFloor, suffix: ' dB')),
_buildInfoRow(l10n.repeater_txAirtime, _formatDuration(_txAirSecs)),
_buildInfoRow(l10n.repeater_rxAirtime, _formatDuration(_rxAirSecs)),
],
),
),
@@ -467,6 +471,7 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
}
Widget _buildPacketStatsCard() {
final l10n = context.l10n;
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
@@ -477,16 +482,16 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
children: [
Icon(Icons.analytics, color: Theme.of(context).textTheme.headlineSmall?.color),
const SizedBox(width: 8),
const Text(
'Packet Statistics',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
Text(
l10n.repeater_packetStatistics,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
],
),
const Divider(),
_buildInfoRow('Sent', _packetTxText()),
_buildInfoRow('Received', _packetRxText()),
_buildInfoRow('Duplicates', _duplicateText()),
_buildInfoRow(l10n.repeater_sent, _packetTxText()),
_buildInfoRow(l10n.repeater_received, _packetRxText()),
_buildInfoRow(l10n.repeater_duplicates, _duplicateText()),
],
),
),
@@ -559,37 +564,41 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
String _formatDuration(int? seconds) {
if (seconds == null) return '';
final l10n = context.l10n;
final days = seconds ~/ 86400;
final hours = (seconds % 86400) ~/ 3600;
final minutes = (seconds % 3600) ~/ 60;
final secs = seconds % 60;
return '$days days ${hours}h ${minutes}m ${secs}s';
return l10n.repeater_daysHoursMinsSecs(days, hours, minutes, secs);
}
String _packetTxText() {
if (_packetsSent == null) return '';
final l10n = context.l10n;
final flood = _formatValue(_floodTx);
final direct = _formatValue(_directTx);
return 'Total: $_packetsSent, Flood: $flood, Direct: $direct';
return l10n.repeater_packetTxTotal(_packetsSent!, flood, direct);
}
String _packetRxText() {
if (_packetsRecv == null) return '';
final l10n = context.l10n;
final flood = _formatValue(_floodRx);
final direct = _formatValue(_directRx);
return 'Total: $_packetsRecv, Flood: $flood, Direct: $direct';
return l10n.repeater_packetRxTotal(_packetsRecv!, flood, direct);
}
String _duplicateText() {
final l10n = context.l10n;
if (_dupFlood != null || _dupDirect != null) {
final flood = _formatValue(_dupFlood);
final direct = _formatValue(_dupDirect);
return 'Flood: $flood, Direct: $direct';
return l10n.repeater_duplicatesFloodDirect(flood, direct);
}
if (_packetsRecv == null || _floodRx == null || _directRx == null) return '';
final dupTotal = _packetsRecv! - _floodRx! - _directRx!;
if (dupTotal < 0) return '';
return 'Total: $dupTotal';
return l10n.repeater_duplicatesTotal(dupTotal);
}
String _formatValue(num? value, {String? suffix}) {
+12 -10
View File
@@ -3,6 +3,7 @@ import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart';
import '../l10n/l10n.dart';
import '../widgets/device_tile.dart';
import 'contacts_screen.dart';
@@ -14,7 +15,7 @@ class ScannerScreen extends StatelessWidget {
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('MeshCore Open'),
title: Text(context.l10n.scanner_title),
centerTitle: true,
automaticallyImplyLeading: false,
),
@@ -58,7 +59,7 @@ class ScannerScreen extends StatelessWidget {
),
)
: const Icon(Icons.bluetooth_searching),
label: Text(isScanning ? 'Stop' : 'Scan'),
label: Text(isScanning ? context.l10n.scanner_stop : context.l10n.scanner_scan),
);
},
),
@@ -69,25 +70,26 @@ class ScannerScreen extends StatelessWidget {
String statusText;
Color statusColor;
final l10n = context.l10n;
switch (connector.state) {
case MeshCoreConnectionState.scanning:
statusText = 'Scanning for devices...';
statusText = l10n.scanner_scanning;
statusColor = Colors.blue;
break;
case MeshCoreConnectionState.connecting:
statusText = 'Connecting...';
statusText = l10n.scanner_connecting;
statusColor = Colors.orange;
break;
case MeshCoreConnectionState.connected:
statusText = 'Connected to ${connector.deviceDisplayName}';
statusText = l10n.scanner_connectedTo(connector.deviceDisplayName);
statusColor = Colors.green;
break;
case MeshCoreConnectionState.disconnecting:
statusText = 'Disconnecting...';
statusText = l10n.scanner_disconnecting;
statusColor = Colors.orange;
break;
case MeshCoreConnectionState.disconnected:
statusText = 'Not connected';
statusText = l10n.scanner_notConnected;
statusColor = Colors.grey;
break;
}
@@ -123,8 +125,8 @@ class ScannerScreen extends StatelessWidget {
const SizedBox(height: 16),
Text(
connector.state == MeshCoreConnectionState.scanning
? 'Searching for MeshCore devices...'
: 'Tap Scan to find MeshCore devices',
? context.l10n.scanner_searchingDevices
: context.l10n.scanner_tapToScan,
style: TextStyle(
fontSize: 16,
color: Colors.grey[600],
@@ -172,7 +174,7 @@ class ScannerScreen extends StatelessWidget {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Connection failed: $e'),
content: Text(context.l10n.scanner_connectionFailed(e.toString())),
backgroundColor: Colors.red,
),
);
+148 -124
View File
@@ -4,6 +4,7 @@ import 'package:package_info_plus/package_info_plus.dart';
import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart';
import '../l10n/l10n.dart';
import '../models/radio_settings.dart';
import 'app_settings_screen.dart';
import 'app_debug_log_screen.dart';
@@ -18,7 +19,7 @@ class SettingsScreen extends StatefulWidget {
class _SettingsScreenState extends State<SettingsScreen> {
bool _showBatteryVoltage = false;
String _appVersion = '...';
String _appVersion = '';
@override
void initState() {
@@ -35,9 +36,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
return Scaffold(
appBar: AppBar(
title: const Text('Settings'),
title: Text(l10n.settings_title),
centerTitle: true,
),
body: SafeArea(
@@ -47,7 +49,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
return ListView(
padding: const EdgeInsets.all(16),
children: [
_buildDeviceInfoCard(connector),
_buildDeviceInfoCard(context, connector),
const SizedBox(height: 16),
_buildAppSettingsCard(context),
const SizedBox(height: 16),
@@ -66,46 +68,52 @@ class _SettingsScreenState extends State<SettingsScreen> {
);
}
Widget _buildDeviceInfoCard(MeshCoreConnector connector) {
Widget _buildDeviceInfoCard(BuildContext context, MeshCoreConnector connector) {
final l10n = context.l10n;
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Device Info',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
Text(
l10n.settings_deviceInfo,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
_buildInfoRow('Name', connector.deviceDisplayName),
_buildInfoRow('ID', connector.deviceIdLabel),
_buildInfoRow('Status', connector.isConnected ? 'Connected' : 'Disconnected'),
_buildBatteryInfoRow(connector),
_buildInfoRow(l10n.settings_infoName, connector.deviceDisplayName),
_buildInfoRow(l10n.settings_infoId, connector.deviceIdLabel),
_buildInfoRow(l10n.settings_infoStatus, connector.isConnected ? l10n.common_connected : l10n.common_disconnected),
_buildBatteryInfoRow(context, connector),
if (connector.selfName != null)
_buildInfoRow('Node Name', connector.selfName!),
_buildInfoRow(l10n.settings_nodeName, connector.selfName!),
if (connector.selfPublicKey != null)
_buildInfoRow('Public Key', '${pubKeyToHex(connector.selfPublicKey!).substring(0, 16)}...'),
_buildInfoRow('Contacts Count', '${connector.contacts.length}'),
_buildInfoRow('Channel Count', '${connector.channels.length}'),
_buildInfoRow(l10n.settings_infoPublicKey, '${pubKeyToHex(connector.selfPublicKey!).substring(0, 16)}...'),
_buildInfoRow(l10n.settings_infoContactsCount, '${connector.contacts.length}'),
_buildInfoRow(l10n.settings_infoChannelCount, '${connector.channels.length}'),
],
),
),
);
}
Widget _buildBatteryInfoRow(MeshCoreConnector connector) {
Widget _buildBatteryInfoRow(BuildContext context, MeshCoreConnector connector) {
final l10n = context.l10n;
final percent = connector.batteryPercent;
final millivolts = connector.batteryMillivolts;
// figure out display value
final String displayValue;
if (millivolts == null) {
displayValue = '';
displayValue = l10n.common_notAvailable;
} else if (_showBatteryVoltage) {
displayValue = '${(millivolts / 1000.0).toStringAsFixed(2)} V';
displayValue = l10n.common_voltageValue(
(millivolts / 1000.0).toStringAsFixed(2),
);
} else {
displayValue = percent != null ? '$percent%' : '';
displayValue = percent != null
? l10n.common_percentValue(percent)
: l10n.common_notAvailable;
}
final IconData icon;
@@ -127,7 +135,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
}
return _buildInfoRow(
'Battery',
l10n.settings_infoBattery,
displayValue,
leading: Icon(icon, size: 18, color: iconColor),
valueColor: valueColor,
@@ -142,11 +150,12 @@ class _SettingsScreenState extends State<SettingsScreen> {
}
Widget _buildAppSettingsCard(BuildContext context) {
final l10n = context.l10n;
return Card(
child: ListTile(
leading: const Icon(Icons.settings_outlined),
title: const Text('App Settings'),
subtitle: const Text('Notifications, messaging, and map preferences'),
title: Text(l10n.settings_appSettings),
subtitle: Text(l10n.settings_appSettingsSubtitle),
trailing: const Icon(Icons.chevron_right),
onTap: () {
Navigator.push(
@@ -159,45 +168,46 @@ class _SettingsScreenState extends State<SettingsScreen> {
}
Widget _buildNodeSettingsCard(BuildContext context, MeshCoreConnector connector) {
final l10n = context.l10n;
return Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
'Node Settings',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
l10n.settings_nodeSettings,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
),
ListTile(
leading: const Icon(Icons.person_outline),
title: const Text('Node Name'),
subtitle: Text(connector.selfName ?? 'Not set'),
title: Text(l10n.settings_nodeName),
subtitle: Text(connector.selfName ?? l10n.settings_nodeNameNotSet),
trailing: const Icon(Icons.chevron_right),
onTap: () => _editNodeName(context, connector),
),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.radio),
title: const Text('Radio Settings'),
subtitle: const Text('Frequency, power, spreading factor'),
title: Text(l10n.settings_radioSettings),
subtitle: Text(l10n.settings_radioSettingsSubtitle),
trailing: const Icon(Icons.chevron_right),
onTap: () => _showRadioSettings(context, connector),
),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.location_on_outlined),
title: const Text('Location'),
subtitle: const Text('GPS coordinates'),
title: Text(l10n.settings_location),
subtitle: Text(l10n.settings_locationSubtitle),
trailing: const Icon(Icons.chevron_right),
onTap: () => _editLocation(context, connector),
),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.visibility_off_outlined),
title: const Text('Privacy Mode'),
subtitle: const Text('Hide name/location in advertisements'),
title: Text(l10n.settings_privacyMode),
subtitle: Text(l10n.settings_privacyModeSubtitle),
trailing: const Icon(Icons.chevron_right),
onTap: () => _togglePrivacy(context, connector),
),
@@ -207,42 +217,43 @@ class _SettingsScreenState extends State<SettingsScreen> {
}
Widget _buildActionsCard(BuildContext context, MeshCoreConnector connector) {
final l10n = context.l10n;
return Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
'Actions',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
l10n.settings_actions,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
),
ListTile(
leading: const Icon(Icons.cell_tower),
title: const Text('Send Advertisement'),
subtitle: const Text('Broadcast presence now'),
title: Text(l10n.settings_sendAdvertisement),
subtitle: Text(l10n.settings_sendAdvertisementSubtitle),
onTap: () => _sendAdvert(context, connector),
),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.sync),
title: const Text('Sync Time'),
subtitle: const Text('Set device clock to phone time'),
title: Text(l10n.settings_syncTime),
subtitle: Text(l10n.settings_syncTimeSubtitle),
onTap: () => _syncTime(context, connector),
),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.refresh),
title: const Text('Refresh Contacts'),
subtitle: const Text('Reload contact list from device'),
title: Text(l10n.settings_refreshContacts),
subtitle: Text(l10n.settings_refreshContactsSubtitle),
onTap: () => connector.getContacts(),
),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.restart_alt, color: Colors.orange),
title: const Text('Reboot Device'),
subtitle: const Text('Restart the MeshCore device'),
title: Text(l10n.settings_rebootDevice),
subtitle: Text(l10n.settings_rebootDeviceSubtitle),
onTap: () => _confirmReboot(context, connector),
),
],
@@ -251,32 +262,38 @@ class _SettingsScreenState extends State<SettingsScreen> {
}
Widget _buildAboutCard(BuildContext context) {
final l10n = context.l10n;
return Card(
child: ListTile(
leading: const Icon(Icons.info_outline),
title: const Text('About'),
subtitle: Text('MeshCore Open v$_appVersion'),
title: Text(l10n.settings_about),
subtitle: Text(
l10n.settings_aboutVersion(
_appVersion.isEmpty ? l10n.common_loading : _appVersion,
),
),
onTap: () => _showAbout(context),
),
);
}
Widget _buildDebugCard(BuildContext context) {
final l10n = context.l10n;
return Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
'Debug',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
l10n.settings_debug,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
),
ListTile(
leading: const Icon(Icons.bluetooth_outlined),
title: const Text('BLE Debug Log'),
subtitle: const Text('BLE commands, responses, and raw data'),
title: Text(l10n.settings_bleDebugLog),
subtitle: Text(l10n.settings_bleDebugLogSubtitle),
trailing: const Icon(Icons.chevron_right),
onTap: () {
Navigator.push(
@@ -288,8 +305,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.code_outlined),
title: const Text('App Debug Log'),
subtitle: const Text('Application debug messages'),
title: Text(l10n.settings_appDebugLog),
subtitle: Text(l10n.settings_appDebugLogSubtitle),
trailing: const Icon(Icons.chevron_right),
onTap: () {
Navigator.push(
@@ -349,23 +366,24 @@ class _SettingsScreenState extends State<SettingsScreen> {
}
void _editNodeName(BuildContext context, MeshCoreConnector connector) {
final l10n = context.l10n;
final controller = TextEditingController(text: connector.selfName ?? '');
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Node Name'),
title: Text(l10n.settings_nodeName),
content: TextField(
controller: controller,
decoration: const InputDecoration(
hintText: 'Enter node name',
border: OutlineInputBorder(),
decoration: InputDecoration(
hintText: l10n.settings_nodeNameHint,
border: const OutlineInputBorder(),
),
maxLength: 31,
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
child: Text(l10n.common_cancel),
),
TextButton(
onPressed: () async {
@@ -374,10 +392,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
await connector.refreshDeviceInfo();
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Name updated')),
SnackBar(content: Text(l10n.settings_nodeNameUpdated)),
);
},
child: const Text('Save'),
child: Text(l10n.common_save),
),
],
),
@@ -392,29 +410,30 @@ class _SettingsScreenState extends State<SettingsScreen> {
}
void _editLocation(BuildContext context, MeshCoreConnector connector) {
final l10n = context.l10n;
final latController = TextEditingController();
final lonController = TextEditingController();
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Location'),
title: Text(l10n.settings_location),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: latController,
decoration: const InputDecoration(
labelText: 'Latitude',
border: OutlineInputBorder(),
decoration: InputDecoration(
labelText: l10n.settings_latitude,
border: const OutlineInputBorder(),
),
keyboardType: const TextInputType.numberWithOptions(decimal: true, signed: true),
),
const SizedBox(height: 16),
TextField(
controller: lonController,
decoration: const InputDecoration(
labelText: 'Longitude',
border: OutlineInputBorder(),
decoration: InputDecoration(
labelText: l10n.settings_longitude,
border: const OutlineInputBorder(),
),
keyboardType: const TextInputType.numberWithOptions(decimal: true, signed: true),
),
@@ -423,7 +442,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
child: Text(l10n.common_cancel),
),
TextButton(
onPressed: () async {
@@ -441,14 +460,14 @@ class _SettingsScreenState extends State<SettingsScreen> {
if (lat == null || lon == null) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Enter both latitude and longitude.')),
SnackBar(content: Text(l10n.settings_locationBothRequired)),
);
return;
}
if (lat < -90 || lat > 90 || lon < -180 || lon > 180) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Invalid latitude or longitude.')),
SnackBar(content: Text(l10n.settings_locationInvalid)),
);
return;
}
@@ -457,10 +476,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
await connector.refreshDeviceInfo();
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Location updated')),
SnackBar(content: Text(l10n.settings_locationUpdated)),
);
},
child: const Text('Save'),
child: Text(l10n.common_save),
),
],
),
@@ -468,15 +487,16 @@ class _SettingsScreenState extends State<SettingsScreen> {
}
void _togglePrivacy(BuildContext context, MeshCoreConnector connector) {
final l10n = context.l10n;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Privacy Mode'),
content: const Text('Toggle privacy mode to hide your name and location in advertisements.'),
title: Text(l10n.settings_privacyMode),
content: Text(l10n.settings_privacyModeToggle),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
child: Text(l10n.common_cancel),
),
TextButton(
onPressed: () async {
@@ -485,10 +505,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
await connector.refreshDeviceInfo();
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Privacy mode enabled')),
SnackBar(content: Text(l10n.settings_privacyModeEnabled)),
);
},
child: const Text('Enable'),
child: Text(l10n.common_enable),
),
TextButton(
onPressed: () async {
@@ -497,10 +517,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
await connector.refreshDeviceInfo();
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Privacy mode disabled')),
SnackBar(content: Text(l10n.settings_privacyModeDisabled)),
);
},
child: const Text('Disable'),
child: Text(l10n.common_disable),
),
],
),
@@ -508,36 +528,39 @@ class _SettingsScreenState extends State<SettingsScreen> {
}
void _sendAdvert(BuildContext context, MeshCoreConnector connector) {
final l10n = context.l10n;
connector.sendSelfAdvert(flood: true);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Advertisement sent')),
SnackBar(content: Text(l10n.settings_advertisementSent)),
);
}
void _syncTime(BuildContext context, MeshCoreConnector connector) {
final l10n = context.l10n;
connector.syncTime();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Time synchronized')),
SnackBar(content: Text(l10n.settings_timeSynchronized)),
);
}
void _confirmReboot(BuildContext context, MeshCoreConnector connector) {
final l10n = context.l10n;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Reboot Device'),
content: const Text('Are you sure you want to reboot the device? You will be disconnected.'),
title: Text(l10n.settings_rebootDevice),
content: Text(l10n.settings_rebootDeviceConfirm),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
child: Text(l10n.common_cancel),
),
TextButton(
onPressed: () {
Navigator.pop(context);
connector.rebootDevice();
},
child: const Text('Reboot', style: TextStyle(color: Colors.orange)),
child: Text(l10n.common_reboot, style: const TextStyle(color: Colors.orange)),
),
],
),
@@ -545,16 +568,15 @@ class _SettingsScreenState extends State<SettingsScreen> {
}
void _showAbout(BuildContext context) {
final l10n = context.l10n;
showAboutDialog(
context: context,
applicationName: 'MeshCore Open',
applicationVersion: _appVersion,
applicationLegalese: '2024 MeshCore Open Source Project',
applicationName: l10n.appTitle,
applicationVersion: _appVersion.isEmpty ? l10n.common_loading : _appVersion,
applicationLegalese: l10n.settings_aboutLegalese,
children: [
const SizedBox(height: 16),
const Text(
'An open-source Flutter client for MeshCore LoRa mesh networking devices.',
),
Text(l10n.settings_aboutDescription),
],
);
}
@@ -643,19 +665,20 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> {
}
Future<void> _saveSettings() async {
final l10n = context.l10n;
final freqMHz = double.tryParse(_frequencyController.text);
final txPower = int.tryParse(_txPowerController.text);
if (freqMHz == null || freqMHz < 300 || freqMHz > 2500) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Invalid frequency (300-2500 MHz)')),
SnackBar(content: Text(l10n.settings_frequencyInvalid)),
);
return;
}
if (txPower == null || txPower < 0 || txPower > 22) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Invalid TX power (0-22 dBm)')),
SnackBar(content: Text(l10n.settings_txPowerInvalid)),
);
return;
}
@@ -673,12 +696,12 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> {
if (!mounted) return;
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Radio settings updated')),
SnackBar(content: Text(l10n.settings_radioSettingsUpdated)),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: $e')),
SnackBar(content: Text(l10n.settings_error(e.toString()))),
);
}
}
@@ -696,36 +719,37 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> {
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
return AlertDialog(
title: const Text('Radio Settings'),
title: Text(l10n.settings_radioSettings),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Presets', style: TextStyle(fontWeight: FontWeight.bold)),
Text(l10n.settings_presets, style: const TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: [
_PresetChip(
label: '915 MHz',
label: l10n.settings_preset915Mhz,
onTap: () => _applyPreset(RadioSettings.preset915MHz),
),
_PresetChip(
label: '868 MHz',
label: l10n.settings_preset868Mhz,
onTap: () => _applyPreset(RadioSettings.preset868MHz),
),
_PresetChip(
label: '433 MHz',
label: l10n.settings_preset433Mhz,
onTap: () => _applyPreset(RadioSettings.preset433MHz),
),
_PresetChip(
label: 'Long Range',
label: l10n.settings_longRange,
onTap: () => _applyPreset(RadioSettings.presetLongRange),
),
_PresetChip(
label: 'Fast Speed',
label: l10n.settings_fastSpeed,
onTap: () => _applyPreset(RadioSettings.presetFastSpeed),
),
],
@@ -733,19 +757,19 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> {
const SizedBox(height: 24),
TextField(
controller: _frequencyController,
decoration: const InputDecoration(
labelText: 'Frequency (MHz)',
border: OutlineInputBorder(),
helperText: '300.0 - 2500.0',
decoration: InputDecoration(
labelText: l10n.settings_frequency,
border: const OutlineInputBorder(),
helperText: l10n.settings_frequencyHelper,
),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
),
const SizedBox(height: 16),
DropdownButtonFormField<LoRaBandwidth>(
initialValue: _bandwidth,
decoration: const InputDecoration(
labelText: 'Bandwidth',
border: OutlineInputBorder(),
decoration: InputDecoration(
labelText: l10n.settings_bandwidth,
border: const OutlineInputBorder(),
),
items: LoRaBandwidth.values
.map((bw) => DropdownMenuItem(
@@ -760,9 +784,9 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> {
const SizedBox(height: 16),
DropdownButtonFormField<LoRaSpreadingFactor>(
initialValue: _spreadingFactor,
decoration: const InputDecoration(
labelText: 'Spreading Factor',
border: OutlineInputBorder(),
decoration: InputDecoration(
labelText: l10n.settings_spreadingFactor,
border: const OutlineInputBorder(),
),
items: LoRaSpreadingFactor.values
.map((sf) => DropdownMenuItem(
@@ -777,9 +801,9 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> {
const SizedBox(height: 16),
DropdownButtonFormField<LoRaCodingRate>(
initialValue: _codingRate,
decoration: const InputDecoration(
labelText: 'Coding Rate',
border: OutlineInputBorder(),
decoration: InputDecoration(
labelText: l10n.settings_codingRate,
border: const OutlineInputBorder(),
),
items: LoRaCodingRate.values
.map((cr) => DropdownMenuItem(
@@ -794,10 +818,10 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> {
const SizedBox(height: 16),
TextField(
controller: _txPowerController,
decoration: const InputDecoration(
labelText: 'TX Power (dBm)',
border: OutlineInputBorder(),
helperText: '0 - 22',
decoration: InputDecoration(
labelText: l10n.settings_txPower,
border: const OutlineInputBorder(),
helperText: l10n.settings_txPowerHelper,
),
keyboardType: TextInputType.number,
),
@@ -807,11 +831,11 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> {
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
child: Text(l10n.common_cancel),
),
FilledButton(
onPressed: _saveSettings,
child: const Text('Save'),
child: Text(l10n.common_save),
),
],
);
+112 -43
View File
@@ -5,6 +5,7 @@ import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../l10n/l10n.dart';
import '../models/contact.dart';
import '../models/path_selection.dart';
import '../connector/meshcore_connector.dart';
@@ -30,7 +31,8 @@ class TelemetryScreen extends StatefulWidget {
class _TelemetryScreenState extends State<TelemetryScreen> {
static const int _statusPayloadOffset = 8;
static const int _statusStatsSize = 52;
static const int _statusResponseBytes = _statusPayloadOffset + _statusStatsSize;
static const int _statusResponseBytes =
_statusPayloadOffset + _statusStatsSize;
Uint8List _tagData = Uint8List(4);
int _timeEstment = 0;
@@ -66,7 +68,8 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
}
// Check if it's a binary response
if (frame[0] == pushCodeBinaryResponse && listEquals(frame.sublist(2, 6), _tagData)) {
if (frame[0] == pushCodeBinaryResponse &&
listEquals(frame.sublist(2, 6), _tagData)) {
_handleStatusResponse(context, frame.sublist(6));
}
});
@@ -78,10 +81,10 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Received Telemetry Data'),
SnackBar(
content: Text(context.l10n.telemetry_receivedData),
backgroundColor: Colors.green,
)
),
);
_statusTimeout?.cancel();
if (!mounted) return;
@@ -111,7 +114,10 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
final repeater = _resolveRepeater(connector);
final selection = await connector.preparePathForContactSend(repeater);
_pendingStatusSelection = selection;
final frame = buildSendBinaryReq(repeater.publicKey, payload: Uint8List.fromList([reqTypeGetTelemetry]));
final frame = buildSendBinaryReq(
repeater.publicKey,
payload: Uint8List.fromList([reqTypeGetTelemetry]),
);
await connector.sendFrame(frame);
final pathLengthValue = selection.useFlood ? -1 : selection.hopCount;
@@ -130,8 +136,8 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
_isLoaded = false;
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Telemetry request timed out.'),
SnackBar(
content: Text(context.l10n.telemetry_requestTimeout),
backgroundColor: Colors.red,
),
);
@@ -146,7 +152,7 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Error loading telemetry: $e'),
content: Text(context.l10n.telemetry_errorLoading(e.toString())),
backgroundColor: Colors.red,
),
);
@@ -173,6 +179,7 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final connector = context.watch<MeshCoreConnector>();
final repeater = _resolveRepeater(connector);
final isFloodMode = repeater.pathOverride == -1;
@@ -183,10 +190,16 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
const Text('Repeater Telemetry', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
Text(
l10n.repeater_telemetry,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
Text(
repeater.name,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.normal),
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.normal,
),
),
],
),
@@ -194,7 +207,7 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
actions: [
PopupMenuButton<String>(
icon: Icon(isFloodMode ? Icons.waves : Icons.route),
tooltip: 'Routing mode',
tooltip: l10n.repeater_routingMode,
onSelected: (mode) async {
if (mode == 'flood') {
await connector.setPathOverride(repeater, pathLen: -1);
@@ -207,12 +220,20 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
value: 'auto',
child: Row(
children: [
Icon(Icons.auto_mode, size: 20, color: !isFloodMode ? Theme.of(context).primaryColor : null),
Icon(
Icons.auto_mode,
size: 20,
color: !isFloodMode
? Theme.of(context).primaryColor
: null,
),
const SizedBox(width: 8),
Text(
'Auto (use saved path)',
l10n.repeater_autoUseSavedPath,
style: TextStyle(
fontWeight: !isFloodMode ? FontWeight.bold : FontWeight.normal,
fontWeight: !isFloodMode
? FontWeight.bold
: FontWeight.normal,
),
),
],
@@ -222,12 +243,20 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
value: 'flood',
child: Row(
children: [
Icon(Icons.waves, size: 20, color: isFloodMode ? Theme.of(context).primaryColor : null),
Icon(
Icons.waves,
size: 20,
color: isFloodMode
? Theme.of(context).primaryColor
: null,
),
const SizedBox(width: 8),
Text(
'Force Flood Mode',
l10n.repeater_forceFloodMode,
style: TextStyle(
fontWeight: isFloodMode ? FontWeight.bold : FontWeight.normal,
fontWeight: isFloodMode
? FontWeight.bold
: FontWeight.normal,
),
),
],
@@ -237,8 +266,9 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
),
IconButton(
icon: const Icon(Icons.timeline),
tooltip: 'Path management',
onPressed: () => PathManagementDialog.show(context, contact: repeater),
tooltip: l10n.repeater_pathManagement,
onPressed: () =>
PathManagementDialog.show(context, contact: repeater),
),
IconButton(
icon: _isLoading
@@ -249,7 +279,7 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
)
: const Icon(Icons.refresh),
onPressed: _isLoading ? null : _loadTelemetry,
tooltip: 'Refresh',
tooltip: l10n.repeater_refresh,
),
],
),
@@ -260,16 +290,24 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
child: ListView(
padding: const EdgeInsets.all(16),
children: [
if (!_isLoaded && !_hasData && (_parsedTelemetry == null || _parsedTelemetry!.isEmpty))
const Center(
if (!_isLoaded &&
!_hasData &&
(_parsedTelemetry == null || _parsedTelemetry!.isEmpty))
Center(
child: Text(
'No telemetry data available.',
style: TextStyle(fontSize: 16, color: Colors.grey),
l10n.telemetry_noData,
style: const TextStyle(fontSize: 16, color: Colors.grey),
),
),
if ((_isLoaded || _hasData) && _parsedTelemetry != null && _parsedTelemetry!.isNotEmpty)
if ((_isLoaded || _hasData) &&
_parsedTelemetry != null &&
_parsedTelemetry!.isNotEmpty)
for (final entry in _parsedTelemetry ?? [])
_buildChannelInfoCard(entry['values'], 'Channel ${entry['channel']}', entry['channel']),
_buildChannelInfoCard(
entry['values'],
l10n.telemetry_channelTitle(entry['channel']),
entry['channel'],
),
],
),
),
@@ -277,7 +315,12 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
);
}
Widget _buildChannelInfoCard(Map<String, dynamic> channelData, String title, int channel) {
Widget _buildChannelInfoCard(
Map<String, dynamic> channelData,
String title,
int channel,
) {
final l10n = context.l10n;
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
@@ -286,26 +329,47 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
children: [
Row(
children: [
Icon(Icons.info_outline, color: Theme.of(context).textTheme.headlineSmall?.color),
Icon(
Icons.info_outline,
color: Theme.of(context).textTheme.headlineSmall?.color,
),
const SizedBox(width: 8),
Text(
title,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
],
),
const Divider(),
for (final entry in channelData.entries)
if (entry.key == 'voltage' && channel == 1)
_buildInfoRow('Battery', _batteryText(entry.value))
_buildInfoRow(
l10n.telemetry_batteryLabel,
_batteryText(entry.value),
)
else if (entry.key == 'voltage')
_buildInfoRow('Voltage', '${entry.value}V')
_buildInfoRow(
l10n.telemetry_voltageLabel,
l10n.telemetry_voltageValue(entry.value.toString()),
)
else if (entry.key == 'temperature' && channel == 1)
_buildInfoRow('MCU Temperature', _temperatureText(entry.value))
_buildInfoRow(
l10n.telemetry_mcuTemperatureLabel,
_temperatureText(entry.value),
)
else if (entry.key == 'temperature')
_buildInfoRow('Temperature', _temperatureText(entry.value))
_buildInfoRow(
l10n.telemetry_temperatureLabel,
_temperatureText(entry.value),
)
else if (entry.key == 'current' && channel == 1)
_buildInfoRow('Current', '${entry.value}A')
_buildInfoRow(
l10n.telemetry_currentLabel,
l10n.telemetry_currentValue(entry.value.toString()),
)
else
_buildInfoRow(entry.key, entry.value.toString()),
],
@@ -341,11 +405,12 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
);
}
String _batteryText(double? _batteryMv) {
if (_batteryMv == null) return '';
final percent = _batteryPercentFromMv(_batteryMv);
final volts = _batteryMv.toStringAsFixed(2);
return '$percent% / ${volts}V';
String _batteryText(double? batteryMv) {
final l10n = context.l10n;
if (batteryMv == null) return l10n.common_notAvailable;
final percent = _batteryPercentFromMv(batteryMv);
final volts = batteryMv.toStringAsFixed(2);
return l10n.telemetry_batteryValue(percent, volts);
}
int _batteryPercentFromMv(double millivolts) {
@@ -357,8 +422,12 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
}
String _temperatureText(double? tempC) {
if (tempC == null) return '';
final l10n = context.l10n;
if (tempC == null) return l10n.common_notAvailable;
final tempF = (tempC * 9 / 5) + 32;
return '${tempC.toStringAsFixed(1)}°C / ${tempF.toStringAsFixed(1)}°F';
return l10n.telemetry_temperatureValue(
tempC.toStringAsFixed(1),
tempF.toStringAsFixed(1),
);
}
}
}