mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-04-20 22:13:48 +00:00
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
This commit is contained in:
parent
a4bbeffddc
commit
e97fb9bd24
3 changed files with 150 additions and 24 deletions
|
|
@ -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<ChannelChatScreen> {
|
|||
),
|
||||
);
|
||||
}
|
||||
|
||||
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(),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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<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),
|
||||
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),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
|
|
|||
125
lib/widgets/byte_count_input.dart
Normal file
125
lib/widgets/byte_count_input.dart
Normal file
|
|
@ -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<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;
|
||||
|
||||
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<TextEditingValue>(
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue