resolved codex suggestion. Added smaz compresson aware length limitter. on Location share ask for confirmation on public channel and preselect label input field for easier editing.

This commit is contained in:
ericz 2026-03-11 21:13:50 +01:00
parent 35cc73a2f7
commit cd24bb82a1
20 changed files with 135 additions and 112 deletions

View file

@ -2,10 +2,32 @@ import 'dart:convert';
import 'package:flutter/services.dart';
String truncateToUtf8Bytes(String text, int maxBytes) {
if (maxBytes <= 0) return '';
final buffer = StringBuffer();
var usedBytes = 0;
for (final rune in text.runes) {
final character = String.fromCharCode(rune);
final characterBytes = utf8.encode(character).length;
if (usedBytes + characterBytes > maxBytes) break;
buffer.write(character);
usedBytes += characterBytes;
}
return buffer.toString();
}
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,10 +35,9 @@ 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);
final truncated = _truncate(newValue.text);
return TextEditingValue(
text: truncated,
selection: TextSelection.collapsed(offset: truncated.length),
@ -24,16 +45,13 @@ class Utf8LengthLimitingTextInputFormatter extends TextInputFormatter {
);
}
String _truncateToMaxBytes(String text, int limit) {
final buffer = StringBuffer();
var used = 0;
for (final rune in text.runes) {
final char = String.fromCharCode(rune);
final charBytes = utf8.encode(char).length;
if (used + charBytes > limit) break;
buffer.write(char);
used += charBytes;
String _truncate(String text) {
if (encoder == null) return truncateToUtf8Bytes(text, maxBytes);
final runes = text.runes.toList();
while (runes.isNotEmpty &&
_effectiveByteLength(String.fromCharCodes(runes)) > maxBytes) {
runes.removeLast();
}
return buffer.toString();
return String.fromCharCodes(runes);
}
}

View file

@ -1550,7 +1550,7 @@ class AppLocalizationsBg extends AppLocalizations {
String get map_sharedPin => 'Споделено копие';
@override
String get map_sharedAt => 'Shared';
String get map_sharedAt => 'Споделено';
@override
String get map_joinRoom => 'Присъедини се към стаята';

View file

@ -1550,7 +1550,7 @@ class AppLocalizationsDe extends AppLocalizations {
String get map_sharedPin => 'Gemeinsames Passwort';
@override
String get map_sharedAt => 'Shared';
String get map_sharedAt => 'Geteilt';
@override
String get map_joinRoom => 'Beitreten Sie dem Raum';

View file

@ -1548,7 +1548,7 @@ class AppLocalizationsEs extends AppLocalizations {
String get map_sharedPin => 'Pin compartido';
@override
String get map_sharedAt => 'Shared';
String get map_sharedAt => 'Compartido';
@override
String get map_joinRoom => 'Únete a la sala';

View file

@ -1555,7 +1555,7 @@ class AppLocalizationsFr extends AppLocalizations {
String get map_sharedPin => 'Clé partagée';
@override
String get map_sharedAt => 'Shared';
String get map_sharedAt => 'Partagé';
@override
String get map_joinRoom => 'Rejoindre la salle';

View file

@ -1547,7 +1547,7 @@ class AppLocalizationsIt extends AppLocalizations {
String get map_sharedPin => 'Condividi PIN';
@override
String get map_sharedAt => 'Shared';
String get map_sharedAt => 'Condiviso';
@override
String get map_joinRoom => 'Unisciti alla stanza';

View file

@ -1540,7 +1540,7 @@ class AppLocalizationsNl extends AppLocalizations {
String get map_sharedPin => 'Gedeelde pin';
@override
String get map_sharedAt => 'Shared';
String get map_sharedAt => 'Gedeeld';
@override
String get map_joinRoom => 'Sluit Kamer';

View file

@ -1549,7 +1549,7 @@ class AppLocalizationsPl extends AppLocalizations {
String get map_sharedPin => 'Podzielony PIN';
@override
String get map_sharedAt => 'Shared';
String get map_sharedAt => 'Udostępnione';
@override
String get map_joinRoom => 'Dołącz do pokoju';

View file

@ -1549,7 +1549,7 @@ class AppLocalizationsPt extends AppLocalizations {
String get map_sharedPin => 'Pin compartilhado';
@override
String get map_sharedAt => 'Shared';
String get map_sharedAt => 'Compartilhado';
@override
String get map_joinRoom => 'Junte-se à Sala';

View file

@ -1551,7 +1551,7 @@ class AppLocalizationsRu extends AppLocalizations {
String get map_sharedPin => 'Общая метка';
@override
String get map_sharedAt => 'Shared';
String get map_sharedAt => 'Поделено';
@override
String get map_joinRoom => 'Присоединиться к комнате';

View file

@ -1543,7 +1543,7 @@ class AppLocalizationsSk extends AppLocalizations {
String get map_sharedPin => 'Zdieľaný PIN';
@override
String get map_sharedAt => 'Shared';
String get map_sharedAt => 'Zdieľané';
@override
String get map_joinRoom => 'Pripojiť miestnosť';

View file

@ -1536,7 +1536,7 @@ class AppLocalizationsSl extends AppLocalizations {
String get map_sharedPin => 'Deljeno naslovno geslo';
@override
String get map_sharedAt => 'Shared';
String get map_sharedAt => 'Deljeno';
@override
String get map_joinRoom => 'Pridružiti sobo';

View file

@ -1533,7 +1533,7 @@ class AppLocalizationsSv extends AppLocalizations {
String get map_sharedPin => 'Delad PIN';
@override
String get map_sharedAt => 'Shared';
String get map_sharedAt => 'Delad';
@override
String get map_joinRoom => 'Gå med i rum';

View file

@ -1548,7 +1548,7 @@ class AppLocalizationsUk extends AppLocalizations {
String get map_sharedPin => 'Спільний пін';
@override
String get map_sharedAt => 'Shared';
String get map_sharedAt => 'Поділено';
@override
String get map_joinRoom => 'Приєднатися до кімнати';

View file

@ -1054,13 +1054,13 @@ class AppLocalizationsZh extends AppLocalizations {
String get chat_sendGif => '发送 GIF';
@override
String get chat_insertEmoji => 'Insert emoji';
String get chat_insertEmoji => '插入表情';
@override
String get chat_shareLocation => 'Share location';
String get chat_shareLocation => '分享位置';
@override
String get chat_locationUnavailable => 'Location not available';
String get chat_locationUnavailable => '位置不可用';
@override
String get chat_reply => '回复';
@ -1459,7 +1459,7 @@ class AppLocalizationsZh extends AppLocalizations {
String get map_sharedPin => '共享标记';
@override
String get map_sharedAt => 'Shared';
String get map_sharedAt => '已分享';
@override
String get map_joinRoom => '加入房间';

View file

@ -14,6 +14,7 @@ import '../connector/meshcore_protocol.dart';
import '../helpers/link_handler.dart';
import '../helpers/reaction_helper.dart';
import '../helpers/utf8_length_limiter.dart';
import '../helpers/smaz.dart';
import '../l10n/l10n.dart';
import '../models/channel.dart';
import '../models/channel_message.dart';
@ -827,28 +828,30 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
);
}
void _insertTextAtCursor(String text) {
final currentValue = _textController.value;
final selection = currentValue.selection;
final newText = selection.isValid
? currentValue.text.replaceRange(selection.start, selection.end, text)
: currentValue.text + text;
final caret =
(selection.isValid ? selection.start : currentValue.text.length) +
text.length;
_textController.value = currentValue.copyWith(
text: newText,
selection: TextSelection.collapsed(offset: caret),
);
}
void _showEmojiPickerForComposer(BuildContext context) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => EmojiPicker(
title: context.l10n.chat_insertEmoji,
onEmojiSelected: (emoji) {
final currentValue = _textController.value;
final selection = currentValue.selection;
final newText = selection.isValid
? currentValue.text.replaceRange(
selection.start,
selection.end,
emoji,
)
: currentValue.text + emoji;
final caret =
(selection.isValid ? selection.start : currentValue.text.length) +
emoji.length;
_textController.value = currentValue.copyWith(
text: newText,
selection: TextSelection.collapsed(offset: caret),
);
_insertTextAtCursor(emoji);
_textFieldFocusNode.requestFocus();
},
),
@ -875,7 +878,11 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
final defaultLabel =
'${connector.deviceDisplayName} ${DateTime.now().toUtc().toIso8601String()}';
final controller = TextEditingController(
text: _truncateToUtf8Bytes(defaultLabel, maxLabelBytes),
text: truncateToUtf8Bytes(defaultLabel, maxLabelBytes),
);
controller.selection = TextSelection(
baseOffset: 0,
extentOffset: controller.text.length,
);
var label = await showDialog<String>(
@ -916,23 +923,39 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
);
return;
}
if (widget.channel.isPublicChannel) {
final channelLabel = widget.channel.name.isEmpty
? context.l10n.channels_channelIndex(widget.channel.index)
: widget.channel.name;
final canShare = await _confirmPublicShare(channelLabel);
if (!mounted || !canShare) return;
}
connector.sendChannelMessage(widget.channel, markerText);
}
String _truncateToUtf8Bytes(String text, int maxBytes) {
if (maxBytes <= 0) return '';
final buffer = StringBuffer();
var usedBytes = 0;
for (final rune in text.runes) {
final character = String.fromCharCode(rune);
final characterBytes = utf8.encode(character).length;
if (usedBytes + characterBytes > maxBytes) break;
buffer.write(character);
usedBytes += characterBytes;
}
return buffer.toString();
Future<bool> _confirmPublicShare(String channelLabel) async {
final result = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(context.l10n.map_publicLocationShare),
content: Text(
context.l10n.map_publicLocationShareConfirm(channelLabel),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: Text(context.l10n.common_cancel),
),
TextButton(
onPressed: () => Navigator.pop(dialogContext, true),
child: Text(context.l10n.common_share),
),
],
),
);
return result ?? false;
}
Widget _buildAvatar(String senderName) {
@ -1043,6 +1066,9 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
Widget _buildMessageComposer() {
final connector = context.watch<MeshCoreConnector>();
final maxBytes = maxChannelMessageBytes(connector.selfName);
final smazEncoder = connector.isChannelSmazEnabled(widget.channel.index)
? Smaz.encodeIfSmaller
: null;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
@ -1169,7 +1195,10 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
controller: _textController,
focusNode: _textFieldFocusNode,
inputFormatters: [
Utf8LengthLimitingTextInputFormatter(maxBytes),
Utf8LengthLimitingTextInputFormatter(
maxBytes,
encoder: smazEncoder,
),
],
textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration(
@ -1305,6 +1334,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
context: context,
isScrollControlled: true,
builder: (context) => EmojiPicker(
title: context.l10n.chat_addReaction,
onEmojiSelected: (emoji) {
_sendReaction(message, emoji);
},

View file

@ -17,6 +17,7 @@ import '../widgets/message_status_icon.dart';
import '../helpers/chat_scroll_controller.dart';
import '../helpers/link_handler.dart';
import '../helpers/utf8_length_limiter.dart';
import '../helpers/smaz.dart';
import '../models/channel_message.dart';
import '../models/contact.dart';
import '../models/message.dart';
@ -336,6 +337,10 @@ class _ChatScreenState extends State<ChatScreen> {
Widget _buildInputBar(MeshCoreConnector connector) {
final maxBytes = maxContactMessageBytes();
final colorScheme = Theme.of(context).colorScheme;
final smazEncoder =
connector.isContactSmazEnabled(widget.contact.publicKeyHex)
? Smaz.encodeIfSmaller
: null;
return Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
@ -442,7 +447,10 @@ class _ChatScreenState extends State<ChatScreen> {
controller: _textController,
focusNode: _textFieldFocusNode,
inputFormatters: [
Utf8LengthLimitingTextInputFormatter(maxBytes),
Utf8LengthLimitingTextInputFormatter(
maxBytes,
encoder: smazEncoder,
),
],
textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration(
@ -497,6 +505,7 @@ class _ChatScreenState extends State<ChatScreen> {
context: context,
isScrollControlled: true,
builder: (context) => EmojiPicker(
title: context.l10n.chat_insertEmoji,
onEmojiSelected: (emoji) {
_insertTextAtCursor(emoji);
_textFieldFocusNode.requestFocus();
@ -524,7 +533,11 @@ class _ChatScreenState extends State<ChatScreen> {
final defaultLabel =
'${connector.deviceDisplayName} ${DateTime.now().toUtc().toIso8601String()}';
final controller = TextEditingController(
text: _truncateToUtf8Bytes(defaultLabel, maxLabelBytes),
text: truncateToUtf8Bytes(defaultLabel, maxLabelBytes),
);
controller.selection = TextSelection(
baseOffset: 0,
extentOffset: controller.text.length,
);
var label = await showDialog<String>(
@ -567,22 +580,6 @@ class _ChatScreenState extends State<ChatScreen> {
connector.sendMessage(widget.contact, markerText);
}
String _truncateToUtf8Bytes(String text, int maxBytes) {
if (maxBytes <= 0) return '';
final buffer = StringBuffer();
var usedBytes = 0;
for (final rune in text.runes) {
final character = String.fromCharCode(rune);
final characterBytes = utf8.encode(character).length;
if (usedBytes + characterBytes > maxBytes) break;
buffer.write(character);
usedBytes += characterBytes;
}
return buffer.toString();
}
void _showGifPicker(BuildContext context) {
showModalBottomSheet(
context: context,
@ -1309,6 +1306,7 @@ class _ChatScreenState extends State<ChatScreen> {
context: context,
isScrollControlled: true,
builder: (context) => EmojiPicker(
title: context.l10n.chat_addReaction,
onEmojiSelected: (emoji) {
_sendReaction(message, senderContact, emoji);
},

View file

@ -1486,6 +1486,10 @@ class _MapScreenState extends State<MapScreen> {
String defaultLabel,
) async {
final controller = TextEditingController(text: defaultLabel);
controller.selection = TextSelection(
baseOffset: 0,
extentOffset: controller.text.length,
);
return showDialog<String>(
context: context,
builder: (dialogContext) => AlertDialog(

View file

@ -4,8 +4,9 @@ import '../l10n/l10n.dart';
class EmojiPicker extends StatelessWidget {
final Function(String) onEmojiSelected;
final String? title;
const EmojiPicker({super.key, required this.onEmojiSelected});
const EmojiPicker({super.key, required this.onEmojiSelected, this.title});
static const List<String> quickEmojis = ['👍', '❤️', '😂', '🎉', '👏', '🔥'];
@ -223,7 +224,7 @@ class EmojiPicker extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
l10n.chat_addReaction,
title ?? l10n.chat_addReaction,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,

View file

@ -1,29 +1 @@
{
"bg": [],
"de": [],
"es": [],
"fr": [],
"it": [],
"nl": [],
"pl": [],
"pt": [],
"ru": [],
"sk": [],
"sl": [],
"sv": [],
"uk": [],
"zh": []
}
{}