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 e5b5f67..b203cbb 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,33 @@ 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(), + encoder: + connector.isChannelSmazEnabled(widget.channel.index) + ? (text) => connector.prepareChannelOutboundText( + widget.channel.index, + text, + ) + : null, 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(), ); }, ), @@ -1195,7 +1201,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 2aee61c..ffa8344 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,35 @@ 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), + encoder: + connector.isContactSmazEnabled( + widget.contact.publicKeyHex, + ) + ? (text) => connector.prepareContactOutboundText( + widget.contact, + text, + ) + : null, 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), ); }, ), @@ -672,7 +683,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 new file mode 100644 index 0000000..ca43252 --- /dev/null +++ b/lib/widgets/byte_count_input.dart @@ -0,0 +1,137 @@ +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; + + /// 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, + 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, + this.encoder, + }); + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: controller, + builder: (context, value, _) { + 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); + + 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( + maxLines: null, + controller: controller, + focusNode: focusNode, + inputFormatters: [ + ...extraFormatters, + Utf8LengthLimitingTextInputFormatter( + maxBytes, + encoder: encoder, + ), + ], + 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), + ), + ), + ), + ], + ); + }, + ); + } +}