mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-06-22 18:34:29 +10: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:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user