diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index 60a825c..ca5dc64 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:math' as math; import 'package:crypto/crypto.dart' as crypto; import 'package:pointycastle/export.dart'; @@ -8,6 +9,7 @@ import 'package:flutter_blue_plus/flutter_blue_plus.dart'; import '../models/channel.dart'; import '../models/channel_message.dart'; +import '../models/companion_radio_stats.dart'; import '../models/contact.dart'; import '../models/message.dart'; import '../models/path_selection.dart'; @@ -143,6 +145,10 @@ class MeshCoreConnector extends ChangeNotifier { Timer? _selfInfoRetryTimer; Timer? _reconnectTimer; Timer? _batteryPollTimer; + Timer? _radioStatsPollTimer; + int _radioStatsPollRefCount = 0; + final ValueNotifier radioStatsNotifier = + ValueNotifier(null); int _reconnectAttempts = 0; bool _notifyListenersDirty = false; static const Duration _notifyListenersDebounce = Duration(milliseconds: 50); @@ -160,6 +166,10 @@ class MeshCoreConnector extends ChangeNotifier { int? _currentCr; bool? _clientRepeat; int? _firmwareVerCode; + int _pathHashByteWidth = 1; + CompanionRadioStats? _latestRadioStats; + Stopwatch? _airtimeBumpStopwatch; + int _prevTotalAirSecs = 0; int? _batteryMillivolts; double? _selfLatitude; double? _selfLongitude; @@ -173,9 +183,13 @@ class MeshCoreConnector extends ChangeNotifier { DateTime _lastRxTime = DateTime.now(); DateTime _lastRadioRxTime = DateTime.fromMillisecondsSinceEpoch(0); DateTime _lastContactMsgRxTime = DateTime.fromMillisecondsSinceEpoch(0); + DateTime _lastChannelMsgRxTime = DateTime.fromMillisecondsSinceEpoch(0); static const int _radioQuietMs = 3000; static const int _radioQuietMaxWaitMs = 3000; - static const int _contactMsgBackoffMs = 5000; + /// When companion radio stats are unavailable, keep the legacy fixed backoff. + static const int _contactMsgBackoffFallbackMs = 5000; + static const int _contactMsgBackoffMinMs = 500; + static const int _contactMsgBackoffMaxMs = 15000; bool _batteryRequested = false; bool _awaitingSelfInfo = false; bool _hasReceivedDeviceInfo = false; @@ -323,6 +337,18 @@ class MeshCoreConnector extends ChangeNotifier { List get directRepeaters => _directRepeaters; int? get currentTxPower => _currentTxPower; int? get maxTxPower => _maxTxPower; + + int get pathHashByteWidth => _pathHashByteWidth; + + CompanionRadioStats? get latestRadioStats => _latestRadioStats; + + bool get supportsCompanionRadioStats => (_firmwareVerCode ?? 0) >= 8; + + bool get radioStatsAirActivityPulse { + final sw = _airtimeBumpStopwatch; + if (sw == null || !sw.isRunning) return false; + return sw.elapsed < const Duration(seconds: 2); + } int? get currentFreqHz => _currentFreqHz; int? get currentBwHz => _currentBwHz; int? get currentSf => _currentSf; @@ -779,15 +805,71 @@ class MeshCoreConnector extends ChangeNotifier { } } - Future _waitForRadioQuiet() async { - // Wait for backoff after receiving a contact message (avoid collision - // with their transmission still propagating through repeaters) - final msSinceContactMsg = DateTime.now() - .difference(_lastContactMsgRxTime) - .inMilliseconds; - if (msSinceContactMsg < _contactMsgBackoffMs) { - final waitMs = _contactMsgBackoffMs - msSinceContactMsg; - debugPrint('Contact message backoff: waiting ${waitMs}ms'); + /// After an incoming DM or channel message, wait before TX so we do not + /// collide with mesh propagation. With companion stats, scale wait by RF + /// conditions (up to [_contactMsgBackoffMaxMs]); otherwise use + /// [_contactMsgBackoffFallbackMs]. + int _contactMessageBackoffTargetMs() { + if (!supportsCompanionRadioStats || _latestRadioStats == null) { + return _contactMsgBackoffFallbackMs; + } + final stats = _latestRadioStats!; + final nf = stats.noiseFloorDbm.toDouble(); + // Quieter (more negative) → lower score; noisier → higher. + const noiseQuietDbm = -118.0; + const noiseNoisyDbm = -88.0; + final noiseT = + ((nf - noiseQuietDbm) / (noiseNoisyDbm - noiseQuietDbm)).clamp(0.0, 1.0); + + final snr = stats.lastSnrDb; + const snrGood = 12.0; + const snrBad = -2.0; + final snrT = + (1.0 - ((snr - snrBad) / (snrGood - snrBad))).clamp(0.0, 1.0); + + final airBusy = _recentAirtimeBusyFraction(); + final severity = + (math.max(noiseT, snrT) * 0.82 + airBusy * 0.18).clamp(0.0, 1.0); + + return (_contactMsgBackoffMinMs + + severity * (_contactMsgBackoffMaxMs - _contactMsgBackoffMinMs)) + .round(); + } + + /// 1.0 shortly after TX/RX airtime counters increase, decaying to 0 over ~8s. + double _recentAirtimeBusyFraction() { + final sw = _airtimeBumpStopwatch; + if (sw == null || !sw.isRunning) return 0; + final ms = sw.elapsedMilliseconds; + const windowMs = 8000; + if (ms >= windowMs) return 0; + return 1.0 - (ms / windowMs); + } + + /// Start of the post-inbound cool-down: the later of BLE message RX time and + /// companion airtime bump ([_airtimeBumpStopwatch], same as the activity dot). + DateTime _postTxBackoffAnchor(DateTime lastInboundRxTime) { + if (!supportsCompanionRadioStats) return lastInboundRxTime; + final sw = _airtimeBumpStopwatch; + if (sw == null || !sw.isRunning) return lastInboundRxTime; + final bumpAt = DateTime.now().subtract(sw.elapsed); + return bumpAt.isAfter(lastInboundRxTime) ? bumpAt : lastInboundRxTime; + } + + Future _waitForRadioQuiet({ + required DateTime lastInboundRxTime, + }) async { + // Wait for backoff after inbound traffic / RF airtime (avoid collision with + // mesh propagation). Elapsed time uses the dot's airtime bump when newer. + final backoffTargetMs = _contactMessageBackoffTargetMs(); + final anchor = _postTxBackoffAnchor(lastInboundRxTime); + final msSinceAnchor = DateTime.now().difference(anchor).inMilliseconds; + if (msSinceAnchor < backoffTargetMs) { + final waitMs = backoffTargetMs - msSinceAnchor; + debugPrint( + 'Post-inbound backoff: waiting ${waitMs}ms ' + '(target=${backoffTargetMs}ms, anchorAge=${msSinceAnchor}ms)', + ); await Future.delayed(Duration(milliseconds: waitMs)); } @@ -821,7 +903,7 @@ class MeshCoreConnector extends ChangeNotifier { ) async { if (!isConnected || text.isEmpty) return; try { - await _waitForRadioQuiet(); + await _waitForRadioQuiet(lastInboundRxTime: _lastContactMsgRxTime); final outboundText = prepareContactOutboundText(contact, text); await sendFrame( buildSendTextMsgFrame( @@ -1097,6 +1179,7 @@ class MeshCoreConnector extends ChangeNotifier { ); await _requestDeviceInfo(); _startBatteryPolling(); + if (_radioStatsPollRefCount > 0) _startRadioStatsPolling(); var gotSelfInfo = await _waitForSelfInfo( timeout: const Duration(seconds: 3), ); @@ -1202,6 +1285,7 @@ class MeshCoreConnector extends ChangeNotifier { _pendingInitialChannelSync = true; await _requestDeviceInfo(); _startBatteryPolling(); + if (_radioStatsPollRefCount > 0) _startRadioStatsPolling(); var gotSelfInfo = await _waitForSelfInfo( timeout: const Duration(seconds: 3), @@ -1489,6 +1573,7 @@ class MeshCoreConnector extends ChangeNotifier { await _requestDeviceInfo(); _startBatteryPolling(); + if (_radioStatsPollRefCount > 0) _startRadioStatsPolling(); final gotSelfInfo = await _waitForSelfInfo( timeout: const Duration(seconds: 3), @@ -1516,6 +1601,7 @@ class MeshCoreConnector extends ChangeNotifier { _pendingInitialContactsSync = false; _bleInitialSyncStarted = false; _pendingDeferredChannelSyncAfterContacts = false; + _pathHashByteWidth = 1; } bool get _shouldAutoReconnect => @@ -1592,6 +1678,7 @@ class MeshCoreConnector extends ChangeNotifier { } _setState(MeshCoreConnectionState.disconnecting); _stopBatteryPolling(); + _stopRadioStatsPolling(); await _usbFrameSubscription?.cancel(); _usbFrameSubscription = null; @@ -1730,6 +1817,49 @@ class MeshCoreConnector extends ChangeNotifier { _batteryPollTimer = null; } + void _startRadioStatsPolling() { + _radioStatsPollTimer?.cancel(); + _radioStatsPollTimer = Timer.periodic(const Duration(seconds: 1), (_) { + if (!isConnected) { + _stopRadioStatsPolling(); + return; + } + unawaited(requestRadioStats()); + }); + } + + void _stopRadioStatsPolling() { + _radioStatsPollTimer?.cancel(); + _radioStatsPollTimer = null; + } + + void acquireRadioStatsPolling() { + _radioStatsPollRefCount++; + if (_radioStatsPollRefCount == 1 && isConnected) { + _startRadioStatsPolling(); + } + } + + void releaseRadioStatsPolling() { + _radioStatsPollRefCount = (_radioStatsPollRefCount - 1).clamp(0, 999); + if (_radioStatsPollRefCount == 0) { + _stopRadioStatsPolling(); + } + } + + Future requestRadioStats() async { + if (!isConnected) return; + if (!supportsCompanionRadioStats) return; + try { + await sendFrame(buildGetStatsFrame(statsTypeRadio)); + } catch (_) {} + } + + Future setPathHashMode(int mode) async { + if (!isConnected) return; + await sendFrame(buildSetPathHashModeFrame(mode.clamp(0, 2))); + } + Future refreshDeviceInfo() async { if (!isConnected) return; if (PlatformInfo.isWeb && @@ -2219,6 +2349,7 @@ class MeshCoreConnector extends ChangeNotifier { // Send the reaction to the device (don't add as a visible message) final reactionQueueId = _nextReactionSendQueueId(); _pendingChannelSentQueue.add(reactionQueueId); + await _waitForRadioQuiet(lastInboundRxTime: _lastChannelMsgRxTime); await sendFrame( buildSendChannelTextMsgFrame(channel.index, text), channelSendQueueId: reactionQueueId, @@ -2243,6 +2374,7 @@ class MeshCoreConnector extends ChangeNotifier { (isChannelSmazEnabled(channel.index) && !isStructuredPayload) ? Smaz.encodeIfSmaller(text) : text; + await _waitForRadioQuiet(lastInboundRxTime: _lastChannelMsgRxTime); await sendFrame( buildSendChannelTextMsgFrame(channel.index, outboundText), channelSendQueueId: message.messageId, @@ -2808,6 +2940,9 @@ class MeshCoreConnector extends ChangeNotifier { case respCodeBattAndStorage: _handleBatteryAndStorage(frame); break; + case respCodeStats: + _handleStatsFrame(frame); + break; case respCodeCustomVars: _handleCustomVars(frame); break; @@ -2880,8 +3015,8 @@ class MeshCoreConnector extends ChangeNotifier { final reader = BufferReader(frame); try { reader.skipBytes(2); - _currentTxPower = reader.readByte(); - _maxTxPower = reader.readByte(); + _currentTxPower = reader.readInt8(); + _maxTxPower = reader.readInt8(); _selfPublicKey = reader.readBytes(pubKeySize); _selfLatitude = reader.readInt32LE() / 1000000.0; _selfLongitude = reader.readInt32LE() / 1000000.0; @@ -2975,6 +3110,13 @@ class MeshCoreConnector extends ChangeNotifier { if (frame.length >= 81) { _clientRepeat = frame[80] != 0; } + // Path hash mode v10+ (byte 81): width = mode + 1 byte(s) per hop + if (frame.length >= 82) { + final mode = (frame[81] & 0xFF).clamp(0, 2); + _pathHashByteWidth = mode + 1; + } else { + _pathHashByteWidth = 1; + } // Firmware reports MAX_CONTACTS / 2 for v3+ device info. final reportedContacts = frame[2]; @@ -3034,6 +3176,19 @@ class MeshCoreConnector extends ChangeNotifier { unawaited(_requestNextQueuedMessage()); } + void _handleStatsFrame(Uint8List frame) { + final stats = CompanionRadioStats.tryParse(frame); + if (stats == null) return; + final total = stats.txAirSecs + stats.rxAirSecs; + if (total > _prevTotalAirSecs) { + (_airtimeBumpStopwatch ??= Stopwatch()).reset(); + _airtimeBumpStopwatch!.start(); + } + _prevTotalAirSecs = total; + _latestRadioStats = stats; + radioStatsNotifier.value = stats; + } + void _handleBatteryAndStorage(Uint8List frame) { // Frame format from C++: // [0] = RESP_CODE_BATT_AND_STORAGE @@ -3402,9 +3557,10 @@ class MeshCoreConnector extends ChangeNotifier { } bool _pathMatchesContact(Uint8List pathBytes, Uint8List publicKey) { - if (pathBytes.isEmpty || publicKey.length < pathHashSize) return false; - for (int i = 0; i + pathHashSize <= pathBytes.length; i += pathHashSize) { - final prefix = pathBytes.sublist(i, i + pathHashSize); + final w = _pathHashByteWidth; + if (pathBytes.isEmpty || publicKey.length < w) return false; + for (int i = 0; i + w <= pathBytes.length; i += w) { + final prefix = pathBytes.sublist(i, i + w); if (_matchesPrefix(publicKey, prefix)) { return true; } @@ -3689,6 +3845,7 @@ class MeshCoreConnector extends ChangeNotifier { if (_shouldDropSelfChannelMessage(parsed.senderName, parsed.pathBytes)) { return; } + _lastChannelMsgRxTime = DateTime.now(); final contentHash = _computeContentHash( parsed.channelIndex!, parsed.timestamp.millisecondsSinceEpoch ~/ 1000, @@ -4680,6 +4837,12 @@ class MeshCoreConnector extends ChangeNotifier { void _handleDisconnection() { _stopBatteryPolling(); + _stopRadioStatsPolling(); + _latestRadioStats = null; + radioStatsNotifier.value = null; + _prevTotalAirSecs = 0; + _airtimeBumpStopwatch?.stop(); + _airtimeBumpStopwatch = null; for (final entry in _pendingRepeaterAcks.values) { entry.timeout?.cancel(); @@ -4818,6 +4981,8 @@ class MeshCoreConnector extends ChangeNotifier { _notifyListenersTimer?.cancel(); _reconnectTimer?.cancel(); _batteryPollTimer?.cancel(); + _radioStatsPollTimer?.cancel(); + radioStatsNotifier.dispose(); _receivedFramesController.close(); _usbManager.dispose(); _tcpConnector.dispose(); diff --git a/lib/connector/meshcore_protocol.dart b/lib/connector/meshcore_protocol.dart index b368756..b42e3e5 100644 --- a/lib/connector/meshcore_protocol.dart +++ b/lib/connector/meshcore_protocol.dart @@ -209,6 +209,8 @@ const int cmdSetCustomVar = 41; const int cmdSendBinaryReq = 50; const int cmdSetAutoAddConfig = 58; const int cmdGetAutoAddConfig = 59; +const int cmdSetPathHashMode = 61; +const int cmdGetStats = 56; // Text message types const int txtTypePlain = 0; @@ -245,6 +247,11 @@ const int respCodeChannelMsgRecvV3 = 17; const int respCodeChannelInfo = 18; const int respCodeCustomVars = 21; const int respCodeAutoAddConfig = 25; +const int respCodeStats = 24; + +const int statsTypeCore = 0; +const int statsTypeRadio = 1; +const int statsTypePackets = 2; // Push codes (async from device) const int pushCodeAdvert = 0x80; @@ -554,6 +561,17 @@ Uint8List buildGetBattAndStorageFrame() { return Uint8List.fromList([cmdGetBattAndStorage]); } +/// Companion radio stats: [56][statsType] where statsType is statsTypeCore/Radio/Packets. +Uint8List buildGetStatsFrame(int statsType) { + return Uint8List.fromList([cmdGetStats, statsType & 0xFF]); +} + +/// Path hash width on air: [61][0][mode], mode 0..2 → (mode+1) bytes per hop hash. +Uint8List buildSetPathHashModeFrame(int mode) { + final m = mode.clamp(0, 2); + return Uint8List.fromList([cmdSetPathHashMode, 0, m]); +} + // Build CMD_SET_DEVICE_TIME frame Uint8List buildSetDeviceTimeFrame(int timestamp) { final writer = BufferWriter(); diff --git a/lib/helpers/link_handler.dart b/lib/helpers/link_handler.dart index 57a5e59..e83897a 100644 --- a/lib/helpers/link_handler.dart +++ b/lib/helpers/link_handler.dart @@ -5,6 +5,17 @@ import '../l10n/l10n.dart'; import '../utils/platform_info.dart'; class LinkHandler { + static TextStyle defaultLinkStyle(BuildContext context, TextStyle base) { + final brightness = Theme.of(context).brightness; + final orange = brightness == Brightness.dark + ? const Color(0xFFFFB74D) + : const Color(0xFFE65100); + return base.copyWith( + color: orange, + decoration: TextDecoration.underline, + ); + } + /// Returns a [SelectableLinkify] on desktop or a [Linkify] on mobile. static Widget buildLinkifyText({ required BuildContext context, @@ -13,13 +24,9 @@ class LinkHandler { TextStyle? linkStyle, }) { final effectiveLinkStyle = - linkStyle ?? - style.copyWith( - color: Colors.green, - decoration: TextDecoration.underline, - ); + linkStyle ?? defaultLinkStyle(context, style); const options = LinkifyOptions(humanize: false, defaultToHttps: false); - const linkifiers = [UrlLinkifier()]; + const linkifiers = [UrlLinkifier(), EmailLinkifier()]; void onOpen(LinkableElement link) => handleLinkTap(context, link.url); if (PlatformInfo.isDesktop) { diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index d8623d3..113fa2b 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1977,5 +1977,68 @@ "discoveredContacts_copyContact": "Copy Contact to clipboard", "discoveredContacts_deleteContact": "Delete Discovered Contact", "discoveredContacts_deleteContactAll": "Delete All Discovered Contacts", - "discoveredContacts_deleteContactAllContent": "Are you sure you want to delete all discovered contacts?" -} \ No newline at end of file + "discoveredContacts_deleteContactAllContent": "Are you sure you want to delete all discovered contacts?", + "chat_sendCooldown": "Please wait a moment before sending again.", + "appSettings_jumpToOldestUnread": "Jump to oldest unread", + "appSettings_jumpToOldestUnreadSubtitle": "When opening a chat with unread messages, scroll to the first unread instead of the latest.", + "appSettings_languageHu": "Hungarian", + "appSettings_languageJa": "Japanese", + "appSettings_languageKo": "Korean", + "radioStats_tooltip": "Radio & mesh stats", + "radioStats_screenTitle": "Radio stats", + "radioStats_notConnected": "Connect to a device to view radio statistics.", + "radioStats_firmwareTooOld": "Radio statistics require companion firmware v8 or newer.", + "radioStats_waiting": "Waiting for data…", + "radioStats_noiseFloor": "Noise floor: {noiseDbm} dBm", + "@radioStats_noiseFloor": { + "placeholders": { + "noiseDbm": { + "type": "int" + } + } + }, + "radioStats_lastRssi": "Last RSSI: {rssiDbm} dBm", + "@radioStats_lastRssi": { + "placeholders": { + "rssiDbm": { + "type": "int" + } + } + }, + "radioStats_lastSnr": "Last SNR: {snr} dB", + "@radioStats_lastSnr": { + "placeholders": { + "snr": { + "type": "String" + } + } + }, + "radioStats_txAir": "TX airtime (total): {seconds} s", + "@radioStats_txAir": { + "placeholders": { + "seconds": { + "type": "int" + } + } + }, + "radioStats_rxAir": "RX airtime (total): {seconds} s", + "@radioStats_rxAir": { + "placeholders": { + "seconds": { + "type": "int" + } + } + }, + "radioStats_chartCaption": "Noise floor (dBm) over recent samples.", + "radioStats_stripNoise": "Noise floor: {noiseDbm} dBm", + "@radioStats_stripNoise": { + "placeholders": { + "noiseDbm": { + "type": "int" + } + } + }, + "radioStats_stripWaiting": "Fetching radio stats…", + "radioStats_settingsTile": "Radio stats", + "radioStats_settingsSubtitle": "Noise floor, RSSI, SNR, and airtime" +} diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index ce5833a..d2d4040 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -10,7 +10,10 @@ import 'app_localizations_de.dart'; import 'app_localizations_en.dart'; import 'app_localizations_es.dart'; import 'app_localizations_fr.dart'; +import 'app_localizations_hu.dart'; import 'app_localizations_it.dart'; +import 'app_localizations_ja.dart'; +import 'app_localizations_ko.dart'; import 'app_localizations_nl.dart'; import 'app_localizations_pl.dart'; import 'app_localizations_pt.dart'; @@ -112,7 +115,10 @@ abstract class AppLocalizations { Locale('en'), Locale('es'), Locale('fr'), + Locale('hu'), Locale('it'), + Locale('ja'), + Locale('ko'), Locale('nl'), Locale('pl'), Locale('pt'), @@ -6016,6 +6022,132 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Are you sure you want to delete all discovered contacts?'** String get discoveredContacts_deleteContactAllContent; + + /// No description provided for @chat_sendCooldown. + /// + /// In en, this message translates to: + /// **'Please wait a moment before sending again.'** + String get chat_sendCooldown; + + /// No description provided for @appSettings_jumpToOldestUnread. + /// + /// In en, this message translates to: + /// **'Jump to oldest unread'** + String get appSettings_jumpToOldestUnread; + + /// No description provided for @appSettings_jumpToOldestUnreadSubtitle. + /// + /// In en, this message translates to: + /// **'When opening a chat with unread messages, scroll to the first unread instead of the latest.'** + String get appSettings_jumpToOldestUnreadSubtitle; + + /// No description provided for @appSettings_languageHu. + /// + /// In en, this message translates to: + /// **'Hungarian'** + String get appSettings_languageHu; + + /// No description provided for @appSettings_languageJa. + /// + /// In en, this message translates to: + /// **'Japanese'** + String get appSettings_languageJa; + + /// No description provided for @appSettings_languageKo. + /// + /// In en, this message translates to: + /// **'Korean'** + String get appSettings_languageKo; + + /// No description provided for @radioStats_tooltip. + /// + /// In en, this message translates to: + /// **'Radio & mesh stats'** + String get radioStats_tooltip; + + /// No description provided for @radioStats_screenTitle. + /// + /// In en, this message translates to: + /// **'Radio stats'** + String get radioStats_screenTitle; + + /// No description provided for @radioStats_notConnected. + /// + /// In en, this message translates to: + /// **'Connect to a device to view radio statistics.'** + String get radioStats_notConnected; + + /// No description provided for @radioStats_firmwareTooOld. + /// + /// In en, this message translates to: + /// **'Radio statistics require companion firmware v8 or newer.'** + String get radioStats_firmwareTooOld; + + /// No description provided for @radioStats_waiting. + /// + /// In en, this message translates to: + /// **'Waiting for data…'** + String get radioStats_waiting; + + /// No description provided for @radioStats_noiseFloor. + /// + /// In en, this message translates to: + /// **'Noise floor: {noiseDbm} dBm'** + String radioStats_noiseFloor(int noiseDbm); + + /// No description provided for @radioStats_lastRssi. + /// + /// In en, this message translates to: + /// **'Last RSSI: {rssiDbm} dBm'** + String radioStats_lastRssi(int rssiDbm); + + /// No description provided for @radioStats_lastSnr. + /// + /// In en, this message translates to: + /// **'Last SNR: {snr} dB'** + String radioStats_lastSnr(String snr); + + /// No description provided for @radioStats_txAir. + /// + /// In en, this message translates to: + /// **'TX airtime (total): {seconds} s'** + String radioStats_txAir(int seconds); + + /// No description provided for @radioStats_rxAir. + /// + /// In en, this message translates to: + /// **'RX airtime (total): {seconds} s'** + String radioStats_rxAir(int seconds); + + /// No description provided for @radioStats_chartCaption. + /// + /// In en, this message translates to: + /// **'Noise floor (dBm) over recent samples.'** + String get radioStats_chartCaption; + + /// No description provided for @radioStats_stripNoise. + /// + /// In en, this message translates to: + /// **'Noise floor: {noiseDbm} dBm'** + String radioStats_stripNoise(int noiseDbm); + + /// No description provided for @radioStats_stripWaiting. + /// + /// In en, this message translates to: + /// **'Fetching radio stats…'** + String get radioStats_stripWaiting; + + /// No description provided for @radioStats_settingsTile. + /// + /// In en, this message translates to: + /// **'Radio stats'** + String get radioStats_settingsTile; + + /// No description provided for @radioStats_settingsSubtitle. + /// + /// In en, this message translates to: + /// **'Noise floor, RSSI, SNR, and airtime'** + String get radioStats_settingsSubtitle; } class _AppLocalizationsDelegate @@ -6034,7 +6166,10 @@ class _AppLocalizationsDelegate 'en', 'es', 'fr', + 'hu', 'it', + 'ja', + 'ko', 'nl', 'pl', 'pt', @@ -6063,8 +6198,14 @@ AppLocalizations lookupAppLocalizations(Locale locale) { return AppLocalizationsEs(); case 'fr': return AppLocalizationsFr(); + case 'hu': + return AppLocalizationsHu(); case 'it': return AppLocalizationsIt(); + case 'ja': + return AppLocalizationsJa(); + case 'ko': + return AppLocalizationsKo(); case 'nl': return AppLocalizationsNl(); case 'pl': diff --git a/lib/l10n/app_localizations_bg.dart b/lib/l10n/app_localizations_bg.dart index 53d8ef2..e010040 100644 --- a/lib/l10n/app_localizations_bg.dart +++ b/lib/l10n/app_localizations_bg.dart @@ -3484,4 +3484,87 @@ class AppLocalizationsBg extends AppLocalizations { @override String get discoveredContacts_deleteContactAllContent => 'Сигурни ли сте, че искате да изтриете всички открити контакти?'; + + @override + String get chat_sendCooldown => + 'Моля, изчакайте малко, преди да изпратите отново.'; + + @override + String get appSettings_jumpToOldestUnread => + 'Преминете към най-старата непочетена статия'; + + @override + String get appSettings_jumpToOldestUnreadSubtitle => + 'Когато отворите чат с непрочетени съобщения, плъзнете надолу, за да видите първото непрочетено съобщение, вместо най-новото.'; + + @override + String get appSettings_languageHu => 'Унгарски'; + + @override + String get appSettings_languageJa => 'Японски'; + + @override + String get appSettings_languageKo => 'Корейски'; + + @override + String get radioStats_tooltip => 'Статистика за радио и мрежа'; + + @override + String get radioStats_screenTitle => + 'Статистически данни за радиопредаванията'; + + @override + String get radioStats_notConnected => + 'Свържете се с устройство, за да видите статистически данни за радиопредаване.'; + + @override + String get radioStats_firmwareTooOld => + 'Статистиката на радиостанцията изисква съвместимо софтуерно решение версия 8 или по-нова.'; + + @override + String get radioStats_waiting => 'Изчакване на данни…'; + + @override + String radioStats_noiseFloor(int noiseDbm) { + return 'Ниво на шума: $noiseDbm dBm'; + } + + @override + String radioStats_lastRssi(int rssiDbm) { + return 'Последен RSSI: $rssiDbm dBm'; + } + + @override + String radioStats_lastSnr(String snr) { + return 'Последна стойност на SNR: $snr dB'; + } + + @override + String radioStats_txAir(int seconds) { + return 'Време на въздух (общо): $seconds секунди'; + } + + @override + String radioStats_rxAir(int seconds) { + return 'Общо време на използване на RX (в секунди): $seconds с'; + } + + @override + String get radioStats_chartCaption => + 'Ниво на шума (dBm) за последните измервания.'; + + @override + String radioStats_stripNoise(int noiseDbm) { + return 'Ниво на шума: $noiseDbm dBm'; + } + + @override + String get radioStats_stripWaiting => 'Извличане на данни за радиото…'; + + @override + String get radioStats_settingsTile => 'Статистически данни за радиостанции'; + + @override + String get radioStats_settingsSubtitle => + 'Ниво на шума, RSSI, SNR и време на пренос'; } diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 535eb45..3967829 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -3494,4 +3494,86 @@ class AppLocalizationsDe extends AppLocalizations { @override String get discoveredContacts_deleteContactAllContent => 'Sind Sie sicher, dass Sie alle gefundenen Kontakte löschen möchten?'; + + @override + String get chat_sendCooldown => + 'Bitte warten Sie einen Moment, bevor Sie erneut senden.'; + + @override + String get appSettings_jumpToOldestUnread => + 'Zum ältesten, nicht gelesenen Eintrag springen'; + + @override + String get appSettings_jumpToOldestUnreadSubtitle => + 'Wenn Sie ein Chatfenster öffnen, in dem Nachrichten vorhanden sind, die noch nicht gelesen wurden, scrollen Sie zu der ersten unlesenen Nachricht, anstatt zur neuesten.'; + + @override + String get appSettings_languageHu => 'Ungarisch'; + + @override + String get appSettings_languageJa => 'Japanisch'; + + @override + String get appSettings_languageKo => 'Koreanisch'; + + @override + String get radioStats_tooltip => 'Daten zu Radio- und Mesh-Netzwerken'; + + @override + String get radioStats_screenTitle => 'Senderinformationen'; + + @override + String get radioStats_notConnected => + 'Verbinden Sie ein Gerät, um Radiostatisiken anzuzeigen.'; + + @override + String get radioStats_firmwareTooOld => + 'Für die Verwendung der Funkstatistiken ist die Firmware-Version 8 oder höher erforderlich.'; + + @override + String get radioStats_waiting => 'Warte auf Daten…'; + + @override + String radioStats_noiseFloor(int noiseDbm) { + return 'Rauschpegel: $noiseDbm dBm'; + } + + @override + String radioStats_lastRssi(int rssiDbm) { + return 'Letzter RSSI-Wert: $rssiDbm dBm'; + } + + @override + String radioStats_lastSnr(String snr) { + return 'Letzter SNR: $snr dB'; + } + + @override + String radioStats_txAir(int seconds) { + return 'Gesamt-TX-Zeit: $seconds s'; + } + + @override + String radioStats_rxAir(int seconds) { + return 'Gesamt-RX-Zeit: $seconds s'; + } + + @override + String get radioStats_chartCaption => + 'Rauschpegel (dBm) basierend auf den letzten Messwerten.'; + + @override + String radioStats_stripNoise(int noiseDbm) { + return 'Rauschpegel: $noiseDbm dBm'; + } + + @override + String get radioStats_stripWaiting => 'Abrufen von Radiostatus…'; + + @override + String get radioStats_settingsTile => 'Senderinformationen'; + + @override + String get radioStats_settingsSubtitle => + 'Rauschpegel, RSSI, Signal-Rausch-Verhältnis (SNR) und Nutzzeit'; } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 2fe75ec..4e90c25 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -3421,4 +3421,84 @@ class AppLocalizationsEn extends AppLocalizations { @override String get discoveredContacts_deleteContactAllContent => 'Are you sure you want to delete all discovered contacts?'; + + @override + String get chat_sendCooldown => 'Please wait a moment before sending again.'; + + @override + String get appSettings_jumpToOldestUnread => 'Jump to oldest unread'; + + @override + String get appSettings_jumpToOldestUnreadSubtitle => + 'When opening a chat with unread messages, scroll to the first unread instead of the latest.'; + + @override + String get appSettings_languageHu => 'Hungarian'; + + @override + String get appSettings_languageJa => 'Japanese'; + + @override + String get appSettings_languageKo => 'Korean'; + + @override + String get radioStats_tooltip => 'Radio & mesh stats'; + + @override + String get radioStats_screenTitle => 'Radio stats'; + + @override + String get radioStats_notConnected => + 'Connect to a device to view radio statistics.'; + + @override + String get radioStats_firmwareTooOld => + 'Radio statistics require companion firmware v8 or newer.'; + + @override + String get radioStats_waiting => 'Waiting for data…'; + + @override + String radioStats_noiseFloor(int noiseDbm) { + return 'Noise floor: $noiseDbm dBm'; + } + + @override + String radioStats_lastRssi(int rssiDbm) { + return 'Last RSSI: $rssiDbm dBm'; + } + + @override + String radioStats_lastSnr(String snr) { + return 'Last SNR: $snr dB'; + } + + @override + String radioStats_txAir(int seconds) { + return 'TX airtime (total): $seconds s'; + } + + @override + String radioStats_rxAir(int seconds) { + return 'RX airtime (total): $seconds s'; + } + + @override + String get radioStats_chartCaption => + 'Noise floor (dBm) over recent samples.'; + + @override + String radioStats_stripNoise(int noiseDbm) { + return 'Noise floor: $noiseDbm dBm'; + } + + @override + String get radioStats_stripWaiting => 'Fetching radio stats…'; + + @override + String get radioStats_settingsTile => 'Radio stats'; + + @override + String get radioStats_settingsSubtitle => + 'Noise floor, RSSI, SNR, and airtime'; } diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index 70e0a79..af57fc2 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -3487,4 +3487,86 @@ class AppLocalizationsEs extends AppLocalizations { @override String get discoveredContacts_deleteContactAllContent => '¿Está seguro de que desea eliminar todos los contactos descubiertos!'; + + @override + String get chat_sendCooldown => + 'Por favor, espere un momento antes de reenviar.'; + + @override + String get appSettings_jumpToOldestUnread => + 'Ve a el mensaje más antiguo sin leer'; + + @override + String get appSettings_jumpToOldestUnreadSubtitle => + 'Cuando abras una conversación con mensajes sin leer, desplázate hacia el primer mensaje sin leer en lugar del más reciente.'; + + @override + String get appSettings_languageHu => 'Húngaro'; + + @override + String get appSettings_languageJa => 'Japonés'; + + @override + String get appSettings_languageKo => 'Coreano'; + + @override + String get radioStats_tooltip => 'Estadísticas de radio y malla'; + + @override + String get radioStats_screenTitle => 'Estadísticas de radio'; + + @override + String get radioStats_notConnected => + 'Conéctese a un dispositivo para visualizar estadísticas de radio.'; + + @override + String get radioStats_firmwareTooOld => + 'Las estadísticas de radio requieren un firmware compatible v8 o posterior.'; + + @override + String get radioStats_waiting => 'Esperando datos…'; + + @override + String radioStats_noiseFloor(int noiseDbm) { + return 'Nivel de ruido: $noiseDbm dBm'; + } + + @override + String radioStats_lastRssi(int rssiDbm) { + return 'Último RSSI: $rssiDbm dBm'; + } + + @override + String radioStats_lastSnr(String snr) { + return 'Último SNR: $snr dB'; + } + + @override + String radioStats_txAir(int seconds) { + return 'Tiempo de emisión en Texas (total): $seconds s'; + } + + @override + String radioStats_rxAir(int seconds) { + return 'Tiempo de transmisión de RX (total): $seconds s'; + } + + @override + String get radioStats_chartCaption => + 'Nivel de ruido (dBm) en muestras recientes.'; + + @override + String radioStats_stripNoise(int noiseDbm) { + return 'Nivel de ruido: $noiseDbm dBm'; + } + + @override + String get radioStats_stripWaiting => 'Obteniendo estadísticas de la radio…'; + + @override + String get radioStats_settingsTile => 'Estadísticas de radio'; + + @override + String get radioStats_settingsSubtitle => + 'Nivel de ruido, RSSI, SNR y tiempo de transmisión'; } diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 5be46c8..22ff6a8 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -3511,4 +3511,88 @@ class AppLocalizationsFr extends AppLocalizations { @override String get discoveredContacts_deleteContactAllContent => 'Êtes-vous sûr de vouloir supprimer tous les contacts découverts ?'; + + @override + String get chat_sendCooldown => + 'Veuillez patienter un instant avant de réessayer.'; + + @override + String get appSettings_jumpToOldestUnread => + 'Accéder au message le plus ancien non lu'; + + @override + String get appSettings_jumpToOldestUnreadSubtitle => + 'Lorsque vous ouvrez une conversation contenant des messages non lus, faites défiler la page jusqu\'au premier message non lu, plutôt que jusqu\'au dernier.'; + + @override + String get appSettings_languageHu => 'Hongrois'; + + @override + String get appSettings_languageJa => 'Japonais'; + + @override + String get appSettings_languageKo => 'Coréen'; + + @override + String get radioStats_tooltip => + 'Statistiques des radios et des réseaux sans fil'; + + @override + String get radioStats_screenTitle => 'Statistiques de radio'; + + @override + String get radioStats_notConnected => + 'Connectez-vous à un appareil pour visualiser les statistiques de la radio.'; + + @override + String get radioStats_firmwareTooOld => + 'Les statistiques radio nécessitent un firmware compatible v8 ou une version ultérieure.'; + + @override + String get radioStats_waiting => 'En attente des données…'; + + @override + String radioStats_noiseFloor(int noiseDbm) { + return 'Niveau de bruit : $noiseDbm dBm'; + } + + @override + String radioStats_lastRssi(int rssiDbm) { + return 'Dernier RSSI : $rssiDbm dBm'; + } + + @override + String radioStats_lastSnr(String snr) { + return 'Dernier SNR : $snr dB'; + } + + @override + String radioStats_txAir(int seconds) { + return 'Temps d\'antenne à la télévision du Texas (total) : $seconds s'; + } + + @override + String radioStats_rxAir(int seconds) { + return 'Temps d\'utilisation de l\'appareil RX (total) : $seconds s'; + } + + @override + String get radioStats_chartCaption => + 'Niveau de bruit (dBm) sur les échantillons récents.'; + + @override + String radioStats_stripNoise(int noiseDbm) { + return 'Niveau de bruit : $noiseDbm dBm'; + } + + @override + String get radioStats_stripWaiting => + 'Récupération des statistiques de la radio…'; + + @override + String get radioStats_settingsTile => 'Statistiques de radio'; + + @override + String get radioStats_settingsSubtitle => + 'Niveau de bruit, RSSI, rapport signal/bruit (SNR) et temps d\'antenne'; } diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart index 63b501f..5e1aa0b 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -3491,4 +3491,86 @@ class AppLocalizationsIt extends AppLocalizations { @override String get discoveredContacts_deleteContactAllContent => 'Sei sicuro di voler eliminare tutti i contatti scoperti?'; + + @override + String get chat_sendCooldown => + 'Si prega di attendere un momento prima di inviare nuovamente.'; + + @override + String get appSettings_jumpToOldestUnread => + 'Vai al messaggio più vecchio non letto'; + + @override + String get appSettings_jumpToOldestUnreadSubtitle => + 'Quando si apre una chat con messaggi non letti, scorrete verso l\'alto fino al primo messaggio non letto, invece che al più recente.'; + + @override + String get appSettings_languageHu => 'Ungherese'; + + @override + String get appSettings_languageJa => 'Giapponese'; + + @override + String get appSettings_languageKo => 'Coreano'; + + @override + String get radioStats_tooltip => 'Statistiche per radio e reti'; + + @override + String get radioStats_screenTitle => 'Statistiche radio'; + + @override + String get radioStats_notConnected => + 'Connettiti a un dispositivo per visualizzare le statistiche radio.'; + + @override + String get radioStats_firmwareTooOld => + 'Le statistiche radio richiedono il firmware versione 8 o successiva.'; + + @override + String get radioStats_waiting => 'In attesa dei dati…'; + + @override + String radioStats_noiseFloor(int noiseDbm) { + return 'Livello di rumore: $noiseDbm dBm'; + } + + @override + String radioStats_lastRssi(int rssiDbm) { + return 'Ultimo valore RSSI: $rssiDbm dBm'; + } + + @override + String radioStats_lastSnr(String snr) { + return 'Ultimo SNR: $snr dB'; + } + + @override + String radioStats_txAir(int seconds) { + return 'Tempo di trasmissione in diretta (totale): $seconds s'; + } + + @override + String radioStats_rxAir(int seconds) { + return 'Tempo di trasmissione RX (totale): $seconds s'; + } + + @override + String get radioStats_chartCaption => + 'Livello di rumore (dBm) misurato su campioni recenti.'; + + @override + String radioStats_stripNoise(int noiseDbm) { + return 'Livello di rumore: $noiseDbm dBm'; + } + + @override + String get radioStats_stripWaiting => 'Recupero delle statistiche radio…'; + + @override + String get radioStats_settingsTile => 'Statistiche radio'; + + @override + String get radioStats_settingsSubtitle => + 'Livello di rumore, RSSI, rapporto segnale/rumore (SNR) e tempo di trasmissione'; } diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index adf2392..50774c9 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -3469,4 +3469,86 @@ class AppLocalizationsNl extends AppLocalizations { @override String get discoveredContacts_deleteContactAllContent => 'Weet u zeker dat u alle ontdekte contacten wilt verwijderen?'; + + @override + String get chat_sendCooldown => + 'Gelieve even te wachten voordat u opnieuw verzendt.'; + + @override + String get appSettings_jumpToOldestUnread => + 'Ga naar het oudste ongelezen bericht'; + + @override + String get appSettings_jumpToOldestUnreadSubtitle => + 'Bij het openen van een chat met ongelezen berichten, scroll dan naar het eerste ongelezen bericht, in plaats van naar het meest recente.'; + + @override + String get appSettings_languageHu => 'Hongaars'; + + @override + String get appSettings_languageJa => 'Japanisch'; + + @override + String get appSettings_languageKo => 'Koreaans'; + + @override + String get radioStats_tooltip => 'Statistieken voor radio en mesh-netwerken'; + + @override + String get radioStats_screenTitle => 'Statistieken over radio'; + + @override + String get radioStats_notConnected => + 'Verbind met een apparaat om radio-statistieken te bekijken.'; + + @override + String get radioStats_firmwareTooOld => + 'Om de statistieken via radio te kunnen gebruiken, is firmware versie 8 of een nieuwere vereist.'; + + @override + String get radioStats_waiting => 'Wacht op gegevens…'; + + @override + String radioStats_noiseFloor(int noiseDbm) { + return 'Ruisfrequentie: $noiseDbm dBm'; + } + + @override + String radioStats_lastRssi(int rssiDbm) { + return 'Laatste RSSI-waarde: $rssiDbm dBm'; + } + + @override + String radioStats_lastSnr(String snr) { + return 'Laatste SNR: $snr dB'; + } + + @override + String radioStats_txAir(int seconds) { + return 'TX-tijd (totaal): $seconds s'; + } + + @override + String radioStats_rxAir(int seconds) { + return 'Tijd besteed met RX (totaal): $seconds s'; + } + + @override + String get radioStats_chartCaption => + 'Ruisfrequentie (dBm) over recente metingen.'; + + @override + String radioStats_stripNoise(int noiseDbm) { + return 'Ruisfrequentie: $noiseDbm dBm'; + } + + @override + String get radioStats_stripWaiting => 'Radio-statistieken ophalen…'; + + @override + String get radioStats_settingsTile => 'Statistieken over radio'; + + @override + String get radioStats_settingsSubtitle => + 'Ruimtelijke ruis, RSSI, SNR en beschikbare tijd'; } diff --git a/lib/l10n/app_localizations_pl.dart b/lib/l10n/app_localizations_pl.dart index e5d7d36..b1a58c1 100644 --- a/lib/l10n/app_localizations_pl.dart +++ b/lib/l10n/app_localizations_pl.dart @@ -3503,4 +3503,86 @@ class AppLocalizationsPl extends AppLocalizations { @override String get discoveredContacts_deleteContactAllContent => 'Czy na pewno chcesz usunąć wszystkie znalezione kontakty?'; + + @override + String get chat_sendCooldown => + 'Prosimy o chwilowe oczekiwanie przed ponownym wysłaniem.'; + + @override + String get appSettings_jumpToOldestUnread => + 'Przejdź do najstarszego nieodczytanej wiadomości'; + + @override + String get appSettings_jumpToOldestUnreadSubtitle => + 'Przy otwieraniu czatu z nieodczytanymi wiadomościami, przewijaj, aby przejść do pierwszej nieodczytanej wiadomości, zamiast do najnowszej.'; + + @override + String get appSettings_languageHu => 'Węgierski'; + + @override + String get appSettings_languageJa => 'Japoński'; + + @override + String get appSettings_languageKo => 'Koreański'; + + @override + String get radioStats_tooltip => 'Statystyki dotyczące radia i siatki'; + + @override + String get radioStats_screenTitle => 'Statystyki radiowe'; + + @override + String get radioStats_notConnected => + 'Połącz się z urządzeniem, aby wyświetlić statystyki radiowe.'; + + @override + String get radioStats_firmwareTooOld => + 'Statystyki radiowe wymagają towarzyszącej oprogramowania w wersji 8 lub nowszej.'; + + @override + String get radioStats_waiting => 'Czekam na dane…'; + + @override + String radioStats_noiseFloor(int noiseDbm) { + return 'Poziom szumów: $noiseDbm dBm'; + } + + @override + String radioStats_lastRssi(int rssiDbm) { + return 'Ostatni poziom RSSI: $rssiDbm dBm'; + } + + @override + String radioStats_lastSnr(String snr) { + return 'Ostatni poziom SNR: $snr dB'; + } + + @override + String radioStats_txAir(int seconds) { + return 'Czas emisji w stacji TX (całkowity): $seconds s'; + } + + @override + String radioStats_rxAir(int seconds) { + return 'Czas wykorzystania kanału RX (całkowity): $seconds s'; + } + + @override + String get radioStats_chartCaption => + 'Poziom szumów (dBm) w ostatnich próbkach.'; + + @override + String radioStats_stripNoise(int noiseDbm) { + return 'Poziom szumów: $noiseDbm dBm'; + } + + @override + String get radioStats_stripWaiting => 'Pobieranie danych dotyczących radia…'; + + @override + String get radioStats_settingsTile => 'Statystyki radiowe'; + + @override + String get radioStats_settingsSubtitle => + 'Szum tła, RSSI, SNR oraz czas dostępny'; } diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index 1bc971a..0d6a099 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -3484,4 +3484,86 @@ class AppLocalizationsPt extends AppLocalizations { @override String get discoveredContacts_deleteContactAllContent => 'Tem certeza de que deseja excluir todos os contatos descobertos?'; + + @override + String get chat_sendCooldown => + 'Por favor, aguarde um momento antes de reenviar.'; + + @override + String get appSettings_jumpToOldestUnread => + 'Vá para a mensagem mais antiga não lida'; + + @override + String get appSettings_jumpToOldestUnreadSubtitle => + 'Ao abrir uma conversa com mensagens não lidas, role para a primeira mensagem não lida, em vez da mais recente.'; + + @override + String get appSettings_languageHu => 'Húngaro'; + + @override + String get appSettings_languageJa => 'Japonês'; + + @override + String get appSettings_languageKo => 'Coreano'; + + @override + String get radioStats_tooltip => 'Estatísticas de rádio e malha'; + + @override + String get radioStats_screenTitle => 'Estatísticas de rádio'; + + @override + String get radioStats_notConnected => + 'Conecte-se a um dispositivo para visualizar estatísticas de rádio.'; + + @override + String get radioStats_firmwareTooOld => + 'As estatísticas de rádio exigem o firmware v8 ou uma versão mais recente.'; + + @override + String get radioStats_waiting => 'Aguardando dados…'; + + @override + String radioStats_noiseFloor(int noiseDbm) { + return 'Nível de ruído: $noiseDbm dBm'; + } + + @override + String radioStats_lastRssi(int rssiDbm) { + return 'Último RSSI: $rssiDbm dBm'; + } + + @override + String radioStats_lastSnr(String snr) { + return 'Último SNR: $snr dB'; + } + + @override + String radioStats_txAir(int seconds) { + return 'Tempo de transmissão da TX (total): $seconds s'; + } + + @override + String radioStats_rxAir(int seconds) { + return 'Tempo de uso do RX (total): $seconds s'; + } + + @override + String get radioStats_chartCaption => + 'Nível de ruído (dBm) em amostras recentes.'; + + @override + String radioStats_stripNoise(int noiseDbm) { + return 'Nível de ruído: $noiseDbm dBm'; + } + + @override + String get radioStats_stripWaiting => 'Obtendo estatísticas de rádio…'; + + @override + String get radioStats_settingsTile => 'Estatísticas de rádio'; + + @override + String get radioStats_settingsSubtitle => + 'Nível de ruído, RSSI, SNR e tempo de transmissão'; } diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index f71bff0..0c08b6c 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -3498,4 +3498,86 @@ class AppLocalizationsRu extends AppLocalizations { @override String get discoveredContacts_deleteContactAllContent => 'Вы уверены, что хотите удалить все обнаруженные контакты?'; + + @override + String get chat_sendCooldown => + 'Пожалуйста, подождите немного, прежде чем отправлять сообщение снова.'; + + @override + String get appSettings_jumpToOldestUnread => + 'Перейти к самому старому непрочитанному сообщению'; + + @override + String get appSettings_jumpToOldestUnreadSubtitle => + 'При открытии чата с непрочитанными сообщениями, прокрутите страницу, чтобы увидеть первое непрочитанное сообщение, а не последнее.'; + + @override + String get appSettings_languageHu => 'Венгерский'; + + @override + String get appSettings_languageJa => 'Японский'; + + @override + String get appSettings_languageKo => 'Корейский'; + + @override + String get radioStats_tooltip => 'Статистика радио и беспроводной сети'; + + @override + String get radioStats_screenTitle => 'Статистика радиовещания'; + + @override + String get radioStats_notConnected => + 'Подключитесь к устройству, чтобы просмотреть статистику радио.'; + + @override + String get radioStats_firmwareTooOld => + 'Для работы радиостатистики требуется установленная версия прошивки v8 или более новая.'; + + @override + String get radioStats_waiting => 'Ожидаем данных…'; + + @override + String radioStats_noiseFloor(int noiseDbm) { + return 'Уровень шума: $noiseDbm дБм'; + } + + @override + String radioStats_lastRssi(int rssiDbm) { + return 'Последнее значение RSSI: $rssiDbm дБм'; + } + + @override + String radioStats_lastSnr(String snr) { + return 'Последнее значение SNR: $snr дБ'; + } + + @override + String radioStats_txAir(int seconds) { + return 'Время эфира на телеканале TX (общее): $seconds секунд'; + } + + @override + String radioStats_rxAir(int seconds) { + return 'Общее время использования RX (в секундах): $seconds с'; + } + + @override + String get radioStats_chartCaption => + 'Уровень шума (дБм) на основе последних измерений.'; + + @override + String radioStats_stripNoise(int noiseDbm) { + return 'Уровень шума: $noiseDbm дБм'; + } + + @override + String get radioStats_stripWaiting => 'Получение данных о радио…'; + + @override + String get radioStats_settingsTile => 'Статистика радиовещания'; + + @override + String get radioStats_settingsSubtitle => + 'Уровень шума, RSSI, SNR и время передачи'; } diff --git a/lib/l10n/app_localizations_sk.dart b/lib/l10n/app_localizations_sk.dart index d5d00a0..c3884cf 100644 --- a/lib/l10n/app_localizations_sk.dart +++ b/lib/l10n/app_localizations_sk.dart @@ -3464,4 +3464,84 @@ class AppLocalizationsSk extends AppLocalizations { @override String get discoveredContacts_deleteContactAllContent => 'Ste si istí, že chcete zmazať všetky objavené kontakty?'; + + @override + String get chat_sendCooldown => 'Prosím, počkajte chvíľu, než zašlete znova.'; + + @override + String get appSettings_jumpToOldestUnread => 'Presk oceň'; + + @override + String get appSettings_jumpToOldestUnreadSubtitle => + 'Pri otvorení chatu s neprečítanými správami, prejdite do prvého neprečítaného, namiesto poslednej.'; + + @override + String get appSettings_languageHu => 'Maďarský'; + + @override + String get appSettings_languageJa => 'Japonský'; + + @override + String get appSettings_languageKo => 'Kórejský'; + + @override + String get radioStats_tooltip => 'Statistiky rádiových a sieťových kanálov'; + + @override + String get radioStats_screenTitle => 'Štatistiky rádiových vysielaní'; + + @override + String get radioStats_notConnected => + 'Pripojte sa k zariadeniu, aby ste mohli sledovať štatistiky rádiového vysielania.'; + + @override + String get radioStats_firmwareTooOld => + 'Statistické údaje z rádia vyžadujú sprievodný softvér verzie v8 alebo novšej.'; + + @override + String get radioStats_waiting => 'Čakám na údaje…'; + + @override + String radioStats_noiseFloor(int noiseDbm) { + return 'Úroveň hluku: $noiseDbm dBm'; + } + + @override + String radioStats_lastRssi(int rssiDbm) { + return 'Posledný údaj RSSI: $rssiDbm dBm'; + } + + @override + String radioStats_lastSnr(String snr) { + return 'Posledná hodnota SNR: $snr dB'; + } + + @override + String radioStats_txAir(int seconds) { + return 'Čas vysielania na TX (celkový): $seconds s'; + } + + @override + String radioStats_rxAir(int seconds) { + return 'Čas RX (celkový): $seconds s'; + } + + @override + String get radioStats_chartCaption => + 'Úroveň šumu (dBm) pre posledné vzorky.'; + + @override + String radioStats_stripNoise(int noiseDbm) { + return 'Úroveň hluku: $noiseDbm dBm'; + } + + @override + String get radioStats_stripWaiting => 'Získavanie údajov o rádiu…'; + + @override + String get radioStats_settingsTile => 'Štatistiky rádiových vysielaní'; + + @override + String get radioStats_settingsSubtitle => + 'Úroveň hluku, RSSI, SNR a časové rozloženie'; } diff --git a/lib/l10n/app_localizations_sl.dart b/lib/l10n/app_localizations_sl.dart index 47066c9..953a89b 100644 --- a/lib/l10n/app_localizations_sl.dart +++ b/lib/l10n/app_localizations_sl.dart @@ -3467,4 +3467,86 @@ class AppLocalizationsSl extends AppLocalizations { @override String get discoveredContacts_deleteContactAllContent => 'Ste prepričani, da želite izbrisati vse odkrite kontakte?'; + + @override + String get chat_sendCooldown => + 'Prosimo, počakajte trenutek, preden pošljete ponovno.'; + + @override + String get appSettings_jumpToOldestUnread => + 'Pritisnite za najstarejše nepročitano sporočilo'; + + @override + String get appSettings_jumpToOldestUnreadSubtitle => + 'Ko odpirate klepet z neprebranimi sporočili, se premaknite na prvo neprebrano sporočilo, namesto najnovejšega.'; + + @override + String get appSettings_languageHu => 'Madžarski'; + + @override + String get appSettings_languageJa => 'Japonski'; + + @override + String get appSettings_languageKo => 'Korejski'; + + @override + String get radioStats_tooltip => 'Statistike za radio in mrežo'; + + @override + String get radioStats_screenTitle => 'Radijske statistike'; + + @override + String get radioStats_notConnected => + 'Povežite se z napravo, da si ogledate statistiko o radiju.'; + + @override + String get radioStats_firmwareTooOld => + 'Statistika za radio zahteva združljivo programsko opremo v8 ali kasnejše različice.'; + + @override + String get radioStats_waiting => 'Čakam na podatke…'; + + @override + String radioStats_noiseFloor(int noiseDbm) { + return 'Število šuma: $noiseDbm dBm'; + } + + @override + String radioStats_lastRssi(int rssiDbm) { + return 'Najkasnejše vrednost RSSI: $rssiDbm dBm'; + } + + @override + String radioStats_lastSnr(String snr) { + return 'Najkasnejše vrednost SNR: $snr dB'; + } + + @override + String radioStats_txAir(int seconds) { + return 'Čas na TX (skupno): $seconds s'; + } + + @override + String radioStats_rxAir(int seconds) { + return 'Čas, namenjen RX-ju (skupno): $seconds s'; + } + + @override + String get radioStats_chartCaption => + 'Ravnovredna raven šuma (dBm) za nedavne vzorce.'; + + @override + String radioStats_stripNoise(int noiseDbm) { + return 'Število šuma: $noiseDbm dBm'; + } + + @override + String get radioStats_stripWaiting => 'Prejemanje statistike o radiju…'; + + @override + String get radioStats_settingsTile => 'Radijske statistike'; + + @override + String get radioStats_settingsSubtitle => + 'Število šumov, RSSI, SNR in čas, ki ga je napolnila oprema'; } diff --git a/lib/l10n/app_localizations_sv.dart b/lib/l10n/app_localizations_sv.dart index 6e5fd20..3bf5887 100644 --- a/lib/l10n/app_localizations_sv.dart +++ b/lib/l10n/app_localizations_sv.dart @@ -3444,4 +3444,86 @@ class AppLocalizationsSv extends AppLocalizations { @override String get discoveredContacts_deleteContactAllContent => 'Är du säker på att du vill ta bort alla upptäckta kontakter?'; + + @override + String get chat_sendCooldown => + 'Vänligen vänta en stund innan du skickar igen.'; + + @override + String get appSettings_jumpToOldestUnread => + 'Gå direkt till det äldsta, obesvarade meddelandet'; + + @override + String get appSettings_jumpToOldestUnreadSubtitle => + 'När du öppnar en chatt med oinlästa meddelanden, scrolla till det första oinlästa meddelandet istället för det senaste.'; + + @override + String get appSettings_languageHu => 'Ungerskt'; + + @override + String get appSettings_languageJa => 'Japanska'; + + @override + String get appSettings_languageKo => 'Koreanska'; + + @override + String get radioStats_tooltip => 'Radio- och mesh-statistik'; + + @override + String get radioStats_screenTitle => 'Radiostation'; + + @override + String get radioStats_notConnected => + 'Anslut till en enhet för att visa radiostatistik.'; + + @override + String get radioStats_firmwareTooOld => + 'Radio statistik kräver kompatibel firmware version 8 eller senare.'; + + @override + String get radioStats_waiting => 'Väntar på data…'; + + @override + String radioStats_noiseFloor(int noiseDbm) { + return 'Bakgrundsnivå: $noiseDbm dBm'; + } + + @override + String radioStats_lastRssi(int rssiDbm) { + return 'Senaste RSSI-värde: $rssiDbm dBm'; + } + + @override + String radioStats_lastSnr(String snr) { + return 'Senaste SNR: $snr dB'; + } + + @override + String radioStats_txAir(int seconds) { + return 'TX-tid (total): $seconds sekunder'; + } + + @override + String radioStats_rxAir(int seconds) { + return 'RX-tid (total): $seconds s'; + } + + @override + String get radioStats_chartCaption => + 'Ljudnivå (dBm) baserat på de senaste mätningarna.'; + + @override + String radioStats_stripNoise(int noiseDbm) { + return 'Bakgrundsnivå: $noiseDbm dBm'; + } + + @override + String get radioStats_stripWaiting => 'Hämtar radiostatistik…'; + + @override + String get radioStats_settingsTile => 'Radiostation'; + + @override + String get radioStats_settingsSubtitle => + 'Bakgrundsnivå, RSSI, SNR och tillgänglig tid'; } diff --git a/lib/l10n/app_localizations_uk.dart b/lib/l10n/app_localizations_uk.dart index c068ed3..8b9d505 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -3501,4 +3501,86 @@ class AppLocalizationsUk extends AppLocalizations { @override String get discoveredContacts_deleteContactAllContent => 'Ви впевнені, що хочете видалити всі виявлені контакти?'; + + @override + String get chat_sendCooldown => + 'Будь ласка, зачекайте трохи, перш ніж відправляти знову.'; + + @override + String get appSettings_jumpToOldestUnread => + 'Перейти до найстарішого непрочитаного повідомлення'; + + @override + String get appSettings_jumpToOldestUnreadSubtitle => + 'При відкритті чату з не прочитаними повідомленнями, прокрутіть до першого не прочитаного повідомлення, а не до останнього.'; + + @override + String get appSettings_languageHu => 'Угорський'; + + @override + String get appSettings_languageJa => 'Японська'; + + @override + String get appSettings_languageKo => 'Кореєська'; + + @override + String get radioStats_tooltip => 'Статистика радіо та мережі'; + + @override + String get radioStats_screenTitle => 'Дані про радіостанції'; + + @override + String get radioStats_notConnected => + 'Підключіться до пристрою, щоб переглядати статистику радіопередач.'; + + @override + String get radioStats_firmwareTooOld => + 'Статистика радіо приймача вимагає супутнього програмного забезпечення версії 8 або новішої.'; + + @override + String get radioStats_waiting => 'Очікую на отримання даних…'; + + @override + String radioStats_noiseFloor(int noiseDbm) { + return 'Рівень шуму: $noiseDbm дБм'; + } + + @override + String radioStats_lastRssi(int rssiDbm) { + return 'Останній показник RSSI: $rssiDbm дБм'; + } + + @override + String radioStats_lastSnr(String snr) { + return 'Останній показник SNR: $snr дБ'; + } + + @override + String radioStats_txAir(int seconds) { + return 'Час трансляції на телеканалі TX (загальний): $seconds секунд'; + } + + @override + String radioStats_rxAir(int seconds) { + return 'Загальний час використання RX: $seconds секунд'; + } + + @override + String get radioStats_chartCaption => + 'Рівень шуму (дБм) на основі останніх вимірювань.'; + + @override + String radioStats_stripNoise(int noiseDbm) { + return 'Рівень шуму: $noiseDbm дБм'; + } + + @override + String get radioStats_stripWaiting => 'Отримано статистику радіо…'; + + @override + String get radioStats_settingsTile => 'Дані про радіостанції'; + + @override + String get radioStats_settingsSubtitle => + 'Рівень шуму, RSSI, SNR та час, протягом якого пристрій використовує радіоканал.'; } diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 78e9c94..de334f6 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -3222,4 +3222,80 @@ class AppLocalizationsZh extends AppLocalizations { @override String get discoveredContacts_deleteContactAllContent => '您确定要删除所有发现的联系人吗?'; + + @override + String get chat_sendCooldown => '请稍等片刻后再尝试发送。'; + + @override + String get appSettings_jumpToOldestUnread => '跳转到最旧未读的文章'; + + @override + String get appSettings_jumpToOldestUnreadSubtitle => + '在打开包含未读消息的聊天时,请滚动到第一个未读消息,而不是最新的消息。'; + + @override + String get appSettings_languageHu => '匈牙利'; + + @override + String get appSettings_languageJa => '日语'; + + @override + String get appSettings_languageKo => '韩语'; + + @override + String get radioStats_tooltip => '无线电和网状结构统计数据'; + + @override + String get radioStats_screenTitle => '广播统计数据'; + + @override + String get radioStats_notConnected => '连接到设备以查看收音机统计信息。'; + + @override + String get radioStats_firmwareTooOld => '使用无线电统计功能需要配合使用 v8 或更高版本的固件。'; + + @override + String get radioStats_waiting => '正在等待数据…'; + + @override + String radioStats_noiseFloor(int noiseDbm) { + return '噪声水平:$noiseDbm dBm'; + } + + @override + String radioStats_lastRssi(int rssiDbm) { + return '上次 RSSI 值:$rssiDbm dBm'; + } + + @override + String radioStats_lastSnr(String snr) { + return '上次 SNR:$snr dB'; + } + + @override + String radioStats_txAir(int seconds) { + return 'TX 频道播出时间(总时长):$seconds 秒'; + } + + @override + String radioStats_rxAir(int seconds) { + return 'RX 使用时长(总时长):$seconds 秒'; + } + + @override + String get radioStats_chartCaption => '近期的噪声水平(dBm)。'; + + @override + String radioStats_stripNoise(int noiseDbm) { + return '噪声水平:$noiseDbm dBm'; + } + + @override + String get radioStats_stripWaiting => '正在获取收音机数据…'; + + @override + String get radioStats_settingsTile => '广播统计数据'; + + @override + String get radioStats_settingsSubtitle => '噪声水平、RSSI、信噪比和空中时间'; } diff --git a/lib/models/app_settings.dart b/lib/models/app_settings.dart index 31c1741..36d36a6 100644 --- a/lib/models/app_settings.dart +++ b/lib/models/app_settings.dart @@ -48,6 +48,7 @@ class AppSettings { final bool mapShowDiscoveryContacts; final String tcpServerAddress; final int tcpServerPort; + final bool jumpToOldestUnread; AppSettings({ this.clearPathOnMaxRetry = false, @@ -84,6 +85,7 @@ class AppSettings { this.mapShowDiscoveryContacts = true, this.tcpServerAddress = '', this.tcpServerPort = 0, + this.jumpToOldestUnread = false, }) : batteryChemistryByDeviceId = batteryChemistryByDeviceId ?? {}, batteryChemistryByRepeaterId = batteryChemistryByRepeaterId ?? {}, mutedChannels = mutedChannels ?? {}; @@ -124,6 +126,7 @@ class AppSettings { 'map_show_discovery_contacts': mapShowDiscoveryContacts, 'tcp_server_address': tcpServerAddress, 'tcp_server_port': tcpServerPort, + 'jump_to_oldest_unread': jumpToOldestUnread, }; } @@ -192,6 +195,7 @@ class AppSettings { json['map_show_discovery_contacts'] as bool? ?? true, tcpServerAddress: json['tcp_server_address'] as String? ?? '', tcpServerPort: json['tcp_server_port'] as int? ?? 0, + jumpToOldestUnread: json['jump_to_oldest_unread'] as bool? ?? false, ); } @@ -230,6 +234,7 @@ class AppSettings { bool? mapShowDiscoveryContacts, String? tcpServerAddress, int? tcpServerPort, + bool? jumpToOldestUnread, }) { return AppSettings( clearPathOnMaxRetry: clearPathOnMaxRetry ?? this.clearPathOnMaxRetry, @@ -278,6 +283,7 @@ class AppSettings { mapShowDiscoveryContacts ?? this.mapShowDiscoveryContacts, tcpServerAddress: tcpServerAddress ?? this.tcpServerAddress, tcpServerPort: tcpServerPort ?? this.tcpServerPort, + jumpToOldestUnread: jumpToOldestUnread ?? this.jumpToOldestUnread, ); } } diff --git a/lib/models/contact.dart b/lib/models/contact.dart index fe1c915..2699f93 100644 --- a/lib/models/contact.dart +++ b/lib/models/contact.dart @@ -119,15 +119,14 @@ class Contact { ); } - String get pathIdList { + /// Formats path bytes into comma-separated hex groups of [hashByteWidth] bytes. + String pathFormattedIdList(int hashByteWidth) { final pathBytes = pathBytesForDisplay; if (pathBytes.isEmpty) return ''; + final w = hashByteWidth.clamp(1, 8); final parts = []; - final groupSize = pathHashSize; - for (int i = 0; i < pathBytes.length; i += groupSize) { - final end = (i + groupSize) <= pathBytes.length - ? (i + groupSize) - : pathBytes.length; + for (int i = 0; i < pathBytes.length; i += w) { + final end = (i + w) <= pathBytes.length ? (i + w) : pathBytes.length; final chunk = pathBytes.sublist(i, end); parts.add( chunk @@ -138,6 +137,9 @@ class Contact { return parts.join(','); } + /// Default grouping uses legacy single-byte hop hash width. + String get pathIdList => pathFormattedIdList(pathHashSize); + String get shortPubKeyHex { return "<${publicKeyHex.substring(0, 8)}...${publicKeyHex.substring(publicKeyHex.length - 8)}>"; } diff --git a/lib/screens/app_settings_screen.dart b/lib/screens/app_settings_screen.dart index 7e0980e..f417715 100644 --- a/lib/screens/app_settings_screen.dart +++ b/lib/screens/app_settings_screen.dart @@ -291,6 +291,16 @@ class AppSettingsScreen extends StatelessWidget { }, ), const Divider(height: 1), + SwitchListTile( + secondary: const Icon(Icons.vertical_align_top), + title: Text(context.l10n.appSettings_jumpToOldestUnread), + subtitle: Text( + context.l10n.appSettings_jumpToOldestUnreadSubtitle, + ), + value: settingsService.settings.jumpToOldestUnread, + onChanged: settingsService.setJumpToOldestUnread, + ), + const Divider(height: 1), SwitchListTile( secondary: const Icon(Icons.alt_route), title: Text(context.l10n.appSettings_autoRouteRotation), @@ -689,6 +699,12 @@ class AppSettingsScreen extends StatelessWidget { return context.l10n.appSettings_languageRu; case 'uk': return context.l10n.appSettings_languageUk; + case 'hu': + return context.l10n.appSettings_languageHu; + case 'ja': + return context.l10n.appSettings_languageJa; + case 'ko': + return context.l10n.appSettings_languageKo; default: return context.l10n.appSettings_languageSystem; } @@ -776,6 +792,18 @@ class AppSettingsScreen extends StatelessWidget { title: Text(context.l10n.appSettings_languageUk), value: 'uk', ), + RadioListTile( + title: Text(context.l10n.appSettings_languageHu), + value: 'hu', + ), + RadioListTile( + title: Text(context.l10n.appSettings_languageJa), + value: 'ja', + ), + RadioListTile( + title: Text(context.l10n.appSettings_languageKo), + value: 'ko', + ), ], ), ), diff --git a/lib/screens/channel_chat_screen.dart b/lib/screens/channel_chat_screen.dart index 913b288..7cfba56 100644 --- a/lib/screens/channel_chat_screen.dart +++ b/lib/screens/channel_chat_screen.dart @@ -26,6 +26,7 @@ import '../widgets/gif_message.dart'; import '../widgets/jump_to_bottom_button.dart'; import '../widgets/gif_picker.dart'; import '../widgets/message_status_icon.dart'; +import '../widgets/radio_stats_entry.dart'; import 'channel_message_path_screen.dart'; import 'map_screen.dart'; @@ -47,6 +48,8 @@ class _ChannelChatScreenState extends State { bool _isLoadingOlder = false; MeshCoreConnector? _connector; + DateTime? _lastChannelSendAt; + bool _channelSkipNextBottomSnap = false; @override void initState() { @@ -55,11 +58,45 @@ class _ChannelChatScreenState extends State { _scrollController.onScrollNearTop = _loadOlderMessages; SchedulerBinding.instance.addPostFrameCallback((_) { if (!mounted) return; - _connector = context.read(); - _connector?.setActiveChannel(widget.channel.index); + final connector = context.read(); + final settings = context.read().settings; + final idx = widget.channel.index; + final unread = connector.getUnreadCountForChannelIndex(idx); + ChannelMessage? anchor; + if (settings.jumpToOldestUnread && unread > 0) { + anchor = _findOldestUnreadChannelAnchor( + connector.getChannelMessages(widget.channel), + unread, + ); + } + connector.setActiveChannel(idx); + _connector = connector; + if (anchor != null) { + _channelSkipNextBottomSnap = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _scrollToMessage(anchor!.messageId); + }); + } }); } + ChannelMessage? _findOldestUnreadChannelAnchor( + List messages, + int unreadCount, + ) { + if (unreadCount <= 0 || messages.isEmpty) return null; + var n = 0; + ChannelMessage? oldest; + for (final m in messages.reversed) { + if (m.isOutgoing) continue; + n++; + oldest = m; + if (n >= unreadCount) break; + } + return oldest; + } + void _onTextFieldFocusChange() { if (_textFieldFocusNode.hasFocus && mounted) { _scrollController.handleKeyboardOpen(); @@ -167,6 +204,7 @@ class _ChannelChatScreenState extends State { ), centerTitle: false, actions: [ + const RadioStatsIconButton(), PopupMenuButton( icon: const Icon(Icons.more_vert), onSelected: (value) { @@ -243,6 +281,10 @@ class _ChannelChatScreenState extends State { // Auto-scroll to bottom if user is already at bottom WidgetsBinding.instance.addPostFrameCallback((_) { + if (_channelSkipNextBottomSnap) { + _channelSkipNextBottomSnap = false; + return; + } _scrollController.scrollToBottomIfAtBottom(); }); @@ -468,11 +510,6 @@ class _ChannelChatScreenState extends State { style: TextStyle( fontSize: bodyFontSize * textScale, ), - linkStyle: TextStyle( - fontSize: bodyFontSize * textScale, - color: Colors.green, - decoration: TextDecoration.underline, - ), ), ), if (!enableTracing && isOutgoing) ...[ @@ -1079,6 +1116,16 @@ class _ChannelChatScreenState extends State { final text = _textController.text.trim(); if (text.isEmpty) return; + final now = DateTime.now(); + if (_lastChannelSendAt != null && + now.difference(_lastChannelSendAt!) < const Duration(seconds: 1)) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.chat_sendCooldown)), + ); + return; + } + _lastChannelSendAt = now; + final connector = context.read(); String messageText = text; diff --git a/lib/screens/channel_message_path_screen.dart b/lib/screens/channel_message_path_screen.dart index ea07eae..dd36c21 100644 --- a/lib/screens/channel_message_path_screen.dart +++ b/lib/screens/channel_message_path_screen.dart @@ -64,6 +64,8 @@ class ChannelMessagePathScreen extends StatelessWidget { flipPathAround: true, reversePathAround: !(!channelMessage && !message.isOutgoing), + pathHashByteWidth: + context.read().pathHashByteWidth, ), ), ), diff --git a/lib/screens/channels_screen.dart b/lib/screens/channels_screen.dart index 51d2453..3e34568 100644 --- a/lib/screens/channels_screen.dart +++ b/lib/screens/channels_screen.dart @@ -127,7 +127,10 @@ class _ChannelsScreenState extends State canPop: allowBack, child: Scaffold( appBar: AppBar( - title: AppBarTitle(context.l10n.channels_title), + title: AppBarTitle( + context.l10n.channels_title, + indicators: false, + ), centerTitle: true, automaticallyImplyLeading: false, actions: [ diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart index 574ffbe..1a5a79b 100644 --- a/lib/screens/chat_screen.dart +++ b/lib/screens/chat_screen.dart @@ -36,6 +36,7 @@ import '../widgets/gif_message.dart'; import '../widgets/jump_to_bottom_button.dart'; import '../widgets/gif_picker.dart'; import '../widgets/path_selection_dialog.dart'; +import '../widgets/radio_stats_entry.dart'; import '../utils/app_logger.dart'; import '../l10n/l10n.dart'; import 'telemetry_screen.dart'; @@ -53,8 +54,11 @@ class _ChatScreenState extends State { final _textController = TextEditingController(); final _scrollController = ChatScrollController(); final _textFieldFocusNode = FocusNode(); + final GlobalKey _unreadScrollKey = GlobalKey(); bool _isLoadingOlder = false; MeshCoreConnector? _connector; + Message? _pendingUnreadScrollTarget; + DateTime? _lastTextSendAt; @override void initState() { @@ -63,11 +67,50 @@ class _ChatScreenState extends State { _scrollController.onScrollNearTop = _loadOlderMessages; SchedulerBinding.instance.addPostFrameCallback((_) { if (!mounted) return; - _connector = context.read(); - _connector?.setActiveContact(widget.contact.publicKeyHex); + final connector = context.read(); + final settings = context.read().settings; + final keyHex = widget.contact.publicKeyHex; + final unread = connector.getUnreadCountForContactKey(keyHex); + Message? anchor; + if (settings.jumpToOldestUnread && unread > 0) { + anchor = _findOldestUnreadAnchor( + connector.getMessages(widget.contact), + unread, + ); + } + connector.setActiveContact(keyHex); + _connector = connector; + if (anchor != null) { + setState(() => _pendingUnreadScrollTarget = anchor); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + final ctx = _unreadScrollKey.currentContext; + if (ctx != null) { + Scrollable.ensureVisible( + ctx, + duration: const Duration(milliseconds: 350), + alignment: 0.15, + ); + } + setState(() => _pendingUnreadScrollTarget = null); + }); + } }); } + Message? _findOldestUnreadAnchor(List messages, int unreadCount) { + if (unreadCount <= 0 || messages.isEmpty) return null; + var n = 0; + Message? oldest; + for (final m in messages.reversed) { + if (m.isOutgoing || m.isCli) continue; + n++; + oldest = m; + if (n >= unreadCount) break; + } + return oldest; + } + void _onTextFieldFocusChange() { if (_textFieldFocusNode.hasFocus && mounted) { _scrollController.handleKeyboardOpen(); @@ -319,6 +362,7 @@ class _ChatScreenState extends State { ); }, ), + const RadioStatsIconButton(), ], ), body: Consumer( @@ -378,6 +422,7 @@ class _ChatScreenState extends State { // Auto-scroll to bottom if user is already at bottom WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; + if (_pendingUnreadScrollTarget != null) return; _scrollController.scrollToBottomIfAtBottom(); }); @@ -424,7 +469,7 @@ class _ChatScreenState extends State { (service) => service.scale, ); final resolvedContact = _resolveContact(connector); - return _MessageBubble( + final bubble = _MessageBubble( message: message, senderName: resolvedContact.type == advTypeRoom ? "${contact.name} [$fourByteHex]" @@ -436,6 +481,10 @@ class _ChatScreenState extends State { onRetryReaction: (msg, emoji) => _sendReaction(msg, contact, emoji), ); + if (identical(message, _pendingUnreadScrollTarget)) { + return KeyedSubtree(key: _unreadScrollKey, child: bubble); + } + return bubble; }, ); }, @@ -561,6 +610,16 @@ class _ChatScreenState extends State { final text = _textController.text.trim(); if (text.isEmpty) return; + final now = DateTime.now(); + if (_lastTextSendAt != null && + now.difference(_lastTextSendAt!) < const Duration(seconds: 1)) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.chat_sendCooldown)), + ); + return; + } + _lastTextSendAt = now; + final maxBytes = maxContactMessageBytes(); if (utf8.encode(text).length > maxBytes) { ScaffoldMessenger.of(context).showSnackBar( @@ -950,6 +1009,7 @@ class _ChatScreenState extends State { path: Uint8List.fromList(pathBytes), flipPathAround: true, targetContact: widget.contact, + pathHashByteWidth: connector.pathHashByteWidth, ), ), ), @@ -1212,7 +1272,9 @@ class _ChatScreenState extends State { connector.getContacts(); } - final pathForInput = currentContact.pathIdList; + final pathForInput = currentContact.pathFormattedIdList( + connector.pathHashByteWidth, + ); final currentPathLabel = _currentPathLabel(currentContact); // Filter out the current contact from available contacts @@ -1607,11 +1669,6 @@ class _MessageBubble extends StatelessWidget { color: textColor, fontSize: bodyFontSize * textScale, ), - linkStyle: TextStyle( - color: Colors.green, - decoration: TextDecoration.underline, - fontSize: bodyFontSize * textScale, - ), ), ), if (!enableTracing && isOutgoing) ...[ diff --git a/lib/screens/contacts_screen.dart b/lib/screens/contacts_screen.dart index 17eaa24..cce6a39 100644 --- a/lib/screens/contacts_screen.dart +++ b/lib/screens/contacts_screen.dart @@ -1244,6 +1244,8 @@ class _ContactsScreenState extends State ? Text(context.l10n.contacts_pathTrace) : Text(context.l10n.contacts_ping), onTap: () { + final hw = + context.read().pathHashByteWidth; Navigator.push( context, MaterialPageRoute( @@ -1254,6 +1256,7 @@ class _ContactsScreenState extends State path: contact.pathBytesForDisplay, flipPathAround: true, targetContact: contact, + pathHashByteWidth: hw, ), ), ); @@ -1274,6 +1277,8 @@ class _ContactsScreenState extends State ? Text(context.l10n.contacts_pathTrace) : Text(context.l10n.contacts_ping), onTap: () { + final hw = + context.read().pathHashByteWidth; Navigator.push( context, MaterialPageRoute( @@ -1284,6 +1289,7 @@ class _ContactsScreenState extends State path: contact.pathBytesForDisplay, flipPathAround: contact.pathBytesForDisplay.isNotEmpty, targetContact: contact, + pathHashByteWidth: hw, ), ), ); @@ -1318,6 +1324,8 @@ class _ContactsScreenState extends State leading: const Icon(Icons.radar, color: Colors.green), title: Text(context.l10n.contacts_chatTraceRoute), onTap: () { + final hw = + context.read().pathHashByteWidth; Navigator.push( context, MaterialPageRoute( @@ -1328,6 +1336,7 @@ class _ContactsScreenState extends State path: contact.pathBytesForDisplay, flipPathAround: true, targetContact: contact, + pathHashByteWidth: hw, ), ), ); diff --git a/lib/screens/device_screen.dart b/lib/screens/device_screen.dart index c5967cf..2343400 100644 --- a/lib/screens/device_screen.dart +++ b/lib/screens/device_screen.dart @@ -7,6 +7,8 @@ import '../utils/dialog_utils.dart'; import '../utils/disconnect_navigation_mixin.dart'; import '../utils/route_transitions.dart'; import '../widgets/quick_switch_bar.dart'; +import '../widgets/battery_indicator_chip.dart'; +import '../widgets/radio_stats_entry.dart'; import 'channels_screen.dart'; import 'contacts_screen.dart'; import 'map_screen.dart'; @@ -40,7 +42,22 @@ class _DeviceScreenState extends State canPop: false, child: Scaffold( appBar: AppBar( - leading: _buildBatteryIndicator(connector, context), + leadingWidth: 128, + leading: Row( + mainAxisSize: MainAxisSize.min, + children: [ + BatteryIndicatorChip( + connector: connector, + showVoltage: _showBatteryVoltage, + onPressed: () { + setState(() { + _showBatteryVoltage = !_showBatteryVoltage; + }); + }, + ), + const RadioStatsIconButton(), + ], + ), titleSpacing: 16, centerTitle: false, title: _buildAppBarTitle(connector, theme), @@ -187,7 +204,15 @@ class _DeviceScreenState extends State ), visualDensity: VisualDensity.compact, ), - _buildBatteryIndicator(connector, context), + BatteryIndicatorChip( + connector: connector, + showVoltage: _showBatteryVoltage, + onPressed: () { + setState(() { + _showBatteryVoltage = !_showBatteryVoltage; + }); + }, + ), ], ), ], @@ -205,44 +230,6 @@ class _DeviceScreenState extends State ); } - Widget _buildBatteryIndicator( - MeshCoreConnector connector, - BuildContext context, - ) { - final theme = Theme.of(context); - final colorScheme = theme.colorScheme; - final percent = connector.batteryPercent; - final millivolts = connector.batteryMillivolts; - final percentLabel = percent != null ? '$percent%' : '--%'; - final voltageLabel = millivolts == null - ? '-- V' - : '${(millivolts / 1000.0).toStringAsFixed(2)} V'; - final displayLabel = _showBatteryVoltage ? voltageLabel : percentLabel; - final icon = _batteryIcon(percent); - - return ActionChip( - avatar: Icon(icon, size: 16, color: colorScheme.onSecondaryContainer), - label: Text(displayLabel), - labelStyle: theme.textTheme.labelMedium?.copyWith( - color: colorScheme.onSecondaryContainer, - fontWeight: FontWeight.w600, - ), - backgroundColor: colorScheme.secondaryContainer, - visualDensity: VisualDensity.compact, - onPressed: () { - setState(() { - _showBatteryVoltage = !_showBatteryVoltage; - }); - }, - ); - } - - IconData _batteryIcon(int? percent) { - if (percent == null) return Icons.battery_unknown; - if (percent <= 15) return Icons.battery_alert; - return Icons.battery_full; - } - void _openQuickDestination(int index, BuildContext context) { if (_quickIndex != index) { setState(() { diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index f5efd3b..f42790c 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -2191,12 +2191,15 @@ class _MapScreenState extends State { if (_pathTrace.isNotEmpty) IconButton( onPressed: () { + final hashW = + context.read().pathHashByteWidth; Navigator.push( context, MaterialPageRoute( builder: (context) => PathTraceMapScreen( title: l10n.contacts_pathTrace, path: Uint8List.fromList(_pathTrace), + pathHashByteWidth: hashW, ), ), ); diff --git a/lib/screens/path_trace_map.dart b/lib/screens/path_trace_map.dart index e64a906..5b02931 100644 --- a/lib/screens/path_trace_map.dart +++ b/lib/screens/path_trace_map.dart @@ -55,6 +55,7 @@ class PathTraceMapScreen extends StatefulWidget { final bool flipPathAround; final bool reversePathAround; final Contact? targetContact; + final int pathHashByteWidth; const PathTraceMapScreen({ super.key, @@ -64,6 +65,7 @@ class PathTraceMapScreen extends StatefulWidget { this.flipPathAround = false, this.reversePathAround = false, this.targetContact, + this.pathHashByteWidth = pathHashSize, }); @override @@ -119,8 +121,13 @@ class _PathTraceMapScreenState extends State { Uint8List traceBytes; if (pathBytes.isEmpty) { + final pk = widget.targetContact?.publicKey; + final n = widget.pathHashByteWidth.clamp(1, pubKeySize); + if (pk != null && pk.length >= n) { + return Uint8List.fromList(pk.sublist(0, n)); + } traceBytes = Uint8List(1); - traceBytes[0] = widget.targetContact?.publicKey[0] ?? 0; + traceBytes[0] = pk?[0] ?? 0; return traceBytes; } diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 5d86b41..a926e2b 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -12,6 +12,7 @@ import '../widgets/app_bar.dart'; import 'app_settings_screen.dart'; import 'app_debug_log_screen.dart'; import 'ble_debug_log_screen.dart'; +import '../widgets/radio_stats_entry.dart'; class SettingsScreen extends StatefulWidget { const SettingsScreen({super.key}); @@ -269,6 +270,16 @@ class _SettingsScreenState extends State { onTap: () => _showRadioSettings(context, connector), ), const Divider(height: 1), + ListTile( + leading: const Icon(Icons.sensors_outlined), + title: Text(l10n.radioStats_settingsTile), + subtitle: Text(l10n.radioStats_settingsSubtitle), + trailing: const Icon(Icons.chevron_right), + enabled: connector.isConnected && + connector.supportsCompanionRadioStats, + onTap: () => pushCompanionRadioStatsScreen(context), + ), + const Divider(height: 1), ListTile( leading: const Icon(Icons.location_on_outlined), title: Text(l10n.settings_location), diff --git a/lib/services/app_settings_service.dart b/lib/services/app_settings_service.dart index 6414617..cb69469 100644 --- a/lib/services/app_settings_service.dart +++ b/lib/services/app_settings_service.dart @@ -218,4 +218,8 @@ class AppSettingsService extends ChangeNotifier { Future setTcpServerPort(int value) async { await updateSettings(_settings.copyWith(tcpServerPort: value)); } + + Future setJumpToOldestUnread(bool value) async { + await updateSettings(_settings.copyWith(jumpToOldestUnread: value)); + } } diff --git a/lib/widgets/app_bar.dart b/lib/widgets/app_bar.dart index b95984b..4a49daf 100644 --- a/lib/widgets/app_bar.dart +++ b/lib/widgets/app_bar.dart @@ -3,6 +3,7 @@ import 'package:meshcore_open/connector/meshcore_connector.dart'; import 'package:meshcore_open/widgets/battery_indicator.dart'; import 'package:provider/provider.dart'; +import 'radio_stats_entry.dart'; import 'snr_indicator.dart'; class AppBarTitle extends StatelessWidget { @@ -10,12 +11,14 @@ class AppBarTitle extends StatelessWidget { final Widget? leading; final Widget? trailing; final bool indicators; + final bool showBatteryIndicator; final bool subtitle; const AppBarTitle( this.title, { this.leading, this.trailing, this.indicators = true, + this.showBatteryIndicator = true, this.subtitle = true, super.key, }); @@ -33,7 +36,8 @@ class AppBarTitle extends StatelessWidget { final compact = availableWidth < 170; final showSubtitle = !compact && connector.isConnected && selfName != null && subtitle; - final showBattery = availableWidth >= 60; + final showBattery = + showBatteryIndicator && availableWidth >= 60; final showSnr = availableWidth >= 110; final showIndicators = (showBattery || showSnr) && indicators; @@ -65,6 +69,16 @@ class AppBarTitle extends StatelessWidget { children: [ if (showBattery) BatteryIndicator(connector: connector), if (showSnr) SNRIndicator(connector: connector), + if (connector.supportsCompanionRadioStats) + ValueListenableBuilder( + valueListenable: connector.radioStatsNotifier, + builder: (context, _, child) => Padding( + padding: const EdgeInsets.only(left: 4), + child: AirActivityDot( + active: connector.radioStatsAirActivityPulse, + ), + ), + ), ], ), trailing ?? const SizedBox.shrink(), diff --git a/lib/widgets/path_management_dialog.dart b/lib/widgets/path_management_dialog.dart index e92f301..4e91a69 100644 --- a/lib/widgets/path_management_dialog.dart +++ b/lib/widgets/path_management_dialog.dart @@ -109,6 +109,7 @@ class _PathManagementDialogState extends State<_PathManagementDialog> { path: Uint8List.fromList(pathBytes), flipPathAround: true, targetContact: widget.contact, + pathHashByteWidth: connector.pathHashByteWidth, ), ), ), @@ -135,7 +136,9 @@ class _PathManagementDialogState extends State<_PathManagementDialog> { connector.getContacts(); } - final pathForInput = currentContact.pathIdList; + final pathForInput = currentContact.pathFormattedIdList( + connector.pathHashByteWidth, + ); final availableContacts = connector.allContacts .where((c) => c.publicKeyHex != currentContact.publicKeyHex) .toList(); diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 4084d9b..2428a77 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -14,7 +14,6 @@ import share_plus import shared_preferences_foundation import sqflite_darwin import url_launcher_macos -import wakelock_plus func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FlutterBluePlusPlugin.register(with: registry.registrar(forPlugin: "FlutterBluePlusPlugin")) @@ -26,5 +25,4 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) - WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin")) } diff --git a/pubspec.yaml b/pubspec.yaml index 663622b..f347ed3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -55,7 +55,6 @@ dependencies: cached_network_image: ^3.4.1 flutter_cache_manager: ^3.4.1 flutter_foreground_task: ^9.2.0 - wakelock_plus: ^1.4.0 characters: ^1.4.0 package_info_plus: ^9.0.0 mobile_scanner: ^7.1.4 # QR/barcode scanning diff --git a/tools/translate.py b/tools/translate.py index a51d3d1..905d435 100644 --- a/tools/translate.py +++ b/tools/translate.py @@ -14,6 +14,10 @@ Usage: # Translate all locales (missing strings only): python translate.py --in lib/l10n/app_en.arb --l10n-dir lib/l10n --missing-only + + # New locales copied from app_en.arb still match English → --missing-only skips them. + # Translate every key that still equals the template (e.g. hu, ja, ko): + python translate.py --in lib/l10n/app_en.arb --l10n-dir lib/l10n --copy-of-template --only-locales hu,ja,ko """ import argparse @@ -68,6 +72,7 @@ LOCALE_MAP = { "sk": ("Slovak", "sk"), "sl": ("Slovenian", "sl"), "bg": ("Bulgarian", "bg"), + "hu": ("Hungarian", "hu"), "el": ("Greek", "el"), "he": ("Hebrew", "he"), "th": ("Thai", "th"), @@ -261,6 +266,25 @@ def find_missing_keys(source_data: Dict[str, Any], target_data: Dict[str, Any]) return missing +def find_keys_still_template_copy(source_data: Dict[str, Any], target_data: Dict[str, Any]) -> List[str]: + """Keys whose value is still exactly the same as the template (typical after cp app_en.arb → app_xx.arb).""" + out: List[str] = [] + for key in source_data: + if key == "@@locale" or key.startswith("@"): + continue + src = source_data.get(key) + if not is_translatable_entry(key, src): + continue + if not isinstance(src, str): + continue + tgt = target_data.get(key) + if not isinstance(tgt, str) or tgt.strip() == "": + out.append(key) + elif tgt == src: + out.append(key) + return out + + def get_all_locale_files(l10n_dir: str, template_file: str) -> List[Tuple[str, str]]: """Find all locale .arb files excluding template. Returns [(locale_code, file_path)].""" locales = [] @@ -434,6 +458,15 @@ def main() -> int: ap.add_argument("--to-locale", help="Target locale code (es, fr, de, etc.)") ap.add_argument("--l10n-dir", help="Directory with locale files (translates all locales)") ap.add_argument("--missing-only", action="store_true", help="Only translate missing keys") + ap.add_argument( + "--copy-of-template", + action="store_true", + help="Only translate keys whose target text still equals app_en (use for new locales copied from English)", + ) + ap.add_argument( + "--only-locales", + help="Comma-separated locale codes to process with --l10n-dir (e.g. hu,ja,ko)", + ) ap.add_argument("--model", default="translategemma:latest", help="Ollama model (translategemma:latest or specific versions)") ap.add_argument("--fallback-model", help="Fallback model for failed translations (e.g., translategemma:27b)") ap.add_argument("--host", default="http://localhost:11434", help="Ollama host") @@ -458,6 +491,14 @@ def main() -> int: print("Input JSON must be an object at top-level.", file=sys.stderr) return 2 + if args.missing_only and args.copy_of_template: + print("Use only one of --missing-only or --copy-of-template", file=sys.stderr) + return 2 + + only_locales: Optional[set] = None + if args.only_locales: + only_locales = {x.strip() for x in args.only_locales.split(",") if x.strip()} + # Process all locales if --l10n-dir is provided if args.l10n_dir: locales = get_all_locale_files(args.l10n_dir, args.in_path) @@ -465,6 +506,12 @@ def main() -> int: print(f"No locale files found in {args.l10n_dir}", file=sys.stderr) return 1 + if only_locales is not None: + locales = [(c, p) for c, p in locales if c in only_locales] + missing = only_locales - {c for c, _ in locales} + if missing: + print(f"Warning: no app_*.arb for locale code(s): {', '.join(sorted(missing))}", file=sys.stderr) + print(f"Found {len(locales)} locale file(s) to process") total_translated = 0 @@ -478,7 +525,14 @@ def main() -> int: print(f" [{locale_code}] Failed to read {locale_path}: {e}") continue - if args.missing_only: + missing_keys: Optional[List[str]] + if args.copy_of_template: + missing_keys = find_keys_still_template_copy(source_data, target_data) + if not missing_keys: + print(f" [{locale_code}] No keys still matching template") + continue + print(f" [{locale_code}] {len(missing_keys)} key(s) still same as template") + elif args.missing_only: missing_keys = find_missing_keys(source_data, target_data) if not missing_keys: print(f" [{locale_code}] No missing keys") @@ -509,18 +563,23 @@ def main() -> int: lang_name, lang_code = LOCALE_MAP.get(args.to_locale, (args.to_locale, args.to_locale)) - # Read existing target file if --missing-only + # Read existing target file if --missing-only or --copy-of-template target_data: Dict[str, Any] = {} missing_keys: Optional[List[str]] = None - if args.missing_only and os.path.exists(args.out_path): + if (args.missing_only or args.copy_of_template) and os.path.exists(args.out_path): try: with open(args.out_path, "r", encoding="utf-8") as f: target_data = json.load(f) - missing_keys = find_missing_keys(source_data, target_data) + if args.copy_of_template: + missing_keys = find_keys_still_template_copy(source_data, target_data) + label = "still matching template" + else: + missing_keys = find_missing_keys(source_data, target_data) + label = "missing" if not missing_keys: - print(f"No missing keys in {args.out_path}") + print(f"No {label} keys in {args.out_path}") return 0 - print(f"Found {len(missing_keys)} missing key(s) to translate") + print(f"Found {len(missing_keys)} {label} key(s) to translate") except Exception as e: print(f"Failed to read target file: {e}", file=sys.stderr) return 2