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
+86 -57
View File
@@ -9,6 +9,8 @@ import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart';
import '../services/map_tile_cache_service.dart';
import '../connector/meshcore_protocol.dart';
import '../l10n/app_localizations.dart';
import '../l10n/l10n.dart';
import '../models/channel_message.dart';
import '../models/contact.dart';
@@ -24,22 +26,24 @@ class ChannelMessagePathScreen extends StatelessWidget {
Widget build(BuildContext context) {
return Consumer<MeshCoreConnector>(
builder: (context, connector, _) {
final l10n = context.l10n;
final primaryPath = _selectPrimaryPath(message.pathBytes, message.pathVariants);
final hops = _buildPathHops(primaryPath, connector.contacts);
final hops = _buildPathHops(primaryPath, connector.contacts, l10n);
final hasHopDetails = primaryPath.isNotEmpty;
final observedLabel = _formatObservedHops(
primaryPath.length,
message.pathLength,
l10n,
);
final extraPaths = _otherPaths(primaryPath, message.pathVariants);
return Scaffold(
appBar: AppBar(
title: const Text('Packet Path'),
title: Text(l10n.channelPath_title),
actions: [
IconButton(
icon: const Icon(Icons.map_outlined),
tooltip: 'View map',
tooltip: l10n.channelPath_viewMap,
onPressed: hasHopDetails
? () {
_openPathMap(context);
@@ -57,7 +61,7 @@ class ChannelMessagePathScreen extends StatelessWidget {
const SizedBox(height: 16),
if (extraPaths.isNotEmpty) ...[
Text(
'Other Observed Paths',
l10n.channelPath_otherObservedPaths,
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
@@ -65,17 +69,17 @@ class ChannelMessagePathScreen extends StatelessWidget {
const SizedBox(height: 16),
],
Text(
'Repeater Hops',
l10n.channelPath_repeaterHops,
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
if (!hasHopDetails)
const Text(
'Hop details are not provided for this packet.',
style: TextStyle(color: Colors.grey),
Text(
l10n.channelPath_noHopDetails,
style: const TextStyle(color: Colors.grey),
)
else
..._buildHopTiles(hops),
..._buildHopTiles(context, hops),
],
),
),
@@ -88,6 +92,7 @@ class ChannelMessagePathScreen extends StatelessWidget {
BuildContext context, {
String? observedLabel,
}) {
final l10n = context.l10n;
return Card(
child: Padding(
padding: const EdgeInsets.all(12),
@@ -95,16 +100,16 @@ class ChannelMessagePathScreen extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Message Details',
l10n.channelPath_messageDetails,
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
_buildDetailRow('Sender', message.senderName),
_buildDetailRow('Time', _formatTime(message.timestamp)),
_buildDetailRow(l10n.channelPath_senderLabel, message.senderName),
_buildDetailRow(l10n.channelPath_timeLabel, _formatTime(message.timestamp, l10n)),
if (message.repeatCount > 0)
_buildDetailRow('Repeats', message.repeatCount.toString()),
_buildDetailRow('Path', _formatPathLabel(message.pathLength)),
if (observedLabel != null) _buildDetailRow('Observed', observedLabel),
_buildDetailRow(l10n.channelPath_repeatsLabel, message.repeatCount.toString()),
_buildDetailRow(l10n.channelPath_pathLabelTitle, _formatPathLabel(message.pathLength, l10n)),
if (observedLabel != null) _buildDetailRow(l10n.channelPath_observedLabel, observedLabel),
],
),
),
@@ -115,6 +120,7 @@ class ChannelMessagePathScreen extends StatelessWidget {
BuildContext context,
List<Uint8List> variants,
) {
final l10n = context.l10n;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -124,7 +130,10 @@ class ChannelMessagePathScreen extends StatelessWidget {
child: ListTile(
dense: true,
title: Text(
'Observed path ${i + 1}${_formatHopCount(variants[i].length)}',
l10n.channelPath_observedPathTitle(
i + 1,
_formatHopCount(variants[i].length, l10n),
),
),
subtitle: Text(_formatPathPrefixes(variants[i])),
trailing: const Icon(Icons.map_outlined, size: 20),
@@ -135,7 +144,8 @@ class ChannelMessagePathScreen extends StatelessWidget {
);
}
List<Widget> _buildHopTiles(List<_PathHop> hops) {
List<Widget> _buildHopTiles(BuildContext context, List<_PathHop> hops) {
final l10n = context.l10n;
return [
for (final hop in hops)
Card(
@@ -154,45 +164,52 @@ class ChannelMessagePathScreen extends StatelessWidget {
hop.hasLocation
? '${hop.position!.latitude.toStringAsFixed(5)}, '
'${hop.position!.longitude.toStringAsFixed(5)}'
: 'No location data',
: l10n.channelPath_noLocationData,
),
),
),
];
}
String _formatTime(DateTime time) {
String _formatTime(DateTime time, AppLocalizations l10n) {
final now = DateTime.now();
final diff = now.difference(time);
if (diff.inDays > 0) {
return '${time.day}/${time.month} '
final timeLabel =
'${time.hour}:${time.minute.toString().padLeft(2, '0')}';
return l10n.channelPath_timeWithDate(time.day, time.month, timeLabel);
}
return '${time.hour}:${time.minute.toString().padLeft(2, '0')}';
return l10n.channelPath_timeOnly(
'${time.hour}:${time.minute.toString().padLeft(2, '0')}',
);
}
String _formatPathLabel(int? pathLength) {
if (pathLength == null) return 'Unknown';
if (pathLength < 0) return 'Flood';
if (pathLength == 0) return 'Direct';
return '$pathLength hops';
String _formatPathLabel(int? pathLength, AppLocalizations l10n) {
if (pathLength == null) return l10n.channelPath_unknownPath;
if (pathLength < 0) return l10n.channelPath_floodPath;
if (pathLength == 0) return l10n.channelPath_directPath;
return l10n.chat_hopsCount(pathLength);
}
String? _formatObservedHops(int observedCount, int? pathLength) {
String? _formatObservedHops(
int observedCount,
int? pathLength,
AppLocalizations l10n,
) {
if (observedCount <= 0 && (pathLength == null || pathLength <= 0)) {
return null;
}
if (pathLength == null || pathLength < 0) {
return observedCount > 0 ? '$observedCount hops' : null;
return observedCount > 0 ? l10n.chat_hopsCount(observedCount) : null;
}
if (observedCount == 0) {
return '0 of $pathLength hops';
return l10n.channelPath_observedZeroOf(pathLength);
}
if (observedCount == pathLength) {
return '$observedCount hops';
return l10n.chat_hopsCount(observedCount);
}
return '$observedCount of $pathLength hops';
return l10n.channelPath_observedSomeOf(observedCount, pathLength);
}
Widget _buildDetailRow(String label, String value) {
@@ -274,7 +291,7 @@ class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScree
primaryPath,
);
final selectedIndex = _indexForPath(selectedPath, observedPaths);
final hops = _buildPathHops(selectedPath, connector.contacts);
final hops = _buildPathHops(selectedPath, connector.contacts, context.l10n);
final points = hops
.where((hop) => hop.hasLocation)
.map((hop) => hop.position!)
@@ -297,7 +314,7 @@ class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScree
return Scaffold(
appBar: AppBar(
title: const Text('Path Map'),
title: Text(context.l10n.channelPath_mapTitle),
),
body: SafeArea(
top: false,
@@ -347,9 +364,9 @@ class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScree
Center(
child: Card(
color: Colors.white.withValues(alpha: 0.9),
child: const Padding(
child: Padding(
padding: EdgeInsets.all(12),
child: Text('No repeater locations available for this path.'),
child: Text(context.l10n.channelPath_noRepeaterLocations),
),
),
),
@@ -368,10 +385,11 @@ class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScree
int selectedIndex,
ValueChanged<int> onSelected,
) {
final l10n = context.l10n;
final selectedPath = paths[selectedIndex];
final label = selectedPath.isPrimary
? 'Path ${selectedIndex + 1} (Primary)'
: 'Path ${selectedIndex + 1}';
? l10n.channelPath_primaryPath(selectedIndex + 1)
: l10n.channelPath_pathLabel(selectedIndex + 1);
return Positioned(
left: 16,
right: 16,
@@ -383,9 +401,9 @@ class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScree
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Observed Path',
style: TextStyle(fontWeight: FontWeight.w600),
Text(
l10n.channelPath_observedPathHeader,
style: const TextStyle(fontWeight: FontWeight.w600),
),
const SizedBox(height: 4),
DropdownButtonHideUnderline(
@@ -397,8 +415,8 @@ class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScree
DropdownMenuItem(
value: i,
child: Text(
'${paths[i].isPrimary ? 'Path ${i + 1} (Primary)' : 'Path ${i + 1}'}'
'${_formatHopCount(paths[i].pathBytes.length)}',
'${paths[i].isPrimary ? l10n.channelPath_primaryPath(i + 1) : l10n.channelPath_pathLabel(i + 1)}'
'${_formatHopCount(paths[i].pathBytes.length, l10n)}',
),
),
],
@@ -410,7 +428,10 @@ class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScree
),
const SizedBox(height: 4),
Text(
'$label${_formatPathPrefixes(selectedPath.pathBytes)}',
l10n.channelPath_selectedPathLabel(
label,
_formatPathPrefixes(selectedPath.pathBytes),
),
style: TextStyle(color: Colors.grey[700], fontSize: 12),
),
],
@@ -457,6 +478,7 @@ class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScree
}
Widget _buildLegendCard(BuildContext context, List<_PathHop> hops) {
final l10n = context.l10n;
final maxHeight = MediaQuery.of(context).size.height * 0.35;
final estimatedHeight = 72.0 + (hops.length * 56.0);
final cardHeight = max(96.0, min(maxHeight, estimatedHeight));
@@ -471,18 +493,18 @@ class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScree
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.all(12),
Padding(
padding: const EdgeInsets.all(12),
child: Text(
'Repeater Hops',
style: TextStyle(fontWeight: FontWeight.w600),
l10n.channelPath_repeaterHops,
style: const TextStyle(fontWeight: FontWeight.w600),
),
),
const Divider(height: 1),
Expanded(
child: hops.isEmpty
? const Center(
child: Text('No hop details available for this packet.'),
? Center(
child: Text(l10n.channelPath_noHopDetailsAvailable),
)
: ListView.separated(
padding: const EdgeInsets.symmetric(vertical: 4),
@@ -504,7 +526,7 @@ class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScree
hop.hasLocation
? '${hop.position!.latitude.toStringAsFixed(5)}, '
'${hop.position!.longitude.toStringAsFixed(5)}'
: 'No location data',
: l10n.channelPath_noLocationData,
),
);
},
@@ -523,19 +545,21 @@ class _PathHop {
final int prefix;
final Contact? contact;
final LatLng? position;
final AppLocalizations l10n;
const _PathHop({
required this.index,
required this.prefix,
required this.contact,
required this.position,
required this.l10n,
});
bool get hasLocation => position != null;
String get displayLabel {
final prefixLabel = _formatPrefix(prefix);
return '($prefixLabel) ${_resolveName(contact)}';
return '($prefixLabel) ${_resolveName(contact, l10n)}';
}
}
@@ -549,7 +573,11 @@ class _ObservedPath {
});
}
List<_PathHop> _buildPathHops(Uint8List pathBytes, List<Contact> contacts) {
List<_PathHop> _buildPathHops(
Uint8List pathBytes,
List<Contact> contacts,
AppLocalizations l10n,
) {
final hops = <_PathHop>[];
for (var i = 0; i < pathBytes.length; i++) {
final prefix = pathBytes[i];
@@ -560,6 +588,7 @@ List<_PathHop> _buildPathHops(Uint8List pathBytes, List<Contact> contacts) {
prefix: prefix,
contact: contact,
position: _resolvePosition(contact),
l10n: l10n,
),
);
}
@@ -612,15 +641,15 @@ String _formatPathPrefixes(Uint8List pathBytes) {
.join(',');
}
String _formatHopCount(int count) {
return '$count ${count == 1 ? 'hop' : 'hops'}';
String _formatHopCount(int count, AppLocalizations l10n) {
return l10n.chat_hopsCount(count);
}
String _resolveName(Contact? contact) {
if (contact == null) return 'Unknown Repeater';
String _resolveName(Contact? contact, AppLocalizations l10n) {
if (contact == null) return l10n.channelPath_unknownRepeater;
final name = contact.name.trim();
if (name.isEmpty || name.toLowerCase() == 'unknown') {
return 'Unknown Repeater';
return l10n.channelPath_unknownRepeater;
}
return name;
}