Add radio statistics and localization updates

- Implemented radio statistics features in multiple screens including chat, channels, and settings.
- Added localization for new strings in Swedish, Ukrainian, and Chinese.
- Introduced a setting to jump to the oldest unread message in chat and channels.
- Enhanced path management and display for contacts and messages.
- Updated app settings to include new boolean for jumping to the oldest unread message.
- Improved battery indicator and radio stats display in the app bar.
- Removed unused wakelock_plus dependency and updated plugin registrations.
This commit is contained in:
zjs81
2026-03-23 19:24:27 -07:00
parent 4c492f69ef
commit e7e2bb91b8
38 changed files with 1955 additions and 99 deletions
+28
View File
@@ -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<String?>(
title: Text(context.l10n.appSettings_languageHu),
value: 'hu',
),
RadioListTile<String?>(
title: Text(context.l10n.appSettings_languageJa),
value: 'ja',
),
RadioListTile<String?>(
title: Text(context.l10n.appSettings_languageKo),
value: 'ko',
),
],
),
),
+54 -7
View File
@@ -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<ChannelChatScreen> {
bool _isLoadingOlder = false;
MeshCoreConnector? _connector;
DateTime? _lastChannelSendAt;
bool _channelSkipNextBottomSnap = false;
@override
void initState() {
@@ -55,11 +58,45 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
_scrollController.onScrollNearTop = _loadOlderMessages;
SchedulerBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_connector = context.read<MeshCoreConnector>();
_connector?.setActiveChannel(widget.channel.index);
final connector = context.read<MeshCoreConnector>();
final settings = context.read<AppSettingsService>().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<ChannelMessage> 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<ChannelChatScreen> {
),
centerTitle: false,
actions: [
const RadioStatsIconButton(),
PopupMenuButton<String>(
icon: const Icon(Icons.more_vert),
onSelected: (value) {
@@ -243,6 +281,10 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
// 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<ChannelChatScreen> {
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<ChannelChatScreen> {
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<MeshCoreConnector>();
String messageText = text;
@@ -64,6 +64,8 @@ class ChannelMessagePathScreen extends StatelessWidget {
flipPathAround: true,
reversePathAround:
!(!channelMessage && !message.isOutgoing),
pathHashByteWidth:
context.read<MeshCoreConnector>().pathHashByteWidth,
),
),
),
+4 -1
View File
@@ -127,7 +127,10 @@ class _ChannelsScreenState extends State<ChannelsScreen>
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: [
+66 -9
View File
@@ -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<ChatScreen> {
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<ChatScreen> {
_scrollController.onScrollNearTop = _loadOlderMessages;
SchedulerBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_connector = context.read<MeshCoreConnector>();
_connector?.setActiveContact(widget.contact.publicKeyHex);
final connector = context.read<MeshCoreConnector>();
final settings = context.read<AppSettingsService>().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<Message> 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<ChatScreen> {
);
},
),
const RadioStatsIconButton(),
],
),
body: Consumer<MeshCoreConnector>(
@@ -378,6 +422,7 @@ class _ChatScreenState extends State<ChatScreen> {
// 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<ChatScreen> {
(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<ChatScreen> {
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<ChatScreen> {
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<ChatScreen> {
path: Uint8List.fromList(pathBytes),
flipPathAround: true,
targetContact: widget.contact,
pathHashByteWidth: connector.pathHashByteWidth,
),
),
),
@@ -1212,7 +1272,9 @@ class _ChatScreenState extends State<ChatScreen> {
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) ...[
+9
View File
@@ -1244,6 +1244,8 @@ class _ContactsScreenState extends State<ContactsScreen>
? Text(context.l10n.contacts_pathTrace)
: Text(context.l10n.contacts_ping),
onTap: () {
final hw =
context.read<MeshCoreConnector>().pathHashByteWidth;
Navigator.push(
context,
MaterialPageRoute(
@@ -1254,6 +1256,7 @@ class _ContactsScreenState extends State<ContactsScreen>
path: contact.pathBytesForDisplay,
flipPathAround: true,
targetContact: contact,
pathHashByteWidth: hw,
),
),
);
@@ -1274,6 +1277,8 @@ class _ContactsScreenState extends State<ContactsScreen>
? Text(context.l10n.contacts_pathTrace)
: Text(context.l10n.contacts_ping),
onTap: () {
final hw =
context.read<MeshCoreConnector>().pathHashByteWidth;
Navigator.push(
context,
MaterialPageRoute(
@@ -1284,6 +1289,7 @@ class _ContactsScreenState extends State<ContactsScreen>
path: contact.pathBytesForDisplay,
flipPathAround: contact.pathBytesForDisplay.isNotEmpty,
targetContact: contact,
pathHashByteWidth: hw,
),
),
);
@@ -1318,6 +1324,8 @@ class _ContactsScreenState extends State<ContactsScreen>
leading: const Icon(Icons.radar, color: Colors.green),
title: Text(context.l10n.contacts_chatTraceRoute),
onTap: () {
final hw =
context.read<MeshCoreConnector>().pathHashByteWidth;
Navigator.push(
context,
MaterialPageRoute(
@@ -1328,6 +1336,7 @@ class _ContactsScreenState extends State<ContactsScreen>
path: contact.pathBytesForDisplay,
flipPathAround: true,
targetContact: contact,
pathHashByteWidth: hw,
),
),
);
+27 -40
View File
@@ -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<DeviceScreen>
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<DeviceScreen>
),
visualDensity: VisualDensity.compact,
),
_buildBatteryIndicator(connector, context),
BatteryIndicatorChip(
connector: connector,
showVoltage: _showBatteryVoltage,
onPressed: () {
setState(() {
_showBatteryVoltage = !_showBatteryVoltage;
});
},
),
],
),
],
@@ -205,44 +230,6 @@ class _DeviceScreenState extends State<DeviceScreen>
);
}
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(() {
+3
View File
@@ -2191,12 +2191,15 @@ class _MapScreenState extends State<MapScreen> {
if (_pathTrace.isNotEmpty)
IconButton(
onPressed: () {
final hashW =
context.read<MeshCoreConnector>().pathHashByteWidth;
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => PathTraceMapScreen(
title: l10n.contacts_pathTrace,
path: Uint8List.fromList(_pathTrace),
pathHashByteWidth: hashW,
),
),
);
+8 -1
View File
@@ -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<PathTraceMapScreen> {
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;
}
+11
View File
@@ -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<SettingsScreen> {
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),