From e97fb9bd24b9a7ae206c1885ccbab380361bead8 Mon Sep 17 00:00:00 2001 From: "Enot (ded) Skelly" Date: Wed, 15 Apr 2026 08:35:09 -0700 Subject: [PATCH 1/4] add byte counted text input adds a new widget that counts bytes during entry configurable limit and shows user both count and limit provides color feedback use new widget in chat and channel text entry --- lib/screens/channel_chat_screen.dart | 23 +++-- lib/screens/chat_screen.dart | 26 +++--- lib/widgets/byte_count_input.dart | 125 +++++++++++++++++++++++++++ 3 files changed, 150 insertions(+), 24 deletions(-) create mode 100644 lib/widgets/byte_count_input.dart diff --git a/lib/screens/channel_chat_screen.dart b/lib/screens/channel_chat_screen.dart index e5b5f67..ebf8264 100644 --- a/lib/screens/channel_chat_screen.dart +++ b/lib/screens/channel_chat_screen.dart @@ -13,7 +13,6 @@ import '../helpers/chat_scroll_controller.dart'; import '../connector/meshcore_protocol.dart'; import '../helpers/gif_helper.dart'; import '../helpers/reaction_helper.dart'; -import '../helpers/utf8_length_limiter.dart'; import '../helpers/snack_bar_builder.dart'; import '../l10n/l10n.dart'; import '../models/channel.dart'; @@ -23,6 +22,7 @@ import '../services/app_settings_service.dart'; import '../services/chat_text_scale_service.dart'; import '../services/translation_service.dart'; import '../utils/emoji_utils.dart'; +import '../widgets/byte_count_input.dart'; import '../widgets/chat_zoom_wrapper.dart'; import '../widgets/emoji_picker.dart'; import '../widgets/gif_message.dart'; @@ -1093,27 +1093,26 @@ class _ChannelChatScreenState extends State { ), ); } - - return TextField( + return ByteCountedTextField( + maxBytes: maxBytes, controller: _textController, focusNode: _textFieldFocusNode, - inputFormatters: [ - Utf8LengthLimitingTextInputFormatter(maxBytes), - ], - textCapitalization: TextCapitalization.sentences, + hintText: context.l10n.chat_typeMessage, + onSubmitted: (_) => _sendMessage(), decoration: InputDecoration( hintText: context.l10n.chat_typeMessage, border: OutlineInputBorder( borderRadius: BorderRadius.circular(24), ), + filled: true, + fillColor: Theme.of( + context, + ).colorScheme.surfaceContainerLow, contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 8, + horizontal: 20, + vertical: 14, ), ), - maxLines: null, - textInputAction: TextInputAction.send, - onSubmitted: (_) => _sendMessage(), ); }, ), diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart index 2aee61c..9fc33eb 100644 --- a/lib/screens/chat_screen.dart +++ b/lib/screens/chat_screen.dart @@ -18,7 +18,6 @@ import '../widgets/message_status_icon.dart'; import '../helpers/chat_scroll_controller.dart'; import '../helpers/gif_helper.dart'; import '../helpers/path_helper.dart'; -import '../helpers/utf8_length_limiter.dart'; import '../models/channel_message.dart'; import '../models/contact.dart'; import '../models/message.dart'; @@ -30,6 +29,7 @@ import '../services/path_history_service.dart'; import '../services/translation_service.dart'; import '../widgets/chat_zoom_wrapper.dart'; import '../widgets/elements_ui.dart'; +import '../widgets/byte_count_input.dart'; import 'channel_message_path_screen.dart'; import 'map_screen.dart'; import '../utils/emoji_utils.dart'; @@ -567,24 +567,26 @@ class _ChatScreenState extends State { ), ); } - - return TextField( + return ByteCountedTextField( + maxBytes: maxBytes, controller: _textController, focusNode: _textFieldFocusNode, - inputFormatters: [ - Utf8LengthLimitingTextInputFormatter(maxBytes), - ], - textCapitalization: TextCapitalization.sentences, + hintText: context.l10n.chat_typeMessage, + onSubmitted: (_) => _sendMessage(connector), decoration: InputDecoration( hintText: context.l10n.chat_typeMessage, - border: const OutlineInputBorder(), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(24), + ), + filled: true, + fillColor: Theme.of( + context, + ).colorScheme.surfaceContainerLow, contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, + horizontal: 20, + vertical: 14, ), ), - textInputAction: TextInputAction.send, - onSubmitted: (_) => _sendMessage(connector), ); }, ), diff --git a/lib/widgets/byte_count_input.dart b/lib/widgets/byte_count_input.dart new file mode 100644 index 0000000..5832ae8 --- /dev/null +++ b/lib/widgets/byte_count_input.dart @@ -0,0 +1,125 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../helpers/utf8_length_limiter.dart'; + +/// A [TextField] that displays a live UTF-8 byte counter. +/// +/// The counter appears below the field once the user starts typing and changes +/// colour as the limit is approached (orange at 70 %, error-red at 90 %). +/// +/// All standard [TextField] behaviour (focus nodes, input actions, decoration +/// overrides, etc.) is forwarded so the widget can be dropped into any screen. +class ByteCountedTextField extends StatelessWidget { + /// Maximum number of UTF-8 bytes allowed. + final int maxBytes; + + /// Controller for the text field. + final TextEditingController controller; + + /// Optional focus node forwarded to the inner [TextField]. + final FocusNode? focusNode; + + /// Hint text shown when the field is empty. + final String? hintText; + + /// Keyboard action button (defaults to [TextInputAction.send]). + final TextInputAction textInputAction; + + /// Called when the user submits via the keyboard action button. + final ValueChanged? onSubmitted; + + /// Additional [TextInputFormatter]s applied *before* the byte limiter. + final List extraFormatters; + + /// Text capitalisation forwarded to the inner [TextField]. + final TextCapitalization textCapitalization; + + /// Optional full [InputDecoration] override. When provided, [hintText] is + /// ignored – set it inside the decoration instead. + final InputDecoration? decoration; + + /// Ratio (0–1) at which the counter turns the warning colour (default 0.7). + final double warningThreshold; + + /// Ratio (0–1) at which the counter turns the error colour (default 0.9). + final double errorThreshold; + + /// Whether to hide the counter when the field is empty (default `true`). + final bool hideCounterWhenEmpty; + + const ByteCountedTextField({ + super.key, + required this.maxBytes, + required this.controller, + this.focusNode, + this.hintText, + this.textInputAction = TextInputAction.send, + this.onSubmitted, + this.extraFormatters = const [], + this.textCapitalization = TextCapitalization.sentences, + this.decoration, + this.warningThreshold = 0.7, + this.errorThreshold = 0.9, + this.hideCounterWhenEmpty = true, + }); + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: controller, + builder: (context, value, _) { + final usedBytes = utf8.encode(value.text).length; + final ratio = maxBytes > 0 ? usedBytes / maxBytes : 0.0; + final showCounter = !(hideCounterWhenEmpty && value.text.isEmpty); + + final counterColor = ratio > errorThreshold + ? Theme.of(context).colorScheme.error + : ratio > warningThreshold + ? Colors.orange + : Theme.of(context).colorScheme.onSurfaceVariant; + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextField( + controller: controller, + focusNode: focusNode, + inputFormatters: [ + ...extraFormatters, + Utf8LengthLimitingTextInputFormatter(maxBytes), + ], + textCapitalization: textCapitalization, + decoration: + decoration ?? + InputDecoration( + hintText: hintText, + border: const OutlineInputBorder(), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + ), + textInputAction: textInputAction, + onSubmitted: onSubmitted, + ), + if (showCounter) + Padding( + padding: const EdgeInsets.only(top: 4, right: 4), + child: Align( + alignment: Alignment.centerRight, + child: Text( + '$usedBytes / $maxBytes', + style: TextStyle(fontSize: 11, color: counterColor), + ), + ), + ), + ], + ); + }, + ); + } +} From b572314ae9e7c9c924e63f95b6ea5251d280098d Mon Sep 17 00:00:00 2001 From: ericz Date: Sat, 11 Apr 2026 18:48:43 +0200 Subject: [PATCH 2/4] respect smaz encoding in message byte length calculation. --- lib/connector/meshcore_connector.dart | 18 +++++++++++------- lib/helpers/utf8_length_limiter.dart | 19 ++++++++++++++++--- lib/screens/channel_chat_screen.dart | 11 ++++++++++- lib/screens/chat_screen.dart | 13 ++++++++++++- lib/widgets/byte_count_input.dart | 15 +++++++++++++-- 5 files changed, 62 insertions(+), 14 deletions(-) diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index b432277..fceee15 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -2994,13 +2994,7 @@ class MeshCoreConnector extends ChangeNotifier { _pendingChannelSentQueue.add(message.messageId); notifyListeners(); - final trimmed = text.trim(); - final isStructuredPayload = - trimmed.startsWith('g:') || trimmed.startsWith('m:'); - final outboundText = - (isChannelSmazEnabled(channel.index) && !isStructuredPayload) - ? Smaz.encodeIfSmaller(text) - : text; + final outboundText = prepareChannelOutboundText(channel.index, text); await _waitForRadioQuiet(lastInboundRxTime: _lastChannelMsgRxTime); await sendFrame( buildSendChannelTextMsgFrame(channel.index, outboundText), @@ -4452,6 +4446,16 @@ class MeshCoreConnector extends ChangeNotifier { return text; } + String prepareChannelOutboundText(int channelIndex, String text) { + final trimmed = text.trim(); + final isStructuredPayload = + trimmed.startsWith('g:') || trimmed.startsWith('m:'); + if (!isStructuredPayload && isChannelSmazEnabled(channelIndex)) { + return Smaz.encodeIfSmaller(text); + } + return text; + } + String _channelDisplayName(int channelIndex) { for (final channel in _channels) { if (channel.index != channelIndex) continue; diff --git a/lib/helpers/utf8_length_limiter.dart b/lib/helpers/utf8_length_limiter.dart index c6acdd2..4188ec6 100644 --- a/lib/helpers/utf8_length_limiter.dart +++ b/lib/helpers/utf8_length_limiter.dart @@ -4,8 +4,14 @@ import 'package:flutter/services.dart'; class Utf8LengthLimitingTextInputFormatter extends TextInputFormatter { final int maxBytes; + final String Function(String)? encoder; - const Utf8LengthLimitingTextInputFormatter(this.maxBytes); + const Utf8LengthLimitingTextInputFormatter(this.maxBytes, {this.encoder}); + + int _effectiveByteLength(String text) { + final effective = encoder != null ? encoder!(text) : text; + return utf8.encode(effective).length; + } @override TextEditingValue formatEditUpdate( @@ -13,8 +19,7 @@ class Utf8LengthLimitingTextInputFormatter extends TextInputFormatter { TextEditingValue newValue, ) { if (maxBytes <= 0) return oldValue; - final bytes = utf8.encode(newValue.text); - if (bytes.length <= maxBytes) return newValue; + if (_effectiveByteLength(newValue.text) <= maxBytes) return newValue; final truncated = _truncateToMaxBytes(newValue.text, maxBytes); return TextEditingValue( @@ -25,6 +30,14 @@ class Utf8LengthLimitingTextInputFormatter extends TextInputFormatter { } String _truncateToMaxBytes(String text, int limit) { + if (encoder != null) { + final runes = text.runes.toList(); + while (runes.isNotEmpty && + _effectiveByteLength(String.fromCharCodes(runes)) > maxBytes) { + runes.removeLast(); + } + return String.fromCharCodes(runes); + } final buffer = StringBuffer(); var used = 0; for (final rune in text.runes) { diff --git a/lib/screens/channel_chat_screen.dart b/lib/screens/channel_chat_screen.dart index ebf8264..dfcdcdb 100644 --- a/lib/screens/channel_chat_screen.dart +++ b/lib/screens/channel_chat_screen.dart @@ -10,6 +10,7 @@ import 'package:provider/provider.dart'; import '../connector/meshcore_connector.dart'; import '../utils/platform_info.dart'; import '../helpers/chat_scroll_controller.dart'; +import '../helpers/smaz.dart'; import '../connector/meshcore_protocol.dart'; import '../helpers/gif_helper.dart'; import '../helpers/reaction_helper.dart'; @@ -1099,6 +1100,10 @@ class _ChannelChatScreenState extends State { focusNode: _textFieldFocusNode, hintText: context.l10n.chat_typeMessage, onSubmitted: (_) => _sendMessage(), + encoder: + connector.isChannelSmazEnabled(widget.channel.index) + ? Smaz.encodeIfSmaller + : null, decoration: InputDecoration( hintText: context.l10n.chat_typeMessage, border: OutlineInputBorder( @@ -1194,7 +1199,11 @@ class _ChannelChatScreenState extends State { } final maxBytes = maxChannelMessageBytes(connector.selfName); - if (utf8.encode(messageText).length > maxBytes) { + final outboundText = connector.prepareChannelOutboundText( + widget.channel.index, + messageText, + ); + if (utf8.encode(outboundText).length > maxBytes) { showDismissibleSnackBar( context, content: Text(context.l10n.chat_messageTooLong(maxBytes)), diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart index 9fc33eb..435e6c5 100644 --- a/lib/screens/chat_screen.dart +++ b/lib/screens/chat_screen.dart @@ -14,6 +14,7 @@ import 'package:latlong2/latlong.dart'; import '../connector/meshcore_connector.dart'; import '../connector/meshcore_protocol.dart'; import '../helpers/reaction_helper.dart'; +import '../helpers/smaz.dart'; import '../widgets/message_status_icon.dart'; import '../helpers/chat_scroll_controller.dart'; import '../helpers/gif_helper.dart'; @@ -573,6 +574,12 @@ class _ChatScreenState extends State { focusNode: _textFieldFocusNode, hintText: context.l10n.chat_typeMessage, onSubmitted: (_) => _sendMessage(connector), + encoder: + connector.isContactSmazEnabled( + widget.contact.publicKeyHex, + ) + ? Smaz.encodeIfSmaller + : null, decoration: InputDecoration( hintText: context.l10n.chat_typeMessage, border: OutlineInputBorder( @@ -674,7 +681,11 @@ class _ChatScreenState extends State { } } final maxBytes = maxContactMessageBytes(); - if (utf8.encode(outgoingText).length > maxBytes) { + final outboundText = connector.prepareContactOutboundText( + _resolveContact(connector), + outgoingText, + ); + if (utf8.encode(outboundText).length > maxBytes) { showDismissibleSnackBar( context, content: Text(context.l10n.chat_messageTooLong(maxBytes)), diff --git a/lib/widgets/byte_count_input.dart b/lib/widgets/byte_count_input.dart index 5832ae8..bfb5fcc 100644 --- a/lib/widgets/byte_count_input.dart +++ b/lib/widgets/byte_count_input.dart @@ -50,6 +50,10 @@ class ByteCountedTextField extends StatelessWidget { /// Whether to hide the counter when the field is empty (default `true`). final bool hideCounterWhenEmpty; + /// Optional encoder function to transform text before byte counting/limiting. + /// If provided, byte limits and counters will use the encoded text length. + final String Function(String)? encoder; + const ByteCountedTextField({ super.key, required this.maxBytes, @@ -64,6 +68,7 @@ class ByteCountedTextField extends StatelessWidget { this.warningThreshold = 0.7, this.errorThreshold = 0.9, this.hideCounterWhenEmpty = true, + this.encoder, }); @override @@ -71,7 +76,10 @@ class ByteCountedTextField extends StatelessWidget { return ValueListenableBuilder( valueListenable: controller, builder: (context, value, _) { - final usedBytes = utf8.encode(value.text).length; + final effectiveText = encoder != null + ? encoder!(value.text) + : value.text; + final usedBytes = utf8.encode(effectiveText).length; final ratio = maxBytes > 0 ? usedBytes / maxBytes : 0.0; final showCounter = !(hideCounterWhenEmpty && value.text.isEmpty); @@ -90,7 +98,10 @@ class ByteCountedTextField extends StatelessWidget { focusNode: focusNode, inputFormatters: [ ...extraFormatters, - Utf8LengthLimitingTextInputFormatter(maxBytes), + Utf8LengthLimitingTextInputFormatter( + maxBytes, + encoder: encoder, + ), ], textCapitalization: textCapitalization, decoration: From ddcda4ba5ad161799334355fdfa65ad0f12a107e Mon Sep 17 00:00:00 2001 From: "Enot (ded) Skelly" Date: Fri, 17 Apr 2026 14:07:00 -0700 Subject: [PATCH 3/4] keep multiline editing --- lib/widgets/byte_count_input.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/widgets/byte_count_input.dart b/lib/widgets/byte_count_input.dart index bfb5fcc..ca43252 100644 --- a/lib/widgets/byte_count_input.dart +++ b/lib/widgets/byte_count_input.dart @@ -94,6 +94,7 @@ class ByteCountedTextField extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ TextField( + maxLines: null, controller: controller, focusNode: focusNode, inputFormatters: [ From 8ef8a3849553ede3c8d3471d93ce84fee47e6e61 Mon Sep 17 00:00:00 2001 From: ericz Date: Sat, 18 Apr 2026 00:06:03 +0200 Subject: [PATCH 4/4] change to prepare Outbound Text Functions. --- lib/screens/channel_chat_screen.dart | 6 ++++-- lib/screens/chat_screen.dart | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/lib/screens/channel_chat_screen.dart b/lib/screens/channel_chat_screen.dart index dfcdcdb..b203cbb 100644 --- a/lib/screens/channel_chat_screen.dart +++ b/lib/screens/channel_chat_screen.dart @@ -10,7 +10,6 @@ import 'package:provider/provider.dart'; import '../connector/meshcore_connector.dart'; import '../utils/platform_info.dart'; import '../helpers/chat_scroll_controller.dart'; -import '../helpers/smaz.dart'; import '../connector/meshcore_protocol.dart'; import '../helpers/gif_helper.dart'; import '../helpers/reaction_helper.dart'; @@ -1102,7 +1101,10 @@ class _ChannelChatScreenState extends State { onSubmitted: (_) => _sendMessage(), encoder: connector.isChannelSmazEnabled(widget.channel.index) - ? Smaz.encodeIfSmaller + ? (text) => connector.prepareChannelOutboundText( + widget.channel.index, + text, + ) : null, decoration: InputDecoration( hintText: context.l10n.chat_typeMessage, diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart index 435e6c5..ffa8344 100644 --- a/lib/screens/chat_screen.dart +++ b/lib/screens/chat_screen.dart @@ -14,7 +14,6 @@ import 'package:latlong2/latlong.dart'; import '../connector/meshcore_connector.dart'; import '../connector/meshcore_protocol.dart'; import '../helpers/reaction_helper.dart'; -import '../helpers/smaz.dart'; import '../widgets/message_status_icon.dart'; import '../helpers/chat_scroll_controller.dart'; import '../helpers/gif_helper.dart'; @@ -578,7 +577,10 @@ class _ChatScreenState extends State { connector.isContactSmazEnabled( widget.contact.publicKeyHex, ) - ? Smaz.encodeIfSmaller + ? (text) => connector.prepareContactOutboundText( + widget.contact, + text, + ) : null, decoration: InputDecoration( hintText: context.l10n.chat_typeMessage,