diff --git a/lib/screens/channel_chat_screen.dart b/lib/screens/channel_chat_screen.dart index 64da058..4efffc0 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 '../l10n/l10n.dart'; import '../models/channel.dart'; import '../models/channel_message.dart'; @@ -22,6 +21,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 a4ebc76..613f57c 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'; @@ -566,24 +566,27 @@ 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(), - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(24), ), + filled: true, + fillColor: Theme.of( + context, + ).colorScheme.surfaceContainerLow, + contentPadding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 14, + ), + prefixIcon: const Icon(Icons.message_outlined), ), - 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), + ), + ), + ), + ], + ); + }, + ); + } +}