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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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