Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 11 additions & 7 deletions lib/connector/meshcore_connector.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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;
Expand Down
19 changes: 16 additions & 3 deletions lib/helpers/utf8_length_limiter.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,22 @@ 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(
TextEditingValue oldValue,
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(
Expand All @@ -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) {
Expand Down
35 changes: 22 additions & 13 deletions lib/screens/channel_chat_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@ 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';
import '../helpers/utf8_length_limiter.dart';
import '../l10n/l10n.dart';
import '../models/channel.dart';
import '../models/channel_message.dart';
Expand All @@ -22,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';
Expand Down Expand Up @@ -1093,27 +1094,31 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
),
);
}

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)
? Smaz.encodeIfSmaller
: 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,
),
prefixIcon: const Icon(Icons.message_outlined),
),
maxLines: null,
textInputAction: TextInputAction.send,
onSubmitted: (_) => _sendMessage(),
);
},
),
Expand Down Expand Up @@ -1194,7 +1199,11 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
}

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) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.chat_messageTooLong(maxBytes))),
);
Expand Down
40 changes: 27 additions & 13 deletions lib/screens/chat_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,11 @@ 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';
import '../helpers/path_helper.dart';
import '../helpers/utf8_length_limiter.dart';
import '../models/channel_message.dart';
import '../models/contact.dart';
import '../models/message.dart';
Expand All @@ -30,6 +30,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';
Expand Down Expand Up @@ -566,24 +567,33 @@ class _ChatScreenState extends State<ChatScreen> {
),
);
}

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,
)
? Smaz.encodeIfSmaller
: 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,
),
prefixIcon: const Icon(Icons.message_outlined),
),
textInputAction: TextInputAction.send,
onSubmitted: (_) => _sendMessage(connector),
);
},
),
Expand Down Expand Up @@ -670,7 +680,11 @@ class _ChatScreenState extends State<ChatScreen> {
}
}
final maxBytes = maxContactMessageBytes();
if (utf8.encode(outgoingText).length > maxBytes) {
final outboundText = connector.prepareContactOutboundText(
_resolveContact(connector),
outgoingText,
);
if (utf8.encode(outboundText).length > maxBytes) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.chat_messageTooLong(maxBytes))),
);
Expand Down
136 changes: 136 additions & 0 deletions lib/widgets/byte_count_input.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
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<String>? onSubmitted;

/// Additional [TextInputFormatter]s applied *before* the byte limiter.
final List<TextInputFormatter> 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<TextEditingValue>(
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(
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),
),
),
),
],
);
},
);
}
}
Loading