mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-04-20 22:13:48 +00:00
Add localization support and translation script
- Introduced a new extension for localization in Flutter with `LocalizationExtension` in `l10n.dart`. - Added a Python script `translate.py` for translating ARB/JSON localization files using a local Ollama model, preserving keys and placeholders, and handling ICU format rules.
This commit is contained in:
parent
2495cd840f
commit
b2ce82fe7e
64 changed files with 54716 additions and 1254 deletions
|
|
@ -1,5 +1,6 @@
|
|||
import 'dart:typed_data';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
import '../connector/meshcore_protocol.dart';
|
||||
|
||||
/// Debug widget to show the hex dump of a frame
|
||||
|
|
@ -10,23 +11,32 @@ class DebugFrameViewer {
|
|||
.join(' ');
|
||||
|
||||
final details = StringBuffer();
|
||||
details.writeln('Frame Length: ${frame.length} bytes\n');
|
||||
details.writeln('Command: 0x${frame[0].toRadixString(16).padLeft(2, '0')}');
|
||||
details.writeln(context.l10n.debugFrame_length(frame.length));
|
||||
details.writeln('');
|
||||
details.writeln(
|
||||
context.l10n.debugFrame_command(frame[0].toRadixString(16).padLeft(2, '0')),
|
||||
);
|
||||
|
||||
if (frame[0] == cmdSendTxtMsg && frame.length > 37) {
|
||||
details.writeln('\nText Message Frame:');
|
||||
details.writeln('- Destination PubKey: ${pubKeyToHex(frame.sublist(1, 33))}');
|
||||
details.writeln('- Timestamp: ${readUint32LE(frame, 33)}');
|
||||
details.writeln('- Flags: 0x${frame[37].toRadixString(16).padLeft(2, '0')}');
|
||||
details.writeln('');
|
||||
details.writeln(context.l10n.debugFrame_textMessageHeader);
|
||||
details.writeln(context.l10n.debugFrame_destinationPubKey(pubKeyToHex(frame.sublist(1, 33))));
|
||||
details.writeln(context.l10n.debugFrame_timestamp(readUint32LE(frame, 33)));
|
||||
details.writeln(
|
||||
context.l10n.debugFrame_flags(frame[37].toRadixString(16).padLeft(2, '0')),
|
||||
);
|
||||
final txtType = (frame[37] >> 2) & 0x03;
|
||||
details.writeln('- Text Type: $txtType ${txtType == txtTypeCliData ? "(CLI)" : "(Plain)"}');
|
||||
final typeLabel = txtType == txtTypeCliData
|
||||
? context.l10n.debugFrame_textTypeCli
|
||||
: context.l10n.debugFrame_textTypePlain;
|
||||
details.writeln(context.l10n.debugFrame_textType(txtType, typeLabel));
|
||||
if (frame.length > 38) {
|
||||
final textBytes = frame.sublist(38);
|
||||
final nullIdx = textBytes.indexOf(0);
|
||||
final text = String.fromCharCodes(
|
||||
nullIdx >= 0 ? textBytes.sublist(0, nullIdx) : textBytes
|
||||
);
|
||||
details.writeln('- Text: "$text"');
|
||||
details.writeln(context.l10n.debugFrame_text(text));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -44,9 +54,9 @@ class DebugFrameViewer {
|
|||
style: const TextStyle(fontFamily: 'monospace', fontSize: 12),
|
||||
),
|
||||
const Divider(),
|
||||
const Text(
|
||||
'Hex Dump:',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
Text(
|
||||
context.l10n.debugFrame_hexDump,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
|
|
@ -59,7 +69,7 @@ class DebugFrameViewer {
|
|||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Close'),
|
||||
child: Text(context.l10n.common_close),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
|
||||
/// A reusable tile widget for displaying a MeshCore device in a list
|
||||
class DeviceTile extends StatelessWidget {
|
||||
|
|
@ -23,13 +24,13 @@ class DeviceTile extends StatelessWidget {
|
|||
return ListTile(
|
||||
leading: _buildSignalIcon(rssi),
|
||||
title: Text(
|
||||
name.isNotEmpty ? name : 'Unknown Device',
|
||||
name.isNotEmpty ? name : context.l10n.common_unknownDevice,
|
||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
subtitle: Text(device.remoteId.toString()),
|
||||
trailing: ElevatedButton(
|
||||
onPressed: onTap,
|
||||
child: const Text('Connect'),
|
||||
child: Text(context.l10n.common_connect),
|
||||
),
|
||||
onTap: onTap,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
|
||||
class EmojiPicker extends StatelessWidget {
|
||||
final Function(String) onEmojiSelected;
|
||||
|
|
@ -10,30 +12,39 @@ class EmojiPicker extends StatelessWidget {
|
|||
|
||||
static const List<String> quickEmojis = ['👍', '❤️', '😂', '🎉', '👏', '🔥'];
|
||||
|
||||
static const Map<String, List<String>> emojiCategories = {
|
||||
'Smileys': [
|
||||
static const List<String> _smileys = [
|
||||
'😀', '😃', '😄', '😁', '😅', '😂', '🤣', '😊', '😇', '🙂', '🙃', '😉', '😌', '😍', '🥰', '😘',
|
||||
'😗', '😙', '😚', '😋', '😛', '😝', '😜', '🤪', '🤨', '🧐', '🤓', '😎', '🥸', '🤩', '🥳', '😏',
|
||||
'😒', '😞', '😔', '😟', '😕', '🙁', '😣', '😖', '😫', '😩', '🥺', '😢', '😭', '😤', '😠', '😡',
|
||||
'🤬', '🤯', '😳', '🥵', '🥶', '😱', '😨', '😰', '😥', '😓', '🤗', '🤔', '🤭', '🤫', '🤥', '😶',
|
||||
],
|
||||
'Gestures': [
|
||||
];
|
||||
static const List<String> _gestures = [
|
||||
'👍', '👎', '👊', '✊', '🤛', '🤜', '🤞', '✌️', '🤟', '🤘', '👌', '🤌', '🤏', '👈', '👉', '👆',
|
||||
'👇', '☝️', '👋', '🤚', '🖐️', '✋', '🖖', '👏', '🙌', '👐', '🤲', '🤝', '🙏', '✍️', '💅', '🤳',
|
||||
],
|
||||
'Hearts': [
|
||||
];
|
||||
static const List<String> _hearts = [
|
||||
'❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '🤍', '🤎', '💔', '❤️🔥', '❤️🩹', '💕', '💞', '💓', '💗',
|
||||
'💖', '💘', '💝', '💟', '💌', '💢', '💥', '💫', '💦', '💨', '🕳️', '💬', '👁️🗨️', '🗨️', '🗯️', '💭',
|
||||
],
|
||||
'Objects': [
|
||||
];
|
||||
static const List<String> _objects = [
|
||||
'🎉', '🎊', '🎈', '🎁', '🎀', '🪅', '🪆', '🏆', '🥇', '🥈', '🥉', '⚽', '⚾', '🥎', '🏀', '🏐',
|
||||
'🏈', '🏉', '🎾', '🥏', '🎳', '🏏', '🏑', '🏒', '🥍', '🏓', '🏸', '🥊', '🥋', '🥅', '⛳', '🔥',
|
||||
'⭐', '🌟', '✨', '⚡', '💡', '🔦', '🏮', '🪔', '📱', '💻', '⌚', '📷', '📺', '📻', '🎵', '🎶',
|
||||
],
|
||||
};
|
||||
];
|
||||
|
||||
Map<String, List<String>> _emojiCategories(AppLocalizations l10n) {
|
||||
return {
|
||||
l10n.emojiCategorySmileys: _smileys,
|
||||
l10n.emojiCategoryGestures: _gestures,
|
||||
l10n.emojiCategoryHearts: _hearts,
|
||||
l10n.emojiCategoryObjects: _objects,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
final emojiCategories = _emojiCategories(l10n);
|
||||
return Container(
|
||||
height: MediaQuery.of(context).size.height * 0.5,
|
||||
decoration: BoxDecoration(
|
||||
|
|
@ -47,9 +58,9 @@ class EmojiPicker extends StatelessWidget {
|
|||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'Add Reaction',
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
Text(
|
||||
l10n.chat_addReaction,
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import 'dart:convert';
|
|||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import '../l10n/l10n.dart';
|
||||
|
||||
class GifPicker extends StatefulWidget {
|
||||
final Function(String gifId) onGifSelected;
|
||||
|
|
@ -57,14 +58,16 @@ class _GifPickerState extends State<GifPicker> {
|
|||
_isLoading = false;
|
||||
});
|
||||
} else {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_error = 'Failed to load GIFs';
|
||||
_error = context.l10n.gifPicker_failedLoad;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_error = 'No internet connection';
|
||||
_error = context.l10n.gifPicker_noInternet;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
|
|
@ -95,14 +98,16 @@ class _GifPickerState extends State<GifPicker> {
|
|||
_isLoading = false;
|
||||
});
|
||||
} else {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_error = 'Failed to search GIFs';
|
||||
_error = context.l10n.gifPicker_failedSearch;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_error = 'No internet connection';
|
||||
_error = context.l10n.gifPicker_noInternet;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
|
|
@ -120,9 +125,9 @@ class _GifPickerState extends State<GifPicker> {
|
|||
children: [
|
||||
const Icon(Icons.gif_box, size: 28),
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
'Choose a GIF',
|
||||
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
||||
Text(
|
||||
context.l10n.gifPicker_title,
|
||||
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
|
|
@ -137,7 +142,7 @@ class _GifPickerState extends State<GifPicker> {
|
|||
TextField(
|
||||
controller: _searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Search GIFs...',
|
||||
hintText: context.l10n.gifPicker_searchHint,
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
suffixIcon: _searchController.text.isNotEmpty
|
||||
? IconButton(
|
||||
|
|
@ -172,7 +177,7 @@ class _GifPickerState extends State<GifPicker> {
|
|||
// Powered by Giphy attribution
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Powered by GIPHY',
|
||||
context.l10n.gifPicker_poweredBy,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Colors.grey[600],
|
||||
|
|
@ -205,7 +210,7 @@ class _GifPickerState extends State<GifPicker> {
|
|||
ElevatedButton.icon(
|
||||
onPressed: _loadTrendingGifs,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('Retry'),
|
||||
label: Text(context.l10n.common_retry),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -220,7 +225,7 @@ class _GifPickerState extends State<GifPicker> {
|
|||
Icon(Icons.search_off, size: 64, color: Colors.grey[400]),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No GIFs found',
|
||||
context.l10n.gifPicker_noGifsFound,
|
||||
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
|
||||
enum ContactSortOption {
|
||||
lastSeen,
|
||||
|
|
@ -45,7 +46,7 @@ class SortFilterMenu extends StatelessWidget {
|
|||
super.key,
|
||||
required this.sections,
|
||||
required this.onSelected,
|
||||
this.tooltip = 'Filter and sort',
|
||||
required this.tooltip,
|
||||
this.icon = const Icon(Icons.filter_list_outlined),
|
||||
});
|
||||
|
||||
|
|
@ -131,59 +132,61 @@ class ContactsFilterMenu extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
return SortFilterMenu(
|
||||
tooltip: l10n.listFilter_tooltip,
|
||||
sections: [
|
||||
SortFilterMenuSection(
|
||||
title: 'Sort by',
|
||||
title: l10n.listFilter_sortBy,
|
||||
options: [
|
||||
SortFilterMenuOption(
|
||||
value: _actionSortRecentMessages,
|
||||
label: 'Latest messages',
|
||||
label: l10n.listFilter_latestMessages,
|
||||
checked: sortOption == ContactSortOption.recentMessages,
|
||||
),
|
||||
SortFilterMenuOption(
|
||||
value: _actionSortLastSeen,
|
||||
label: 'Heard recently',
|
||||
label: l10n.listFilter_heardRecently,
|
||||
checked: sortOption == ContactSortOption.lastSeen,
|
||||
),
|
||||
SortFilterMenuOption(
|
||||
value: _actionSortName,
|
||||
label: 'A-Z',
|
||||
label: l10n.listFilter_az,
|
||||
checked: sortOption == ContactSortOption.name,
|
||||
),
|
||||
],
|
||||
),
|
||||
SortFilterMenuSection(
|
||||
title: 'Filters',
|
||||
title: l10n.listFilter_filters,
|
||||
options: [
|
||||
SortFilterMenuOption(
|
||||
value: _actionFilterAll,
|
||||
label: 'All',
|
||||
label: l10n.listFilter_all,
|
||||
checked: typeFilter == ContactTypeFilter.all,
|
||||
),
|
||||
SortFilterMenuOption(
|
||||
value: _actionFilterUsers,
|
||||
label: 'Users',
|
||||
label: l10n.listFilter_users,
|
||||
checked: typeFilter == ContactTypeFilter.users,
|
||||
),
|
||||
SortFilterMenuOption(
|
||||
value: _actionFilterRepeaters,
|
||||
label: 'Repeaters',
|
||||
label: l10n.listFilter_repeaters,
|
||||
checked: typeFilter == ContactTypeFilter.repeaters,
|
||||
),
|
||||
SortFilterMenuOption(
|
||||
value: _actionFilterRooms,
|
||||
label: 'Room servers',
|
||||
label: l10n.listFilter_roomServers,
|
||||
checked: typeFilter == ContactTypeFilter.rooms,
|
||||
),
|
||||
SortFilterMenuOption(
|
||||
value: _actionToggleUnreadOnly,
|
||||
label: 'Unread only',
|
||||
label: l10n.listFilter_unreadOnly,
|
||||
checked: showUnreadOnly,
|
||||
),
|
||||
const SortFilterMenuOption(
|
||||
SortFilterMenuOption(
|
||||
value: _actionNewGroup,
|
||||
label: 'New group',
|
||||
label: l10n.listFilter_newGroup,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
|||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../connector/meshcore_connector.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
import '../models/contact.dart';
|
||||
import '../services/path_history_service.dart';
|
||||
import 'path_selection_dialog.dart';
|
||||
|
|
@ -12,13 +13,11 @@ class PathManagementDialog {
|
|||
static Future<void> show(
|
||||
BuildContext context, {
|
||||
required Contact contact,
|
||||
String title = 'Path Management',
|
||||
}) {
|
||||
return showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => _PathManagementDialog(
|
||||
contact: contact,
|
||||
title: title,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -26,11 +25,9 @@ class PathManagementDialog {
|
|||
|
||||
class _PathManagementDialog extends StatelessWidget {
|
||||
final Contact contact;
|
||||
final String title;
|
||||
|
||||
const _PathManagementDialog({
|
||||
required this.contact,
|
||||
required this.title,
|
||||
});
|
||||
|
||||
Contact _resolveContact(MeshCoreConnector connector) {
|
||||
|
|
@ -40,20 +37,22 @@ class _PathManagementDialog extends StatelessWidget {
|
|||
);
|
||||
}
|
||||
|
||||
String _formatRelativeTime(DateTime time) {
|
||||
String _formatRelativeTime(BuildContext context, DateTime time) {
|
||||
final l10n = context.l10n;
|
||||
final diff = DateTime.now().difference(time);
|
||||
if (diff.inSeconds < 60) return 'Just now';
|
||||
if (diff.inMinutes < 60) return '${diff.inMinutes}m ago';
|
||||
if (diff.inHours < 24) return '${diff.inHours}h ago';
|
||||
return '${diff.inDays}d ago';
|
||||
if (diff.inSeconds < 60) return l10n.time_justNow;
|
||||
if (diff.inMinutes < 60) return l10n.time_minutesAgo(diff.inMinutes);
|
||||
if (diff.inHours < 24) return l10n.time_hoursAgo(diff.inHours);
|
||||
return l10n.time_daysAgo(diff.inDays);
|
||||
}
|
||||
|
||||
void _showFullPathDialog(BuildContext context, List<int> pathBytes) {
|
||||
final l10n = context.l10n;
|
||||
if (pathBytes.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Path details not available yet. Try sending a message to refresh.'),
|
||||
duration: Duration(seconds: 2),
|
||||
SnackBar(
|
||||
content: Text(l10n.chat_pathDetailsNotAvailable),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
return;
|
||||
|
|
@ -66,12 +65,12 @@ class _PathManagementDialog extends StatelessWidget {
|
|||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Full Path'),
|
||||
title: Text(l10n.chat_fullPath),
|
||||
content: SelectableText(formattedPath),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Close'),
|
||||
child: Text(l10n.common_close),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -83,6 +82,7 @@ class _PathManagementDialog extends StatelessWidget {
|
|||
MeshCoreConnector connector,
|
||||
Contact currentContact,
|
||||
) async {
|
||||
final l10n = context.l10n;
|
||||
if (currentContact.pathLength > 0 && currentContact.path.isEmpty && connector.isConnected) {
|
||||
connector.getContacts();
|
||||
}
|
||||
|
|
@ -96,7 +96,6 @@ class _PathManagementDialog extends StatelessWidget {
|
|||
context,
|
||||
availableContacts: availableContacts,
|
||||
initialPath: pathForInput.isEmpty ? null : pathForInput,
|
||||
title: 'Set Custom Path',
|
||||
currentPathLabel: currentContact.pathLabel,
|
||||
onRefresh: connector.isConnected ? connector.getContacts : null,
|
||||
);
|
||||
|
|
@ -111,7 +110,7 @@ class _PathManagementDialog extends StatelessWidget {
|
|||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Path set: ${result.length} ${result.length == 1 ? "hop" : "hops"}'),
|
||||
content: Text(l10n.chat_hopsCount(result.length)),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
|
|
@ -120,27 +119,28 @@ class _PathManagementDialog extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
return Consumer2<MeshCoreConnector, PathHistoryService>(
|
||||
builder: (context, connector, pathService, _) {
|
||||
final currentContact = _resolveContact(connector);
|
||||
final paths = pathService.getRecentPaths(currentContact.publicKeyHex);
|
||||
|
||||
return AlertDialog(
|
||||
title: Text(title),
|
||||
title: Text(l10n.chat_pathManagement),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Current path: ${currentContact.pathLabel}',
|
||||
l10n.path_currentPath(currentContact.pathLabel),
|
||||
style: const TextStyle(fontSize: 12, color: Colors.grey),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (paths.isNotEmpty) ...[
|
||||
const Text(
|
||||
'Recent ACK Paths (tap to use):',
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
|
||||
Text(
|
||||
l10n.chat_recentAckPaths,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
|
||||
),
|
||||
if (paths.length >= 100) ...[
|
||||
const SizedBox(height: 8),
|
||||
|
|
@ -151,9 +151,9 @@ class _PathManagementDialog extends StatelessWidget {
|
|||
color: Colors.amberAccent,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Text(
|
||||
'Path history is full. Remove entries to add new ones.',
|
||||
style: TextStyle(fontSize: 12),
|
||||
child: Text(
|
||||
l10n.chat_pathHistoryFull,
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
@ -172,11 +172,11 @@ class _PathManagementDialog extends StatelessWidget {
|
|||
),
|
||||
),
|
||||
title: Text(
|
||||
'${path.hopCount} ${path.hopCount == 1 ? 'hop' : 'hops'}',
|
||||
l10n.chat_hopsCount(path.hopCount),
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
subtitle: Text(
|
||||
'${(path.tripTimeMs / 1000).toStringAsFixed(2)}s • ${_formatRelativeTime(path.timestamp)} • ${path.successCount} successes',
|
||||
'${(path.tripTimeMs / 1000).toStringAsFixed(2)}s • ${_formatRelativeTime(context, path.timestamp)} • ${path.successCount} ${l10n.chat_successes}',
|
||||
style: const TextStyle(fontSize: 11),
|
||||
),
|
||||
trailing: Row(
|
||||
|
|
@ -184,7 +184,7 @@ class _PathManagementDialog extends StatelessWidget {
|
|||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, size: 16),
|
||||
tooltip: 'Remove path',
|
||||
tooltip: l10n.chat_removePath,
|
||||
onPressed: () async {
|
||||
await pathService.removePathRecord(
|
||||
currentContact.publicKeyHex,
|
||||
|
|
@ -201,9 +201,9 @@ class _PathManagementDialog extends StatelessWidget {
|
|||
onTap: () async {
|
||||
if (path.pathBytes.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Path details not available yet. Try sending a message to refresh.'),
|
||||
duration: Duration(seconds: 2),
|
||||
SnackBar(
|
||||
content: Text(l10n.chat_pathDetailsNotAvailable),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
return;
|
||||
|
|
@ -222,7 +222,7 @@ class _PathManagementDialog extends StatelessWidget {
|
|||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Using ${path.hopCount} ${path.hopCount == 1 ? 'hop' : 'hops'} path'),
|
||||
content: Text(l10n.path_usingHopsPath(path.hopCount)),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
|
|
@ -232,13 +232,13 @@ class _PathManagementDialog extends StatelessWidget {
|
|||
}),
|
||||
const Divider(),
|
||||
] else ...[
|
||||
const Text('No path history yet.\nSend a message to discover paths.'),
|
||||
Text(l10n.chat_noPathHistoryYet),
|
||||
const Divider(),
|
||||
],
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'Path Actions:',
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
|
||||
Text(
|
||||
l10n.chat_pathActions,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ListTile(
|
||||
|
|
@ -248,8 +248,8 @@ class _PathManagementDialog extends StatelessWidget {
|
|||
backgroundColor: Colors.purple,
|
||||
child: Icon(Icons.edit_road, size: 16),
|
||||
),
|
||||
title: const Text('Set Custom Path', style: TextStyle(fontSize: 14)),
|
||||
subtitle: const Text('Manually specify routing path', style: TextStyle(fontSize: 11)),
|
||||
title: Text(l10n.chat_setCustomPath, style: const TextStyle(fontSize: 14)),
|
||||
subtitle: Text(l10n.chat_setCustomPathSubtitle, style: const TextStyle(fontSize: 11)),
|
||||
onTap: () async {
|
||||
await _setCustomPath(context, connector, currentContact);
|
||||
},
|
||||
|
|
@ -261,15 +261,15 @@ class _PathManagementDialog extends StatelessWidget {
|
|||
backgroundColor: Colors.orange,
|
||||
child: Icon(Icons.clear_all, size: 16),
|
||||
),
|
||||
title: const Text('Clear Path', style: TextStyle(fontSize: 14)),
|
||||
subtitle: const Text('Force rediscovery on next send', style: TextStyle(fontSize: 11)),
|
||||
title: Text(l10n.chat_clearPath, style: const TextStyle(fontSize: 14)),
|
||||
subtitle: Text(l10n.chat_clearPathSubtitle, style: const TextStyle(fontSize: 11)),
|
||||
onTap: () async {
|
||||
await connector.clearContactPath(currentContact);
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Path cleared. Next message will rediscover route.'),
|
||||
duration: Duration(seconds: 2),
|
||||
SnackBar(
|
||||
content: Text(l10n.chat_pathCleared),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
Navigator.pop(context);
|
||||
|
|
@ -282,15 +282,15 @@ class _PathManagementDialog extends StatelessWidget {
|
|||
backgroundColor: Colors.blue,
|
||||
child: Icon(Icons.waves, size: 16),
|
||||
),
|
||||
title: const Text('Force Flood Mode', style: TextStyle(fontSize: 14)),
|
||||
subtitle: const Text('Use routing toggle in app bar', style: TextStyle(fontSize: 11)),
|
||||
title: Text(l10n.chat_forceFloodMode, style: const TextStyle(fontSize: 14)),
|
||||
subtitle: Text(l10n.chat_floodModeSubtitle, style: const TextStyle(fontSize: 11)),
|
||||
onTap: () async {
|
||||
await connector.setPathOverride(currentContact, pathLen: -1);
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Flood mode enabled. Toggle back via routing icon in app bar.'),
|
||||
duration: Duration(seconds: 2),
|
||||
SnackBar(
|
||||
content: Text(l10n.chat_floodModeEnabled),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
Navigator.pop(context);
|
||||
|
|
@ -302,7 +302,7 @@ class _PathManagementDialog extends StatelessWidget {
|
|||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Close'),
|
||||
child: Text(l10n.common_close),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,19 +1,20 @@
|
|||
import 'dart:typed_data';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
import '../models/contact.dart';
|
||||
|
||||
class PathSelectionDialog extends StatefulWidget {
|
||||
final List<Contact> availableContacts;
|
||||
final String? initialPath;
|
||||
final String title;
|
||||
final String? initialPath;
|
||||
final String? currentPathLabel;
|
||||
final VoidCallback? onRefresh;
|
||||
|
||||
const PathSelectionDialog({
|
||||
super.key,
|
||||
required this.availableContacts,
|
||||
required this.title,
|
||||
this.initialPath,
|
||||
this.title = 'Enter Custom Path',
|
||||
this.currentPathLabel,
|
||||
this.onRefresh,
|
||||
});
|
||||
|
|
@ -24,8 +25,8 @@ class PathSelectionDialog extends StatefulWidget {
|
|||
static Future<Uint8List?> show(
|
||||
BuildContext context, {
|
||||
required List<Contact> availableContacts,
|
||||
String? title,
|
||||
String? initialPath,
|
||||
String title = 'Enter Custom Path',
|
||||
String? currentPathLabel,
|
||||
VoidCallback? onRefresh,
|
||||
}) {
|
||||
|
|
@ -33,8 +34,8 @@ class PathSelectionDialog extends StatefulWidget {
|
|||
context: context,
|
||||
builder: (context) => PathSelectionDialog(
|
||||
availableContacts: availableContacts,
|
||||
title: title ?? context.l10n.path_enterCustomPath,
|
||||
initialPath: initialPath,
|
||||
title: title,
|
||||
currentPathLabel: currentPathLabel,
|
||||
onRefresh: onRefresh,
|
||||
),
|
||||
|
|
@ -98,6 +99,7 @@ class _PathSelectionDialogState extends State<PathSelectionDialog> {
|
|||
}
|
||||
|
||||
Future<void> _validateAndSubmit() async {
|
||||
final l10n = context.l10n;
|
||||
final path = _controller.text.trim().toUpperCase();
|
||||
if (path.isEmpty) {
|
||||
if (mounted) Navigator.pop(context);
|
||||
|
|
@ -130,7 +132,7 @@ class _PathSelectionDialogState extends State<PathSelectionDialog> {
|
|||
if (invalidPrefixes.isNotEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Invalid hex prefixes: ${invalidPrefixes.join(", ")}'),
|
||||
content: Text(l10n.path_invalidHexPrefixes(invalidPrefixes.join(", "))),
|
||||
duration: const Duration(seconds: 3),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
|
|
@ -141,9 +143,9 @@ class _PathSelectionDialogState extends State<PathSelectionDialog> {
|
|||
// Check max path length (64 hops)
|
||||
if (pathBytesList.length > 64) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Path too long. Maximum 64 hops allowed.'),
|
||||
duration: Duration(seconds: 3),
|
||||
SnackBar(
|
||||
content: Text(l10n.path_tooLong),
|
||||
duration: const Duration(seconds: 3),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
|
|
@ -163,6 +165,7 @@ class _PathSelectionDialogState extends State<PathSelectionDialog> {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
return AlertDialog(
|
||||
title: Text(widget.title),
|
||||
content: SingleChildScrollView(
|
||||
|
|
@ -175,16 +178,16 @@ class _PathSelectionDialogState extends State<PathSelectionDialog> {
|
|||
if (widget.currentPathLabel != null) ...[
|
||||
Row(
|
||||
children: [
|
||||
const Text(
|
||||
'Current path',
|
||||
style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold),
|
||||
Text(
|
||||
l10n.path_currentPathLabel,
|
||||
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const Spacer(),
|
||||
if (widget.onRefresh != null)
|
||||
TextButton.icon(
|
||||
onPressed: widget.onRefresh,
|
||||
icon: const Icon(Icons.refresh, size: 16),
|
||||
label: const Text('Reload'),
|
||||
label: Text(l10n.common_reload),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -194,23 +197,23 @@ class _PathSelectionDialogState extends State<PathSelectionDialog> {
|
|||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
const Text(
|
||||
'Enter 2-character hex prefixes for each hop, separated by commas.',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey),
|
||||
Text(
|
||||
l10n.path_hexPrefixInstructions,
|
||||
style: const TextStyle(fontSize: 12, color: Colors.grey),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'Example: A1,F2,3C (each node uses first byte of its public key)',
|
||||
style: TextStyle(fontSize: 11, color: Colors.grey),
|
||||
Text(
|
||||
l10n.path_hexPrefixExample,
|
||||
style: const TextStyle(fontSize: 11, color: Colors.grey),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _controller,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Path (hex prefixes)',
|
||||
hintText: 'A1,F2,3C',
|
||||
border: OutlineInputBorder(),
|
||||
helperText: 'Max 64 hops. Each prefix is 2 hex characters (1 byte)',
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.path_labelHexPrefixes,
|
||||
hintText: l10n.path_hexPrefixExample,
|
||||
border: const OutlineInputBorder(),
|
||||
helperText: l10n.path_helperMaxHops,
|
||||
),
|
||||
textCapitalization: TextCapitalization.characters,
|
||||
maxLength: 191, // 64 hops * 2 chars + 63 commas
|
||||
|
|
@ -220,36 +223,36 @@ class _PathSelectionDialogState extends State<PathSelectionDialog> {
|
|||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
const Text(
|
||||
'Or select from contacts:',
|
||||
style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold),
|
||||
Text(
|
||||
l10n.path_selectFromContacts,
|
||||
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const Spacer(),
|
||||
if (_selectedContacts.isNotEmpty)
|
||||
TextButton(
|
||||
onPressed: _clearSelection,
|
||||
child: const Text('Clear'),
|
||||
child: Text(l10n.common_clear),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (_validContacts.isEmpty) ...[
|
||||
const Center(
|
||||
Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(16.0),
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(Icons.info_outline, size: 48, color: Colors.grey),
|
||||
SizedBox(height: 16),
|
||||
const Icon(Icons.info_outline, size: 48, color: Colors.grey),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No repeaters or room servers found.',
|
||||
style: TextStyle(fontSize: 14),
|
||||
l10n.path_noRepeatersFound,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Custom paths require intermediate hops that can relay messages.',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey),
|
||||
l10n.path_customPathsRequire,
|
||||
style: const TextStyle(fontSize: 12, color: Colors.grey),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
|
|
@ -300,11 +303,11 @@ class _PathSelectionDialogState extends State<PathSelectionDialog> {
|
|||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
child: Text(l10n.common_cancel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: _validateAndSubmit,
|
||||
child: const Text('Set Path'),
|
||||
child: Text(l10n.path_setPath),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
|
||||
class QuickSwitchBar extends StatelessWidget {
|
||||
final int selectedIndex;
|
||||
|
|
@ -59,18 +60,18 @@ class QuickSwitchBar extends StatelessWidget {
|
|||
height: 60,
|
||||
selectedIndex: selectedIndex,
|
||||
onDestinationSelected: onDestinationSelected,
|
||||
destinations: const [
|
||||
destinations: [
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.people_outline),
|
||||
label: 'Contacts',
|
||||
icon: const Icon(Icons.people_outline),
|
||||
label: context.l10n.nav_contacts,
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.tag),
|
||||
label: 'Channels',
|
||||
icon: const Icon(Icons.tag),
|
||||
label: context.l10n.nav_channels,
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.map_outlined),
|
||||
label: 'Map',
|
||||
icon: const Icon(Icons.map_outlined),
|
||||
label: context.l10n.nav_map,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import 'dart:async';
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
import '../models/contact.dart';
|
||||
import '../services/storage_service.dart';
|
||||
import '../connector/meshcore_connector.dart';
|
||||
|
|
@ -181,7 +182,7 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
|
|||
});
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Login failed: $e'),
|
||||
content: Text(context.l10n.login_failed(e.toString())),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
|
|
@ -223,6 +224,7 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
final connector = context.watch<MeshCoreConnector>();
|
||||
final repeater = _resolveRepeater(connector);
|
||||
final isFloodMode = repeater.pathOverride == -1;
|
||||
|
|
@ -235,7 +237,7 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
|
|||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('Repeater Login'),
|
||||
Text(l10n.login_repeaterLogin),
|
||||
Text(
|
||||
repeater.name,
|
||||
style: TextStyle(
|
||||
|
|
@ -260,17 +262,17 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
|
|||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Enter the repeater password to access settings and status.',
|
||||
style: TextStyle(fontSize: 14),
|
||||
Text(
|
||||
l10n.login_repeaterDescription,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _passwordController,
|
||||
obscureText: _obscurePassword,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Password',
|
||||
hintText: 'Enter password',
|
||||
labelText: l10n.login_password,
|
||||
hintText: l10n.login_enterPassword,
|
||||
border: const OutlineInputBorder(),
|
||||
prefixIcon: const Icon(Icons.lock),
|
||||
suffixIcon: IconButton(
|
||||
|
|
@ -297,13 +299,13 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
|
|||
_savePassword = value ?? false;
|
||||
});
|
||||
},
|
||||
title: const Text(
|
||||
'Save password',
|
||||
style: TextStyle(fontSize: 14),
|
||||
title: Text(
|
||||
l10n.login_savePassword,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
subtitle: const Text(
|
||||
'Password will be stored securely on this device',
|
||||
style: TextStyle(fontSize: 12),
|
||||
subtitle: Text(
|
||||
l10n.login_savePasswordSubtitle,
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
|
|
@ -311,14 +313,14 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
|
|||
const Divider(),
|
||||
Row(
|
||||
children: [
|
||||
const Text(
|
||||
'Routing',
|
||||
style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold),
|
||||
Text(
|
||||
l10n.login_routing,
|
||||
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const Spacer(),
|
||||
PopupMenuButton<String>(
|
||||
icon: Icon(isFloodMode ? Icons.waves : Icons.route),
|
||||
tooltip: 'Routing mode',
|
||||
tooltip: l10n.login_routingMode,
|
||||
onSelected: (mode) async {
|
||||
if (mode == 'flood') {
|
||||
await connector.setPathOverride(repeater, pathLen: -1);
|
||||
|
|
@ -334,7 +336,7 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
|
|||
Icon(Icons.auto_mode, size: 20, color: !isFloodMode ? Theme.of(context).primaryColor : null),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Auto (use saved path)',
|
||||
l10n.login_autoUseSavedPath,
|
||||
style: TextStyle(
|
||||
fontWeight: !isFloodMode ? FontWeight.bold : FontWeight.normal,
|
||||
),
|
||||
|
|
@ -349,7 +351,7 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
|
|||
Icon(Icons.waves, size: 20, color: isFloodMode ? Theme.of(context).primaryColor : null),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Force Flood Mode',
|
||||
l10n.login_forceFloodMode,
|
||||
style: TextStyle(
|
||||
fontWeight: isFloodMode ? FontWeight.bold : FontWeight.normal,
|
||||
),
|
||||
|
|
@ -372,7 +374,7 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
|
|||
child: TextButton.icon(
|
||||
onPressed: () => PathManagementDialog.show(context, contact: repeater),
|
||||
icon: const Icon(Icons.timeline, size: 18),
|
||||
label: const Text('Manage Paths'),
|
||||
label: Text(l10n.login_managePaths),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
@ -380,7 +382,7 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
|
|||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
child: Text(l10n.common_cancel),
|
||||
),
|
||||
if (_isLoggingIn)
|
||||
SizedBox(
|
||||
|
|
@ -399,7 +401,7 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
|
|||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text('Attempt $_currentAttempt/$_maxAttempts'),
|
||||
Text(l10n.login_attempt(_currentAttempt, _maxAttempts)),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -408,7 +410,7 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
|
|||
FilledButton.icon(
|
||||
onPressed: _isLoading ? null : _handleLogin,
|
||||
icon: const Icon(Icons.login, size: 18),
|
||||
label: const Text('Login'),
|
||||
label: Text(l10n.login_login),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import 'dart:async';
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
import '../models/contact.dart';
|
||||
import '../services/storage_service.dart';
|
||||
import '../connector/meshcore_connector.dart';
|
||||
|
|
@ -181,7 +182,7 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
|
|||
});
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Login failed: $e'),
|
||||
content: Text(context.l10n.login_failed(e.toString())),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
|
|
@ -223,6 +224,7 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
final connector = context.watch<MeshCoreConnector>();
|
||||
final repeater = _resolveRepeater(connector);
|
||||
final isFloodMode = repeater.pathOverride == -1;
|
||||
|
|
@ -235,7 +237,7 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
|
|||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('Room Login'),
|
||||
Text(l10n.login_roomLogin),
|
||||
Text(
|
||||
repeater.name,
|
||||
style: TextStyle(
|
||||
|
|
@ -260,17 +262,17 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
|
|||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Enter the room password to access settings and status.',
|
||||
style: TextStyle(fontSize: 14),
|
||||
Text(
|
||||
l10n.login_roomDescription,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _passwordController,
|
||||
obscureText: _obscurePassword,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Password',
|
||||
hintText: 'Enter password',
|
||||
labelText: l10n.login_password,
|
||||
hintText: l10n.login_enterPassword,
|
||||
border: const OutlineInputBorder(),
|
||||
prefixIcon: const Icon(Icons.lock),
|
||||
suffixIcon: IconButton(
|
||||
|
|
@ -297,13 +299,13 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
|
|||
_savePassword = value ?? false;
|
||||
});
|
||||
},
|
||||
title: const Text(
|
||||
'Save password',
|
||||
style: TextStyle(fontSize: 14),
|
||||
title: Text(
|
||||
l10n.login_savePassword,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
subtitle: const Text(
|
||||
'Password will be stored securely on this device',
|
||||
style: TextStyle(fontSize: 12),
|
||||
subtitle: Text(
|
||||
l10n.login_savePasswordSubtitle,
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
|
|
@ -311,14 +313,14 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
|
|||
const Divider(),
|
||||
Row(
|
||||
children: [
|
||||
const Text(
|
||||
'Routing',
|
||||
style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold),
|
||||
Text(
|
||||
l10n.login_routing,
|
||||
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const Spacer(),
|
||||
PopupMenuButton<String>(
|
||||
icon: Icon(isFloodMode ? Icons.waves : Icons.route),
|
||||
tooltip: 'Routing mode',
|
||||
tooltip: l10n.login_routingMode,
|
||||
onSelected: (mode) async {
|
||||
if (mode == 'flood') {
|
||||
await connector.setPathOverride(repeater, pathLen: -1);
|
||||
|
|
@ -334,7 +336,7 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
|
|||
Icon(Icons.auto_mode, size: 20, color: !isFloodMode ? Theme.of(context).primaryColor : null),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Auto (use saved path)',
|
||||
l10n.login_autoUseSavedPath,
|
||||
style: TextStyle(
|
||||
fontWeight: !isFloodMode ? FontWeight.bold : FontWeight.normal,
|
||||
),
|
||||
|
|
@ -349,7 +351,7 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
|
|||
Icon(Icons.waves, size: 20, color: isFloodMode ? Theme.of(context).primaryColor : null),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Force Flood Mode',
|
||||
l10n.login_forceFloodMode,
|
||||
style: TextStyle(
|
||||
fontWeight: isFloodMode ? FontWeight.bold : FontWeight.normal,
|
||||
),
|
||||
|
|
@ -372,7 +374,7 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
|
|||
child: TextButton.icon(
|
||||
onPressed: () => PathManagementDialog.show(context, contact: repeater),
|
||||
icon: const Icon(Icons.timeline, size: 18),
|
||||
label: const Text('Manage Paths'),
|
||||
label: Text(l10n.login_managePaths),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
@ -380,7 +382,7 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
|
|||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
child: Text(l10n.common_cancel),
|
||||
),
|
||||
if (_isLoggingIn)
|
||||
SizedBox(
|
||||
|
|
@ -399,7 +401,7 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
|
|||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text('Attempt $_currentAttempt/$_maxAttempts'),
|
||||
Text(l10n.login_attempt(_currentAttempt, _maxAttempts)),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -408,7 +410,7 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
|
|||
FilledButton.icon(
|
||||
onPressed: _isLoading ? null : _handleLogin,
|
||||
icon: const Icon(Icons.login, size: 18),
|
||||
label: const Text('Login'),
|
||||
label: Text(l10n.login_login),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue